Migración desde trading-platform/apps/frontend - Estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:32:49 -06:00
parent bcd0737ba9
commit 737303d177
155 changed files with 27971 additions and 2 deletions

17
.env Normal file
View File

@ -0,0 +1,17 @@
# Frontend Environment Variables
# API Base URL (for production gateway)
VITE_API_BASE_URL=http://localhost:3000/api
# Auth Service URL
VITE_AUTH_SERVICE_URL=http://localhost:3095
# MCP Service URLs (for direct access in development)
VITE_WALLET_SERVICE_URL=http://localhost:3090
VITE_PRODUCTS_SERVICE_URL=http://localhost:3091
VITE_VIP_SERVICE_URL=http://localhost:3092
VITE_INVESTMENT_SERVICE_URL=http://localhost:3093
VITE_PREDICTIONS_SERVICE_URL=http://localhost:3094
# Feature Flags
VITE_ENABLE_DEVTOOLS=true

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
# Frontend Environment Variables
# API Base URL (for production gateway)
VITE_API_BASE_URL=http://localhost:3000/api
# Auth Service URL
VITE_AUTH_SERVICE_URL=http://localhost:3095
# MCP Service URLs (for direct access in development)
VITE_WALLET_SERVICE_URL=http://localhost:3090
VITE_PRODUCTS_SERVICE_URL=http://localhost:3091
VITE_VIP_SERVICE_URL=http://localhost:3092
VITE_INVESTMENT_SERVICE_URL=http://localhost:3093
VITE_PREDICTIONS_SERVICE_URL=http://localhost:3094
# Feature Flags
VITE_ENABLE_DEVTOOLS=true

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,3 +1,78 @@
# trading-platform-frontend-v2
# Trading Platform Frontend
Frontend de trading-platform - Workspace V2
React SPA para la plataforma de trading con creditos virtuales.
## Stack Tecnologico
- **React 18** + TypeScript 5
- **Vite** para build tooling
- **Tailwind CSS** para estilos
- **Zustand** para state management
- **TanStack Query** para data fetching
- **React Router v6** para routing
## Modulos
| Modulo | Descripcion | Puerto Backend |
|--------|-------------|----------------|
| Wallet | Billetera virtual, depositos, retiros | 3090 |
| Products | Marketplace de productos/servicios | 3091 |
| VIP | Suscripciones Gold/Platinum/Diamond | 3092 |
| Investment | Agentes Atlas/Orion/Nova | 3093 |
| Predictions | Paquetes de predicciones ML | 3094 |
## Estructura
```
src/
├── components/ # Componentes compartidos
├── services/ # Cliente API
├── modules/
│ ├── wallet/
│ │ ├── types/
│ │ ├── services/
│ │ ├── stores/
│ │ ├── hooks/
│ │ ├── components/
│ │ └── pages/
│ ├── products/
│ ├── vip/
│ ├── investment/
│ └── predictions/
├── routes.tsx
├── App.tsx
└── main.tsx
```
## Desarrollo
```bash
# Instalar dependencias
npm install
# Iniciar servidor de desarrollo
npm run dev
# Build para produccion
npm run build
# Type checking
npm run type-check
```
## Variables de Entorno
```env
VITE_API_BASE_URL=http://localhost:3000/api
VITE_ENABLE_DEVTOOLS=true
```
## Docker
```bash
# Build imagen
docker build -t trading-frontend .
# Ejecutar con docker-compose
docker-compose -f docker-compose.mcp.yml up frontend
```

589
docs/MASTER_INVENTORY.yml Normal file
View File

@ -0,0 +1,589 @@
# MASTER_INVENTORY.yml - Trading Platform Frontend
# Sistema SIMCO v3.8+ | Ciclo CAPVED
# Ultima actualizacion: 2026-01-13
metadata:
proyecto: trading-platform-frontend
version: 1.0.0
tema: STC Theme (Gold/Black)
stack:
- React 18
- TypeScript
- Vite
- Tailwind CSS
- shadcn/ui
- Radix UI Primitives
- Zustand (state management)
- TanStack Query (data fetching)
# =============================================================================
# COMPONENTES UI BASE (shadcn/ui migrados)
# =============================================================================
componentes_ui:
estado: COMPLETADO
ruta_base: src/components/ui/
items:
- nombre: button.tsx
tipo: componente
dependencias: [class-variance-authority, @radix-ui/react-slot]
variantes: [default, destructive, outline, secondary, ghost, link]
estado: migrado
- nombre: card.tsx
tipo: componente
dependencias: []
exports: [Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent]
estado: migrado
- nombre: input.tsx
tipo: componente
dependencias: []
estado: migrado
- nombre: label.tsx
tipo: componente
dependencias: [@radix-ui/react-label]
estado: migrado
- nombre: badge.tsx
tipo: componente
dependencias: [class-variance-authority]
variantes: [default, secondary, destructive, outline]
estado: migrado
- nombre: dialog.tsx
tipo: componente
dependencias: [@radix-ui/react-dialog, lucide-react]
exports: [Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription]
estado: migrado
- nombre: select.tsx
tipo: componente
dependencias: [@radix-ui/react-select, lucide-react]
exports: [Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton]
estado: migrado
- nombre: progress.tsx
tipo: componente
dependencias: [@radix-ui/react-progress]
modificaciones:
- descripcion: Agregado prop indicatorClassName para estilos custom
fecha: 2026-01-13
estado: migrado
- nombre: skeleton.tsx
tipo: componente
dependencias: []
notas: Creado para estados de carga
estado: creado
- nombre: sheet.tsx
tipo: componente
dependencias: [@radix-ui/react-dialog, class-variance-authority, lucide-react]
exports: [Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription]
variantes_posicion: [top, bottom, left, right]
notas: Creado para CartSidebar
estado: creado
- nombre: tabs.tsx
tipo: componente
dependencias: [@radix-ui/react-tabs]
exports: [Tabs, TabsList, TabsTrigger, TabsContent]
estado: migrado
- nombre: avatar.tsx
tipo: componente
dependencias: [@radix-ui/react-avatar]
exports: [Avatar, AvatarImage, AvatarFallback]
estado: migrado
- nombre: dropdown-menu.tsx
tipo: componente
dependencias: [@radix-ui/react-dropdown-menu, lucide-react]
estado: migrado
- nombre: separator.tsx
tipo: componente
dependencias: [@radix-ui/react-separator]
estado: migrado
- nombre: switch.tsx
tipo: componente
dependencias: [@radix-ui/react-switch]
estado: migrado
- nombre: textarea.tsx
tipo: componente
dependencias: []
estado: migrado
- nombre: tooltip.tsx
tipo: componente
dependencias: [@radix-ui/react-tooltip]
exports: [Tooltip, TooltipTrigger, TooltipContent, TooltipProvider]
estado: migrado
- nombre: alert.tsx
tipo: componente
dependencias: [class-variance-authority]
exports: [Alert, AlertTitle, AlertDescription]
variantes: [default, destructive]
estado: migrado
- nombre: form.tsx
tipo: componente
dependencias: [@radix-ui/react-label, @radix-ui/react-slot, react-hook-form]
estado: migrado
- nombre: scroll-area.tsx
tipo: componente
dependencias: [@radix-ui/react-scroll-area]
estado: migrado
- nombre: table.tsx
tipo: componente
dependencias: []
exports: [Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption]
estado: migrado
# =============================================================================
# MODULOS DE APLICACION
# =============================================================================
modulos:
# ---------------------------------------------------------------------------
# MODULO AUTH
# ---------------------------------------------------------------------------
auth:
estado: COMPLETADO
ruta: src/modules/auth/
pages:
- nombre: LoginPage.tsx
descripcion: Pagina de inicio de sesion
componentes_ui: [Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Input, Label, Button, Separator]
iconos: [Eye, EyeOff, Loader2]
tema: STC Gold/Black
estado: migrado
- nombre: RegisterPage.tsx
descripcion: Pagina de registro de organizacion
componentes_ui: [Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Input, Label, Button, Separator, Checkbox]
iconos: [Eye, EyeOff, Loader2, Check, Circle]
tema: STC Gold/Black
caracteristicas:
- Password strength validation
- Auto-generate organization slug
- Terms acceptance checkbox
estado: migrado
- nombre: ForgotPasswordPage.tsx
descripcion: Pagina de recuperacion de contrasena
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Input, Label, Button]
iconos: [Loader2, Mail, ArrowLeft, AlertTriangle]
tema: STC Gold/Black
estado: migrado
- nombre: ResetPasswordPage.tsx
descripcion: Pagina de restablecimiento de contrasena
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Input, Label, Button]
iconos: [Loader2, Lock, Check, Circle, AlertTriangle]
tema: STC Gold/Black
estado: migrado
# ---------------------------------------------------------------------------
# MODULO WALLET
# ---------------------------------------------------------------------------
wallet:
estado: COMPLETADO
ruta: src/modules/wallet/
pages:
- nombre: WalletPage.tsx
descripcion: Pagina principal de billetera
componentes_ui: [Button, Skeleton]
tema: STC Gold/Black
estado: migrado
components:
- nombre: WalletCard.tsx
descripcion: Tarjeta de balance de billetera
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Progress, Badge]
iconos: [Wallet]
tema: STC Gold/Black
estado: migrado
- nombre: TransactionList.tsx
descripcion: Lista de transacciones
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Skeleton]
iconos: [ArrowUpRight, ArrowDownLeft]
tema: STC Gold/Black
estado: migrado
- nombre: DepositModal.tsx
descripcion: Modal para depositos
componentes_ui: [Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Input, Label, Button]
iconos: [Loader2]
tema: STC Gold/Black
fix_aplicado: Removido import no utilizado de X
estado: migrado
- nombre: WithdrawModal.tsx
descripcion: Modal para retiros
componentes_ui: [Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Input, Label, Button]
iconos: [Loader2]
tema: STC Gold/Black
estado: migrado
# ---------------------------------------------------------------------------
# MODULO PRODUCTS
# ---------------------------------------------------------------------------
products:
estado: COMPLETADO
ruta: src/modules/products/
pages:
- nombre: ProductsPage.tsx
descripcion: Pagina de marketplace de productos
componentes_ui: [Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Card, CardContent, CardHeader, CardTitle, Input, Button, Badge]
iconos: [Search, SlidersHorizontal, ShoppingCart]
tema: STC Gold/Black
estado: migrado
components:
- nombre: ProductGrid.tsx
descripcion: Grid de productos con skeleton loading
componentes_ui: [Skeleton]
tema: STC Gold/Black
estado: migrado
- nombre: ProductCard.tsx
descripcion: Tarjeta de producto individual
componentes_ui: [Card, CardContent, Badge, Button]
iconos: [Star, ShoppingCart]
tema: STC Gold/Black
estado: migrado
- nombre: CartSidebar.tsx
descripcion: Sidebar de carrito de compras
componentes_ui: [Sheet, SheetContent, SheetHeader, SheetTitle, Button]
iconos: [Trash2, Plus, Minus, Loader2]
tema: STC Gold/Black
fix_aplicado:
- Removido import no utilizado de X
- Agregado tipo explicito a parametro open
estado: migrado
# ---------------------------------------------------------------------------
# MODULO VIP
# ---------------------------------------------------------------------------
vip:
estado: COMPLETADO
ruta: src/modules/vip/
pages:
- nombre: VipPage.tsx
descripcion: Pagina de suscripciones VIP
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Button, Skeleton]
tema: STC Gold/Black
estado: migrado
components:
- nombre: TierCard.tsx
descripcion: Tarjeta de tier VIP (GOLD, PLATINUM, DIAMOND)
componentes_ui: [Card, CardContent, CardFooter, CardHeader, Button, Badge]
iconos: [Loader2, Check]
tema: STC Gold/Black
colores_tier:
GOLD: bg-gold/10, border-gold/50, text-gold
PLATINUM: bg-gray-300/10, border-gray-300/50, text-gray-300
DIAMOND: bg-cyan-400/10, border-cyan-400/50, text-cyan-400
estado: migrado
- nombre: SubscriptionCard.tsx
descripcion: Tarjeta de suscripcion activa
componentes_ui: [Card, CardContent, CardHeader, Button, Badge, Progress]
iconos: []
tema: STC Gold/Black
gradiente: from-gold/20 to-primary-700/50 (header)
estado: migrado
- nombre: ModelAccessList.tsx
descripcion: Lista de acceso a modelos ML por tier
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Badge]
iconos: [Check, Lock]
tema: STC Gold/Black
notas: Tier badges con colores gold/gray/cyan
estado: migrado
# ---------------------------------------------------------------------------
# MODULO INVESTMENT
# ---------------------------------------------------------------------------
investment:
estado: COMPLETADO
ruta: src/modules/investment/
pages:
- nombre: InvestmentPage.tsx
descripcion: Pagina de agentes de inversion
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Skeleton]
tema: STC Gold/Black
gradiente: from-gold/20 to-primary-700/50 (Portfolio Summary)
estado: migrado
components:
- nombre: AgentCard.tsx
descripcion: Tarjeta de agente de inversion
componentes_ui: [Card, CardContent, CardFooter, Button, Badge]
iconos: []
tema: STC Gold/Black
gradiente: from-gold/20 to-primary-700/20 (ORION agent)
estado: migrado
- nombre: AllocationCard.tsx
descripcion: Tarjeta de asignacion de inversion
componentes_ui: [Card, CardContent, CardFooter, CardHeader, Button, Badge]
iconos: []
tema: STC Gold/Black
notas: Badge variants para estados (ACTIVE, PENDING, etc.)
estado: migrado
- nombre: AllocationModal.tsx
descripcion: Modal para gestionar asignaciones
componentes_ui: [Dialog, DialogContent, DialogHeader, DialogTitle, Input, Label, Button, Tabs, TabsList, TabsTrigger, TabsContent]
iconos: [Loader2]
tema: STC Gold/Black
estado: migrado
# ---------------------------------------------------------------------------
# MODULO PREDICTIONS
# ---------------------------------------------------------------------------
predictions:
estado: COMPLETADO
ruta: src/modules/predictions/
pages:
- nombre: PredictionsPage.tsx
descripcion: Marketplace de predicciones ML
componentes_ui: [Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Card, CardContent, CardHeader, CardTitle, Button, Label, Skeleton]
iconos: [History, Loader2]
tema: STC Gold/Black
gradiente: from-gold/20 to-primary-700/50 (Request Section)
estado: migrado
- nombre: PredictionHistoryPage.tsx
descripcion: Pagina de historial y validacion de predicciones
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Button, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, Skeleton]
iconos: []
tema: STC Gold/Black
caracteristicas:
- Filtros por tipo, asset class, status
- Modal de detalle de prediccion
- StatsOverview integrado
fix_aplicado: Removido import no utilizado de X
estado: migrado
components:
- nombre: PackageCard.tsx
descripcion: Tarjeta de paquete de predicciones
componentes_ui: [Card, CardContent, CardFooter, CardHeader, Button, Badge]
iconos: [Loader2]
tema: STC Gold/Black
colores_tipo:
AMD: border-blue-500/50 bg-blue-500/10
RANGE: border-green-500/50 bg-green-500/10
TPSL: border-gold/50 bg-gold/10
ICT_SMC: border-orange-500/50 bg-orange-500/10
STRATEGY_ENSEMBLE: border-cyan-500/50 bg-cyan-500/10
estado: migrado
- nombre: PredictionCard.tsx
descripcion: Tarjeta de prediccion individual
componentes_ui: [Card, CardContent, CardHeader, Badge]
iconos: []
tema: STC Gold/Black
caracteristicas:
- Direction indicator (LONG/SHORT/NEUTRAL)
- Confidence display
- Entry/Target/StopLoss prices
- Outcome section (win/loss/partial)
estado: migrado
- nombre: StatsOverview.tsx
descripcion: Vista de estadisticas de predicciones
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Progress]
iconos: []
tema: STC Gold/Black
caracteristicas:
- Total predictions, win rate, avg P&L
- By type breakdown con progress bars
- By asset class breakdown
- Outcome distribution bar
estado: migrado
# ---------------------------------------------------------------------------
# MODULO RBAC
# ---------------------------------------------------------------------------
rbac:
estado: COMPLETADO
ruta: src/modules/rbac/
components:
- nombre: PermissionGate.tsx
descripcion: Componente de control de acceso por permisos
tipo: utilidad (sin UI)
exports: [RequirePermission, RequirePermissions, RequireRole, PermissionGate]
notas: Componente logico, no requiere tema visual
estado: completo
# ---------------------------------------------------------------------------
# MODULO DASHBOARD
# ---------------------------------------------------------------------------
dashboard:
estado: COMPLETADO
ruta: src/modules/dashboard/
pages:
- nombre: DashboardPage.tsx
descripcion: Pagina principal del dashboard con overview
componentes_ui: [Card, CardContent, CardHeader, CardTitle, Button, Badge, Skeleton]
iconos: [Wallet, Star, Bot, Target, TrendingUp, TrendingDown, ShoppingCart, ArrowRight, LayoutDashboard]
tema: STC Gold/Black
caracteristicas:
- Welcome header con gradiente gold
- Quick stats (balance, VIP, P&L, positions)
- Quick actions links
- Recent activity section
- Market overview placeholder
estado: creado
# ---------------------------------------------------------------------------
# MODULO SETTINGS
# ---------------------------------------------------------------------------
settings:
estado: COMPLETADO
ruta: src/modules/settings/
pages:
- nombre: SettingsPage.tsx
descripcion: Pagina de configuracion de usuario
componentes_ui: [Card, CardContent, CardDescription, CardHeader, CardTitle, Button, Input, Label, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Separator, Avatar, AvatarFallback]
iconos: [User, Shield, Bell, Palette, Save, Loader2, Eye, EyeOff, Check]
tema: STC Gold/Black
caracteristicas:
- Tab Profile (datos personales, avatar)
- Tab Security (cambio password, 2FA, sesiones)
- Tab Preferences (notificaciones, tema)
estado: creado
# =============================================================================
# COMPONENTES GLOBALES
# =============================================================================
componentes_globales:
estado: COMPLETADO
ruta_base: src/
items:
- nombre: components/Layout.tsx
descripcion: Layout principal de la aplicacion
componentes_ui: [Button, Badge, DropdownMenu, Tooltip, Avatar]
iconos: [Moon, Sun, Settings, LogOut, Wallet, ShoppingCart, Star, Bot, Target]
tema: STC Gold/Black
caracteristicas:
- Top navigation con rutas
- Mobile navigation responsive
- User dropdown menu
- Theme toggle
- VIP badge
- Wallet balance display
estado: migrado
- nombre: components/ProtectedRoute.tsx
descripcion: Componentes de rutas protegidas y publicas
exports: [ProtectedRoute, PublicRoute]
tema: STC Gold/Black (spinner)
fix_aplicado: Migrado spinner de purple a gold
estado: migrado
- nombre: routes.tsx
descripcion: Definicion de rutas de la aplicacion
caracteristicas:
- Lazy loading para code splitting
- PageLoader con tema STC
- Pagina 404 con tema STC
- Rutas publicas y protegidas
fix_aplicado:
- Migrado PageLoader de purple a gold
- Migrado 404 page de purple a gold
estado: migrado
- nombre: App.tsx
descripcion: Componente principal de la aplicacion
dependencias: [Layout, AppRoutes, TooltipProvider, QueryClientProvider, Toaster]
tema: STC Gold/Black (toast styles)
estado: completo
# =============================================================================
# CONFIGURACION DE TEMA
# =============================================================================
configuracion_tema:
archivo_tailwind: tailwind.config.js
archivo_css: src/index.css
colores_principales:
gold: "#ffd700"
primary-50: "#f8f5e8"
primary-100: "#eee8cc"
primary-200: "#ddd19c"
primary-300: "#c9b462"
primary-400: "#b89a35"
primary-500: "#9a7d1f"
primary-600: "#7a6118"
primary-700: "#5d4a15"
primary-800: "#423614"
primary-900: "#111827"
primary-950: "#0a0c10"
variables_css:
background: "222.2 47.4% 11.2%"
foreground: "210 40% 98%"
primary: "43 100% 50%"
secondary: "43 80% 45%"
accent: "43 60% 40%"
muted: "217.2 32.6% 17.5%"
border: "217.2 32.6% 25%"
# =============================================================================
# VALIDACION FINAL
# =============================================================================
validacion:
build: PASSED
lint: PASSED
typecheck: PASSED
fecha_validacion: 2026-01-13
resultado_build: "1991 modules transformed in 3.19s"
componentes_migrados_sesion_2:
- investment/AgentCard.tsx
- investment/AllocationCard.tsx
- investment/AllocationModal.tsx
- vip/TierCard.tsx
- vip/SubscriptionCard.tsx
- vip/ModelAccessList.tsx
- predictions/PackageCard.tsx
- predictions/PredictionCard.tsx
- predictions/StatsOverview.tsx
- predictions/PredictionHistoryPage.tsx
componentes_migrados_sesion_3:
- routes.tsx (PageLoader, 404 page)
- components/ProtectedRoute.tsx (spinner)
- Documentacion actualizada con LoginPage, RegisterPage, RBAC, componentes globales
componentes_creados_sesion_4:
- dashboard/DashboardPage.tsx (pagina principal)
- settings/SettingsPage.tsx (configuracion usuario)
- routes.tsx (rutas Dashboard y Settings)
- components/Layout.tsx (Dashboard en navegacion)
- build: "1997 modules transformed in 3.49s"

300
docs/PROXIMA-ACCION.md Normal file
View File

@ -0,0 +1,300 @@
# PROXIMA-ACCION.md - Trading Platform Frontend
**Sistema:** SIMCO v3.8+ | Ciclo CAPVED
**Ultima actualizacion:** 2026-01-13
---
## Estado Actual
### Completado - Frontend Trading Platform
- [x] Migracion de tema STC (Gold/Black)
- [x] 23 componentes UI shadcn/ui migrados
- [x] 20 paginas de modulos creadas/migradas
- [x] Dashboard Page con overview completo
- [x] Settings Page con perfil, seguridad y preferencias
- [x] Configuracion Tailwind CSS con paleta dorada
- [x] Variables CSS para modo dark
- [x] Build validado (1997 modules, 3.49s)
- [x] Documentacion SIMCO actualizada
### Estructura de Modulos
```
src/
├── components/
│ ├── ui/ # 23 componentes shadcn/ui
│ ├── Layout.tsx # Layout principal STC
│ └── ProtectedRoute.tsx # Rutas protegidas STC
├── routes.tsx # PageLoader, 404, rutas STC
├── App.tsx # App principal
└── modules/
├── auth/
│ └── pages/
│ ├── LoginPage.tsx ✓
│ ├── RegisterPage.tsx ✓
│ ├── ForgotPasswordPage.tsx ✓
│ └── ResetPasswordPage.tsx ✓
├── dashboard/ # NUEVO
│ └── pages/
│ └── DashboardPage.tsx ✓
├── settings/ # NUEVO
│ └── pages/
│ └── SettingsPage.tsx ✓
├── wallet/
│ ├── pages/
│ │ └── WalletPage.tsx ✓
│ └── components/
│ ├── WalletCard.tsx ✓
│ ├── TransactionList.tsx ✓
│ ├── DepositModal.tsx ✓
│ └── WithdrawModal.tsx ✓
├── products/
│ ├── pages/
│ │ └── ProductsPage.tsx ✓
│ └── components/
│ ├── ProductGrid.tsx ✓
│ ├── ProductCard.tsx ✓
│ └── CartSidebar.tsx ✓
├── vip/
│ ├── pages/
│ │ └── VipPage.tsx ✓
│ └── components/
│ ├── TierCard.tsx ✓
│ ├── SubscriptionCard.tsx ✓
│ └── ModelAccessList.tsx ✓
├── investment/
│ ├── pages/
│ │ └── InvestmentPage.tsx ✓
│ └── components/
│ ├── AgentCard.tsx ✓
│ ├── AllocationCard.tsx ✓
│ └── AllocationModal.tsx ✓
├── predictions/
│ ├── pages/
│ │ ├── PredictionsPage.tsx ✓
│ │ └── PredictionHistoryPage.tsx ✓
│ └── components/
│ ├── PackageCard.tsx ✓
│ ├── PredictionCard.tsx ✓
│ └── StatsOverview.tsx ✓
└── rbac/
└── components/
└── PermissionGate.tsx ✓ (logico)
```
---
## Proximas Acciones Sugeridas
### Prioridad Alta
1. **Conectar con Backend Real**
- Configurar variables de entorno para APIs
- Implementar autenticacion real con backend
- Conectar hooks con servicios reales
- Modo: @FULL
2. **Implementar datos reales en Dashboard**
- Conectar balance real desde wallet API
- Mostrar predicciones activas reales
- Calcular P&L real
- Modo: @FULL
3. **Implementar funcionalidad Settings**
- Conectar cambio de password con API
- Implementar actualizar perfil
- Guardar preferencias en backend
- Modo: @FULL
### Prioridad Media
4. **Agregar tests unitarios**
- Tests para componentes UI
- Tests para hooks personalizados
- Modo: @FULL
5. **Optimizar bundle size**
- Analizar con rollup-plugin-visualizer
- Implementar code-splitting adicional
- Modo: @ANALYSIS + @FULL
6. **Agregar animaciones y transiciones**
- Framer Motion para transiciones de pagina
- Micro-interacciones en botones y cards
- Modo: @FULL
### Prioridad Baja
7. **PWA y offline support**
- Service worker
- Manifest.json
- Modo: @FULL
8. **Internacionalizacion (i18n)**
- react-intl o i18next
- Traducciones ES/EN
- Modo: @FULL
---
## Rutas Disponibles
| Ruta | Pagina | Protegida |
|------|--------|-----------|
| `/` | Dashboard | Si |
| `/dashboard` | Dashboard | Si |
| `/login` | Login | No |
| `/register` | Register | No |
| `/forgot-password` | Forgot Password | No |
| `/reset-password` | Reset Password | No |
| `/wallet` | Wallet | Si |
| `/products` | Products/Marketplace | Si |
| `/vip` | VIP Subscriptions | Si |
| `/investment` | Investment Agents | Si |
| `/predictions` | Predictions | Si |
| `/predictions/history` | Prediction History | Si |
| `/settings` | User Settings | Si |
---
## Comandos de Validacion
```bash
# Build de produccion
npm run build
# Lint
npm run lint
# Typecheck
npm run typecheck
# Tests (si existen)
npm run test
# Preview de produccion
npm run preview
```
---
## Notas Tecnicas
### Paleta de Colores STC
- **Gold:** #ffd700 (acento principal)
- **Primary-900:** #111827 (fondo principal)
- **Primary-800:** #1f2937 (cards, elementos elevados)
- **Primary-700:** #374151 (bordes, elementos secundarios)
### Patron de Estilos Consistente
```tsx
// Cards
className="bg-primary-800 border-primary-700"
// Textos
className="text-white" // Principal
className="text-gray-400" // Secundario
className="text-gold" // Destacado
// Gradientes destacados
className="bg-gradient-to-r from-gold/20 to-primary-700/50 rounded-xl border border-gold/30"
// Botones
variant="secondary" // Accion principal (gold)
variant="ghost" // Accion secundaria
variant="outline" // Terciaria
// Loading spinners
className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gold"
// Fondos de pagina
className="min-h-screen bg-primary-900"
// Tabs activos
className="data-[state=active]:bg-gold data-[state=active]:text-primary-900"
// Switches
className="data-[state=checked]:bg-gold"
```
### Componentes UI Disponibles
```
button, card, input, label, badge, dialog, select, progress,
skeleton, sheet, tabs, avatar, dropdown-menu, separator, switch,
textarea, tooltip, alert, form, scroll-area, table, checkbox, popover
```
---
## Arquitectura MCP Services (Backend)
### Servicios MCP y Puertos
| Servicio | Puerto | Descripcion | Variable .env |
|----------|--------|-------------|---------------|
| **mcp-auth** | 3095 | Autenticacion, RBAC, Teams | `VITE_AUTH_SERVICE_URL` |
| **mcp-wallet** | 3090 | Wallet virtual, transacciones | `VITE_WALLET_SERVICE_URL` |
| **mcp-products** | 3091 | Marketplace, productos | `VITE_PRODUCTS_SERVICE_URL` |
| **mcp-vip** | 3092 | Suscripciones VIP, tiers | `VITE_VIP_SERVICE_URL` |
| **mcp-investment** | 3093 | Agentes, allocations | `VITE_INVESTMENT_SERVICE_URL` |
| **mcp-predictions** | 3094 | Predicciones ML | `VITE_PREDICTIONS_SERVICE_URL` |
### Cadena de Dependencias
```
mcp-auth (3095) <- Servicio base de autenticacion
└─> mcp-wallet (3090)
├─> mcp-products (3091)
├─> mcp-vip (3092)
│ │
│ └─> mcp-predictions (3094)
└─> mcp-investment (3093)
```
### Comandos Docker
```bash
# Desde trading-platform root
cd /home/isem/workspace-v2/projects/trading-platform
# Crear red (primera vez)
docker network create trading-network
# Levantar servicios MCP
docker-compose -f docker-compose.mcp.yml up -d
# Ver logs
docker-compose -f docker-compose.mcp.yml logs -f
# Verificar estado
docker-compose -f docker-compose.mcp.yml ps
```
### Variables de Entorno (.env)
```bash
# API Gateway
VITE_API_BASE_URL=http://localhost:3000/api
# Servicios MCP (desarrollo)
VITE_AUTH_SERVICE_URL=http://localhost:3095
VITE_WALLET_SERVICE_URL=http://localhost:3090
VITE_PRODUCTS_SERVICE_URL=http://localhost:3091
VITE_VIP_SERVICE_URL=http://localhost:3092
VITE_INVESTMENT_SERVICE_URL=http://localhost:3093
VITE_PREDICTIONS_SERVICE_URL=http://localhost:3094
```
---
## Referencias
- Inventario completo: `docs/MASTER_INVENTORY.yml`
- Configuracion tema: `tailwind.config.js`, `src/index.css`
- Componentes UI: `src/components/ui/`
- Docker MCP: `docker-compose.mcp.yml`
- Inventario Backend: `docs/90-transversal/inventarios/BACKEND_INVENTORY.yml`

View File

@ -0,0 +1,299 @@
# Auditoria: Migracion STC Theme (Gold/Black)
**Sistema:** SIMCO v3.8+ | Ciclo CAPVED
**Proyecto:** trading-platform-frontend
**Fecha:** 2026-01-13
**Estado:** COMPLETADO
---
## 1. Contexto (C)
### 1.1 Objetivo
Migrar el frontend de trading-platform al tema STC (Gold/Black) utilizando componentes shadcn/ui y Radix primitives, importando estilos y temas desde stc-platform-web.
### 1.2 Requisitos Originales
- Tonalidades doradas/azules (NO blanco/negro/gris como colores primarios)
- Integraciones Supabase
- Plataforma SaaS
- Componentes reutilizables y consistentes
### 1.3 Fuente de Referencia
- Proyecto origen: `stc-platform-web`
- Componentes: shadcn/ui con personalizacion STC
---
## 2. Analisis (A)
### 2.1 Componentes UI Necesarios
| Componente | Estado | Accion |
|------------|--------|--------|
| button.tsx | Existia | Migrado |
| card.tsx | Existia | Migrado |
| input.tsx | Existia | Migrado |
| label.tsx | Existia | Migrado |
| badge.tsx | Existia | Migrado |
| dialog.tsx | Existia | Migrado |
| select.tsx | No existia | Creado |
| progress.tsx | Existia | Modificado |
| skeleton.tsx | No existia | Creado |
| sheet.tsx | No existia | Creado |
| tabs.tsx | Existia | Migrado |
| avatar.tsx | Existia | Migrado |
| dropdown-menu.tsx | Existia | Migrado |
| separator.tsx | Existia | Migrado |
| switch.tsx | Existia | Migrado |
| textarea.tsx | Existia | Migrado |
| tooltip.tsx | Existia | Migrado |
| alert.tsx | Existia | Migrado |
| form.tsx | Existia | Migrado |
| scroll-area.tsx | Existia | Migrado |
| table.tsx | Existia | Migrado |
### 2.2 Paginas a Migrar
| Modulo | Pagina | Estado |
|--------|--------|--------|
| auth | ForgotPasswordPage.tsx | Migrado |
| auth | ResetPasswordPage.tsx | Migrado |
| wallet | WalletPage.tsx | Migrado |
| wallet | WalletCard.tsx | Migrado |
| wallet | TransactionList.tsx | Migrado |
| wallet | DepositModal.tsx | Migrado |
| wallet | WithdrawModal.tsx | Migrado |
| products | ProductsPage.tsx | Migrado |
| products | ProductGrid.tsx | Migrado |
| products | ProductCard.tsx | Migrado |
| products | CartSidebar.tsx | Migrado |
| vip | VipPage.tsx | Migrado |
| investment | InvestmentPage.tsx | Migrado |
| predictions | PredictionsPage.tsx | Migrado |
---
## 3. Planeacion (P)
### 3.1 Fases de Ejecucion
**Fase 1: Infraestructura Base**
- [x] Configurar tailwind.config.js con paleta STC
- [x] Configurar index.css con variables CSS
- [x] Actualizar index.html con clase dark
**Fase 2: Componentes UI**
- [x] Migrar 21 componentes shadcn/ui
- [x] Crear skeleton.tsx
- [x] Crear sheet.tsx
- [x] Modificar progress.tsx (indicatorClassName)
**Fase 3: Modulos de Aplicacion**
- [x] Migrar modulo auth
- [x] Migrar modulo wallet
- [x] Migrar modulo products
- [x] Migrar modulo vip
- [x] Migrar modulo investment
- [x] Migrar modulo predictions
**Fase 4: Validacion y Documentacion**
- [x] Validar build
- [x] Crear documentacion SIMCO
---
## 4. Validacion (V)
### 4.1 Errores Corregidos
#### Error 1: TS6133 - Import no utilizado (DepositModal.tsx)
```typescript
// ANTES (ERROR)
import { X, Loader2 } from 'lucide-react';
// DESPUES (CORREGIDO)
import { Loader2 } from 'lucide-react';
```
#### Error 2: TS2307 - Modulo no encontrado (skeleton)
```typescript
// ERROR
Cannot find module '@/components/ui/skeleton'
// SOLUCION
Creado src/components/ui/skeleton.tsx
```
#### Error 3: TS2322 - Prop inexistente (progress)
```typescript
// ERROR
Property 'indicatorClassName' does not exist on type 'ProgressProps'
// SOLUCION
Modificado progress.tsx agregando interface extendida:
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
indicatorClassName?: string;
}
```
#### Error 4: TS6133 - Import no utilizado (CartSidebar.tsx)
```typescript
// ANTES (ERROR)
import { X, Trash2, Plus, Minus, Loader2 } from 'lucide-react';
// DESPUES (CORREGIDO)
import { Trash2, Plus, Minus, Loader2 } from 'lucide-react';
```
#### Error 5: TS2307 - Modulo no encontrado (sheet)
```typescript
// ERROR
Cannot find module '@/components/ui/sheet'
// SOLUCION
Creado src/components/ui/sheet.tsx basado en Radix Dialog
```
#### Error 6: TS7006 - Tipo implicito any
```typescript
// ANTES (ERROR)
onOpenChange={(open) => !open && onClose()}
// DESPUES (CORREGIDO)
onOpenChange={(open: boolean) => !open && onClose()}
```
### 4.2 Build Final
```
npm run build
✓ 1991 modules transformed.
dist/index.html 0.62 kB │ gzip: 0.38 kB
dist/assets/index-CfdqD5v-.css 41.26 kB │ gzip: 8.09 kB
dist/assets/index-BFCqTQrb.js 502.71 kB │ gzip: 163.06 kB
✓ built in 2.85s
```
---
## 5. Ejecucion (E)
### 5.1 Componentes Creados
#### skeleton.tsx
```typescript
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }
```
#### sheet.tsx
- Basado en @radix-ui/react-dialog
- Variantes de posicion: top, bottom, left, right
- Animaciones slide-in/slide-out
- Overlay con blur
### 5.2 Componentes Modificados
#### progress.tsx
```typescript
// Agregado interface extendida
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
indicatorClassName?: string;
}
// Uso del prop en Indicator
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
```
### 5.3 Patron de Tema Aplicado
Todas las paginas siguen el patron consistente:
```typescript
// Contenedor principal
<div className="min-h-screen bg-background p-6">
<div className="max-w-6xl mx-auto space-y-8">
{/* Header con titulo gold */}
<h1 className="text-3xl font-bold text-white">
Titulo <span className="text-gold">Destacado</span>
</h1>
{/* Cards con tema dark */}
<Card className="bg-primary-800 border-primary-700">
<CardContent>
<span className="text-gray-400">Label</span>
<span className="text-white">Valor</span>
<span className="text-gold">Destacado</span>
</CardContent>
</Card>
{/* Gradientes gold para secciones destacadas */}
<div className="bg-gradient-to-r from-gold/20 to-primary-700/50 rounded-xl border border-gold/30">
...
</div>
</div>
</div>
```
---
## 6. Documentacion (D)
### 6.1 Archivos Creados
- `docs/MASTER_INVENTORY.yml` - Inventario completo de componentes
- `docs/auditorias/AUDITORIA-MIGRACION-STC-THEME.md` - Este documento
- `docs/PROXIMA-ACCION.md` - Siguientes pasos
### 6.2 Dependencias npm Agregadas
```json
{
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.511.0",
"tailwind-merge": "^3.3.0"
}
```
---
## 7. Resumen Ejecutivo
| Metrica | Valor |
|---------|-------|
| Componentes UI migrados | 21 |
| Componentes UI creados | 2 (skeleton, sheet) |
| Componentes UI modificados | 1 (progress) |
| Paginas migradas | 14 |
| Errores TypeScript corregidos | 6 |
| Build final | PASSED |
| Tiempo build | 2.85s |
| Modulos transformados | 1991 |
**Estado Final:** COMPLETADO Y VALIDADO

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Trading Platform - Virtual credits trading simulation" />
<title>Trading Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

92
nginx.conf Normal file
View File

@ -0,0 +1,92 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# API Proxies
location /api/wallet/ {
proxy_pass http://mcp-wallet:3090/;
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_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /api/products/ {
proxy_pass http://mcp-products:3091/;
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_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /api/vip/ {
proxy_pass http://mcp-vip:3092/;
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_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /api/investment/ {
proxy_pass http://mcp-investment:3093/;
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_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /api/predictions/ {
proxy_pass http://mcp-predictions:3094/;
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_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

8849
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
package.json Normal file
View File

@ -0,0 +1,73 @@
{
"name": "@trading-platform/frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-query-devtools": "^5.17.0",
"axios": "^1.6.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.2.0",
"lucide-react": "^0.562.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.21.1",
"tailwind-merge": "^3.4.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.2.47",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^23.2.0",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"vite": "^5.0.11",
"vitest": "^1.2.0"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

69
src/App.tsx Normal file
View File

@ -0,0 +1,69 @@
/**
* Main App Component
*/
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toaster } from 'react-hot-toast';
import { Layout } from './components/Layout';
import { AppRoutes } from './routes';
import { TooltipProvider } from '@/components/ui/tooltip';
// Create React Query client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30 * 1000, // 30 seconds
},
mutations: {
retry: 0,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={300}>
<BrowserRouter>
<Layout>
<AppRoutes />
</Layout>
{/* Toast notifications */}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#1f2937',
color: '#fff',
border: '1px solid #374151',
},
success: {
iconTheme: {
primary: '#10b981',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
</BrowserRouter>
</TooltipProvider>
{/* React Query Devtools (dev only) */}
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
);
}
export default App;

71
src/__tests__/setup.ts Normal file
View File

@ -0,0 +1,71 @@
/**
* Vitest Test Setup
*/
import '@testing-library/jest-dom';
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value;
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
// Reset localStorage before each test
beforeEach(() => {
localStorage.clear();
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
}),
});
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = () => {};
unobserve = () => {};
disconnect = () => {};
}
Object.defineProperty(window, 'IntersectionObserver', {
writable: true,
value: MockIntersectionObserver,
});
// Mock ResizeObserver
class MockResizeObserver {
observe = () => {};
unobserve = () => {};
disconnect = () => {};
}
Object.defineProperty(window, 'ResizeObserver', {
writable: true,
value: MockResizeObserver,
});

244
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,244 @@
/**
* Main Layout Component - STC Theme (Gold/Black)
*/
import { FC, ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Moon, Sun, Settings, LogOut, Wallet, Star, Bot, Target, LayoutDashboard, BookOpen, Brain, Zap } from 'lucide-react';
import { useWalletStore } from '../modules/wallet/stores/wallet.store';
import { useVipStore, selectIsVip } from '../modules/vip/stores/vip.store';
import { useUser, useIsAuthenticated, useLogout } from '../modules/auth';
import { useTheme } from '../hooks/useTheme';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
interface LayoutProps {
children: ReactNode;
}
const navItems = [
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/courses', label: 'Cursos', icon: BookOpen },
{ path: '/bots', label: 'Bots', icon: Bot },
{ path: '/agents', label: 'Agentes', icon: Brain },
{ path: '/wallet', label: 'Wallet', icon: Wallet },
{ path: '/wallet/credits', label: 'Créditos', icon: Zap },
{ path: '/predictions', label: 'Señales', icon: Target },
{ path: '/vip', label: 'VIP', icon: Star },
];
// Auth routes that don't need the layout
const authRoutes = ['/login', '/register', '/forgot-password', '/reset-password'];
export const Layout: FC<LayoutProps> = ({ children }) => {
const location = useLocation();
const { wallet } = useWalletStore();
const isVip = useVipStore(selectIsVip);
const user = useUser();
const isAuthenticated = useIsAuthenticated();
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const { isDark, toggleTheme } = useTheme();
// Don't render layout for auth routes
const isAuthRoute = authRoutes.some((route) => location.pathname.startsWith(route));
if (isAuthRoute) {
return <>{children}</>;
}
// Get user initials
const userInitials = user
? (user.firstName?.[0] || '') + (user.lastName?.[0] || '') || user.email[0].toUpperCase()
: 'U';
return (
<div className="min-h-screen bg-background flex flex-col">
{/* Top Navigation */}
<nav className="bg-primary-800 border-b border-primary-700 sticky top-0 z-30">
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center gap-2">
<span className="text-2xl">📈</span>
<span className="text-xl font-bold text-gold">Trading Platform</span>
</Link>
{/* Navigation Links */}
{isAuthenticated && (
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.startsWith(item.path);
return (
<Tooltip key={item.path}>
<TooltipTrigger asChild>
<Link
to={item.path}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 ${
isActive
? 'bg-gold text-primary-900'
: 'text-gray-300 hover:bg-primary-700 hover:text-gold'
}`}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
</TooltipTrigger>
<TooltipContent>
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
)}
{/* Right Side - Theme Toggle, Balance, VIP & User */}
<div className="flex items-center gap-3">
{/* Theme Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="text-gray-300 hover:text-gold hover:bg-primary-700"
>
{isDark ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isDark ? 'Switch to light mode' : 'Switch to dark mode'}</p>
</TooltipContent>
</Tooltip>
{isAuthenticated ? (
<>
{/* VIP Badge */}
{isVip && (
<Badge className="bg-gold/20 text-gold border-gold/50 hover:bg-gold/30">
<Star className="h-3 w-3 mr-1 fill-gold" />
VIP
</Badge>
)}
{/* Balance */}
{wallet && (
<div className="bg-primary-700 px-4 py-2 rounded-lg hidden sm:flex items-center gap-2">
<Wallet className="h-4 w-4 text-gold" />
<span className="text-gray-400 text-sm">Balance:</span>
<span className="text-gold font-bold">
${wallet.balance.toFixed(2)}
</span>
</div>
)}
{/* User Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full p-0">
<Avatar className="h-10 w-10 bg-gold text-primary-900">
<AvatarFallback className="bg-gold text-primary-900 font-bold">
{userInitials}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64 bg-primary-800 border-primary-700" align="end">
<DropdownMenuLabel className="text-white">
<p className="font-medium truncate">
{user?.displayName || user?.email}
</p>
<p className="text-gray-400 text-sm truncate font-normal">
{user?.email}
</p>
</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-primary-700" />
<DropdownMenuItem asChild>
<Link
to="/settings"
className="flex items-center gap-3 text-gray-300 hover:text-gold cursor-pointer"
>
<Settings className="h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-primary-700" />
<DropdownMenuItem
onClick={() => logout({})}
disabled={isLoggingOut}
className="flex items-center gap-3 text-red-400 hover:text-red-300 cursor-pointer focus:text-red-300"
>
<LogOut className="h-4 w-4" />
{isLoggingOut ? 'Logging out...' : 'Logout'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<Button variant="secondary" asChild>
<Link to="/login">Sign In</Link>
</Button>
)}
</div>
</div>
</div>
{/* Mobile Navigation */}
{isAuthenticated && (
<div className="md:hidden border-t border-primary-700 px-4 py-2 overflow-x-auto">
<div className="flex gap-2">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname.startsWith(item.path);
return (
<Link
key={item.path}
to={item.path}
className={`px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap flex items-center gap-1 ${
isActive
? 'bg-gold text-primary-900'
: 'bg-primary-700 text-gray-300'
}`}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</div>
</div>
)}
</nav>
{/* Main Content */}
<main className="flex-1">{children}</main>
{/* Footer */}
<footer className="bg-primary-800 border-t border-primary-700 py-6">
<div className="max-w-7xl mx-auto px-4 text-center text-gray-500 text-sm">
<p>&copy; {new Date().getFullYear()} <span className="text-gold">Trading Platform</span>. Virtual credits for educational purposes only.</p>
<p className="mt-1">Not financial advice. Trade at your own risk.</p>
</div>
</footer>
</div>
);
};

View File

@ -0,0 +1,57 @@
/**
* Protected Route Component
* Redirects to login if not authenticated
*/
import { FC, ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useIsAuthenticated, useAuthLoading } from '../modules/auth';
interface ProtectedRouteProps {
children: ReactNode;
}
export const ProtectedRoute: FC<ProtectedRouteProps> = ({ children }) => {
const isAuthenticated = useIsAuthenticated();
const isLoading = useAuthLoading();
const location = useLocation();
// Show loading spinner while checking auth state - STC Theme (Gold/Black)
if (isLoading) {
return (
<div className="min-h-screen bg-primary-900 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-gold"></div>
</div>
);
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
};
/**
* Public Route Component
* Redirects to dashboard if already authenticated
*/
interface PublicRouteProps {
children: ReactNode;
}
export const PublicRoute: FC<PublicRouteProps> = ({ children }) => {
const isAuthenticated = useIsAuthenticated();
const location = useLocation();
// Get the intended destination from state, default to dashboard
const from = (location.state as { from?: Location })?.from?.pathname || '/dashboard';
// Redirect to dashboard if already authenticated
if (isAuthenticated) {
return <Navigate to={from} replace />;
}
return <>{children}</>;
};

View File

@ -0,0 +1,269 @@
import React, { useState, useEffect } from 'react';
import { Loader2, ExternalLink, AlertCircle, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface NotionContentViewerProps {
pageId?: string;
pageUrl?: string;
useEmbed?: boolean;
theme?: 'light' | 'dark';
className?: string;
onError?: (error: Error) => void;
}
interface NotionBlock {
id: string;
type: string;
content: string;
children?: NotionBlock[];
}
/**
* NotionContentViewer Component
*
* Renders Notion content either via API (for styled content) or embed (for quick integration).
*
* Usage:
* - With API: <NotionContentViewer pageId="abc123" />
* - With Embed: <NotionContentViewer pageUrl="https://notion.so/..." useEmbed />
*/
export const NotionContentViewer: React.FC<NotionContentViewerProps> = ({
pageId,
pageUrl,
useEmbed = false,
theme = 'dark',
className = '',
onError
}) => {
const [content, setContent] = useState<NotionBlock[] | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Construct embed URL from page ID or use provided URL
const getEmbedUrl = () => {
if (pageUrl) {
// Convert notion.so URL to embeddable format
const url = new URL(pageUrl);
if (url.hostname.includes('notion.so') || url.hostname.includes('notion.site')) {
return pageUrl;
}
}
if (pageId) {
// Construct embed URL from page ID
return `https://notion.so/${pageId.replace(/-/g, '')}`;
}
return null;
};
useEffect(() => {
const fetchContent = async () => {
if (useEmbed) {
// For embed mode, just verify URL and stop loading
setIsLoading(false);
return;
}
if (!pageId) {
setError('No page ID provided');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setError(null);
// Call backend API to fetch Notion content
// This requires a backend endpoint that uses @notionhq/client
const response = await fetch(`/api/notion/pages/${pageId}`);
if (!response.ok) {
throw new Error(`Failed to fetch Notion content: ${response.statusText}`);
}
const data = await response.json();
setContent(data.blocks);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load content';
setError(errorMessage);
onError?.(err instanceof Error ? err : new Error(errorMessage));
} finally {
setIsLoading(false);
}
};
fetchContent();
}, [pageId, useEmbed, onError]);
// Render block content (simplified - expand for full Notion block support)
const renderBlock = (block: NotionBlock) => {
switch (block.type) {
case 'paragraph':
return (
<p key={block.id} className="text-primary-300 mb-4 leading-relaxed">
{block.content}
</p>
);
case 'heading_1':
return (
<h1 key={block.id} className="text-2xl font-bold text-white mb-4 mt-6">
{block.content}
</h1>
);
case 'heading_2':
return (
<h2 key={block.id} className="text-xl font-bold text-white mb-3 mt-5">
{block.content}
</h2>
);
case 'heading_3':
return (
<h3 key={block.id} className="text-lg font-semibold text-white mb-2 mt-4">
{block.content}
</h3>
);
case 'bulleted_list_item':
return (
<li key={block.id} className="text-primary-300 ml-4 mb-2">
{block.content}
</li>
);
case 'numbered_list_item':
return (
<li key={block.id} className="text-primary-300 ml-4 mb-2 list-decimal">
{block.content}
</li>
);
case 'code':
return (
<pre key={block.id} className="bg-primary-800 p-4 rounded-lg overflow-x-auto mb-4">
<code className="text-sm text-gold font-mono">{block.content}</code>
</pre>
);
case 'quote':
return (
<blockquote key={block.id} className="border-l-4 border-gold pl-4 italic text-primary-400 mb-4">
{block.content}
</blockquote>
);
case 'divider':
return <hr key={block.id} className="border-primary-700 my-6" />;
case 'callout':
return (
<div key={block.id} className="bg-gold/10 border border-gold/30 rounded-lg p-4 mb-4">
<p className="text-primary-200">{block.content}</p>
</div>
);
default:
return (
<p key={block.id} className="text-primary-300 mb-4">
{block.content}
</p>
);
}
};
// Loading state
if (isLoading) {
return (
<div className={cn('flex items-center justify-center py-12', className)}>
<div className="text-center">
<Loader2 className="h-8 w-8 text-gold animate-spin mx-auto mb-4" />
<p className="text-primary-400">Cargando contenido...</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className={cn('flex items-center justify-center py-12', className)}>
<div className="text-center">
<AlertCircle className="h-12 w-12 text-danger mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Error al cargar contenido</h3>
<p className="text-primary-400 mb-4">{error}</p>
{pageUrl && (
<Button variant="outline" className="btn-outline" asChild>
<a href={pageUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
Abrir en Notion
</a>
</Button>
)}
</div>
</div>
);
}
// Embed mode
if (useEmbed) {
const embedUrl = getEmbedUrl();
if (!embedUrl) {
return (
<div className={cn('flex items-center justify-center py-12', className)}>
<div className="text-center">
<FileText className="h-12 w-12 text-primary-600 mx-auto mb-4" />
<p className="text-primary-400">No se proporcionó URL de Notion</p>
</div>
</div>
);
}
return (
<div className={cn('w-full', className)}>
<iframe
src={embedUrl}
className={cn(
'w-full min-h-[600px] rounded-lg border',
theme === 'dark' ? 'border-primary-700' : 'border-gray-200'
)}
style={{
height: '100%',
minHeight: '600px',
background: theme === 'dark' ? '#111827' : '#ffffff'
}}
title="Notion Content"
allowFullScreen
/>
<div className="flex justify-end mt-2">
<a
href={embedUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary-500 hover:text-gold flex items-center transition-colors"
>
<ExternalLink className="h-3 w-3 mr-1" />
Abrir en Notion
</a>
</div>
</div>
);
}
// API mode - render blocks
if (!content || content.length === 0) {
return (
<div className={cn('flex items-center justify-center py-12', className)}>
<div className="text-center">
<FileText className="h-12 w-12 text-primary-600 mx-auto mb-4" />
<p className="text-primary-400">No hay contenido disponible</p>
</div>
</div>
);
}
return (
<div className={cn(
'prose prose-invert max-w-none',
theme === 'dark' ? 'bg-primary-900' : 'bg-white',
className
)}>
{content.map(renderBlock)}
</div>
);
};
export default NotionContentViewer;

View File

@ -0,0 +1,55 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-primary-700", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 text-primary-400" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,47 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,35 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,47 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
size?: 'default' | 'sm' | 'lg' | 'icon'
asChild?: boolean
}
const buttonVariants = {
variant: {
default: "bg-primary-900 text-gold font-semibold shadow-lg hover:bg-primary-800 hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
destructive: "bg-danger text-white shadow-sm hover:bg-danger/90",
outline: "border border-primary-900 bg-transparent text-primary-900 shadow-sm hover:bg-primary-900 hover:text-gold transition-all duration-200",
secondary: "bg-gold text-primary-900 font-semibold shadow-lg hover:bg-gold/90 hover:shadow-xl transition-all duration-200",
ghost: "hover:bg-primary-100 hover:text-primary-900",
link: "text-primary-900 underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', asChild = false, ...props }, ref) => {
const Comp = asChild ? React.Fragment : "button"
return (
<Comp
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
buttonVariants.variant[variant],
buttonVariants.size[size],
className
)}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,67 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,119 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,197 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,29 @@
// UI Components - Barrel Export
// Based on STC Platform Web design system
// Basic Components
export * from './button'
export * from './input'
export * from './label'
export * from './card'
export * from './badge'
export * from './separator'
export * from './textarea'
// Form Components
export * from './checkbox'
export * from './switch'
export * from './select'
export * from './progress'
// Layout Components
export * from './tabs'
export * from './table'
export * from './scroll-area'
export * from './avatar'
// Overlay Components
export * from './dialog'
export * from './dropdown-menu'
export * from './popover'
export * from './tooltip'

View File

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,23 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,30 @@
import * as React from 'react'
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from '@/lib/utils'
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
indicatorClassName?: string;
}
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
ProgressProps
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,45 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,157 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,29 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: 'horizontal' | 'vertical'
decorative?: boolean
}
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<div
ref={ref}
role={decorative ? 'none' : 'separator'}
aria-orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
)
Separator.displayName = 'Separator'
export { Separator }

140
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

119
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,119 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,23 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }

View File

@ -0,0 +1,27 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

52
src/hooks/useTheme.ts Normal file
View File

@ -0,0 +1,52 @@
/**
* Theme Hook - Manages dark/light mode
*/
import { useState, useEffect } from 'react';
type Theme = 'light' | 'dark';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
// Check localStorage first
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) return stored;
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
// Default to dark for trading platform
return 'dark';
});
useEffect(() => {
const root = window.document.documentElement;
// Remove both classes first
root.classList.remove('light', 'dark');
// Add the current theme class
root.classList.add(theme);
// Store in localStorage
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
const setDarkMode = () => setTheme('dark');
const setLightMode = () => setTheme('light');
return {
theme,
isDark: theme === 'dark',
isLight: theme === 'light',
toggleTheme,
setDarkMode,
setLightMode,
};
}

239
src/index.css Normal file
View File

@ -0,0 +1,239 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 84% 4.9%;
--primary-foreground: 51 100% 50%;
--secondary: 51 100% 50%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 222.2 84% 4.9%;
--primary-foreground: 51 100% 50%;
--secondary: 51 100% 50%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 51 100% 50%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans;
font-feature-settings: "rlig" 1, "calt" 1;
}
html {
scroll-behavior: smooth;
}
}
@layer components {
/* Smart Trading Club Custom Components */
.btn-primary {
@apply bg-primary-900 hover:bg-primary-800 text-gold font-semibold py-3 px-6 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
}
.btn-secondary {
@apply bg-gold hover:bg-gold/90 text-primary-900 font-semibold py-3 px-6 rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl;
}
.btn-outline {
@apply border-2 border-gold text-gold hover:bg-gold hover:text-primary-900 font-semibold py-3 px-6 rounded-lg transition-all duration-200;
}
.btn-ghost {
@apply text-gold hover:bg-gold/10 font-semibold py-3 px-6 rounded-lg transition-all duration-200;
}
.card-stc {
@apply bg-white rounded-xl shadow-lg border border-gray-100 p-6 transition-all duration-200 hover:shadow-xl;
}
.card-dark {
@apply bg-primary-900 rounded-xl shadow-lg border border-primary-800 p-6 transition-all duration-200 hover:shadow-xl;
}
.card-glass {
@apply bg-primary-900/50 backdrop-blur-sm rounded-xl border border-primary-700/50 p-6 transition-all duration-200;
}
.card-premium {
@apply bg-gradient-to-br from-primary-900 to-primary-800 rounded-xl shadow-lg border border-gold/20 p-6 transition-all duration-200 hover:border-gold/40;
}
.metric-positive {
@apply text-success font-semibold;
}
.metric-negative {
@apply text-danger font-semibold;
}
.gradient-bg {
@apply bg-gradient-to-br from-primary-900 via-primary-800 to-primary-700;
}
.gold-gradient {
@apply bg-gradient-to-r from-gold to-yellow-400;
}
.gold-gradient-text {
@apply bg-gradient-to-r from-gold via-gold-400 to-gold-500 bg-clip-text text-transparent;
}
.trading-chart {
@apply rounded-lg border border-gray-200 bg-white shadow-sm;
}
/* Badge variants */
.badge-success {
@apply bg-success/10 text-success px-2 py-1 rounded-full text-xs font-medium;
}
.badge-danger {
@apply bg-danger/10 text-danger px-2 py-1 rounded-full text-xs font-medium;
}
.badge-warning {
@apply bg-warning/10 text-warning px-2 py-1 rounded-full text-xs font-medium;
}
.badge-gold {
@apply bg-gold/10 text-gold px-2 py-1 rounded-full text-xs font-medium;
}
/* Risk level indicators */
.risk-low {
@apply text-success;
}
.risk-medium {
@apply text-warning;
}
.risk-high {
@apply text-danger;
}
/* Course level indicators */
.level-beginner {
@apply bg-success/10 text-success;
}
.level-intermediate {
@apply bg-warning/10 text-warning;
}
.level-advanced {
@apply bg-danger/10 text-danger;
}
/* Stat cards */
.stat-card {
@apply bg-primary-800/50 rounded-lg p-4 border border-primary-700;
}
.stat-value {
@apply text-2xl font-bold text-white;
}
.stat-label {
@apply text-sm text-primary-400;
}
/* Input variants */
.input-dark {
@apply bg-primary-800 border-primary-700 text-white placeholder:text-primary-500 focus:border-gold focus:ring-gold/20;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1f2937;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Font rendering */
:root {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

14
src/main.tsx Normal file
View File

@ -0,0 +1,14 @@
/**
* Application Entry Point
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,262 @@
/**
* Auth Store Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useAuthStore } from '../stores/auth.store';
import type { User, Tenant, Session } from '../types';
// Mock user data
const mockUser: User = {
id: 'user-123',
tenantId: 'tenant-456',
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe',
displayName: 'John Doe',
avatarUrl: null,
phone: null,
status: 'active',
isOwner: true,
emailVerified: true,
emailVerifiedAt: '2024-01-01T00:00:00Z',
phoneVerified: false,
phoneVerifiedAt: null,
mfaEnabled: false,
passwordChangedAt: '2024-01-01T00:00:00Z',
failedLoginAttempts: 0,
lockedUntil: null,
lastLoginAt: '2024-01-10T12:00:00Z',
lastLoginIp: '192.168.1.1',
preferences: {
theme: 'dark',
language: 'en',
notifications: { email: true, push: true, sms: false },
},
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-10T12:00:00Z',
};
const mockTenant: Tenant = {
id: 'tenant-456',
name: 'Test Org',
slug: 'test-org',
status: 'active',
};
const mockSession: Session = {
id: 'session-789',
deviceType: 'desktop',
deviceName: 'Chrome on Windows',
browser: 'Chrome',
browserVersion: '120',
os: 'Windows',
osVersion: '11',
ipAddress: '192.168.1.1',
lastActiveAt: '2024-01-10T12:00:00Z',
expiresAt: '2024-01-17T12:00:00Z',
createdAt: '2024-01-10T12:00:00Z',
};
describe('Auth Store', () => {
beforeEach(() => {
// Reset store to initial state
useAuthStore.getState().reset();
localStorage.clear();
});
describe('Initial State', () => {
it('should have correct initial state', () => {
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.tenant).toBeNull();
expect(state.session).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
});
});
describe('setUser', () => {
it('should set user', () => {
useAuthStore.getState().setUser(mockUser);
expect(useAuthStore.getState().user).toEqual(mockUser);
});
it('should clear user when set to null', () => {
useAuthStore.getState().setUser(mockUser);
useAuthStore.getState().setUser(null);
expect(useAuthStore.getState().user).toBeNull();
});
});
describe('setTenant', () => {
it('should set tenant', () => {
useAuthStore.getState().setTenant(mockTenant);
expect(useAuthStore.getState().tenant).toEqual(mockTenant);
});
});
describe('setSession', () => {
it('should set session', () => {
useAuthStore.getState().setSession(mockSession);
expect(useAuthStore.getState().session).toEqual(mockSession);
});
});
describe('setAuthenticated', () => {
it('should set authenticated state', () => {
useAuthStore.getState().setAuthenticated(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
});
describe('setLoading', () => {
it('should set loading state', () => {
useAuthStore.getState().setLoading(true);
expect(useAuthStore.getState().isLoading).toBe(true);
});
});
describe('setError', () => {
it('should set error', () => {
useAuthStore.getState().setError('Test error');
expect(useAuthStore.getState().error).toBe('Test error');
});
it('should clear error when set to null', () => {
useAuthStore.getState().setError('Test error');
useAuthStore.getState().setError(null);
expect(useAuthStore.getState().error).toBeNull();
});
});
describe('login', () => {
it('should set all auth state and store tokens', () => {
const accessToken = 'access-token-123';
const refreshToken = 'refresh-token-456';
useAuthStore.getState().login(mockUser, mockTenant, mockSession, accessToken, refreshToken);
const state = useAuthStore.getState();
expect(state.user).toEqual(mockUser);
expect(state.tenant).toEqual(mockTenant);
expect(state.session).toEqual(mockSession);
expect(state.isAuthenticated).toBe(true);
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
// Verify tokens stored in localStorage
expect(localStorage.getItem('accessToken')).toBe(accessToken);
expect(localStorage.getItem('refreshToken')).toBe(refreshToken);
expect(localStorage.getItem('tenantId')).toBe(mockTenant.id);
expect(localStorage.getItem('userId')).toBe(mockUser.id);
});
});
describe('logout', () => {
it('should clear all auth state and tokens', () => {
// First login
useAuthStore.getState().login(mockUser, mockTenant, mockSession, 'access', 'refresh');
// Then logout
useAuthStore.getState().logout();
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.tenant).toBeNull();
expect(state.session).toBeNull();
expect(state.isAuthenticated).toBe(false);
// Verify tokens removed from localStorage
expect(localStorage.getItem('accessToken')).toBeNull();
expect(localStorage.getItem('refreshToken')).toBeNull();
expect(localStorage.getItem('tenantId')).toBeNull();
expect(localStorage.getItem('userId')).toBeNull();
});
});
describe('updateUser', () => {
it('should update user properties', () => {
useAuthStore.getState().setUser(mockUser);
useAuthStore.getState().updateUser({
firstName: 'Jane',
displayName: 'Jane Doe',
});
const user = useAuthStore.getState().user;
expect(user?.firstName).toBe('Jane');
expect(user?.displayName).toBe('Jane Doe');
expect(user?.lastName).toBe('Doe'); // Unchanged
expect(user?.email).toBe('test@example.com'); // Unchanged
});
it('should not update if no user exists', () => {
useAuthStore.getState().updateUser({ firstName: 'Jane' });
expect(useAuthStore.getState().user).toBeNull();
});
});
describe('reset', () => {
it('should reset to initial state', () => {
// Set some state
useAuthStore.getState().setUser(mockUser);
useAuthStore.getState().setTenant(mockTenant);
useAuthStore.getState().setAuthenticated(true);
useAuthStore.getState().setError('Some error');
// Reset
useAuthStore.getState().reset();
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.tenant).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.error).toBeNull();
});
});
});
describe('Auth Store Selectors', () => {
beforeEach(() => {
useAuthStore.getState().reset();
});
it('useUser should return user', () => {
useAuthStore.getState().setUser(mockUser);
// Access via selector pattern
const user = useAuthStore.getState().user;
expect(user).toEqual(mockUser);
});
it('useTenant should return tenant', () => {
useAuthStore.getState().setTenant(mockTenant);
const tenant = useAuthStore.getState().tenant;
expect(tenant).toEqual(mockTenant);
});
it('useIsAuthenticated should return authentication state', () => {
expect(useAuthStore.getState().isAuthenticated).toBe(false);
useAuthStore.getState().setAuthenticated(true);
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
});

View File

@ -0,0 +1,247 @@
/**
* Auth Types Tests
*/
import { describe, it, expect } from 'vitest';
import type {
User,
UserStatus,
DeviceType,
LoginInput,
RegisterInput,
AuthTokens,
} from '../types';
describe('Auth Types', () => {
describe('UserStatus', () => {
it('should accept valid user statuses', () => {
const statuses: UserStatus[] = ['pending', 'active', 'suspended', 'banned', 'deleted'];
statuses.forEach((status) => {
const user: Partial<User> = { status };
expect(user.status).toBe(status);
});
});
});
describe('DeviceType', () => {
it('should accept valid device types', () => {
const types: DeviceType[] = ['desktop', 'mobile', 'tablet', 'unknown'];
types.forEach((type) => {
expect(['desktop', 'mobile', 'tablet', 'unknown']).toContain(type);
});
});
});
describe('LoginInput', () => {
it('should have required fields', () => {
const input: LoginInput = {
email: 'test@example.com',
password: 'password123',
};
expect(input.email).toBeDefined();
expect(input.password).toBeDefined();
});
it('should allow optional tenantId', () => {
const input: LoginInput = {
email: 'test@example.com',
password: 'password123',
tenantId: 'tenant-123',
};
expect(input.tenantId).toBe('tenant-123');
});
});
describe('RegisterInput', () => {
it('should have required fields', () => {
const input: RegisterInput = {
email: 'test@example.com',
password: 'SecurePass123',
tenantName: 'My Company',
tenantSlug: 'my-company',
};
expect(input.email).toBeDefined();
expect(input.password).toBeDefined();
expect(input.tenantName).toBeDefined();
expect(input.tenantSlug).toBeDefined();
});
it('should allow optional name fields', () => {
const input: RegisterInput = {
email: 'test@example.com',
password: 'SecurePass123',
firstName: 'John',
lastName: 'Doe',
tenantName: 'My Company',
tenantSlug: 'my-company',
};
expect(input.firstName).toBe('John');
expect(input.lastName).toBe('Doe');
});
});
describe('AuthTokens', () => {
it('should have correct structure', () => {
const tokens: AuthTokens = {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 900,
tokenType: 'Bearer',
};
expect(tokens.accessToken).toBe('access-token');
expect(tokens.refreshToken).toBe('refresh-token');
expect(tokens.expiresIn).toBe(900);
expect(tokens.tokenType).toBe('Bearer');
});
});
});
describe('Input Validation Patterns', () => {
describe('Email Validation', () => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
it('should validate correct emails', () => {
const validEmails = [
'test@example.com',
'user.name@domain.co.uk',
'user+tag@example.org',
'firstname.lastname@company.com',
];
validEmails.forEach((email) => {
expect(emailRegex.test(email)).toBe(true);
});
});
it('should reject invalid emails', () => {
const invalidEmails = [
'invalid-email',
'@nodomain.com',
'noat.com',
'spaces @email.com',
'missing@domain',
];
invalidEmails.forEach((email) => {
expect(emailRegex.test(email)).toBe(false);
});
});
});
describe('Password Validation', () => {
const validatePassword = (password: string) => {
const errors: string[] = [];
if (password.length < 8) {
errors.push('At least 8 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('One uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('One lowercase letter');
}
if (!/\d/.test(password)) {
errors.push('One number');
}
return errors;
};
it('should accept valid passwords', () => {
const validPasswords = [
'SecurePass1',
'MyPassword123',
'Test1234',
'Complex1Password',
];
validPasswords.forEach((password) => {
expect(validatePassword(password)).toHaveLength(0);
});
});
it('should reject short passwords', () => {
const errors = validatePassword('Short1');
expect(errors).toContain('At least 8 characters');
});
it('should reject passwords without uppercase', () => {
const errors = validatePassword('lowercase123');
expect(errors).toContain('One uppercase letter');
});
it('should reject passwords without lowercase', () => {
const errors = validatePassword('UPPERCASE123');
expect(errors).toContain('One lowercase letter');
});
it('should reject passwords without numbers', () => {
const errors = validatePassword('NoNumbers');
expect(errors).toContain('One number');
});
});
describe('Tenant Slug Validation', () => {
const slugRegex = /^[a-z0-9-]+$/;
it('should validate correct slugs', () => {
const validSlugs = [
'my-company',
'company123',
'test-org-2024',
'simple',
];
validSlugs.forEach((slug) => {
expect(slugRegex.test(slug)).toBe(true);
});
});
it('should reject invalid slugs', () => {
const invalidSlugs = [
'My-Company', // uppercase
'has spaces',
'special@chars',
'Under_score',
];
invalidSlugs.forEach((slug) => {
expect(slugRegex.test(slug)).toBe(false);
});
});
});
describe('Slug Generation', () => {
const generateSlug = (name: string): string => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
it('should convert name to slug', () => {
expect(generateSlug('My Company')).toBe('my-company');
expect(generateSlug('Test Organization')).toBe('test-organization');
expect(generateSlug('Company 123')).toBe('company-123');
});
it('should handle special characters', () => {
expect(generateSlug("John's Company")).toBe('johns-company');
expect(generateSlug('Test & Co')).toBe('test--co'); // Double dash from &
});
it('should handle multiple spaces', () => {
expect(generateSlug('Too Many Spaces')).toBe('too-many-spaces');
});
});
});

View File

@ -0,0 +1,6 @@
/**
* Auth Components - Re-exports
*/
// No additional components yet
// Future: SessionList, PasswordStrengthIndicator, etc.

View File

@ -0,0 +1,240 @@
/**
* Auth Hooks
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-hot-toast';
import { authApi } from '../services/auth.api';
import { useAuthStore } from '../stores/auth.store';
import type {
LoginInput,
RegisterInput,
PasswordResetRequestInput,
PasswordResetInput,
ChangePasswordInput,
} from '../types';
// Query keys
export const authKeys = {
all: ['auth'] as const,
sessions: () => [...authKeys.all, 'sessions'] as const,
};
// Login mutation
export function useLogin() {
const navigate = useNavigate();
const { login, setLoading, setError } = useAuthStore();
return useMutation({
mutationFn: (input: LoginInput) => authApi.login(input),
onMutate: () => {
setLoading(true);
setError(null);
},
onSuccess: (data) => {
const { user, tokens, session } = data;
// Get tenant from user (for now, extract from tenantId)
const tenant = {
id: user.tenantId,
name: '', // Will be populated from backend
slug: '',
status: 'active' as const,
};
login(
user,
tenant,
{
id: session.id,
deviceType: session.deviceType,
deviceName: null,
browser: null,
browserVersion: null,
os: null,
osVersion: null,
ipAddress: null,
lastActiveAt: new Date().toISOString(),
expiresAt: session.expiresAt,
createdAt: new Date().toISOString(),
},
tokens.accessToken,
tokens.refreshToken
);
toast.success('Login successful');
navigate('/dashboard');
},
onError: (error: Error & { response?: { data?: { error?: string; code?: string } } }) => {
const message = error.response?.data?.error || error.message || 'Login failed';
setError(message);
toast.error(message);
},
onSettled: () => {
setLoading(false);
},
});
}
// Register mutation
export function useRegister() {
const navigate = useNavigate();
const { setLoading, setError } = useAuthStore();
return useMutation({
mutationFn: (input: RegisterInput) => authApi.register(input),
onMutate: () => {
setLoading(true);
setError(null);
},
onSuccess: () => {
toast.success('Registration successful! Please check your email to verify your account.');
navigate('/login');
},
onError: (error: Error & { response?: { data?: { error?: string } } }) => {
const message = error.response?.data?.error || error.message || 'Registration failed';
setError(message);
toast.error(message);
},
onSettled: () => {
setLoading(false);
},
});
}
// Logout mutation
export function useLogout() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { logout, setLoading } = useAuthStore();
return useMutation({
mutationFn: (params?: { sessionId?: string; logoutAll?: boolean }) =>
authApi.logout(params?.sessionId, params?.logoutAll),
onMutate: () => {
setLoading(true);
},
onSuccess: () => {
logout();
queryClient.clear();
toast.success('Logged out successfully');
navigate('/login');
},
onError: () => {
// Still logout locally even if API fails
logout();
queryClient.clear();
navigate('/login');
},
onSettled: () => {
setLoading(false);
},
});
}
// Request password reset mutation
export function useRequestPasswordReset() {
const { setLoading, setError } = useAuthStore();
return useMutation({
mutationFn: (input: PasswordResetRequestInput) => authApi.requestPasswordReset(input),
onMutate: () => {
setLoading(true);
setError(null);
},
onSuccess: () => {
toast.success('If the email exists, you will receive a password reset link.');
},
onError: () => {
// Show same message to prevent email enumeration
toast.success('If the email exists, you will receive a password reset link.');
},
onSettled: () => {
setLoading(false);
},
});
}
// Reset password mutation
export function useResetPassword() {
const navigate = useNavigate();
const { setLoading, setError } = useAuthStore();
return useMutation({
mutationFn: (input: PasswordResetInput) => authApi.resetPassword(input),
onMutate: () => {
setLoading(true);
setError(null);
},
onSuccess: () => {
toast.success('Password reset successfully. You can now log in with your new password.');
navigate('/login');
},
onError: (error: Error & { response?: { data?: { error?: string } } }) => {
const message = error.response?.data?.error || 'Password reset failed';
setError(message);
toast.error(message);
},
onSettled: () => {
setLoading(false);
},
});
}
// Change password mutation
export function useChangePassword() {
const { setLoading, setError } = useAuthStore();
return useMutation({
mutationFn: (input: ChangePasswordInput) => authApi.changePassword(input),
onMutate: () => {
setLoading(true);
setError(null);
},
onSuccess: () => {
toast.success('Password changed successfully');
},
onError: (error: Error & { response?: { data?: { error?: string } } }) => {
const message = error.response?.data?.error || 'Failed to change password';
setError(message);
toast.error(message);
},
onSettled: () => {
setLoading(false);
},
});
}
// Get user sessions query
export function useSessions() {
return useQuery({
queryKey: authKeys.sessions(),
queryFn: authApi.getSessions,
staleTime: 60 * 1000, // 1 minute
enabled: !!localStorage.getItem('accessToken'),
});
}
// Revoke session mutation
export function useRevokeSession() {
const queryClient = useQueryClient();
const { logout } = useAuthStore();
return useMutation({
mutationFn: (sessionId: string) => authApi.logout(sessionId),
onSuccess: (_, sessionId) => {
// Check if it's the current session
const currentSessionId = localStorage.getItem('sessionId');
if (sessionId === currentSessionId) {
logout();
} else {
queryClient.invalidateQueries({ queryKey: authKeys.sessions() });
toast.success('Session revoked');
}
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to revoke session');
},
});
}

28
src/modules/auth/index.ts Normal file
View File

@ -0,0 +1,28 @@
/**
* Auth Module - Main entry point
*/
// Types
export * from './types';
// Services
export { authApi } from './services/auth.api';
// Stores
export { useAuthStore, useUser, useTenant, useIsAuthenticated, useAuthLoading, useAuthError } from './stores/auth.store';
// Hooks
export {
authKeys,
useLogin,
useRegister,
useLogout,
useRequestPasswordReset,
useResetPassword,
useChangePassword,
useSessions,
useRevokeSession,
} from './hooks/useAuth';
// Pages
export { LoginPage, RegisterPage, ForgotPasswordPage, ResetPasswordPage } from './pages';

View File

@ -0,0 +1,168 @@
/**
* Forgot Password Page - STC Theme (Gold/Black)
*/
import { FC, useState } from 'react';
import { Link } from 'react-router-dom';
import { Loader2, Mail, ArrowLeft } from 'lucide-react';
import { useRequestPasswordReset } from '../hooks/useAuth';
import { useAuthLoading } from '../stores/auth.store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
export const ForgotPasswordPage: FC = () => {
const [email, setEmail] = useState('');
const [tenantId, setTenantId] = useState('');
const [submitted, setSubmitted] = useState(false);
const { mutate: requestReset } = useRequestPasswordReset();
const isLoading = useAuthLoading();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
requestReset(
{ email, tenantId },
{
onSuccess: () => {
setSubmitted(true);
},
onError: () => {
// Still show success to prevent email enumeration
setSubmitted(true);
},
}
);
};
if (submitted) {
return (
<div className="min-h-screen bg-primary-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gold">Trading Platform</h1>
</div>
<Card className="bg-primary-800 border-primary-700 text-center">
<CardContent className="pt-8 pb-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Mail className="w-8 h-8 text-green-400" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Check your email</h2>
<p className="text-gray-400 mb-6">
If an account with that email exists, we've sent you a password reset link.
Please check your inbox and spam folder.
</p>
<Link
to="/login"
className="inline-flex items-center text-gold hover:text-gold/80 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to login
</Link>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-primary-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gold">Trading Platform</h1>
<p className="text-gray-400 mt-2">Reset your password</p>
</div>
{/* Form */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-white">Forgot password?</CardTitle>
<CardDescription className="text-gray-400">
Enter your email address and tenant ID, and we'll send you a link to reset your password.
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{/* Tenant ID Field */}
<div className="space-y-2">
<Label htmlFor="tenantId" className="text-gray-300">
Organization ID
</Label>
<Input
id="tenantId"
type="text"
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500"
placeholder="Your organization ID"
required
disabled={isLoading}
/>
<p className="text-xs text-gray-500">
Contact your administrator if you don't know your organization ID.
</p>
</div>
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email" className="text-gray-300">
Email Address
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500"
placeholder="you@example.com"
required
disabled={isLoading}
/>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={isLoading}
className="w-full"
variant="secondary"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
'Send Reset Link'
)}
</Button>
{/* Back to Login */}
<div className="text-center">
<Link
to="/login"
className="inline-flex items-center text-gold hover:text-gold/80 transition-colors text-sm"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to login
</Link>
</div>
</CardContent>
</form>
</Card>
{/* Footer */}
<p className="text-center text-gray-500 text-sm mt-8">
&copy; {new Date().getFullYear()} Trading Platform. All rights reserved.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,159 @@
/**
* Login Page - STC Theme (Gold/Black)
*/
import { FC, useState } from 'react';
import { Link } from 'react-router-dom';
import { Eye, EyeOff, Loader2 } from 'lucide-react';
import { useLogin } from '../hooks/useAuth';
import { useAuthLoading, useAuthError } from '../stores/auth.store';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
export const LoginPage: FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { mutate: login } = useLogin();
const isLoading = useAuthLoading();
const error = useAuthError();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
login({ email, password });
};
return (
<div className="min-h-screen bg-primary-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gold">Trading Platform</h1>
<p className="text-gray-400 mt-2">Sign in to your account</p>
</div>
{/* Login Form */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-white">Welcome back</CardTitle>
<CardDescription className="text-gray-400">
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{/* Error Alert */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/50 rounded-lg">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email" className="text-gray-300">
Email Address
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500 focus:ring-gold focus:border-gold"
placeholder="you@example.com"
required
disabled={isLoading}
/>
</div>
{/* Password Field */}
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500 focus:ring-gold focus:border-gold pr-10"
placeholder="Enter your password"
required
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gold transition-colors"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* Forgot Password Link */}
<div className="text-right">
<Link
to="/forgot-password"
className="text-sm text-gold hover:text-gold/80 transition-colors"
>
Forgot your password?
</Link>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
{/* Submit Button */}
<Button
type="submit"
disabled={isLoading}
className="w-full"
variant="secondary"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</Button>
{/* Divider */}
<div className="relative w-full">
<Separator className="bg-primary-600" />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary-800 px-2 text-xs text-gray-400">
or
</span>
</div>
{/* Register Link */}
<p className="text-center text-gray-400 text-sm">
Don't have an account?{' '}
<Link to="/register" className="text-gold hover:text-gold/80 transition-colors font-medium">
Create one
</Link>
</p>
</CardFooter>
</form>
</Card>
{/* Footer */}
<p className="text-center text-gray-500 text-sm mt-8">
&copy; {new Date().getFullYear()} Trading Platform. All rights reserved.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,343 @@
/**
* Register Page - STC Theme (Gold/Black)
*/
import { FC, useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Eye, EyeOff, Loader2, Check, Circle } from 'lucide-react';
import { useRegister } from '../hooks/useAuth';
import { useAuthLoading, useAuthError } from '../stores/auth.store';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Checkbox } from '@/components/ui/checkbox';
export const RegisterPage: FC = () => {
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: '',
tenantName: '',
tenantSlug: '',
});
const [showPassword, setShowPassword] = useState(false);
const [acceptTerms, setAcceptTerms] = useState(false);
const { mutate: register } = useRegister();
const isLoading = useAuthLoading();
const error = useAuthError();
// Auto-generate slug from tenant name
const handleTenantNameChange = (name: string) => {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
setFormData((prev) => ({
...prev,
tenantName: name,
tenantSlug: slug,
}));
};
// Password strength validation
const passwordValidation = useMemo(() => {
const password = formData.password;
return {
minLength: password.length >= 8,
hasUppercase: /[A-Z]/.test(password),
hasLowercase: /[a-z]/.test(password),
hasNumber: /\d/.test(password),
};
}, [formData.password]);
const isPasswordValid = Object.values(passwordValidation).every(Boolean);
const passwordsMatch = formData.password === formData.confirmPassword;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!isPasswordValid || !passwordsMatch || !acceptTerms) {
return;
}
register({
email: formData.email,
password: formData.password,
firstName: formData.firstName || undefined,
lastName: formData.lastName || undefined,
tenantName: formData.tenantName,
tenantSlug: formData.tenantSlug,
});
};
const handleChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
};
return (
<div className="min-h-screen bg-primary-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gold">Trading Platform</h1>
<p className="text-gray-400 mt-2">Create your organization account</p>
</div>
{/* Register Form */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-white">Get started</CardTitle>
<CardDescription className="text-gray-400">
Create your account to start trading
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{/* Error Alert */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/50 rounded-lg">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* Organization Name */}
<div className="space-y-2">
<Label htmlFor="tenantName" className="text-gray-300">
Organization Name *
</Label>
<Input
id="tenantName"
type="text"
value={formData.tenantName}
onChange={(e) => handleTenantNameChange(e.target.value)}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500"
placeholder="My Company"
required
disabled={isLoading}
/>
</div>
{/* Organization Slug */}
<div className="space-y-2">
<Label htmlFor="tenantSlug" className="text-gray-300">
Organization URL *
</Label>
<div className="flex">
<span className="inline-flex items-center px-3 bg-primary-600 border border-r-0 border-primary-600 rounded-l-md text-gray-400 text-sm">
app.trading.com/
</span>
<Input
id="tenantSlug"
type="text"
value={formData.tenantSlug}
onChange={handleChange('tenantSlug')}
className="rounded-l-none bg-primary-700 border-primary-600 text-white placeholder:text-gray-500"
placeholder="my-company"
pattern="^[a-z0-9-]+$"
title="Only lowercase letters, numbers, and hyphens"
required
disabled={isLoading}
/>
</div>
</div>
{/* Name Fields */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName" className="text-gray-300">
First Name
</Label>
<Input
id="firstName"
type="text"
value={formData.firstName}
onChange={handleChange('firstName')}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500"
placeholder="John"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName" className="text-gray-300">
Last Name
</Label>
<Input
id="lastName"
type="text"
value={formData.lastName}
onChange={handleChange('lastName')}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500"
placeholder="Doe"
disabled={isLoading}
/>
</div>
</div>
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email" className="text-gray-300">
Email Address *
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={handleChange('email')}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500"
placeholder="you@example.com"
required
disabled={isLoading}
/>
</div>
{/* Password Field */}
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">
Password *
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange('password')}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500 pr-10"
placeholder="Create a strong password"
required
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gold transition-colors"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{/* Password Requirements */}
{formData.password && (
<div className="mt-2 space-y-1">
<p className={`text-xs flex items-center gap-1 ${passwordValidation.minLength ? 'text-green-400' : 'text-gray-400'}`}>
{passwordValidation.minLength ? <Check className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
At least 8 characters
</p>
<p className={`text-xs flex items-center gap-1 ${passwordValidation.hasUppercase ? 'text-green-400' : 'text-gray-400'}`}>
{passwordValidation.hasUppercase ? <Check className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
One uppercase letter
</p>
<p className={`text-xs flex items-center gap-1 ${passwordValidation.hasLowercase ? 'text-green-400' : 'text-gray-400'}`}>
{passwordValidation.hasLowercase ? <Check className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
One lowercase letter
</p>
<p className={`text-xs flex items-center gap-1 ${passwordValidation.hasNumber ? 'text-green-400' : 'text-gray-400'}`}>
{passwordValidation.hasNumber ? <Check className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
One number
</p>
</div>
)}
</div>
{/* Confirm Password Field */}
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-gray-300">
Confirm Password *
</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleChange('confirmPassword')}
className={`bg-primary-700 text-white placeholder:text-gray-500 ${
formData.confirmPassword && !passwordsMatch
? 'border-red-500'
: 'border-primary-600'
}`}
placeholder="Confirm your password"
required
disabled={isLoading}
/>
{formData.confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-400">Passwords do not match</p>
)}
</div>
{/* Terms Checkbox */}
<div className="flex items-start space-x-3">
<Checkbox
id="terms"
checked={acceptTerms}
onCheckedChange={(checked) => setAcceptTerms(checked as boolean)}
disabled={isLoading}
className="mt-1 border-primary-600 data-[state=checked]:bg-gold data-[state=checked]:text-primary-900"
/>
<label htmlFor="terms" className="text-sm text-gray-400 cursor-pointer">
I agree to the{' '}
<Link to="/terms" className="text-gold hover:text-gold/80">
Terms of Service
</Link>{' '}
and{' '}
<Link to="/privacy" className="text-gold hover:text-gold/80">
Privacy Policy
</Link>
</label>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
{/* Submit Button */}
<Button
type="submit"
disabled={isLoading || !isPasswordValid || !passwordsMatch || !acceptTerms}
className="w-full"
variant="secondary"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating account...
</>
) : (
'Create Account'
)}
</Button>
{/* Divider */}
<div className="relative w-full">
<Separator className="bg-primary-600" />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-primary-800 px-2 text-xs text-gray-400">
or
</span>
</div>
{/* Login Link */}
<p className="text-center text-gray-400 text-sm">
Already have an account?{' '}
<Link to="/login" className="text-gold hover:text-gold/80 transition-colors font-medium">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
{/* Footer */}
<p className="text-center text-gray-500 text-sm mt-8">
&copy; {new Date().getFullYear()} Trading Platform. All rights reserved.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,234 @@
/**
* Reset Password Page - STC Theme (Gold/Black)
*/
import { FC, useState, useMemo } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Eye, EyeOff, Loader2, Check, Circle, AlertTriangle, ArrowLeft } from 'lucide-react';
import { useResetPassword } from '../hooks/useAuth';
import { useAuthLoading, useAuthError } from '../stores/auth.store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
export const ResetPasswordPage: FC = () => {
const [searchParams] = useSearchParams();
const token = searchParams.get('token') || '';
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const { mutate: resetPassword } = useResetPassword();
const isLoading = useAuthLoading();
const error = useAuthError();
// Password strength validation
const passwordValidation = useMemo(() => {
return {
minLength: password.length >= 8,
hasUppercase: /[A-Z]/.test(password),
hasLowercase: /[a-z]/.test(password),
hasNumber: /\d/.test(password),
};
}, [password]);
const isPasswordValid = Object.values(passwordValidation).every(Boolean);
const passwordsMatch = password === confirmPassword;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!token) {
return;
}
if (!isPasswordValid || !passwordsMatch) {
return;
}
resetPassword({ token, newPassword: password });
};
if (!token) {
return (
<div className="min-h-screen bg-primary-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gold">Trading Platform</h1>
</div>
<Card className="bg-primary-800 border-primary-700 text-center">
<CardContent className="pt-8 pb-8">
<div className="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Invalid Reset Link</h2>
<p className="text-gray-400 mb-6">
This password reset link is invalid or has expired.
Please request a new one.
</p>
<Button asChild variant="secondary" className="w-full mb-4">
<Link to="/forgot-password">
Request New Reset Link
</Link>
</Button>
<Link
to="/login"
className="inline-flex items-center justify-center text-gold hover:text-gold/80 transition-colors text-sm"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to login
</Link>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-primary-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gold">Trading Platform</h1>
<p className="text-gray-400 mt-2">Create a new password</p>
</div>
{/* Form */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl text-white">Reset your password</CardTitle>
<CardDescription className="text-gray-400">
Enter your new password below
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{/* Error Alert */}
{error && (
<div className="p-4 bg-red-500/10 border border-red-500/50 rounded-lg">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{/* New Password Field */}
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">
New Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500 pr-10"
placeholder="Enter new password"
required
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gold transition-colors"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
{/* Password Requirements */}
{password && (
<div className="mt-2 space-y-1">
<p className={`text-xs flex items-center gap-1 ${passwordValidation.minLength ? 'text-green-400' : 'text-gray-400'}`}>
{passwordValidation.minLength ? <Check className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
At least 8 characters
</p>
<p className={`text-xs flex items-center gap-1 ${passwordValidation.hasUppercase ? 'text-green-400' : 'text-gray-400'}`}>
{passwordValidation.hasUppercase ? <Check className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
One uppercase letter
</p>
<p className={`text-xs flex items-center gap-1 ${passwordValidation.hasLowercase ? 'text-green-400' : 'text-gray-400'}`}>
{passwordValidation.hasLowercase ? <Check className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
One lowercase letter
</p>
<p className={`text-xs flex items-center gap-1 ${passwordValidation.hasNumber ? 'text-green-400' : 'text-gray-400'}`}>
{passwordValidation.hasNumber ? <Check className="h-3 w-3" /> : <Circle className="h-3 w-3" />}
One number
</p>
</div>
)}
</div>
{/* Confirm Password Field */}
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-gray-300">
Confirm Password
</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={`bg-primary-700 text-white placeholder:text-gray-500 ${
confirmPassword && !passwordsMatch
? 'border-red-500'
: 'border-primary-600'
}`}
placeholder="Confirm new password"
required
disabled={isLoading}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-400">Passwords do not match</p>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={isLoading || !isPasswordValid || !passwordsMatch}
className="w-full"
variant="secondary"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Resetting password...
</>
) : (
'Reset Password'
)}
</Button>
{/* Back to Login */}
<div className="text-center">
<Link
to="/login"
className="inline-flex items-center text-gold hover:text-gold/80 transition-colors text-sm"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to login
</Link>
</div>
</CardContent>
</form>
</Card>
{/* Footer */}
<p className="text-center text-gray-500 text-sm mt-8">
&copy; {new Date().getFullYear()} Trading Platform. All rights reserved.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,8 @@
/**
* Auth Pages - Re-exports
*/
export { LoginPage } from './LoginPage';
export { RegisterPage } from './RegisterPage';
export { ForgotPasswordPage } from './ForgotPasswordPage';
export { ResetPasswordPage } from './ResetPasswordPage';

View File

@ -0,0 +1,190 @@
/**
* Auth API Service
*/
import axios from 'axios';
import type {
LoginInput,
LoginResponse,
RegisterInput,
RegisterResponse,
RefreshResponse,
PasswordResetRequestInput,
PasswordResetInput,
ChangePasswordInput,
Session,
} from '../types';
const AUTH_BASE_URL = import.meta.env.VITE_AUTH_SERVICE_URL || 'http://localhost:3095';
// Create separate axios instance for auth (no token interceptors)
const authClient = axios.create({
baseURL: AUTH_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Helper to get device info
function getDeviceInfo() {
const ua = navigator.userAgent;
const isMobile = /iPhone|iPad|iPod|Android/i.test(ua);
const isTablet = /iPad|Android/i.test(ua) && !/Mobile/i.test(ua);
let deviceType: 'desktop' | 'mobile' | 'tablet' | 'unknown' = 'unknown';
if (isTablet) deviceType = 'tablet';
else if (isMobile) deviceType = 'mobile';
else deviceType = 'desktop';
// Extract browser info
let browser = 'Unknown';
let browserVersion = '';
if (ua.includes('Firefox')) {
browser = 'Firefox';
browserVersion = ua.match(/Firefox\/(\d+)/)?.[1] || '';
} else if (ua.includes('Chrome')) {
browser = 'Chrome';
browserVersion = ua.match(/Chrome\/(\d+)/)?.[1] || '';
} else if (ua.includes('Safari')) {
browser = 'Safari';
browserVersion = ua.match(/Version\/(\d+)/)?.[1] || '';
} else if (ua.includes('Edge')) {
browser = 'Edge';
browserVersion = ua.match(/Edg\/(\d+)/)?.[1] || '';
}
// Extract OS info
let os = 'Unknown';
let osVersion = '';
if (ua.includes('Windows')) {
os = 'Windows';
osVersion = ua.match(/Windows NT (\d+\.\d+)/)?.[1] || '';
} else if (ua.includes('Mac OS')) {
os = 'macOS';
osVersion = ua.match(/Mac OS X (\d+[._]\d+)/)?.[1]?.replace('_', '.') || '';
} else if (ua.includes('Linux')) {
os = 'Linux';
} else if (ua.includes('Android')) {
os = 'Android';
osVersion = ua.match(/Android (\d+)/)?.[1] || '';
} else if (ua.includes('iOS')) {
os = 'iOS';
osVersion = ua.match(/OS (\d+)/)?.[1] || '';
}
return {
deviceType,
deviceName: `${browser} on ${os}`,
browser,
browserVersion,
os,
osVersion,
userAgent: ua,
};
}
export const authApi = {
// Login
async login(input: LoginInput): Promise<LoginResponse> {
const { data } = await authClient.post('/api/auth/login', {
...input,
deviceInfo: getDeviceInfo(),
});
return data;
},
// Register
async register(input: RegisterInput): Promise<RegisterResponse> {
const { data } = await authClient.post('/api/auth/register', input);
return data;
},
// Refresh token
async refresh(refreshToken: string): Promise<RefreshResponse> {
const { data } = await authClient.post('/api/auth/refresh', { refreshToken });
return data;
},
// Logout
async logout(sessionId?: string, logoutAll?: boolean): Promise<void> {
const token = localStorage.getItem('accessToken');
const tenantId = localStorage.getItem('tenantId');
const userId = localStorage.getItem('userId');
if (!token || !tenantId || !userId) {
return;
}
await authClient.post(
'/api/auth/logout',
{ sessionId, logoutAll, userId, tenantId },
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
},
// Request password reset
async requestPasswordReset(input: PasswordResetRequestInput): Promise<{ message: string }> {
const { data } = await authClient.post('/api/auth/password-reset/request', input);
return data;
},
// Reset password with token
async resetPassword(input: PasswordResetInput): Promise<{ message: string }> {
const { data } = await authClient.post('/api/auth/password-reset', input);
return data;
},
// Change password (authenticated)
async changePassword(input: ChangePasswordInput): Promise<{ message: string }> {
const token = localStorage.getItem('accessToken');
const tenantId = localStorage.getItem('tenantId');
const userId = localStorage.getItem('userId');
if (!token || !tenantId || !userId) {
throw new Error('Not authenticated');
}
const { data } = await authClient.post(
'/api/auth/password/change',
{ ...input, userId, tenantId },
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return data;
},
// Verify token
async verifyToken(token: string): Promise<{ sub: string; email: string; tenantId: string }> {
const { data } = await authClient.post('/api/auth/verify', { token });
return data;
},
// Get user sessions
async getSessions(): Promise<Session[]> {
const token = localStorage.getItem('accessToken');
const tenantId = localStorage.getItem('tenantId');
const userId = localStorage.getItem('userId');
if (!token || !tenantId || !userId) {
throw new Error('Not authenticated');
}
const { data } = await authClient.get('/api/auth/sessions', {
params: { userId, tenantId },
headers: {
Authorization: `Bearer ${token}`,
},
});
return data;
},
};

View File

@ -0,0 +1,103 @@
/**
* Auth Store - Zustand
*/
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import type { User, Tenant, Session, AuthState } from '../types';
interface AuthActions {
setUser: (user: User | null) => void;
setTenant: (tenant: Tenant | null) => void;
setSession: (session: Session | null) => void;
setAuthenticated: (isAuthenticated: boolean) => void;
setLoading: (isLoading: boolean) => void;
setError: (error: string | null) => void;
login: (user: User, tenant: Tenant, session: Session, accessToken: string, refreshToken: string) => void;
logout: () => void;
updateUser: (updates: Partial<User>) => void;
reset: () => void;
}
const initialState: AuthState = {
user: null,
tenant: null,
session: null,
isAuthenticated: false,
isLoading: false,
error: null,
};
export const useAuthStore = create<AuthState & AuthActions>()(
devtools(
persist(
(set, get) => ({
...initialState,
setUser: (user) => set({ user }),
setTenant: (tenant) => set({ tenant }),
setSession: (session) => set({ session }),
setAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
login: (user, tenant, session, accessToken, refreshToken) => {
// Store tokens in localStorage
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('tenantId', tenant.id);
localStorage.setItem('userId', user.id);
set({
user,
tenant,
session,
isAuthenticated: true,
isLoading: false,
error: null,
});
},
logout: () => {
// Clear tokens from localStorage
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('tenantId');
localStorage.removeItem('userId');
set(initialState);
},
updateUser: (updates) => {
const currentUser = get().user;
if (currentUser) {
set({ user: { ...currentUser, ...updates } });
}
},
reset: () => set(initialState),
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
tenant: state.tenant,
isAuthenticated: state.isAuthenticated,
}),
}
),
{ name: 'auth' }
)
);
// Selector hooks for optimized re-renders
export const useUser = () => useAuthStore((state) => state.user);
export const useTenant = () => useAuthStore((state) => state.tenant);
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
export const useAuthLoading = () => useAuthStore((state) => state.isLoading);
export const useAuthError = () => useAuthStore((state) => state.error);

View File

@ -0,0 +1,156 @@
/**
* Auth Types
*/
// User status enum
export type UserStatus = 'pending' | 'active' | 'suspended' | 'banned' | 'deleted';
// Session status enum
export type SessionStatus = 'active' | 'expired' | 'revoked';
// Device type enum
export type DeviceType = 'desktop' | 'mobile' | 'tablet' | 'unknown';
// User preferences
export interface UserPreferences {
theme: 'light' | 'dark';
language: string;
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
}
// User interface (sanitized, no password)
export interface User {
id: string;
tenantId: string;
email: string;
firstName: string | null;
lastName: string | null;
displayName: string | null;
avatarUrl: string | null;
phone: string | null;
status: UserStatus;
isOwner: boolean;
emailVerified: boolean;
emailVerifiedAt: string | null;
phoneVerified: boolean;
phoneVerifiedAt: string | null;
mfaEnabled: boolean;
passwordChangedAt: string;
failedLoginAttempts: number;
lockedUntil: string | null;
lastLoginAt: string | null;
lastLoginIp: string | null;
preferences: UserPreferences;
createdAt: string;
updatedAt: string;
}
// Tenant interface
export interface Tenant {
id: string;
name: string;
slug: string;
status: 'pending' | 'active' | 'suspended' | 'deleted';
}
// Session interface
export interface Session {
id: string;
deviceType: DeviceType;
deviceName: string | null;
browser: string | null;
browserVersion: string | null;
os: string | null;
osVersion: string | null;
ipAddress: string | null;
lastActiveAt: string;
expiresAt: string;
createdAt: string;
}
// Auth tokens
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: 'Bearer';
}
// Login input
export interface LoginInput {
email: string;
password: string;
tenantId?: string;
}
// Login response
export interface LoginResponse {
user: User;
tokens: AuthTokens;
session: {
id: string;
deviceType: DeviceType;
expiresAt: string;
};
}
// Register input
export interface RegisterInput {
email: string;
password: string;
firstName?: string;
lastName?: string;
tenantName: string;
tenantSlug: string;
}
// Register response
export interface RegisterResponse {
user: User;
tenant: Tenant;
}
// Refresh response
export interface RefreshResponse {
accessToken: string;
expiresIn: number;
}
// Password reset request input
export interface PasswordResetRequestInput {
email: string;
tenantId: string;
}
// Password reset input
export interface PasswordResetInput {
token: string;
newPassword: string;
}
// Change password input
export interface ChangePasswordInput {
currentPassword: string;
newPassword: string;
}
// Auth error response
export interface AuthError {
error: string;
code: string;
details?: Record<string, unknown>;
}
// Auth state for store
export interface AuthState {
user: User | null;
tenant: Tenant | null;
session: Session | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}

View File

@ -0,0 +1,5 @@
/**
* Dashboard Module Exports
*/
export { DashboardPage } from './pages/DashboardPage';

View File

@ -0,0 +1,350 @@
/**
* Dashboard Page - STC Theme (Gold/Black)
* Main landing page after login with overview of all user activities
*/
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { Wallet, Star, Bot, Target, TrendingUp, TrendingDown, ShoppingCart, ArrowRight, ArrowUpRight, ArrowDownLeft } from 'lucide-react';
import { useUser } from '@/modules/auth';
import { useWallet, useTransactions } from '@/modules/wallet/hooks/useWallet';
import { useVipStore, selectIsVip, selectCurrentTier } from '@/modules/vip/stores/vip.store';
import { usePredictions, usePredictionStats } from '@/modules/predictions/hooks/usePredictions';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
export const DashboardPage: FC = () => {
const user = useUser();
const isVip = useVipStore(selectIsVip);
const currentTier = useVipStore(selectCurrentTier);
// Fetch real data
const { data: wallet, isLoading: walletLoading } = useWallet();
const { data: transactions, isLoading: transactionsLoading } = useTransactions({ limit: 5 });
const { data: predictions, isLoading: predictionsLoading } = usePredictions({ status: 'delivered', limit: 10 });
const { data: stats, isLoading: statsLoading } = usePredictionStats();
// Calculate active predictions count
const activePredictionsCount = predictions?.filter(p => p.status === 'delivered').length || 0;
// Calculate today's P&L from stats
const todayPnl = stats?.averagePnlPercent || 0;
const isPnlPositive = todayPnl >= 0;
// Get greeting based on time of day
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
};
const quickActions = [
{ label: 'Deposit Funds', path: '/wallet', icon: Wallet, color: 'text-green-400' },
{ label: 'Browse Products', path: '/products', icon: ShoppingCart, color: 'text-blue-400' },
{ label: 'Get Predictions', path: '/predictions', icon: Target, color: 'text-gold' },
{ label: 'View Agents', path: '/investment', icon: Bot, color: 'text-cyan-400' },
];
// Format transaction for display
const formatTransaction = (tx: { type: string; amount: number; description?: string | null; createdAt: string }) => {
const isCredit = tx.amount > 0;
return {
icon: isCredit ? ArrowDownLeft : ArrowUpRight,
iconColor: isCredit ? 'text-green-400' : 'text-red-400',
bgColor: isCredit ? 'bg-green-500/20' : 'bg-red-500/20',
amount: `${isCredit ? '+' : ''}${tx.amount.toFixed(2)}`,
description: tx.description || tx.type,
time: new Date(tx.createdAt).toLocaleDateString(),
};
};
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-7xl mx-auto space-y-8">
{/* Welcome Header */}
<div className="bg-gradient-to-r from-gold/20 to-primary-700/50 rounded-xl p-6 border border-gold/30">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">
{getGreeting()}, <span className="text-gold">{user?.firstName || user?.displayName || 'Trader'}</span>
</h1>
<p className="text-gray-400 mt-1">
Welcome to your Trading Platform dashboard
</p>
</div>
{isVip && (
<Badge className="bg-gold/20 text-gold border-gold/50 text-lg py-2 px-4">
<Star className="h-4 w-4 mr-2 fill-gold" />
{currentTier} Member
</Badge>
)}
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Wallet Balance */}
<Card className="bg-primary-800 border-primary-700">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm">Wallet Balance</p>
{walletLoading ? (
<Skeleton className="h-8 w-24 mt-1 bg-primary-700" />
) : (
<p className="text-2xl font-bold text-gold mt-1">
${wallet?.balance?.toFixed(2) || '0.00'}
</p>
)}
</div>
<div className="h-12 w-12 rounded-lg bg-gold/20 flex items-center justify-center">
<Wallet className="h-6 w-6 text-gold" />
</div>
</div>
<Link
to="/wallet"
className="text-gold text-sm hover:text-gold/80 mt-3 inline-flex items-center"
>
Manage Wallet <ArrowRight className="h-4 w-4 ml-1" />
</Link>
</CardContent>
</Card>
{/* VIP Status */}
<Card className="bg-primary-800 border-primary-700">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm">VIP Status</p>
<p className={`text-2xl font-bold mt-1 ${isVip ? 'text-gold' : 'text-gray-500'}`}>
{isVip ? currentTier : 'Not Active'}
</p>
</div>
<div className={`h-12 w-12 rounded-lg flex items-center justify-center ${isVip ? 'bg-gold/20' : 'bg-primary-700'}`}>
<Star className={`h-6 w-6 ${isVip ? 'text-gold fill-gold' : 'text-gray-500'}`} />
</div>
</div>
<Link
to="/vip"
className="text-gold text-sm hover:text-gold/80 mt-3 inline-flex items-center"
>
{isVip ? 'View Benefits' : 'Upgrade Now'} <ArrowRight className="h-4 w-4 ml-1" />
</Link>
</CardContent>
</Card>
{/* Win Rate / P&L */}
<Card className="bg-primary-800 border-primary-700">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm">Win Rate</p>
{statsLoading ? (
<Skeleton className="h-8 w-24 mt-1 bg-primary-700" />
) : (
<p className={`text-2xl font-bold mt-1 ${(stats?.winRate || 0) >= 50 ? 'text-green-400' : 'text-red-400'}`}>
{stats?.winRate?.toFixed(1) || '0.0'}%
</p>
)}
</div>
<div className={`h-12 w-12 rounded-lg flex items-center justify-center ${isPnlPositive ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
{isPnlPositive ? (
<TrendingUp className="h-6 w-6 text-green-400" />
) : (
<TrendingDown className="h-6 w-6 text-red-400" />
)}
</div>
</div>
<Link
to="/predictions/history"
className="text-gold text-sm hover:text-gold/80 mt-3 inline-flex items-center"
>
View History <ArrowRight className="h-4 w-4 ml-1" />
</Link>
</CardContent>
</Card>
{/* Active Predictions */}
<Card className="bg-primary-800 border-primary-700">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm">Active Predictions</p>
{predictionsLoading ? (
<Skeleton className="h-8 w-16 mt-1 bg-primary-700" />
) : (
<p className="text-2xl font-bold text-white mt-1">
{activePredictionsCount}
</p>
)}
</div>
<div className="h-12 w-12 rounded-lg bg-cyan-500/20 flex items-center justify-center">
<Target className="h-6 w-6 text-cyan-400" />
</div>
</div>
<Link
to="/predictions"
className="text-gold text-sm hover:text-gold/80 mt-3 inline-flex items-center"
>
Get Predictions <ArrowRight className="h-4 w-4 ml-1" />
</Link>
</CardContent>
</Card>
</div>
{/* Quick Actions & Activity */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Quick Actions */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader>
<CardTitle className="text-white">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{quickActions.map((action) => {
const Icon = action.icon;
return (
<Link
key={action.path}
to={action.path}
className="flex items-center gap-3 p-3 rounded-lg bg-primary-700/50 hover:bg-primary-700 transition-colors"
>
<div className="h-10 w-10 rounded-lg bg-primary-600 flex items-center justify-center">
<Icon className={`h-5 w-5 ${action.color}`} />
</div>
<span className="text-gray-300 font-medium">{action.label}</span>
<ArrowRight className="h-4 w-4 text-gray-500 ml-auto" />
</Link>
);
})}
</CardContent>
</Card>
{/* Recent Activity */}
<Card className="bg-primary-800 border-primary-700 lg:col-span-2">
<CardHeader>
<CardTitle className="text-white">Recent Activity</CardTitle>
</CardHeader>
<CardContent>
{transactionsLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-primary-700/50">
<Skeleton className="h-10 w-10 rounded-lg bg-primary-600" />
<div className="flex-1">
<Skeleton className="h-4 w-32 bg-primary-600" />
<Skeleton className="h-3 w-20 mt-1 bg-primary-600" />
</div>
<Skeleton className="h-4 w-16 bg-primary-600" />
</div>
))}
</div>
) : transactions && transactions.length > 0 ? (
<div className="space-y-3">
{transactions.slice(0, 5).map((tx) => {
const formatted = formatTransaction(tx);
const Icon = formatted.icon;
return (
<div key={tx.id} className="flex items-center gap-3 p-3 rounded-lg bg-primary-700/50">
<div className={`h-10 w-10 rounded-lg ${formatted.bgColor} flex items-center justify-center`}>
<Icon className={`h-5 w-5 ${formatted.iconColor}`} />
</div>
<div className="flex-1">
<p className="text-white text-sm font-medium">{formatted.description}</p>
<p className="text-gray-500 text-xs">{formatted.time}</p>
</div>
<p className={`font-bold ${formatted.iconColor}`}>
${formatted.amount}
</p>
</div>
);
})}
<Link
to="/wallet"
className="text-gold text-sm hover:text-gold/80 inline-flex items-center mt-2"
>
View all transactions <ArrowRight className="h-4 w-4 ml-1" />
</Link>
</div>
) : (
<div className="text-center py-8">
<div className="text-gray-500 text-5xl mb-4">📊</div>
<h3 className="text-lg font-semibold text-white mb-2">No recent activity</h3>
<p className="text-gray-400 text-sm mb-4">
Start trading to see your activity here
</p>
<Button variant="secondary" asChild>
<Link to="/predictions">Get Started</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
{/* Performance Summary */}
{stats && !statsLoading && (stats.totalPredictions > 0) && (
<Card className="bg-primary-800 border-primary-700">
<CardHeader>
<CardTitle className="text-white">Performance Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 rounded-lg bg-primary-700/50 text-center">
<p className="text-gray-400 text-sm">Total Predictions</p>
<p className="text-2xl font-bold text-gold">{stats.totalPredictions}</p>
</div>
<div className="p-4 rounded-lg bg-primary-700/50 text-center">
<p className="text-gray-400 text-sm">Wins</p>
<p className="text-2xl font-bold text-green-400">{stats.winCount}</p>
</div>
<div className="p-4 rounded-lg bg-primary-700/50 text-center">
<p className="text-gray-400 text-sm">Losses</p>
<p className="text-2xl font-bold text-red-400">{stats.lossCount}</p>
</div>
<div className="p-4 rounded-lg bg-primary-700/50 text-center">
<p className="text-gray-400 text-sm">Avg P&L</p>
<p className={`text-2xl font-bold ${stats.averagePnlPercent >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{stats.averagePnlPercent >= 0 ? '+' : ''}{stats.averagePnlPercent.toFixed(2)}%
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Market Overview Placeholder */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white">Market Overview</CardTitle>
<Badge variant="outline" className="text-gray-400">Demo</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ symbol: 'BTC/USD', price: '43,250.00', change: '+2.34%', up: true },
{ symbol: 'ETH/USD', price: '2,285.50', change: '+1.87%', up: true },
{ symbol: 'EUR/USD', price: '1.0892', change: '-0.12%', up: false },
{ symbol: 'XAU/USD', price: '2,024.30', change: '+0.45%', up: true },
].map((item) => (
<div key={item.symbol} className="p-4 rounded-lg bg-primary-700/50">
<p className="text-gray-400 text-sm">{item.symbol}</p>
<p className="text-white font-bold text-lg">{item.price}</p>
<p className={`text-sm flex items-center gap-1 ${item.up ? 'text-green-400' : 'text-red-400'}`}>
{item.up ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{item.change}
</p>
</div>
))}
</div>
<p className="text-gray-500 text-xs mt-4 text-center">
* Market data is for demonstration purposes only
</p>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,385 @@
import React from 'react';
import { Link } from 'react-router-dom';
import {
Clock,
Star,
Users,
BookOpen,
Play,
Award,
ChevronRight
} from 'lucide-react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { Course } from '../types';
interface CourseCardProps {
course: Course;
showProgress?: boolean;
progress?: number;
variant?: 'default' | 'compact' | 'featured';
onEnroll?: (courseId: string) => void;
onContinue?: (courseId: string) => void;
className?: string;
}
export const CourseCard: React.FC<CourseCardProps> = ({
course,
showProgress = false,
progress = 0,
variant = 'default',
onEnroll,
onContinue,
className = ''
}) => {
const getLevelColor = (level: string) => {
switch (level) {
case 'beginner': return 'level-beginner';
case 'intermediate': return 'level-intermediate';
case 'advanced': return 'level-advanced';
case 'expert': return 'bg-primary-900 text-gold';
default: return 'bg-primary-700 text-primary-300';
}
};
const getLevelText = (level: string) => {
switch (level) {
case 'beginner': return 'Principiante';
case 'intermediate': return 'Intermedio';
case 'advanced': return 'Avanzado';
case 'expert': return 'Experto';
default: return level;
}
};
const formatPrice = (price: number) => {
return price === 0 ? 'Gratis' : `$${price.toLocaleString()}`;
};
const isEnrolled = progress > 0 || showProgress;
const isCompleted = progress >= 100;
if (variant === 'compact') {
return (
<div className={cn('group', className)}>
<Card className="h-full card-dark hover:border-gold/30 transition-all duration-300">
<div className="flex">
<div className="relative w-24 h-16 flex-shrink-0 overflow-hidden rounded-l-lg">
<img
src={course.thumbnail_url || '/placeholder-course.jpg'}
alt={course.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
{course.preview_video_url && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
<Play className="w-4 h-4 text-gold" />
</div>
)}
</div>
<div className="flex-1 p-3">
<div className="flex items-start justify-between mb-1">
<h3 className="font-semibold text-sm line-clamp-1 text-white group-hover:text-gold transition-colors">
{course.title}
</h3>
<Badge className={cn('text-xs px-2 py-0.5', getLevelColor(course.level))}>
{getLevelText(course.level)}
</Badge>
</div>
<p className="text-xs text-primary-400 mb-2 line-clamp-1">
{course.short_description || course.description}
</p>
<div className="flex items-center justify-between">
<div className="flex items-center text-xs text-primary-400 space-x-2">
<div className="flex items-center">
<Clock className="w-3 h-3 mr-1" />
{course.estimated_hours}h
</div>
<div className="flex items-center">
<Star className="w-3 h-3 mr-1 fill-current text-gold" />
{course.average_rating.toFixed(1)}
</div>
</div>
<span className="font-bold text-sm text-gold">
{formatPrice(course.price)}
</span>
</div>
{showProgress && (
<div className="mt-2">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-primary-400">Progreso</span>
<span className="text-gold">{progress}%</span>
</div>
<div className="w-full bg-primary-700 rounded-full h-1">
<div
className="bg-gold h-1 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
</div>
</Card>
</div>
);
}
if (variant === 'featured') {
return (
<div className={cn('group', className)}>
<Card className="h-full card-premium hover:shadow-xl transition-all duration-300">
<div className="relative overflow-hidden rounded-t-lg">
<img
src={course.thumbnail_url || '/placeholder-course.jpg'}
alt={course.title}
className="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute top-4 left-4">
<Badge className="bg-gold text-primary-900 font-semibold">
<Award className="w-3 h-3 mr-1" />
Destacado
</Badge>
</div>
<div className="absolute top-4 right-4">
<Badge className={cn('px-2 py-1', getLevelColor(course.level))}>
{getLevelText(course.level)}
</Badge>
</div>
{course.preview_video_url && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="sm" className="btn-secondary rounded-full">
<Play className="w-4 h-4 mr-2" />
Vista previa
</Button>
</div>
)}
{showProgress && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<div className="flex items-center justify-between text-white text-sm mb-2">
<span>Progreso del curso</span>
<span className="text-gold font-semibold">{progress}%</span>
</div>
<div className="w-full bg-white/30 rounded-full h-2">
<div
className="bg-gold h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
<CardHeader className="space-y-3">
<div>
<h3 className="text-xl font-bold line-clamp-2 text-white group-hover:text-gold transition-colors">
{course.title}
</h3>
<p className="text-primary-400 text-sm line-clamp-2 mt-2">
{course.description}
</p>
</div>
{course.instructor && (
<div className="flex items-center space-x-2">
<img
src={course.instructor.avatar_url || '/placeholder-avatar.jpg'}
alt={course.instructor.full_name}
className="w-6 h-6 rounded-full border border-gold/30"
/>
<span className="text-sm text-primary-400">
Por <span className="text-gold">{course.instructor.full_name}</span>
</span>
</div>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-4 gap-4 text-sm">
<div className="text-center stat-card">
<Clock className="w-4 h-4 mx-auto mb-1 text-primary-400" />
<span className="block font-medium text-white">{course.estimated_hours}h</span>
</div>
<div className="text-center stat-card">
<BookOpen className="w-4 h-4 mx-auto mb-1 text-primary-400" />
<span className="block font-medium text-white">{course.total_lessons}</span>
</div>
<div className="text-center stat-card">
<Star className="w-4 h-4 mx-auto mb-1 text-gold fill-current" />
<span className="block font-medium text-white">{course.average_rating.toFixed(1)}</span>
</div>
<div className="text-center stat-card">
<Users className="w-4 h-4 mx-auto mb-1 text-primary-400" />
<span className="block font-medium text-white">{course.total_enrollments}</span>
</div>
</div>
{course.has_certificate && (
<div className="flex items-center text-sm text-primary-400">
<Award className="w-4 h-4 mr-2 text-gold" />
Incluye certificado de finalización
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-primary-700">
<div className="text-2xl font-bold text-gold">
{formatPrice(course.price)}
</div>
<Button
className="btn-secondary group/btn"
onClick={() => {
if (isEnrolled) {
onContinue?.(course.id);
} else {
onEnroll?.(course.id);
}
}}
>
{isCompleted ? 'Revisar' : isEnrolled ? 'Continuar' : 'Inscribirse'}
<ChevronRight className="w-4 h-4 ml-1 group-hover/btn:translate-x-1 transition-transform" />
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
// Default variant
return (
<div className={cn('group', className)}>
<Card className="h-full card-dark hover:border-gold/30 transition-all duration-300">
<div className="relative overflow-hidden rounded-t-lg">
<img
src={course.thumbnail_url || '/placeholder-course.jpg'}
alt={course.title}
className="w-full h-48 object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute top-3 left-3">
<Badge className={cn('px-2 py-1', getLevelColor(course.level))}>
{getLevelText(course.level)}
</Badge>
</div>
<div className="absolute top-3 right-3 space-x-2">
{course.is_featured && (
<Badge className="bg-gold text-primary-900">
Destacado
</Badge>
)}
{isEnrolled && (
<Badge className={isCompleted ? 'badge-success' : 'badge-gold'}>
{isCompleted ? 'Completado' : 'Inscrito'}
</Badge>
)}
</div>
{course.preview_video_url && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
<Button size="sm" className="btn-secondary rounded-full">
<Play className="w-4 h-4 mr-1" />
Preview
</Button>
</div>
)}
</div>
<CardHeader>
<div className="space-y-2">
<h3 className="font-bold text-lg line-clamp-2 text-white group-hover:text-gold transition-colors">
<Link to={`/courses/${course.slug}`}>
{course.title}
</Link>
</h3>
<p className="text-primary-400 text-sm line-clamp-2">
{course.short_description || course.description}
</p>
</div>
{course.instructor && (
<div className="flex items-center space-x-2 mt-2">
<img
src={course.instructor.avatar_url || '/placeholder-avatar.jpg'}
alt={course.instructor.full_name}
className="w-5 h-5 rounded-full border border-primary-600"
/>
<span className="text-sm text-primary-400">
Por {course.instructor.full_name}
</span>
</div>
)}
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-primary-400">
<div className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
{course.estimated_hours}h
</div>
<div className="flex items-center">
<BookOpen className="w-4 h-4 mr-1" />
{course.total_lessons} lecciones
</div>
<div className="flex items-center">
<Star className="w-4 h-4 mr-1 fill-current text-gold" />
{course.average_rating.toFixed(1)}
</div>
<div className="flex items-center">
<Users className="w-4 h-4 mr-1" />
{course.total_enrollments}
</div>
</div>
{showProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-primary-400">Progreso</span>
<span className="font-medium text-gold">{progress}%</span>
</div>
<div className="w-full bg-primary-700 rounded-full h-2">
<div
className="bg-gold h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-primary-700">
<div className="text-xl font-bold text-gold">
{formatPrice(course.price)}
</div>
<Button
size="sm"
className={isEnrolled ? 'btn-outline' : 'btn-secondary'}
onClick={() => {
if (isEnrolled) {
onContinue?.(course.id);
} else {
onEnroll?.(course.id);
}
}}
>
{isCompleted ? 'Revisar' : isEnrolled ? 'Continuar' : 'Inscribirse'}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
export default CourseCard;

View File

@ -0,0 +1,2 @@
export { CourseCard } from './CourseCard';
export { default as CourseCardDefault } from './CourseCard';

View File

@ -0,0 +1,171 @@
/**
* Education Hooks
* TanStack Query hooks for courses, lessons, and enrollments
*/
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { educationApi, type CourseFilters } from '../services/education.api';
// Query keys
export const educationKeys = {
all: ['education'] as const,
courses: () => [...educationKeys.all, 'courses'] as const,
courseList: (filters?: CourseFilters) => [...educationKeys.courses(), 'list', filters] as const,
courseFeatured: () => [...educationKeys.courses(), 'featured'] as const,
courseDetail: (slug: string) => [...educationKeys.courses(), 'detail', slug] as const,
courseModules: (courseId: string) => [...educationKeys.courses(), 'modules', courseId] as const,
lessons: () => [...educationKeys.all, 'lessons'] as const,
lessonDetail: (lessonId: string) => [...educationKeys.lessons(), lessonId] as const,
enrollments: () => [...educationKeys.all, 'enrollments'] as const,
myEnrollments: () => [...educationKeys.enrollments(), 'me'] as const,
courseEnrollment: (courseId: string) => [...educationKeys.enrollments(), 'course', courseId] as const,
instructors: () => [...educationKeys.all, 'instructors'] as const,
instructorDetail: (id: string) => [...educationKeys.instructors(), id] as const,
notion: () => [...educationKeys.all, 'notion'] as const,
notionPage: (pageId: string) => [...educationKeys.notion(), pageId] as const,
};
// ============ COURSES ============
// Get courses with filters
export function useCourses(filters?: CourseFilters) {
return useQuery({
queryKey: educationKeys.courseList(filters),
queryFn: () => educationApi.getCourses(filters),
placeholderData: keepPreviousData,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Get featured courses
export function useFeaturedCourses(limit = 6) {
return useQuery({
queryKey: educationKeys.courseFeatured(),
queryFn: () => educationApi.getFeaturedCourses(limit),
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
// Get single course by slug
export function useCourse(slug: string) {
return useQuery({
queryKey: educationKeys.courseDetail(slug),
queryFn: () => educationApi.getCourseBySlug(slug),
enabled: !!slug,
staleTime: 5 * 60 * 1000,
});
}
// Get course modules
export function useCourseModules(courseId: string) {
return useQuery({
queryKey: educationKeys.courseModules(courseId),
queryFn: () => educationApi.getCourseModules(courseId),
enabled: !!courseId,
staleTime: 5 * 60 * 1000,
});
}
// ============ LESSONS ============
// Get lesson detail
export function useLesson(lessonId: string) {
return useQuery({
queryKey: educationKeys.lessonDetail(lessonId),
queryFn: () => educationApi.getLesson(lessonId),
enabled: !!lessonId,
staleTime: 5 * 60 * 1000,
});
}
// ============ ENROLLMENTS ============
// Get user's enrollments
export function useMyEnrollments() {
return useQuery({
queryKey: educationKeys.myEnrollments(),
queryFn: educationApi.getMyEnrollments,
staleTime: 60 * 1000, // 1 minute
});
}
// Get enrollment for specific course
export function useCourseEnrollment(courseId: string) {
return useQuery({
queryKey: educationKeys.courseEnrollment(courseId),
queryFn: () => educationApi.getCourseEnrollment(courseId),
enabled: !!courseId,
staleTime: 60 * 1000,
});
}
// Enroll in course mutation
export function useEnrollInCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (courseId: string) => educationApi.enrollInCourse(courseId),
onSuccess: (enrollment) => {
queryClient.invalidateQueries({ queryKey: educationKeys.enrollments() });
queryClient.invalidateQueries({ queryKey: educationKeys.courseEnrollment(enrollment.course_id) });
toast.success('Successfully enrolled in course!');
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to enroll in course');
},
});
}
// Complete lesson mutation
export function useCompleteLesson() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (lessonId: string) => educationApi.completeLesson(lessonId),
onSuccess: (result, lessonId) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: educationKeys.enrollments() });
queryClient.invalidateQueries({ queryKey: educationKeys.lessonDetail(lessonId) });
if (result.completed) {
toast.success('Lesson completed! Great job!');
}
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to mark lesson as complete');
},
});
}
// Update lesson progress mutation
export function useUpdateLessonProgress() {
return useMutation({
mutationFn: ({ lessonId, progress }: { lessonId: string; progress: number }) =>
educationApi.updateLessonProgress(lessonId, progress),
});
}
// ============ INSTRUCTORS ============
// Get instructor detail
export function useInstructor(instructorId: string) {
return useQuery({
queryKey: educationKeys.instructorDetail(instructorId),
queryFn: () => educationApi.getInstructor(instructorId),
enabled: !!instructorId,
staleTime: 30 * 60 * 1000, // 30 minutes
});
}
// ============ NOTION CONTENT ============
// Get Notion page content
export function useNotionContent(pageId: string) {
return useQuery({
queryKey: educationKeys.notionPage(pageId),
queryFn: () => educationApi.getNotionContent(pageId),
enabled: !!pageId,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}

View File

@ -0,0 +1,29 @@
/**
* Education Module Exports
*/
// Types
export * from './types';
// API Service
export { educationApi, type CourseFilters, type PaginatedResponse, type NotionBlock } from './services/education.api';
// Hooks
export {
educationKeys,
useCourses,
useFeaturedCourses,
useCourse,
useCourseModules,
useLesson,
useMyEnrollments,
useCourseEnrollment,
useEnrollInCourse,
useCompleteLesson,
useUpdateLessonProgress,
useInstructor,
useNotionContent,
} from './hooks/useEducation';
// Components
export { CourseCard } from './components/CourseCard';

View File

@ -0,0 +1,619 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
Clock,
Star,
Users,
BookOpen,
Play,
Award,
ChevronLeft,
ChevronRight,
CheckCircle,
Lock,
FileText
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { NotionContentViewer } from '@/components/shared/NotionContentViewer';
import { cn } from '@/lib/utils';
import type { Course, Module, Lesson } from '../types';
// Mock data - replace with actual API call
const mockCourse: Course & { modules: Module[] } = {
id: '1',
slug: 'trading-fundamentals',
title: 'Fundamentos del Trading',
description: 'Aprende los conceptos básicos del trading desde cero. Este curso te proporcionará una base sólida para comenzar tu carrera como trader. Cubriremos análisis técnico, gestión de riesgo, psicología del trading y mucho más.',
short_description: 'Conceptos básicos para comenzar en el trading',
thumbnail_url: '/courses/trading-fundamentals.jpg',
preview_video_url: 'https://youtube.com/embed/example',
level: 'beginner',
price: 0,
estimated_hours: 10,
total_lessons: 24,
average_rating: 4.8,
total_enrollments: 1250,
is_featured: true,
has_certificate: true,
instructor: {
id: 'inst-1',
full_name: 'Carlos Martínez',
avatar_url: '/instructors/carlos.jpg',
bio: 'Trader profesional con más de 10 años de experiencia en los mercados financieros. Especialista en análisis técnico y gestión de riesgo.'
},
modules: [
{
id: 'm1',
course_id: '1',
title: 'Introducción al Trading',
description: 'Conceptos fundamentales del trading',
order: 1,
lessons: [
{
id: 'l1',
course_id: '1',
module_id: 'm1',
title: '¿Qué es el Trading?',
description: 'Introducción al mundo del trading',
content_type: 'notion',
notion_url: 'https://notion.so/trading-intro',
duration_minutes: 15,
order: 1
},
{
id: 'l2',
course_id: '1',
module_id: 'm1',
title: 'Tipos de Mercados Financieros',
description: 'Forex, acciones, criptomonedas y más',
content_type: 'video',
video_url: 'https://youtube.com/embed/markets',
duration_minutes: 20,
order: 2
},
{
id: 'l3',
course_id: '1',
module_id: 'm1',
title: 'Quiz: Conceptos Básicos',
description: 'Evalúa tu comprensión',
content_type: 'quiz',
duration_minutes: 10,
order: 3
}
]
},
{
id: 'm2',
course_id: '1',
title: 'Análisis Técnico Básico',
description: 'Aprende a leer gráficos',
order: 2,
lessons: [
{
id: 'l4',
course_id: '1',
module_id: 'm2',
title: 'Tipos de Gráficos',
description: 'Velas, líneas y barras',
content_type: 'notion',
notion_url: 'https://notion.so/chart-types',
duration_minutes: 25,
order: 1
},
{
id: 'l5',
course_id: '1',
module_id: 'm2',
title: 'Soportes y Resistencias',
description: 'Niveles clave del precio',
content_type: 'notion',
notion_url: 'https://notion.so/support-resistance',
duration_minutes: 30,
order: 2
},
{
id: 'l6',
course_id: '1',
module_id: 'm2',
title: 'Tendencias y Patrones',
description: 'Identificando direccionalidad',
content_type: 'video',
video_url: 'https://youtube.com/embed/trends',
duration_minutes: 35,
order: 3
}
]
},
{
id: 'm3',
course_id: '1',
title: 'Gestión de Riesgo',
description: 'Protege tu capital',
order: 3,
lessons: [
{
id: 'l7',
course_id: '1',
module_id: 'm3',
title: 'Principios de Gestión de Riesgo',
description: 'La base del trading exitoso',
content_type: 'notion',
notion_url: 'https://notion.so/risk-management',
duration_minutes: 20,
order: 1
},
{
id: 'l8',
course_id: '1',
module_id: 'm3',
title: 'Calculando el Tamaño de Posición',
description: 'Cuánto arriesgar por operación',
content_type: 'notion',
notion_url: 'https://notion.so/position-sizing',
duration_minutes: 25,
order: 2
}
]
}
]
};
// Mock enrollment data
const mockEnrollment = {
isEnrolled: true,
progress: 35,
completedLessons: ['l1', 'l2', 'l3'],
currentLesson: 'l4'
};
const CourseDetailPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
const [showContent, setShowContent] = useState(false);
// TODO: Replace mock with API call using slug
// const { data: course } = useCourse(slug);
const course = slug ? mockCourse : mockCourse; // Uses slug to silence lint, will be replaced with API
const enrollment = mockEnrollment;
const getLevelColor = (level: string) => {
switch (level) {
case 'beginner': return 'level-beginner';
case 'intermediate': return 'level-intermediate';
case 'advanced': return 'level-advanced';
case 'expert': return 'bg-primary-900 text-gold';
default: return 'bg-primary-700 text-primary-300';
}
};
const getLevelText = (level: string) => {
switch (level) {
case 'beginner': return 'Principiante';
case 'intermediate': return 'Intermedio';
case 'advanced': return 'Avanzado';
case 'expert': return 'Experto';
default: return level;
}
};
const isLessonCompleted = (lessonId: string) => {
return enrollment.completedLessons.includes(lessonId);
};
const isLessonLocked = (_lesson: Lesson, moduleIndex: number, lessonIndex: number) => {
if (!enrollment.isEnrolled) return true;
// First lesson is always unlocked, others require previous completion
if (moduleIndex === 0 && lessonIndex === 0) return false;
// Check if previous lesson is completed
const prevLessons = course.modules
.slice(0, moduleIndex + 1)
.flatMap(m => m.lessons)
.slice(0, -1);
const lastPrevLesson = prevLessons[prevLessons.length - 1];
return lastPrevLesson && !isLessonCompleted(lastPrevLesson.id);
};
const handleStartLesson = (lesson: Lesson) => {
setActiveLesson(lesson);
setShowContent(true);
};
const getContentIcon = (type: string) => {
switch (type) {
case 'video': return <Play className="h-4 w-4" />;
case 'quiz': return <FileText className="h-4 w-4" />;
default: return <BookOpen className="h-4 w-4" />;
}
};
// Show lesson content view
if (showContent && activeLesson) {
return (
<div className="min-h-screen bg-primary-900">
{/* Top Bar */}
<div className="bg-primary-800 border-b border-primary-700 py-3 px-4">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<button
onClick={() => setShowContent(false)}
className="flex items-center text-primary-400 hover:text-gold transition-colors"
>
<ChevronLeft className="h-5 w-5 mr-1" />
Volver al curso
</button>
<div className="text-sm text-primary-400">
{course.title}
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-primary-400">
Progreso: {enrollment.progress}%
</span>
</div>
</div>
</div>
<div className="flex">
{/* Sidebar with lessons */}
<aside className="w-80 bg-primary-800 border-r border-primary-700 min-h-[calc(100vh-57px)] hidden lg:block">
<div className="p-4">
<h3 className="font-semibold text-white mb-4">Contenido del curso</h3>
<Accordion type="multiple" className="space-y-2">
{course.modules.map((module, moduleIndex) => (
<AccordionItem
key={module.id}
value={module.id}
className="border border-primary-700 rounded-lg overflow-hidden"
>
<AccordionTrigger className="px-4 py-3 hover:bg-primary-700/50 text-left">
<div>
<div className="font-medium text-white text-sm">{module.title}</div>
<div className="text-xs text-primary-500 mt-1">
{module.lessons.length} lecciones
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-0 pb-0">
{module.lessons.map((lesson, lessonIndex) => {
const isActive = activeLesson?.id === lesson.id;
const isCompleted = isLessonCompleted(lesson.id);
const isLocked = isLessonLocked(lesson, moduleIndex, lessonIndex);
return (
<button
key={lesson.id}
onClick={() => !isLocked && handleStartLesson(lesson)}
disabled={isLocked}
className={cn(
'w-full px-4 py-3 flex items-center text-left transition-colors',
isActive && 'bg-gold/10 border-l-2 border-gold',
!isActive && !isLocked && 'hover:bg-primary-700/50',
isLocked && 'opacity-50 cursor-not-allowed'
)}
>
<div className="mr-3">
{isCompleted ? (
<CheckCircle className="h-4 w-4 text-success" />
) : isLocked ? (
<Lock className="h-4 w-4 text-primary-500" />
) : (
getContentIcon(lesson.content_type)
)}
</div>
<div className="flex-1 min-w-0">
<div className={cn(
'text-sm truncate',
isActive ? 'text-gold' : 'text-primary-300'
)}>
{lesson.title}
</div>
<div className="text-xs text-primary-500">
{lesson.duration_minutes} min
</div>
</div>
</button>
);
})}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 p-6">
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<Badge className={cn('mb-2', getLevelColor(course.level))}>
{activeLesson.content_type === 'video' ? 'Video' :
activeLesson.content_type === 'quiz' ? 'Quiz' : 'Lectura'}
</Badge>
<h1 className="text-2xl font-bold text-white mb-2">{activeLesson.title}</h1>
<p className="text-primary-400">{activeLesson.description}</p>
</div>
{/* Content based on type */}
{activeLesson.content_type === 'video' && activeLesson.video_url && (
<div className="aspect-video rounded-lg overflow-hidden bg-primary-800 mb-6">
<iframe
src={activeLesson.video_url}
className="w-full h-full"
title={activeLesson.title}
allowFullScreen
/>
</div>
)}
{activeLesson.content_type === 'notion' && activeLesson.notion_url && (
<div className="card-dark p-6 mb-6">
<NotionContentViewer
pageUrl={activeLesson.notion_url}
useEmbed
theme="dark"
/>
</div>
)}
{activeLesson.content_type === 'quiz' && (
<div className="card-dark p-6 mb-6 text-center">
<FileText className="h-12 w-12 text-gold mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">
Quiz: {activeLesson.title}
</h3>
<p className="text-primary-400 mb-4">
Pon a prueba tus conocimientos con este quiz interactivo.
</p>
<Button className="btn-secondary">
Iniciar Quiz
</Button>
</div>
)}
{/* Navigation */}
<div className="flex justify-between items-center pt-6 border-t border-primary-700">
<Button variant="outline" className="btn-outline">
<ChevronLeft className="h-4 w-4 mr-2" />
Lección Anterior
</Button>
<Button className="btn-secondary">
Completar y Continuar
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
</div>
</div>
</main>
</div>
</div>
);
}
// Course overview
return (
<div className="min-h-screen bg-primary-900">
{/* Hero Section */}
<div className="gradient-bg py-12 px-4">
<div className="max-w-7xl mx-auto">
<Link
to="/courses"
className="inline-flex items-center text-primary-400 hover:text-gold mb-6 transition-colors"
>
<ChevronLeft className="h-4 w-4 mr-1" />
Volver a cursos
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Course Info */}
<div className="lg:col-span-2">
<div className="flex items-center space-x-3 mb-4">
<Badge className={cn('px-3 py-1', getLevelColor(course.level))}>
{getLevelText(course.level)}
</Badge>
{course.is_featured && (
<Badge className="bg-gold text-primary-900">
<Award className="h-3 w-3 mr-1" />
Destacado
</Badge>
)}
</div>
<h1 className="text-3xl font-bold text-white mb-4">{course.title}</h1>
<p className="text-primary-300 text-lg mb-6">{course.description}</p>
{/* Stats */}
<div className="flex flex-wrap gap-6 mb-6">
<div className="flex items-center text-primary-400">
<Clock className="h-5 w-5 mr-2" />
{course.estimated_hours} horas
</div>
<div className="flex items-center text-primary-400">
<BookOpen className="h-5 w-5 mr-2" />
{course.total_lessons} lecciones
</div>
<div className="flex items-center text-gold">
<Star className="h-5 w-5 mr-2 fill-current" />
{course.average_rating.toFixed(1)}
</div>
<div className="flex items-center text-primary-400">
<Users className="h-5 w-5 mr-2" />
{course.total_enrollments.toLocaleString()} estudiantes
</div>
</div>
{/* Instructor */}
{course.instructor && (
<div className="flex items-center space-x-4 p-4 bg-primary-800/50 rounded-lg">
<img
src={course.instructor.avatar_url || '/placeholder-avatar.jpg'}
alt={course.instructor.full_name}
className="w-12 h-12 rounded-full border-2 border-gold"
/>
<div>
<div className="text-sm text-primary-400">Instructor</div>
<div className="font-semibold text-white">{course.instructor.full_name}</div>
{course.instructor.bio && (
<p className="text-sm text-primary-400 mt-1 line-clamp-2">
{course.instructor.bio}
</p>
)}
</div>
</div>
)}
</div>
{/* Enrollment Card */}
<div className="lg:col-span-1">
<div className="card-premium p-6 sticky top-6">
{course.thumbnail_url && (
<div className="relative rounded-lg overflow-hidden mb-4">
<img
src={course.thumbnail_url}
alt={course.title}
className="w-full aspect-video object-cover"
/>
{course.preview_video_url && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<Button className="btn-secondary rounded-full">
<Play className="h-5 w-5 mr-2" />
Vista previa
</Button>
</div>
)}
</div>
)}
<div className="text-center mb-4">
<div className="text-3xl font-bold text-gold mb-1">
{course.price === 0 ? 'Gratis' : `$${course.price}`}
</div>
{course.has_certificate && (
<div className="flex items-center justify-center text-sm text-primary-400">
<Award className="h-4 w-4 mr-1 text-gold" />
Incluye certificado
</div>
)}
</div>
{enrollment.isEnrolled ? (
<>
<div className="mb-4">
<div className="flex justify-between text-sm mb-2">
<span className="text-primary-400">Tu progreso</span>
<span className="text-gold font-semibold">{enrollment.progress}%</span>
</div>
<Progress value={enrollment.progress} className="h-2" />
</div>
<Button
className="w-full btn-secondary mb-3"
onClick={() => {
const currentLesson = course.modules
.flatMap(m => m.lessons)
.find(l => l.id === enrollment.currentLesson);
if (currentLesson) handleStartLesson(currentLesson);
}}
>
Continuar Aprendiendo
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
</>
) : (
<Button className="w-full btn-secondary mb-3">
{course.price === 0 ? 'Inscribirse Gratis' : 'Comprar Curso'}
</Button>
)}
<p className="text-xs text-primary-500 text-center">
Acceso de por vida Actualizaciones incluidas
</p>
</div>
</div>
</div>
</div>
</div>
{/* Course Content */}
<div className="max-w-7xl mx-auto px-4 py-12">
<h2 className="text-xl font-bold text-white mb-6">Contenido del Curso</h2>
<div className="space-y-4">
{course.modules.map((module, moduleIndex) => (
<div key={module.id} className="card-dark overflow-hidden">
<div className="p-4 border-b border-primary-700">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-white">
Módulo {moduleIndex + 1}: {module.title}
</h3>
{module.description && (
<p className="text-sm text-primary-400 mt-1">{module.description}</p>
)}
</div>
<Badge className="badge-gold">
{module.lessons.length} lecciones
</Badge>
</div>
</div>
<div className="divide-y divide-primary-800">
{module.lessons.map((lesson, lessonIndex) => {
const isCompleted = isLessonCompleted(lesson.id);
const isLocked = isLessonLocked(lesson, moduleIndex, lessonIndex);
return (
<div
key={lesson.id}
className={cn(
'p-4 flex items-center',
!isLocked && 'cursor-pointer hover:bg-primary-800/50',
isLocked && 'opacity-50'
)}
onClick={() => !isLocked && enrollment.isEnrolled && handleStartLesson(lesson)}
>
<div className="mr-4">
{isCompleted ? (
<div className="w-8 h-8 rounded-full bg-success/20 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-success" />
</div>
) : isLocked ? (
<div className="w-8 h-8 rounded-full bg-primary-700 flex items-center justify-center">
<Lock className="h-4 w-4 text-primary-500" />
</div>
) : (
<div className="w-8 h-8 rounded-full bg-gold/20 flex items-center justify-center">
{getContentIcon(lesson.content_type)}
</div>
)}
</div>
<div className="flex-1">
<div className="font-medium text-white">{lesson.title}</div>
<div className="text-sm text-primary-400">
{lesson.content_type === 'video' ? 'Video' :
lesson.content_type === 'quiz' ? 'Quiz' : 'Lectura'}
{lesson.duration_minutes} min
</div>
</div>
{!isLocked && enrollment.isEnrolled && (
<ChevronRight className="h-5 w-5 text-primary-500" />
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default CourseDetailPage;

View File

@ -0,0 +1,428 @@
import React, { useState, useMemo } from 'react';
import { Search, Grid, List, BookOpen, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CourseCard } from '../components/CourseCard';
import { useCourses, useEnrollInCourse } from '../hooks/useEducation';
import type { Course } from '../types';
// Mock data - replace with actual API call
const mockCourses: Course[] = [
{
id: '1',
slug: 'trading-fundamentals',
title: 'Fundamentos del Trading',
description: 'Aprende los conceptos básicos del trading desde cero. Este curso te proporcionará una base sólida para comenzar tu carrera como trader.',
short_description: 'Conceptos básicos para comenzar en el trading',
thumbnail_url: '/courses/trading-fundamentals.jpg',
level: 'beginner',
price: 0,
estimated_hours: 10,
total_lessons: 24,
average_rating: 4.8,
total_enrollments: 1250,
is_featured: true,
has_certificate: true,
instructor: {
id: 'inst-1',
full_name: 'Carlos Martínez',
avatar_url: '/instructors/carlos.jpg'
}
},
{
id: '2',
slug: 'technical-analysis-pro',
title: 'Análisis Técnico Profesional',
description: 'Domina el análisis técnico con indicadores avanzados, patrones de velas y estrategias de trading probadas.',
short_description: 'Indicadores y patrones avanzados',
thumbnail_url: '/courses/technical-analysis.jpg',
level: 'intermediate',
price: 149,
estimated_hours: 20,
total_lessons: 45,
average_rating: 4.9,
total_enrollments: 820,
is_featured: true,
has_certificate: true,
instructor: {
id: 'inst-2',
full_name: 'Ana García',
avatar_url: '/instructors/ana.jpg'
}
},
{
id: '3',
slug: 'algorithmic-trading',
title: 'Trading Algorítmico con Python',
description: 'Aprende a crear bots de trading automatizados utilizando Python, APIs de exchanges y machine learning.',
short_description: 'Automatiza tus estrategias con Python',
thumbnail_url: '/courses/algo-trading.jpg',
level: 'advanced',
price: 299,
estimated_hours: 35,
total_lessons: 60,
average_rating: 4.7,
total_enrollments: 450,
has_certificate: true,
instructor: {
id: 'inst-3',
full_name: 'David López',
avatar_url: '/instructors/david.jpg'
}
},
{
id: '4',
slug: 'risk-management',
title: 'Gestión de Riesgo y Capital',
description: 'Protege tu capital con técnicas profesionales de gestión de riesgo. Aprende a calcular el tamaño de posición óptimo.',
short_description: 'Protege tu capital como un profesional',
thumbnail_url: '/courses/risk-management.jpg',
level: 'intermediate',
price: 99,
estimated_hours: 8,
total_lessons: 18,
average_rating: 4.9,
total_enrollments: 680,
has_certificate: true,
instructor: {
id: 'inst-1',
full_name: 'Carlos Martínez',
avatar_url: '/instructors/carlos.jpg'
}
},
{
id: '5',
slug: 'smart-money-concepts',
title: 'Smart Money Concepts',
description: 'Aprende a operar como las instituciones. Descubre cómo identificar la manipulación del mercado y seguir el dinero inteligente.',
short_description: 'Opera como las instituciones',
thumbnail_url: '/courses/smart-money.jpg',
level: 'expert',
price: 399,
estimated_hours: 40,
total_lessons: 72,
average_rating: 4.8,
total_enrollments: 320,
is_featured: true,
has_certificate: true,
instructor: {
id: 'inst-4',
full_name: 'Roberto Sánchez',
avatar_url: '/instructors/roberto.jpg'
}
},
{
id: '6',
slug: 'crypto-trading',
title: 'Trading de Criptomonedas',
description: 'Domina el mercado de criptomonedas. Aprende a analizar proyectos, operar en exchanges y gestionar tu portafolio crypto.',
short_description: 'Domina el mercado crypto',
thumbnail_url: '/courses/crypto-trading.jpg',
level: 'intermediate',
price: 199,
estimated_hours: 25,
total_lessons: 50,
average_rating: 4.6,
total_enrollments: 920,
has_certificate: true,
instructor: {
id: 'inst-5',
full_name: 'María Fernández',
avatar_url: '/instructors/maria.jpg'
}
}
];
type ViewMode = 'grid' | 'list';
type SortOption = 'popular' | 'newest' | 'rating' | 'price-low' | 'price-high';
const CoursesPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedLevel, setSelectedLevel] = useState<string>('all');
const [selectedSort, setSelectedSort] = useState<SortOption>('popular');
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [priceFilter, setPriceFilter] = useState<string>('all');
// API hooks - with fallback to mock data
const { data: coursesData, isLoading, isError } = useCourses({
level: selectedLevel !== 'all' ? selectedLevel as 'beginner' | 'intermediate' | 'advanced' | 'expert' : undefined,
search: searchTerm || undefined,
sortBy: selectedSort === 'price-low' ? 'price_asc' : selectedSort === 'price-high' ? 'price_desc' : selectedSort as 'popular' | 'newest' | 'rating',
});
const { mutate: enrollInCourse, isPending: isEnrolling } = useEnrollInCourse();
// Use API data or fallback to mock
const courses = coursesData?.data || mockCourses;
const filteredCourses = useMemo(() => {
let result = [...courses];
// Search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter(
course =>
course.title.toLowerCase().includes(term) ||
course.description.toLowerCase().includes(term) ||
course.instructor?.full_name.toLowerCase().includes(term)
);
}
// Level filter
if (selectedLevel !== 'all') {
result = result.filter(course => course.level === selectedLevel);
}
// Price filter
if (priceFilter === 'free') {
result = result.filter(course => course.price === 0);
} else if (priceFilter === 'paid') {
result = result.filter(course => course.price > 0);
}
// Sort
switch (selectedSort) {
case 'popular':
result.sort((a, b) => b.total_enrollments - a.total_enrollments);
break;
case 'rating':
result.sort((a, b) => b.average_rating - a.average_rating);
break;
case 'price-low':
result.sort((a, b) => a.price - b.price);
break;
case 'price-high':
result.sort((a, b) => b.price - a.price);
break;
case 'newest':
default:
break;
}
return result;
}, [courses, searchTerm, selectedLevel, selectedSort, priceFilter]);
const handleEnroll = (courseId: string) => {
enrollInCourse(courseId);
};
// Loading state
if (isLoading) {
return (
<div className="min-h-screen bg-primary-900 flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-12 w-12 text-gold animate-spin mx-auto mb-4" />
<p className="text-primary-400">Cargando cursos...</p>
</div>
</div>
);
}
// Error state - show mock data with warning
const showingMockData = isError || !coursesData;
return (
<div className="min-h-screen bg-primary-900">
{/* Hero Section */}
<div className="gradient-bg py-12 px-4">
<div className="max-w-7xl mx-auto">
<div className="flex items-center space-x-3 mb-4">
<BookOpen className="h-8 w-8 text-gold" />
<h1 className="text-3xl font-bold text-white">Academia de Trading</h1>
</div>
<p className="text-primary-300 max-w-2xl">
Aprende de los mejores traders del mercado. Desde fundamentos hasta estrategias avanzadas,
tenemos el curso perfecto para ti.
</p>
{/* Stats */}
<div className="flex flex-wrap gap-6 mt-6">
<div className="stat-card px-4 py-2">
<span className="stat-value text-lg">{coursesData?.meta?.total || courses.length}</span>
<span className="stat-label ml-2">Cursos</span>
</div>
<div className="stat-card px-4 py-2">
<span className="stat-value text-lg">
{courses.reduce((sum, c) => sum + c.total_enrollments, 0).toLocaleString()}
</span>
<span className="stat-label ml-2">Estudiantes</span>
</div>
<div className="stat-card px-4 py-2">
<span className="stat-value text-lg">
{courses.reduce((sum, c) => sum + c.total_lessons, 0)}
</span>
<span className="stat-label ml-2">Lecciones</span>
</div>
{showingMockData && (
<Badge className="bg-warning/20 text-warning border border-warning/30 self-center">
Datos de demostración
</Badge>
)}
</div>
</div>
</div>
{/* Filters Section */}
<div className="max-w-7xl mx-auto px-4 py-6">
<div className="card-dark p-4 mb-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-primary-400" />
<Input
placeholder="Buscar cursos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 input-dark"
/>
</div>
{/* Level Filter */}
<Select value={selectedLevel} onValueChange={setSelectedLevel}>
<SelectTrigger className="w-full lg:w-40 input-dark">
<SelectValue placeholder="Nivel" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos los niveles</SelectItem>
<SelectItem value="beginner">Principiante</SelectItem>
<SelectItem value="intermediate">Intermedio</SelectItem>
<SelectItem value="advanced">Avanzado</SelectItem>
<SelectItem value="expert">Experto</SelectItem>
</SelectContent>
</Select>
{/* Price Filter */}
<Select value={priceFilter} onValueChange={setPriceFilter}>
<SelectTrigger className="w-full lg:w-32 input-dark">
<SelectValue placeholder="Precio" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="free">Gratis</SelectItem>
<SelectItem value="paid">De pago</SelectItem>
</SelectContent>
</Select>
{/* Sort */}
<Select value={selectedSort} onValueChange={(v) => setSelectedSort(v as SortOption)}>
<SelectTrigger className="w-full lg:w-40 input-dark">
<SelectValue placeholder="Ordenar" />
</SelectTrigger>
<SelectContent>
<SelectItem value="popular">Más populares</SelectItem>
<SelectItem value="newest">Más recientes</SelectItem>
<SelectItem value="rating">Mejor valorados</SelectItem>
<SelectItem value="price-low">Precio: menor</SelectItem>
<SelectItem value="price-high">Precio: mayor</SelectItem>
</SelectContent>
</Select>
{/* View Toggle */}
<div className="flex border border-primary-700 rounded-lg overflow-hidden">
<Button
variant="ghost"
size="sm"
className={viewMode === 'grid' ? 'bg-gold text-primary-900' : 'text-primary-400'}
onClick={() => setViewMode('grid')}
>
<Grid className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className={viewMode === 'list' ? 'bg-gold text-primary-900' : 'text-primary-400'}
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Active Filters */}
{(selectedLevel !== 'all' || priceFilter !== 'all' || searchTerm) && (
<div className="flex flex-wrap gap-2 mb-6">
{selectedLevel !== 'all' && (
<Badge className="badge-gold">
Nivel: {selectedLevel}
<button
className="ml-2"
onClick={() => setSelectedLevel('all')}
>
×
</button>
</Badge>
)}
{priceFilter !== 'all' && (
<Badge className="badge-gold">
{priceFilter === 'free' ? 'Gratis' : 'De pago'}
<button
className="ml-2"
onClick={() => setPriceFilter('all')}
>
×
</button>
</Badge>
)}
{searchTerm && (
<Badge className="badge-gold">
Búsqueda: {searchTerm}
<button
className="ml-2"
onClick={() => setSearchTerm('')}
>
×
</button>
</Badge>
)}
</div>
)}
{/* Results Count */}
<div className="flex items-center justify-between mb-6">
<p className="text-primary-400">
{filteredCourses.length} curso{filteredCourses.length !== 1 ? 's' : ''} encontrado{filteredCourses.length !== 1 ? 's' : ''}
</p>
</div>
{/* Courses Grid */}
{filteredCourses.length > 0 ? (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
: 'space-y-4'
}>
{filteredCourses.map((course) => (
<CourseCard
key={course.id}
course={course}
variant={viewMode === 'list' ? 'compact' : 'default'}
onEnroll={isEnrolling ? undefined : handleEnroll}
/>
))}
</div>
) : (
<div className="text-center py-12">
<BookOpen className="h-12 w-12 text-primary-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">
No se encontraron cursos
</h3>
<p className="text-primary-400">
Intenta ajustar los filtros o buscar con otros términos
</p>
</div>
)}
</div>
</div>
);
};
export default CoursesPage;

View File

@ -0,0 +1,148 @@
/**
* Education API Service
* Handles courses, lessons, enrollments, and Notion content
*/
import { api } from '@/services/api';
import type { Course, Module, Lesson, Enrollment, Instructor } from '../types';
const EDUCATION_BASE_URL = import.meta.env.VITE_EDUCATION_SERVICE_URL || 'http://localhost:3091';
// Filter types
export interface CourseFilters {
level?: 'beginner' | 'intermediate' | 'advanced' | 'expert';
priceMin?: number;
priceMax?: number;
isFeatured?: boolean;
instructorId?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: 'newest' | 'popular' | 'rating' | 'price_asc' | 'price_desc';
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export const educationApi = {
// ============ COURSES ============
// Get all courses with filters
async getCourses(filters?: CourseFilters): Promise<PaginatedResponse<Course>> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/courses`, {
params: filters,
});
return data;
},
// Get featured courses
async getFeaturedCourses(limit = 6): Promise<Course[]> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/courses/featured`, {
params: { limit },
});
return data.data;
},
// Get single course by slug
async getCourseBySlug(slug: string): Promise<Course & { modules: Module[] }> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/courses/slug/${slug}`);
return data.data;
},
// Get course by ID
async getCourseById(courseId: string): Promise<Course & { modules: Module[] }> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/courses/${courseId}`);
return data.data;
},
// ============ MODULES & LESSONS ============
// Get course modules
async getCourseModules(courseId: string): Promise<Module[]> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/courses/${courseId}/modules`);
return data.data;
},
// Get lesson by ID
async getLesson(lessonId: string): Promise<Lesson> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/lessons/${lessonId}`);
return data.data;
},
// ============ ENROLLMENTS ============
// Get user's enrollments
async getMyEnrollments(): Promise<Enrollment[]> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/enrollments/me`);
return data.data;
},
// Get enrollment for specific course
async getCourseEnrollment(courseId: string): Promise<Enrollment | null> {
try {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/enrollments/course/${courseId}`);
return data.data;
} catch {
return null;
}
},
// Enroll in course
async enrollInCourse(courseId: string): Promise<Enrollment> {
const { data } = await api.post(`${EDUCATION_BASE_URL}/api/v1/courses/${courseId}/enroll`);
return data.data;
},
// Mark lesson as completed
async completeLesson(lessonId: string): Promise<{ progress: number; completed: boolean }> {
const { data } = await api.post(`${EDUCATION_BASE_URL}/api/v1/lessons/${lessonId}/complete`);
return data.data;
},
// Update lesson progress (for videos)
async updateLessonProgress(lessonId: string, progress: number): Promise<void> {
await api.patch(`${EDUCATION_BASE_URL}/api/v1/lessons/${lessonId}/progress`, {
progress,
});
},
// ============ INSTRUCTORS ============
// Get instructor by ID
async getInstructor(instructorId: string): Promise<Instructor> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/instructors/${instructorId}`);
return data.data;
},
// Get instructor's courses
async getInstructorCourses(instructorId: string): Promise<Course[]> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/instructors/${instructorId}/courses`);
return data.data;
},
// ============ NOTION CONTENT ============
// Get Notion page content (rendered blocks)
async getNotionContent(pageId: string): Promise<{ blocks: NotionBlock[] }> {
const { data } = await api.get(`${EDUCATION_BASE_URL}/api/v1/notion/pages/${pageId}`);
return data.data;
},
};
// Notion block types for API response
export interface NotionBlock {
id: string;
type: string;
content: string;
children?: NotionBlock[];
metadata?: Record<string, unknown>;
}
export default educationApi;

View File

@ -0,0 +1,60 @@
// Education Module Types
export interface Instructor {
id: string;
full_name: string;
avatar_url?: string;
bio?: string;
}
export interface Course {
id: string;
slug: string;
title: string;
description: string;
short_description?: string;
thumbnail_url?: string;
preview_video_url?: string;
level: 'beginner' | 'intermediate' | 'advanced' | 'expert';
price: number;
estimated_hours: number;
total_lessons: number;
average_rating: number;
total_enrollments: number;
is_featured?: boolean;
has_certificate?: boolean;
instructor?: Instructor;
created_at?: string;
updated_at?: string;
}
export interface Lesson {
id: string;
course_id: string;
module_id: string;
title: string;
description?: string;
content_type: 'video' | 'text' | 'notion' | 'quiz';
notion_url?: string;
video_url?: string;
duration_minutes?: number;
order: number;
}
export interface Module {
id: string;
course_id: string;
title: string;
description?: string;
order: number;
lessons: Lesson[];
}
export interface Enrollment {
id: string;
user_id: string;
course_id: string;
progress: number;
completed_at?: string;
enrolled_at: string;
}

View File

@ -0,0 +1,120 @@
/**
* Agent Card Component - STC Theme (Gold/Black)
*/
import { FC } from 'react';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { AgentConfig, AgentType } from '../types';
interface AgentCardProps {
agent: AgentConfig;
userAllocationValue?: number;
onAllocate: (agentType: AgentType) => void;
}
const agentStyles: Record<AgentType, { icon: string; gradient: string; color: string }> = {
ATLAS: {
icon: '🏛️',
gradient: 'from-blue-600/20 to-blue-800/20',
color: 'text-blue-400',
},
ORION: {
icon: '⭐',
gradient: 'from-gold/20 to-primary-700/20',
color: 'text-gold',
},
NOVA: {
icon: '🚀',
gradient: 'from-orange-600/20 to-red-800/20',
color: 'text-orange-400',
},
};
const riskBadgeVariants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
conservative: 'default',
moderate: 'secondary',
aggressive: 'destructive',
};
export const AgentCard: FC<AgentCardProps> = ({ agent, userAllocationValue, onAllocate }) => {
const style = agentStyles[agent.agentType];
return (
<Card className={`bg-gradient-to-br ${style.gradient} border-primary-700 overflow-hidden`}>
<CardContent className="p-6">
<div className="flex items-center gap-4 mb-4">
<div className="text-5xl">{style.icon}</div>
<div>
<h3 className={`text-2xl font-bold ${style.color}`}>{agent.name}</h3>
<Badge variant={riskBadgeVariants[agent.riskLevel]}>
{agent.riskLevel.charAt(0).toUpperCase() + agent.riskLevel.slice(1)} Risk
</Badge>
</div>
</div>
{agent.description && (
<p className="text-gray-400 text-sm mb-4">{agent.description}</p>
)}
{/* Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-primary-800/50 rounded-lg p-3">
<p className="text-gray-400 text-xs">Target Return</p>
<p className="text-xl font-bold text-green-400">+{agent.targetReturnPercent}%</p>
<p className="text-gray-500 text-xs">per year</p>
</div>
<div className="bg-primary-800/50 rounded-lg p-3">
<p className="text-gray-400 text-xs">Max Drawdown</p>
<p className="text-xl font-bold text-red-400">-{agent.maxDrawdownPercent}%</p>
<p className="text-gray-500 text-xs">maximum</p>
</div>
<div className="bg-primary-800/50 rounded-lg p-3">
<p className="text-gray-400 text-xs">Management Fee</p>
<p className="text-lg font-bold text-white">{agent.managementFeePercent}%</p>
<p className="text-gray-500 text-xs">per year</p>
</div>
<div className="bg-primary-800/50 rounded-lg p-3">
<p className="text-gray-400 text-xs">Performance Fee</p>
<p className="text-lg font-bold text-white">{agent.performanceFeePercent}%</p>
<p className="text-gray-500 text-xs">on profits</p>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col bg-primary-800/50 p-4 border-t border-primary-700">
<div className="flex items-center justify-between w-full mb-3">
<div>
<p className="text-gray-400 text-xs">Total AUM</p>
<p className="text-white font-semibold">${agent.totalAum.toLocaleString()}</p>
</div>
<div className="text-right">
<p className="text-gray-400 text-xs">Allocations</p>
<p className="text-white font-semibold">{agent.totalAllocations}</p>
</div>
</div>
{userAllocationValue !== undefined && userAllocationValue > 0 && (
<div className="bg-gold/20 rounded-lg p-2 mb-3 text-center w-full border border-gold/30">
<p className="text-gold/80 text-xs">Your Allocation</p>
<p className="text-white font-bold">${userAllocationValue.toLocaleString()}</p>
</div>
)}
<Button
onClick={() => onAllocate(agent.agentType)}
disabled={!agent.isActive}
variant="secondary"
className="w-full"
>
{userAllocationValue ? 'Manage Allocation' : 'Allocate Funds'}
</Button>
<p className="text-gray-500 text-xs text-center mt-2">
Min: ${agent.minAllocation} · Max: ${agent.maxAllocation.toLocaleString()}
</p>
</CardFooter>
</Card>
);
};

View File

@ -0,0 +1,119 @@
/**
* Allocation Card Component - STC Theme (Gold/Black)
*/
import { FC } from 'react';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { AllocationWithAgent, AgentType } from '../types';
interface AllocationCardProps {
allocation: AllocationWithAgent;
onManage: (allocation: AllocationWithAgent) => void;
}
const agentIcons: Record<AgentType, string> = {
ATLAS: '🏛️',
ORION: '⭐',
NOVA: '🚀',
};
const statusBadgeVariants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
ACTIVE: 'default',
PENDING: 'secondary',
PAUSED: 'outline',
WITHDRAWING: 'secondary',
CLOSED: 'destructive',
};
export const AllocationCard: FC<AllocationCardProps> = ({ allocation, onManage }) => {
const pnl = allocation.currentValue - allocation.allocatedAmount;
const pnlPercent = allocation.allocatedAmount > 0
? (pnl / allocation.allocatedAmount) * 100
: 0;
const isProfit = pnl >= 0;
const isLocked = allocation.lockExpiresAt && new Date(allocation.lockExpiresAt) > new Date();
const daysUntilUnlock = isLocked
? Math.ceil((new Date(allocation.lockExpiresAt!).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: 0;
return (
<Card className="bg-primary-800 border-primary-700 overflow-hidden">
<CardHeader className="p-4 border-b border-primary-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">{agentIcons[allocation.agentType]}</span>
<div>
<h4 className="text-lg font-semibold text-white">{allocation.agentName}</h4>
<p className="text-gray-400 text-sm">{allocation.agentRiskLevel} risk</p>
</div>
</div>
<Badge variant={statusBadgeVariants[allocation.status]}>
{allocation.status}
</Badge>
</div>
</CardHeader>
<CardContent className="p-4 space-y-4">
{/* Current Value */}
<div className="flex justify-between items-center">
<span className="text-gray-400">Current Value</span>
<span className="text-2xl font-bold text-gold">
${allocation.currentValue.toLocaleString()}
</span>
</div>
{/* PnL */}
<div className="flex justify-between items-center">
<span className="text-gray-400">Total P&L</span>
<div className="text-right">
<span className={`text-lg font-semibold ${isProfit ? 'text-green-400' : 'text-red-400'}`}>
{isProfit ? '+' : ''}{pnl.toFixed(2)} ({pnlPercent.toFixed(2)}%)
</span>
</div>
</div>
{/* Details */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-gray-500">Deposited</p>
<p className="text-white">${allocation.totalDeposited.toLocaleString()}</p>
</div>
<div>
<p className="text-gray-500">Withdrawn</p>
<p className="text-white">${allocation.totalWithdrawn.toLocaleString()}</p>
</div>
<div>
<p className="text-gray-500">Profits Distributed</p>
<p className="text-green-400">${allocation.totalProfitDistributed.toLocaleString()}</p>
</div>
<div>
<p className="text-gray-500">Fees Paid</p>
<p className="text-red-400">${allocation.totalFeesPaid.toLocaleString()}</p>
</div>
</div>
{/* Lock Warning */}
{isLocked && (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
<p className="text-yellow-400 text-sm">
Locked for {daysUntilUnlock} more days. Early withdrawal incurs 5% penalty.
</p>
</div>
)}
</CardContent>
<CardFooter className="p-4 border-t border-primary-700">
<Button
onClick={() => onManage(allocation)}
variant="secondary"
className="w-full"
>
Manage
</Button>
</CardFooter>
</Card>
);
};

View File

@ -0,0 +1,200 @@
/**
* Allocation Modal Component - STC Theme (Gold/Black)
*/
import { FC, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useCreateAllocation, useFundAllocation, useWithdraw } from '../hooks/useInvestment';
import { useWalletStore } from '@/modules/wallet/stores/wallet.store';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import type { AgentConfig, AllocationWithAgent } from '../types';
interface AllocationModalProps {
isOpen: boolean;
onClose: () => void;
agent: AgentConfig | null;
existingAllocation?: AllocationWithAgent | null;
}
type ModalMode = 'allocate' | 'fund' | 'withdraw';
export const AllocationModal: FC<AllocationModalProps> = ({
isOpen,
onClose,
agent,
existingAllocation,
}) => {
const [mode, setMode] = useState<ModalMode>(existingAllocation ? 'fund' : 'allocate');
const [amount, setAmount] = useState('');
const { wallet } = useWalletStore();
const createAllocation = useCreateAllocation();
const fundAllocation = useFundAllocation();
const withdraw = useWithdraw();
if (!agent) return null;
const numAmount = parseFloat(amount) || 0;
const canAfford = wallet && wallet.balance >= numAmount;
const canWithdraw = existingAllocation && numAmount <= existingAllocation.currentValue;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!wallet) return;
try {
if (mode === 'allocate') {
await createAllocation.mutateAsync({
agentType: agent.agentType,
walletId: wallet.id,
amount: numAmount,
});
} else if (mode === 'fund' && existingAllocation) {
await fundAllocation.mutateAsync({
allocationId: existingAllocation.id,
amount: numAmount,
});
} else if (mode === 'withdraw' && existingAllocation) {
await withdraw.mutateAsync({
allocationId: existingAllocation.id,
amount: numAmount,
});
}
setAmount('');
onClose();
} catch (error) {
// Error handled by mutation
}
};
const isLoading = createAllocation.isPending || fundAllocation.isPending || withdraw.isPending;
return (
<Dialog open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
<DialogContent className="bg-primary-800 border-primary-700 sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-white">
{existingAllocation ? 'Manage Allocation' : 'New Allocation'} - {agent.name}
</DialogTitle>
</DialogHeader>
{/* Mode Tabs (if existing allocation) */}
{existingAllocation && (
<div className="flex border-b border-primary-700">
{(['fund', 'withdraw'] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`flex-1 py-3 font-medium transition-colors ${
mode === m
? 'text-gold border-b-2 border-gold'
: 'text-gray-400 hover:text-white'
}`}
>
{m === 'fund' ? 'Add Funds' : 'Withdraw'}
</button>
))}
</div>
)}
{/* Body */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Current Allocation Info */}
{existingAllocation && (
<div className="bg-primary-700/50 rounded-lg p-3">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Current Value</span>
<span className="text-gold font-medium">
${existingAllocation.currentValue.toLocaleString()}
</span>
</div>
</div>
)}
{/* Wallet Balance */}
<div className="bg-primary-700/50 rounded-lg p-3">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Wallet Balance</span>
<span className="text-white font-medium">${wallet?.balance.toFixed(2) || '0.00'}</span>
</div>
</div>
{/* Amount Input */}
<div className="space-y-2">
<Label className="text-gray-300">
Amount to {mode === 'withdraw' ? 'Withdraw' : 'Allocate'}
</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
min={agent.minAllocation}
max={mode === 'withdraw' ? existingAllocation?.currentValue : agent.maxAllocation}
step="0.01"
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500 pl-8"
required
/>
</div>
<p className="text-gray-500 text-xs">
Min: ${agent.minAllocation} · Max: ${agent.maxAllocation.toLocaleString()}
</p>
</div>
{/* Validation Messages */}
{mode !== 'withdraw' && numAmount > 0 && !canAfford && (
<p className="text-red-400 text-sm">Insufficient wallet balance</p>
)}
{mode === 'withdraw' && numAmount > 0 && !canWithdraw && (
<p className="text-red-400 text-sm">Amount exceeds allocation value</p>
)}
{/* Lock Warning for Withdraw */}
{mode === 'withdraw' && existingAllocation?.lockExpiresAt && new Date(existingAllocation.lockExpiresAt) > new Date() && (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3">
<p className="text-yellow-400 text-sm">
Early withdrawal penalty of 5% will apply
</p>
</div>
)}
{/* Submit Button */}
<Button
type="submit"
disabled={
isLoading ||
numAmount <= 0 ||
(mode !== 'withdraw' && !canAfford) ||
(mode === 'withdraw' && !canWithdraw)
}
variant={mode === 'withdraw' ? 'destructive' : 'secondary'}
className="w-full"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : mode === 'withdraw' ? (
'Withdraw'
) : mode === 'fund' ? (
'Add Funds'
) : (
'Create Allocation'
)}
</Button>
</form>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,7 @@
/**
* Investment Components Index
*/
export { AgentCard } from './AgentCard';
export { AllocationCard } from './AllocationCard';
export { AllocationModal } from './AllocationModal';

View File

@ -0,0 +1,178 @@
/**
* Investment Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { investmentApi } from '../services/investment.api';
import { useInvestmentStore } from '../stores/investment.store';
import type { CreateAllocationInput, FundAllocationInput, WithdrawInput } from '../types';
// Query keys
export const investmentKeys = {
all: ['investment'] as const,
agents: () => [...investmentKeys.all, 'agents'] as const,
agent: (type: string) => [...investmentKeys.all, 'agent', type] as const,
performance: (type: string, days: number) =>
[...investmentKeys.all, 'performance', type, days] as const,
allocations: () => [...investmentKeys.all, 'allocations'] as const,
allocation: (id: string) => [...investmentKeys.all, 'allocation', id] as const,
transactions: (id: string) => [...investmentKeys.all, 'transactions', id] as const,
summary: () => [...investmentKeys.all, 'summary'] as const,
};
// Get all agents
export function useAgents() {
const { setAgents } = useInvestmentStore();
return useQuery({
queryKey: investmentKeys.agents(),
queryFn: async () => {
const agents = await investmentApi.getAgents();
setAgents(agents);
return agents;
},
staleTime: 5 * 60 * 1000,
});
}
// Get agent performance
export function useAgentPerformance(agentType: string, periodDays = 30) {
return useQuery({
queryKey: investmentKeys.performance(agentType, periodDays),
queryFn: () => investmentApi.getAgentPerformance(agentType, periodDays),
enabled: !!agentType,
staleTime: 60 * 1000,
});
}
// Get user allocations
export function useAllocations() {
const { setAllocations } = useInvestmentStore();
return useQuery({
queryKey: investmentKeys.allocations(),
queryFn: async () => {
const allocations = await investmentApi.getUserAllocations();
setAllocations(allocations);
return allocations;
},
staleTime: 30 * 1000,
});
}
// Get single allocation
export function useAllocation(allocationId: string) {
const { setSelectedAllocation } = useInvestmentStore();
return useQuery({
queryKey: investmentKeys.allocation(allocationId),
queryFn: async () => {
const allocation = await investmentApi.getAllocation(allocationId);
setSelectedAllocation(allocation);
return allocation;
},
enabled: !!allocationId,
});
}
// Get allocation transactions
export function useAllocationTransactions(allocationId: string) {
return useQuery({
queryKey: investmentKeys.transactions(allocationId),
queryFn: () => investmentApi.getAllocationTransactions(allocationId),
enabled: !!allocationId,
});
}
// Get investment summary
export function useInvestmentSummary() {
const { setSummary } = useInvestmentStore();
return useQuery({
queryKey: investmentKeys.summary(),
queryFn: async () => {
const summary = await investmentApi.getSummary();
setSummary(summary);
return summary;
},
staleTime: 30 * 1000,
});
}
// Create allocation
export function useCreateAllocation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateAllocationInput) => investmentApi.createAllocation(input),
onSuccess: (allocation) => {
queryClient.invalidateQueries({ queryKey: investmentKeys.allocations() });
queryClient.invalidateQueries({ queryKey: investmentKeys.summary() });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
toast.success(`Allocated $${allocation.allocatedAmount} to ${allocation.agentName}`);
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to create allocation');
},
});
}
// Fund allocation
export function useFundAllocation() {
const queryClient = useQueryClient();
const { updateAllocation } = useInvestmentStore();
return useMutation({
mutationFn: (input: FundAllocationInput) => investmentApi.fundAllocation(input),
onSuccess: (allocation) => {
updateAllocation(allocation);
queryClient.invalidateQueries({ queryKey: investmentKeys.allocations() });
queryClient.invalidateQueries({ queryKey: investmentKeys.summary() });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
toast.success('Funds added successfully');
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to add funds');
},
});
}
// Withdraw from allocation
export function useWithdraw() {
const queryClient = useQueryClient();
const { updateAllocation } = useInvestmentStore();
return useMutation({
mutationFn: (input: WithdrawInput) => investmentApi.withdraw(input),
onSuccess: (allocation) => {
updateAllocation(allocation);
queryClient.invalidateQueries({ queryKey: investmentKeys.allocations() });
queryClient.invalidateQueries({ queryKey: investmentKeys.summary() });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
toast.success('Withdrawal processed');
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to withdraw');
},
});
}
// Update allocation status
export function useUpdateAllocationStatus() {
const queryClient = useQueryClient();
const { updateAllocation } = useInvestmentStore();
return useMutation({
mutationFn: ({ allocationId, status, reason }: { allocationId: string; status: string; reason?: string }) =>
investmentApi.updateStatus(allocationId, status, reason),
onSuccess: (allocation) => {
updateAllocation(allocation);
queryClient.invalidateQueries({ queryKey: investmentKeys.allocations() });
toast.success(`Allocation ${allocation.status.toLowerCase()}`);
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to update status');
},
});
}

View File

@ -0,0 +1,18 @@
/**
* Investment Module Index
*/
// Components
export * from './components';
// Pages
export { InvestmentPage } from './pages/InvestmentPage';
// Hooks
export * from './hooks/useInvestment';
// Store
export { useInvestmentStore } from './stores/investment.store';
// Types
export * from './types';

View File

@ -0,0 +1,191 @@
/**
* Investment Page - STC Theme (Gold/Black)
*/
import { FC, useState } from 'react';
import { useAgents, useAllocations, useInvestmentSummary } from '../hooks/useInvestment';
import { AgentCard, AllocationCard, AllocationModal } from '../components';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import type { AgentConfig, AllocationWithAgent, AgentType } from '../types';
export const InvestmentPage: FC = () => {
const [showModal, setShowModal] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<AgentConfig | null>(null);
const [selectedAllocation, setSelectedAllocation] = useState<AllocationWithAgent | null>(null);
const { data: agents, isLoading: agentsLoading } = useAgents();
const { data: allocations, isLoading: allocationsLoading } = useAllocations();
const { data: summary } = useInvestmentSummary();
const handleAllocate = (agentType: AgentType) => {
const agent = agents?.find((a) => a.agentType === agentType);
const existingAllocation = allocations?.find(
(a) => a.agentType === agentType && a.status === 'active'
);
setSelectedAgent(agent || null);
setSelectedAllocation(existingAllocation || null);
setShowModal(true);
};
const handleManageAllocation = (allocation: AllocationWithAgent) => {
const agent = agents?.find((a) => a.agentType === allocation.agentType);
setSelectedAgent(agent || null);
setSelectedAllocation(allocation);
setShowModal(true);
};
const handleCloseModal = () => {
setShowModal(false);
setSelectedAgent(null);
setSelectedAllocation(null);
};
// Get allocation value by agent type
const getAllocationValue = (agentType: AgentType) => {
const allocation = allocations?.find(
(a) => a.agentType === agentType && a.status === 'active'
);
return allocation?.currentValue || 0;
};
if (agentsLoading || allocationsLoading) {
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-6xl mx-auto">
<div className="space-y-6">
<Skeleton className="h-32 w-full bg-primary-800" />
<div className="grid grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-96 w-full bg-primary-800" />
))}
</div>
</div>
</div>
</div>
);
}
const activeAllocations = allocations?.filter((a) => a.status === 'active') || [];
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-white">
Investment <span className="text-gold">Agents</span>
</h1>
<p className="text-gray-400 mt-1">
Allocate funds to AI-powered trading agents
</p>
</div>
{/* Portfolio Summary */}
{summary && (summary.totalAllocated > 0 || activeAllocations.length > 0) && (
<div className="bg-gradient-to-r from-gold/20 to-primary-700/50 rounded-xl border border-gold/30 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Your Portfolio</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-gray-400 text-sm">Total Allocated</p>
<p className="text-2xl font-bold text-white">
${summary.totalAllocated.toLocaleString()}
</p>
</div>
<div>
<p className="text-gray-400 text-sm">Current Value</p>
<p className="text-2xl font-bold text-gold">
${summary.currentValue.toLocaleString()}
</p>
</div>
<div>
<p className="text-gray-400 text-sm">Total P&L</p>
<p className={`text-2xl font-bold ${summary.totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{summary.totalPnl >= 0 ? '+' : ''}${summary.totalPnl.toFixed(2)}
</p>
</div>
<div>
<p className="text-gray-400 text-sm">Return</p>
<p className={`text-2xl font-bold ${summary.totalPnlPercent >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{summary.totalPnlPercent >= 0 ? '+' : ''}{summary.totalPnlPercent.toFixed(2)}%
</p>
</div>
</div>
</div>
)}
{/* Agent Cards */}
<div>
<h2 className="text-xl font-semibold text-gold mb-4">Available Agents</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{agents?.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
userAllocationValue={getAllocationValue(agent.agentType)}
onAllocate={handleAllocate}
/>
))}
</div>
</div>
{/* Active Allocations */}
{activeAllocations.length > 0 && (
<div>
<h2 className="text-xl font-semibold text-gold mb-4">Your Allocations</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{activeAllocations.map((allocation) => (
<AllocationCard
key={allocation.id}
allocation={allocation}
onManage={handleManageAllocation}
/>
))}
</div>
</div>
)}
{/* Info Section */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader>
<CardTitle className="text-white">How It Works</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="text-4xl mb-3">1</div>
<h4 className="text-gold font-medium mb-1">Choose an Agent</h4>
<p className="text-gray-400 text-sm">
Select based on your risk tolerance and return expectations
</p>
</div>
<div className="text-center">
<div className="text-4xl mb-3">2</div>
<h4 className="text-gold font-medium mb-1">Allocate Funds</h4>
<p className="text-gray-400 text-sm">
Transfer credits from your wallet to the agent
</p>
</div>
<div className="text-center">
<div className="text-4xl mb-3">3</div>
<h4 className="text-gold font-medium mb-1">Earn Returns</h4>
<p className="text-gray-400 text-sm">
Profits are distributed monthly after fees
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Allocation Modal */}
<AllocationModal
isOpen={showModal}
onClose={handleCloseModal}
agent={selectedAgent}
existingAllocation={selectedAllocation}
/>
</div>
);
};

View File

@ -0,0 +1,98 @@
/**
* Investment API Service
*/
import { api } from '@/services/api';
import type {
AgentConfig,
AllocationWithAgent,
AllocationTransaction,
AllocationSummary,
AgentPerformance,
CreateAllocationInput,
FundAllocationInput,
WithdrawInput,
} from '../types';
const INVESTMENT_BASE_URL = import.meta.env.VITE_INVESTMENT_SERVICE_URL || 'http://localhost:3093';
export const investmentApi = {
// Get all agents
async getAgents(): Promise<AgentConfig[]> {
const { data } = await api.get(`${INVESTMENT_BASE_URL}/api/v1/agents`);
return data.data;
},
// Get single agent
async getAgent(agentType: string): Promise<AgentConfig> {
const { data } = await api.get(`${INVESTMENT_BASE_URL}/api/v1/agents/${agentType}`);
return data.data;
},
// Get agent performance
async getAgentPerformance(agentType: string, periodDays = 30): Promise<AgentPerformance> {
const { data } = await api.get(`${INVESTMENT_BASE_URL}/api/v1/agents/${agentType}/performance`, {
params: { periodDays },
});
return data.data;
},
// Get user allocations
async getUserAllocations(): Promise<AllocationWithAgent[]> {
const { data } = await api.get(`${INVESTMENT_BASE_URL}/api/v1/users/me/allocations`);
return data.data;
},
// Get single allocation
async getAllocation(allocationId: string): Promise<AllocationWithAgent> {
const { data } = await api.get(`${INVESTMENT_BASE_URL}/api/v1/allocations/${allocationId}`);
return data.data;
},
// Get allocation transactions
async getAllocationTransactions(allocationId: string): Promise<AllocationTransaction[]> {
const { data } = await api.get(
`${INVESTMENT_BASE_URL}/api/v1/allocations/${allocationId}/transactions`
);
return data.data;
},
// Get user investment summary
async getSummary(): Promise<AllocationSummary> {
const { data } = await api.get(`${INVESTMENT_BASE_URL}/api/v1/users/me/investment-summary`);
return data.data;
},
// Create allocation
async createAllocation(input: CreateAllocationInput): Promise<AllocationWithAgent> {
const { data } = await api.post(`${INVESTMENT_BASE_URL}/api/v1/allocations`, input);
return data.data;
},
// Fund allocation
async fundAllocation(input: FundAllocationInput): Promise<AllocationWithAgent> {
const { data } = await api.post(
`${INVESTMENT_BASE_URL}/api/v1/allocations/${input.allocationId}/fund`,
{ amount: input.amount }
);
return data.data;
},
// Withdraw from allocation
async withdraw(input: WithdrawInput): Promise<AllocationWithAgent> {
const { data } = await api.post(
`${INVESTMENT_BASE_URL}/api/v1/allocations/${input.allocationId}/withdraw`,
{ amount: input.amount }
);
return data.data;
},
// Update allocation status
async updateStatus(allocationId: string, status: string, reason?: string): Promise<AllocationWithAgent> {
const { data } = await api.patch(
`${INVESTMENT_BASE_URL}/api/v1/allocations/${allocationId}/status`,
{ status, reason }
);
return data.data;
},
};

View File

@ -0,0 +1,80 @@
/**
* Investment Zustand Store
*/
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type { AgentConfig, AllocationWithAgent, AllocationSummary, AgentType } from '../types';
interface InvestmentState {
// State
agents: AgentConfig[];
allocations: AllocationWithAgent[];
summary: AllocationSummary | null;
selectedAgent: AgentType | null;
selectedAllocation: AllocationWithAgent | null;
isLoading: boolean;
error: string | null;
// Actions
setAgents: (agents: AgentConfig[]) => void;
setAllocations: (allocations: AllocationWithAgent[]) => void;
setSummary: (summary: AllocationSummary | null) => void;
setSelectedAgent: (agent: AgentType | null) => void;
setSelectedAllocation: (allocation: AllocationWithAgent | null) => void;
updateAllocation: (allocation: AllocationWithAgent) => void;
setLoading: (isLoading: boolean) => void;
setError: (error: string | null) => void;
reset: () => void;
}
const initialState = {
agents: [],
allocations: [],
summary: null,
selectedAgent: null,
selectedAllocation: null,
isLoading: false,
error: null,
};
export const useInvestmentStore = create<InvestmentState>()(
devtools(
(set) => ({
...initialState,
setAgents: (agents) => set({ agents }, false, 'setAgents'),
setAllocations: (allocations) => set({ allocations }, false, 'setAllocations'),
setSummary: (summary) => set({ summary }, false, 'setSummary'),
setSelectedAgent: (selectedAgent) => set({ selectedAgent }, false, 'setSelectedAgent'),
setSelectedAllocation: (selectedAllocation) =>
set({ selectedAllocation }, false, 'setSelectedAllocation'),
updateAllocation: (allocation) =>
set(
(state) => ({
allocations: state.allocations.map((a) =>
a.id === allocation.id ? allocation : a
),
selectedAllocation:
state.selectedAllocation?.id === allocation.id
? allocation
: state.selectedAllocation,
}),
false,
'updateAllocation'
),
setLoading: (isLoading) => set({ isLoading }, false, 'setLoading'),
setError: (error) => set({ error }, false, 'setError'),
reset: () => set(initialState, false, 'reset'),
}),
{ name: 'InvestmentStore' }
)
);

View File

@ -0,0 +1,109 @@
/**
* Investment Module Types
*/
export type AgentType = 'ATLAS' | 'ORION' | 'NOVA';
// AllocationStatus matches DDL: investment.allocation_status
export type AllocationStatus = 'pending' | 'active' | 'paused' | 'liquidating' | 'closed';
// TransactionType matches DDL: investment.allocation_tx_type
export type TransactionType =
| 'INITIAL_FUNDING'
| 'ADDITIONAL_FUNDING'
| 'PARTIAL_WITHDRAWAL'
| 'FULL_WITHDRAWAL'
| 'PROFIT_REALIZED'
| 'LOSS_REALIZED'
| 'FEE_CHARGED'
| 'REBALANCE';
export interface AgentConfig {
id: string;
agentType: AgentType;
name: string;
description: string | null;
riskLevel: string;
isActive: boolean;
minAllocation: number;
maxAllocation: number;
targetReturnPercent: number;
maxDrawdownPercent: number;
managementFeePercent: number;
performanceFeePercent: number;
totalAum: number;
totalAllocations: number;
}
export interface AgentAllocation {
id: string;
userId: string;
walletId: string;
agentType: AgentType;
status: AllocationStatus;
allocatedAmount: number;
currentValue: number;
totalDeposited: number;
totalWithdrawn: number;
totalProfitDistributed: number;
totalFeesPaid: number;
unrealizedPnl: number;
realizedPnl: number;
lastProfitDistributionAt: string | null;
lockExpiresAt: string | null;
createdAt: string;
}
export interface AllocationWithAgent extends AgentAllocation {
agentName: string;
agentRiskLevel: string;
agentTargetReturn: number;
}
export interface AllocationTransaction {
id: string;
allocationId: string;
type: TransactionType;
amount: number;
balanceBefore: number;
balanceAfter: number;
description: string | null;
createdAt: string;
}
export interface AllocationSummary {
totalAllocated: number;
currentValue: number;
totalPnl: number;
totalPnlPercent: number;
allocationsByAgent: {
agentType: AgentType;
count: number;
totalValue: number;
pnl: number;
}[];
}
export interface AgentPerformance {
agentType: AgentType;
periodDays: number;
totalAum: number;
totalAllocations: number;
periodReturn: number;
periodReturnPercent: number;
maxDrawdown: number;
}
export interface CreateAllocationInput {
agentType: AgentType;
walletId: string;
amount: number;
}
export interface FundAllocationInput {
allocationId: string;
amount: number;
}
export interface WithdrawInput {
allocationId: string;
amount: number;
}

View File

@ -0,0 +1,93 @@
/**
* Prediction Package Card Component - STC Theme (Gold/Black)
*/
import { FC } from 'react';
import { Loader2 } from 'lucide-react';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { PredictionPackage, PredictionType } from '../types';
interface PackageCardProps {
package_: PredictionPackage;
onPurchase: (packageId: string) => void;
isLoading?: boolean;
}
const typeColors: Record<PredictionType, string> = {
AMD: 'border-blue-500/50 bg-blue-500/10',
RANGE: 'border-green-500/50 bg-green-500/10',
TPSL: 'border-gold/50 bg-gold/10',
ICT_SMC: 'border-orange-500/50 bg-orange-500/10',
STRATEGY_ENSEMBLE: 'border-cyan-500/50 bg-cyan-500/10',
};
export const PackageCard: FC<PackageCardProps> = ({ package_, onPurchase, isLoading }) => {
const typeColor = typeColors[package_.predictionType];
return (
<Card className={`border-2 ${typeColor} overflow-hidden`}>
<CardHeader className="p-4 border-b border-primary-700">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold text-white">{package_.name}</h3>
{package_.vipTierRequired && (
<Badge variant="secondary" className="bg-gold/20 text-gold">
{package_.vipTierRequired}+ Required
</Badge>
)}
</div>
{package_.description && (
<p className="text-gray-400 text-sm">{package_.description}</p>
)}
</CardHeader>
<CardContent className="p-4 space-y-3">
<div className="flex justify-between">
<span className="text-gray-400">Model Type</span>
<span className="text-white font-medium">{package_.predictionType}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Predictions</span>
<span className="text-white font-medium">{package_.predictionsCount}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Valid For</span>
<span className="text-white font-medium">{package_.validityDays} days</span>
</div>
<div>
<span className="text-gray-400 text-sm">Asset Classes:</span>
<div className="flex flex-wrap gap-1 mt-1">
{package_.assetClasses.map((ac) => (
<Badge key={ac} variant="outline" className="text-xs">
{ac}
</Badge>
))}
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col p-4 border-t border-primary-700 bg-primary-800/50">
<div className="flex items-center justify-between mb-3 w-full">
<span className="text-gray-400">Price</span>
<span className="text-2xl font-bold text-gold">${package_.priceCredits}</span>
</div>
<Button
onClick={() => onPurchase(package_.id)}
disabled={isLoading || !package_.isActive}
variant="secondary"
className="w-full"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
'Purchase'
)}
</Button>
</CardFooter>
</Card>
);
};

View File

@ -0,0 +1,157 @@
/**
* Prediction Card Component - STC Theme (Gold/Black)
*/
import { FC } from 'react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { PredictionWithOutcome, PredictionType, PredictionStatus, OutcomeResult } from '../types';
interface PredictionCardProps {
prediction: PredictionWithOutcome;
onClick?: (prediction: PredictionWithOutcome) => void;
}
const typeLabels: Record<PredictionType, { label: string; color: string }> = {
AMD: { label: 'AMD', color: 'bg-blue-500/20 text-blue-400' },
RANGE: { label: 'Range', color: 'bg-green-500/20 text-green-400' },
TPSL: { label: 'TP/SL', color: 'bg-gold/20 text-gold' },
ICT_SMC: { label: 'ICT/SMC', color: 'bg-orange-500/20 text-orange-400' },
STRATEGY_ENSEMBLE: { label: 'Ensemble', color: 'bg-cyan-500/20 text-cyan-400' },
};
const statusStyles: Record<PredictionStatus, { label: string; color: string }> = {
pending: { label: 'Pending', color: 'bg-gray-500/20 text-gray-400' },
delivered: { label: 'Active', color: 'bg-blue-500/20 text-blue-400' },
expired: { label: 'Expired', color: 'bg-yellow-500/20 text-yellow-400' },
validated: { label: 'Validated', color: 'bg-green-500/20 text-green-400' },
invalidated: { label: 'Invalidated', color: 'bg-red-500/20 text-red-400' },
};
const outcomeStyles: Record<OutcomeResult, { label: string; color: string; icon: string }> = {
pending: { label: 'Pending', color: 'text-gray-400', icon: '' },
win: { label: 'Win', color: 'text-green-400', icon: '' },
loss: { label: 'Loss', color: 'text-red-400', icon: '' },
partial: { label: 'Partial', color: 'text-yellow-400', icon: '~' },
expired: { label: 'Expired', color: 'text-gray-400', icon: '' },
cancelled: { label: 'Cancelled', color: 'text-gray-500', icon: '' },
};
export const PredictionCard: FC<PredictionCardProps> = ({ prediction, onClick }) => {
const typeStyle = typeLabels[prediction.predictionType];
const statusStyle = statusStyles[prediction.status];
const outcomeStyle = prediction.outcome ? outcomeStyles[prediction.outcome.result] : null;
const expiresAt = new Date(prediction.expiresAt);
const isExpired = expiresAt < new Date();
const hoursRemaining = Math.max(0, (expiresAt.getTime() - Date.now()) / (1000 * 60 * 60));
return (
<Card
onClick={() => onClick?.(prediction)}
className={`bg-primary-800 border-primary-700 overflow-hidden hover:border-primary-600 transition-colors ${
onClick ? 'cursor-pointer' : ''
}`}
>
<CardHeader className="p-4 border-b border-primary-700">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-xl font-bold text-white">{prediction.asset}</span>
<span className="text-gray-500 text-sm">{prediction.assetClass}</span>
</div>
<div className="flex gap-2">
<Badge className={typeStyle.color}>
{typeStyle.label}
</Badge>
<Badge className={statusStyle.color}>
{statusStyle.label}
</Badge>
</div>
</div>
{/* Direction & Confidence */}
<div className="flex items-center gap-4">
<div className={`flex items-center gap-1 ${
prediction.direction === 'LONG' ? 'text-green-400' :
prediction.direction === 'SHORT' ? 'text-red-400' : 'text-gray-400'
}`}>
{prediction.direction === 'LONG' ? '↑' : prediction.direction === 'SHORT' ? '↓' : '→'}
<span className="font-medium">{prediction.direction}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-gray-400 text-sm">Confidence:</span>
<span className={`font-medium ${
prediction.confidence >= 80 ? 'text-green-400' :
prediction.confidence >= 60 ? 'text-gold' : 'text-red-400'
}`}>
{prediction.confidence}%
</span>
</div>
<div className="text-gray-500 text-sm">{prediction.timeframe}</div>
</div>
</CardHeader>
<CardContent className="p-4 grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-gray-500">Entry</p>
<p className="text-white font-medium">
{prediction.entryPrice?.toFixed(5) || '-'}
</p>
</div>
<div>
<p className="text-gray-500">Target</p>
<p className="text-green-400 font-medium">
{prediction.targetPrice?.toFixed(5) || '-'}
</p>
</div>
<div>
<p className="text-gray-500">Stop Loss</p>
<p className="text-red-400 font-medium">
{prediction.stopLoss?.toFixed(5) || '-'}
</p>
</div>
</CardContent>
{/* Outcome (if validated) */}
{prediction.outcome && (
<div className={`px-4 py-3 border-t border-primary-700 ${
prediction.outcome.result === 'win' ? 'bg-green-500/10' :
prediction.outcome.result === 'loss' ? 'bg-red-500/10' : 'bg-primary-700/50'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{outcomeStyle?.icon && <span className={`text-lg ${outcomeStyle?.color}`}>{outcomeStyle?.icon}</span>}
<span className={`font-medium ${outcomeStyle?.color}`}>{outcomeStyle?.label}</span>
</div>
{prediction.outcome.pnlPercent !== null && (
<span className={`font-bold ${
prediction.outcome.pnlPercent >= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{prediction.outcome.pnlPercent >= 0 ? '+' : ''}
{prediction.outcome.pnlPercent.toFixed(2)}%
</span>
)}
</div>
{prediction.outcome.actualPrice && (
<p className="text-gray-400 text-sm mt-1">
Actual: {prediction.outcome.actualPrice.toFixed(5)}
</p>
)}
</div>
)}
{/* Expiry */}
{!prediction.outcome && (
<div className="px-4 py-2 border-t border-primary-700 text-sm">
{isExpired ? (
<span className="text-yellow-400">Expired</span>
) : (
<span className="text-gray-400">
Expires in {hoursRemaining.toFixed(1)} hours
</span>
)}
</div>
)}
</Card>
);
};

View File

@ -0,0 +1,152 @@
/**
* Prediction Stats Overview Component - STC Theme (Gold/Black)
*/
import { FC } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import type { PredictionStats } from '../types';
interface StatsOverviewProps {
stats: PredictionStats;
}
export const StatsOverview: FC<StatsOverviewProps> = ({ stats }) => {
return (
<div className="space-y-6">
{/* Main Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="bg-primary-800 border-primary-700">
<CardContent className="p-4">
<p className="text-gray-400 text-sm">Total Predictions</p>
<p className="text-3xl font-bold text-gold">{stats.totalPredictions}</p>
</CardContent>
</Card>
<Card className="bg-primary-800 border-primary-700">
<CardContent className="p-4">
<p className="text-gray-400 text-sm">Win Rate</p>
<p className={`text-3xl font-bold ${stats.winRate >= 50 ? 'text-green-400' : 'text-red-400'}`}>
{stats.winRate.toFixed(1)}%
</p>
</CardContent>
</Card>
<Card className="bg-primary-800 border-primary-700">
<CardContent className="p-4">
<p className="text-gray-400 text-sm">Avg P&L</p>
<p className={`text-3xl font-bold ${stats.averagePnlPercent >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{stats.averagePnlPercent >= 0 ? '+' : ''}{stats.averagePnlPercent.toFixed(2)}%
</p>
</CardContent>
</Card>
<Card className="bg-primary-800 border-primary-700">
<CardContent className="p-4">
<p className="text-gray-400 text-sm">Win/Loss</p>
<p className="text-3xl font-bold">
<span className="text-green-400">{stats.winCount}</span>
<span className="text-gray-500">/</span>
<span className="text-red-400">{stats.lossCount}</span>
</p>
</CardContent>
</Card>
</div>
{/* Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* By Type */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader>
<CardTitle className="text-white">By Model Type</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{stats.byType.map((item) => (
<div key={item.predictionType} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-gray-300">{item.predictionType}</span>
<span className="text-gray-500 text-sm">({item.total})</span>
</div>
<div className="flex items-center gap-3">
<Progress
value={item.winRate}
className="w-24 h-2 bg-primary-700"
indicatorClassName={item.winRate >= 50 ? 'bg-green-500' : 'bg-red-500'}
/>
<span className={`text-sm font-medium w-12 text-right ${
item.winRate >= 50 ? 'text-green-400' : 'text-red-400'
}`}>
{item.winRate.toFixed(0)}%
</span>
</div>
</div>
))}
</CardContent>
</Card>
{/* By Asset Class */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader>
<CardTitle className="text-white">By Asset Class</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{stats.byAssetClass.map((item) => (
<div key={item.assetClass} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-gray-300">{item.assetClass}</span>
<span className="text-gray-500 text-sm">({item.total})</span>
</div>
<div className="flex items-center gap-3">
<Progress
value={item.winRate}
className="w-24 h-2 bg-primary-700"
indicatorClassName={item.winRate >= 50 ? 'bg-green-500' : 'bg-red-500'}
/>
<span className={`text-sm font-medium w-12 text-right ${
item.winRate >= 50 ? 'text-green-400' : 'text-red-400'
}`}>
{item.winRate.toFixed(0)}%
</span>
</div>
</div>
))}
</CardContent>
</Card>
</div>
{/* Outcome Distribution */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader>
<CardTitle className="text-white">Outcome Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-1 h-8">
<div
className="bg-green-500 h-full rounded-l transition-all"
style={{ width: `${(stats.winCount / stats.totalPredictions) * 100}%` }}
title={`Wins: ${stats.winCount}`}
/>
<div
className="bg-yellow-500 h-full transition-all"
style={{ width: `${(stats.partialCount / stats.totalPredictions) * 100}%` }}
title={`Partial: ${stats.partialCount}`}
/>
<div
className="bg-red-500 h-full transition-all"
style={{ width: `${(stats.lossCount / stats.totalPredictions) * 100}%` }}
title={`Losses: ${stats.lossCount}`}
/>
<div
className="bg-gray-600 h-full rounded-r transition-all"
style={{ width: `${(stats.expiredCount / stats.totalPredictions) * 100}%` }}
title={`Expired: ${stats.expiredCount}`}
/>
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-green-400">Wins: {stats.winCount}</span>
<span className="text-yellow-400">Partial: {stats.partialCount}</span>
<span className="text-red-400">Losses: {stats.lossCount}</span>
<span className="text-gray-400">Expired: {stats.expiredCount}</span>
</div>
</CardContent>
</Card>
</div>
);
};

View File

@ -0,0 +1,7 @@
/**
* Predictions Components Index
*/
export { PredictionCard } from './PredictionCard';
export { StatsOverview } from './StatsOverview';
export { PackageCard } from './PackageCard';

View File

@ -0,0 +1,128 @@
/**
* Predictions Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { predictionsApi } from '../services/predictions.api';
import { usePredictionsStore } from '../stores/predictions.store';
import type { RequestPredictionInput, PurchasePackageInput, PredictionFilters, PredictionWithOutcome } from '../types';
// Query keys
export const predictionKeys = {
all: ['predictions'] as const,
packages: (filters?: object) => [...predictionKeys.all, 'packages', filters] as const,
package: (id: string) => [...predictionKeys.all, 'package', id] as const,
purchases: (activeOnly?: boolean) => [...predictionKeys.all, 'purchases', activeOnly] as const,
purchase: (id: string) => [...predictionKeys.all, 'purchase', id] as const,
predictions: (filters?: PredictionFilters) => [...predictionKeys.all, 'list', filters] as const,
prediction: (id: string) => [...predictionKeys.all, 'detail', id] as const,
stats: () => [...predictionKeys.all, 'stats'] as const,
};
// Packages
export function usePackages(filters?: { predictionType?: string; assetClass?: string }) {
const { setPackages } = usePredictionsStore();
return useQuery({
queryKey: predictionKeys.packages(filters),
queryFn: async () => {
const packages = await predictionsApi.getPackages(filters);
setPackages(packages);
return packages;
},
staleTime: 5 * 60 * 1000,
});
}
// User purchases
export function usePurchases(activeOnly = true) {
const { setPurchases } = usePredictionsStore();
return useQuery({
queryKey: predictionKeys.purchases(activeOnly),
queryFn: async () => {
const purchases = await predictionsApi.getUserPurchases(activeOnly);
setPurchases(purchases);
return purchases;
},
staleTime: 30 * 1000,
});
}
// Predictions list
export function usePredictions(filters?: PredictionFilters) {
const { setPredictions } = usePredictionsStore();
return useQuery<PredictionWithOutcome[], Error>({
queryKey: predictionKeys.predictions(filters),
queryFn: async () => {
const predictions = await predictionsApi.getPredictions(filters);
setPredictions(predictions);
return predictions;
},
placeholderData: (previousData) => previousData,
staleTime: 30 * 1000,
});
}
// Single prediction
export function usePrediction(predictionId: string) {
return useQuery({
queryKey: predictionKeys.prediction(predictionId),
queryFn: () => predictionsApi.getPrediction(predictionId),
enabled: !!predictionId,
});
}
// User stats
export function usePredictionStats() {
const { setStats } = usePredictionsStore();
return useQuery({
queryKey: predictionKeys.stats(),
queryFn: async () => {
const stats = await predictionsApi.getUserStats();
setStats(stats);
return stats;
},
staleTime: 60 * 1000,
});
}
// Purchase package
export function usePurchasePackage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: PurchasePackageInput) => predictionsApi.purchasePackage(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: predictionKeys.purchases() });
queryClient.invalidateQueries({ queryKey: ['wallet'] });
toast.success('Package purchased successfully!');
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to purchase package');
},
});
}
// Request prediction
export function useRequestPrediction() {
const queryClient = useQueryClient();
const { appendPrediction } = usePredictionsStore();
return useMutation({
mutationFn: (input: RequestPredictionInput) => predictionsApi.requestPrediction(input),
onSuccess: (prediction) => {
appendPrediction(prediction);
queryClient.invalidateQueries({ queryKey: predictionKeys.purchases() });
queryClient.invalidateQueries({ queryKey: predictionKeys.predictions() });
queryClient.invalidateQueries({ queryKey: predictionKeys.stats() });
toast.success(`Prediction delivered for ${prediction.asset}`);
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to get prediction');
},
});
}

View File

@ -0,0 +1,19 @@
/**
* Predictions Module Index
*/
// Components
export * from './components';
// Pages
export { PredictionsPage } from './pages/PredictionsPage';
export { PredictionHistoryPage } from './pages/PredictionHistoryPage';
// Hooks
export * from './hooks/usePredictions';
// Store
export { usePredictionsStore } from './stores/predictions.store';
// Types
export * from './types';

View File

@ -0,0 +1,354 @@
/**
* Prediction History & Validation Page - STC Theme (Gold/Black)
* Shows all predictions with outcomes for transparency and validation
*/
import { FC, useState } from 'react';
import { Link } from 'react-router-dom';
import { usePredictions, usePredictionStats } from '../hooks/usePredictions';
import { usePredictionsStore } from '../stores/predictions.store';
import { PredictionCard, StatsOverview } from '../components';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
import type { PredictionType, AssetClass, PredictionStatus, PredictionWithOutcome } from '../types';
const PREDICTION_TYPES: { value: PredictionType | ''; label: string }[] = [
{ value: '', label: 'All Types' },
{ value: 'AMD', label: 'AMD' },
{ value: 'RANGE', label: 'Range' },
{ value: 'TPSL', label: 'TP/SL' },
{ value: 'ICT_SMC', label: 'ICT/SMC' },
{ value: 'STRATEGY_ENSEMBLE', label: 'Ensemble' },
];
const ASSET_CLASSES: { value: AssetClass | ''; label: string }[] = [
{ value: '', label: 'All Assets' },
{ value: 'FOREX', label: 'Forex' },
{ value: 'CRYPTO', label: 'Crypto' },
{ value: 'INDICES', label: 'Indices' },
{ value: 'COMMODITIES', label: 'Commodities' },
];
const STATUSES: { value: PredictionStatus | ''; label: string }[] = [
{ value: '', label: 'All Status' },
{ value: 'delivered', label: 'Active' },
{ value: 'validated', label: 'Validated (Win)' },
{ value: 'invalidated', label: 'Invalidated (Loss)' },
{ value: 'expired', label: 'Expired' },
];
export const PredictionHistoryPage: FC = () => {
const [selectedPrediction, setSelectedPrediction] = useState<PredictionWithOutcome | null>(null);
const { filters, setFilters } = usePredictionsStore();
const predictionsQuery = usePredictions(filters);
const predictions = predictionsQuery.data;
const predictionsLoading = predictionsQuery.isLoading;
const { data: stats, isLoading: statsLoading } = usePredictionStats();
const handleLoadMore = () => {
setFilters({ offset: (filters.offset || 0) + (filters.limit || 20) });
};
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">
Prediction <span className="text-gold">History</span>
</h1>
<p className="text-gray-400 mt-1">
Track and validate all your ML predictions
</p>
</div>
<Button variant="secondary" asChild>
<Link to="/predictions">
Get Predictions
</Link>
</Button>
</div>
{/* Stats Overview */}
{stats && !statsLoading && (
<StatsOverview stats={stats} />
)}
{/* Filters */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader>
<CardTitle className="text-white">Filter Predictions</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4">
{/* Type Filter */}
<Select
value={filters.predictionType || ''}
onValueChange={(value) => setFilters({ predictionType: value as PredictionType || undefined, offset: 0 })}
>
<SelectTrigger className="w-[180px] bg-primary-700 border-primary-600 text-white">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{PREDICTION_TYPES.map(({ value, label }) => (
<SelectItem key={value || 'all'} value={value || 'all'} className="text-white hover:bg-primary-700 focus:bg-primary-700">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Asset Class Filter */}
<Select
value={filters.assetClass || ''}
onValueChange={(value) => setFilters({ assetClass: value as AssetClass || undefined, offset: 0 })}
>
<SelectTrigger className="w-[180px] bg-primary-700 border-primary-600 text-white">
<SelectValue placeholder="All Assets" />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{ASSET_CLASSES.map(({ value, label }) => (
<SelectItem key={value || 'all'} value={value || 'all'} className="text-white hover:bg-primary-700 focus:bg-primary-700">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status Filter */}
<Select
value={filters.status || ''}
onValueChange={(value) => setFilters({ status: value as PredictionStatus || undefined, offset: 0 })}
>
<SelectTrigger className="w-[180px] bg-primary-700 border-primary-600 text-white">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{STATUSES.map(({ value, label }) => (
<SelectItem key={value || 'all'} value={value || 'all'} className="text-white hover:bg-primary-700 focus:bg-primary-700">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Asset Search */}
<Input
type="text"
placeholder="Search asset..."
value={filters.asset || ''}
onChange={(e) => setFilters({ asset: e.target.value || undefined, offset: 0 })}
className="w-[180px] bg-primary-700 border-primary-600 text-white placeholder:text-gray-500"
/>
</div>
</CardContent>
</Card>
{/* Predictions List */}
{predictionsLoading && !predictions?.length ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-48 w-full bg-primary-800" />
))}
</div>
) : predictions && predictions.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{predictions.map((prediction) => (
<PredictionCard
key={prediction.id}
prediction={prediction}
onClick={setSelectedPrediction}
/>
))}
</div>
{/* Load More */}
{predictions.length >= (filters.limit || 20) && (
<div className="text-center">
<Button
onClick={handleLoadMore}
disabled={predictionsLoading}
variant="outline"
>
{predictionsLoading ? 'Loading...' : 'Load More'}
</Button>
</div>
)}
</>
) : (
<div className="text-center py-12">
<div className="text-gray-500 text-6xl mb-4">🎯</div>
<h3 className="text-xl font-semibold text-white mb-2">No predictions found</h3>
<p className="text-gray-400 mb-4">Start by purchasing a prediction package</p>
<Button variant="secondary" asChild>
<Link to="/predictions">
Get Started
</Link>
</Button>
</div>
)}
{/* Prediction Detail Modal */}
<Dialog open={!!selectedPrediction} onOpenChange={(open: boolean) => !open && setSelectedPrediction(null)}>
<DialogContent className="bg-primary-800 border-primary-700 sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">
{selectedPrediction?.asset} Prediction
</DialogTitle>
</DialogHeader>
{selectedPrediction && (
<div className="space-y-4">
{/* Meta */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">Type</p>
<p className="text-white">{selectedPrediction.predictionType}</p>
</div>
<div>
<p className="text-gray-500">Asset Class</p>
<p className="text-white">{selectedPrediction.assetClass}</p>
</div>
<div>
<p className="text-gray-500">Timeframe</p>
<p className="text-white">{selectedPrediction.timeframe}</p>
</div>
<div>
<p className="text-gray-500">Confidence</p>
<p className="text-gold">{selectedPrediction.confidence}%</p>
</div>
</div>
{/* Direction */}
<div className={`text-center py-4 rounded-lg ${
selectedPrediction.direction === 'LONG' ? 'bg-green-500/20' :
selectedPrediction.direction === 'SHORT' ? 'bg-red-500/20' : 'bg-primary-700'
}`}>
<p className="text-gray-400 text-sm">Direction</p>
<p className={`text-3xl font-bold ${
selectedPrediction.direction === 'LONG' ? 'text-green-400' :
selectedPrediction.direction === 'SHORT' ? 'text-red-400' : 'text-gray-300'
}`}>
{selectedPrediction.direction === 'LONG' ? '↑ LONG' :
selectedPrediction.direction === 'SHORT' ? '↓ SHORT' : '→ NEUTRAL'}
</p>
</div>
{/* Price Levels */}
<div className="bg-primary-700/50 rounded-lg p-4 space-y-3">
<div className="flex justify-between">
<span className="text-gray-400">Entry Price</span>
<span className="text-white font-mono">
{selectedPrediction.entryPrice?.toFixed(5) || '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Target Price</span>
<span className="text-green-400 font-mono">
{selectedPrediction.targetPrice?.toFixed(5) || '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Stop Loss</span>
<span className="text-red-400 font-mono">
{selectedPrediction.stopLoss?.toFixed(5) || '-'}
</span>
</div>
</div>
{/* Outcome */}
{selectedPrediction.outcome && (
<div className={`rounded-lg p-4 ${
selectedPrediction.outcome.result === 'win' ? 'bg-green-500/20' :
selectedPrediction.outcome.result === 'loss' ? 'bg-red-500/20' : 'bg-primary-700'
}`}>
<h4 className="text-white font-semibold mb-2">Outcome</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-400">Result</p>
<p className={`font-bold ${
selectedPrediction.outcome.result === 'win' ? 'text-green-400' :
selectedPrediction.outcome.result === 'loss' ? 'text-red-400' : 'text-gray-300'
}`}>
{selectedPrediction.outcome.result}
</p>
</div>
<div>
<p className="text-gray-400">P&L</p>
<p className={`font-bold ${
(selectedPrediction.outcome.pnlPercent || 0) >= 0 ? 'text-green-400' : 'text-red-400'
}`}>
{selectedPrediction.outcome.pnlPercent !== null
? `${selectedPrediction.outcome.pnlPercent >= 0 ? '+' : ''}${selectedPrediction.outcome.pnlPercent.toFixed(2)}%`
: '-'}
</p>
</div>
<div>
<p className="text-gray-400">Actual Price</p>
<p className="text-white font-mono">
{selectedPrediction.outcome.actualPrice?.toFixed(5) || '-'}
</p>
</div>
<div>
<p className="text-gray-400">Verified</p>
<p className="text-white">
{new Date(selectedPrediction.outcome.verifiedAt).toLocaleString()}
</p>
</div>
</div>
{selectedPrediction.outcome.notes && (
<div className="mt-3 pt-3 border-t border-primary-600">
<p className="text-gray-400 text-sm">Notes</p>
<p className="text-white text-sm">{selectedPrediction.outcome.notes}</p>
</div>
)}
</div>
)}
{/* Timestamps */}
<div className="text-sm text-gray-500">
<p>Created: {new Date(selectedPrediction.createdAt).toLocaleString()}</p>
{selectedPrediction.deliveredAt && (
<p>Delivered: {new Date(selectedPrediction.deliveredAt).toLocaleString()}</p>
)}
<p>Expires: {new Date(selectedPrediction.expiresAt).toLocaleString()}</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Transparency Notice */}
<Card className="bg-primary-800 border-primary-700">
<CardHeader>
<CardTitle className="text-white">About Prediction Validation</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-400 text-sm">
All predictions are recorded with immutable timestamps and validated against real market data.
Outcomes are verified automatically when price targets are reached or when predictions expire.
This history page provides full transparency on our ML model performance.
</p>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -0,0 +1,254 @@
/**
* Predictions Marketplace Page - STC Theme (Gold/Black)
*/
import { FC, useState } from 'react';
import { Link } from 'react-router-dom';
import { History, Loader2 } from 'lucide-react';
import { usePackages, usePurchases, useRequestPrediction, usePurchasePackage } from '../hooks/usePredictions';
import { useWalletStore } from '@/modules/wallet/stores/wallet.store';
import { PackageCard } from '../components';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { AssetClass } from '../types';
const ASSETS: Record<AssetClass, string[]> = {
FOREX: ['EURUSD', 'GBPUSD', 'USDJPY', 'AUDUSD', 'USDCAD'],
CRYPTO: ['BTCUSD', 'ETHUSD', 'BNBUSD', 'SOLUSD', 'XRPUSD'],
INDICES: ['SPX500', 'NAS100', 'US30', 'GER40', 'UK100'],
COMMODITIES: ['XAUUSD', 'XAGUSD', 'XTIUSD', 'XNGUSD'],
};
const TIMEFRAMES = ['5m', '15m', '1H', '4H', '1D'];
export const PredictionsPage: FC = () => {
const [selectedPurchase, setSelectedPurchase] = useState<string>('');
const [selectedAsset, setSelectedAsset] = useState<string>('');
const [selectedAssetClass, setSelectedAssetClass] = useState<AssetClass>('FOREX');
const [selectedTimeframe, setSelectedTimeframe] = useState<string>('1H');
const { data: packages, isLoading: packagesLoading } = usePackages();
const { data: purchases } = usePurchases(true);
const { wallet } = useWalletStore();
const purchasePackage = usePurchasePackage();
const requestPrediction = useRequestPrediction();
const handlePurchasePackage = async (packageId: string) => {
if (!wallet) {
alert('Please set up your wallet first');
return;
}
await purchasePackage.mutateAsync({ packageId, walletId: wallet.id });
};
const handleRequestPrediction = async () => {
if (!selectedPurchase || !selectedAsset || !selectedAssetClass) {
alert('Please select a purchase, asset, and timeframe');
return;
}
await requestPrediction.mutateAsync({
purchaseId: selectedPurchase,
asset: selectedAsset,
assetClass: selectedAssetClass,
timeframe: selectedTimeframe,
});
};
const activePurchases = purchases?.filter(
(p) => p.status === 'COMPLETED' && p.predictionsRemaining > 0 && new Date(p.expiresAt) > new Date()
);
return (
<div className="min-h-screen bg-background p-6">
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">
ML <span className="text-gold">Predictions</span>
</h1>
<p className="text-gray-400 mt-1">AI-powered trading signals</p>
</div>
<Button variant="outline" asChild className="border-primary-600 text-white hover:bg-primary-700">
<Link to="/predictions/history">
<History className="w-4 h-4 mr-2" />
View History
</Link>
</Button>
</div>
{/* Active Credits */}
{activePurchases && activePurchases.length > 0 && (
<div className="bg-gradient-to-r from-gold/20 to-primary-700/50 rounded-xl border border-gold/30 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Request a Prediction</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{/* Purchase Select */}
<div className="space-y-2">
<Label className="text-gray-400">Package</Label>
<Select value={selectedPurchase} onValueChange={setSelectedPurchase}>
<SelectTrigger className="bg-primary-700 border-primary-600 text-white">
<SelectValue placeholder="Select package" />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{activePurchases.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-white hover:bg-primary-700 focus:bg-primary-700">
{p.packageName} ({p.predictionsRemaining} left)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Asset Class */}
<div className="space-y-2">
<Label className="text-gray-400">Asset Class</Label>
<Select
value={selectedAssetClass}
onValueChange={(value) => {
setSelectedAssetClass(value as AssetClass);
setSelectedAsset('');
}}
>
<SelectTrigger className="bg-primary-700 border-primary-600 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{Object.keys(ASSETS).map((ac) => (
<SelectItem key={ac} value={ac} className="text-white hover:bg-primary-700 focus:bg-primary-700">
{ac}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Asset */}
<div className="space-y-2">
<Label className="text-gray-400">Asset</Label>
<Select value={selectedAsset} onValueChange={setSelectedAsset}>
<SelectTrigger className="bg-primary-700 border-primary-600 text-white">
<SelectValue placeholder="Select asset" />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{ASSETS[selectedAssetClass].map((asset) => (
<SelectItem key={asset} value={asset} className="text-white hover:bg-primary-700 focus:bg-primary-700">
{asset}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Timeframe */}
<div className="space-y-2">
<Label className="text-gray-400">Timeframe</Label>
<Select value={selectedTimeframe} onValueChange={setSelectedTimeframe}>
<SelectTrigger className="bg-primary-700 border-primary-600 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{TIMEFRAMES.map((tf) => (
<SelectItem key={tf} value={tf} className="text-white hover:bg-primary-700 focus:bg-primary-700">
{tf}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
onClick={handleRequestPrediction}
disabled={requestPrediction.isPending || !selectedPurchase || !selectedAsset}
variant="secondary"
>
{requestPrediction.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
'Get Prediction'
)}
</Button>
</div>
)}
{/* Your Credits Summary */}
{purchases && purchases.length > 0 && (
<Card className="bg-primary-800 border-primary-700">
<CardHeader>
<CardTitle className="text-white">Your Prediction Credits</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{purchases.map((p) => (
<div
key={p.id}
className={`bg-primary-700/50 rounded-lg p-3 ${
p.predictionsRemaining > 0 && new Date(p.expiresAt) > new Date()
? 'border border-gold/50'
: 'opacity-60'
}`}
>
<div className="flex justify-between items-start mb-2">
<span className="text-white font-medium">{p.packageName}</span>
<span className="text-xs bg-primary-600 text-gray-300 px-2 py-0.5 rounded">
{p.predictionType}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Remaining</span>
<span className="text-white">{p.predictionsRemaining}/{p.totalPredictions}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-400">Expires</span>
<span className={new Date(p.expiresAt) < new Date() ? 'text-red-400' : 'text-gray-300'}>
{new Date(p.expiresAt).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Available Packages */}
<div>
<h2 className="text-xl font-semibold text-gold mb-4">Available Packages</h2>
{packagesLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-64 w-full bg-primary-800" />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{packages?.map((pkg) => (
<PackageCard
key={pkg.id}
package_={pkg}
onPurchase={handlePurchasePackage}
isLoading={purchasePackage.isPending}
/>
))}
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,71 @@
/**
* Predictions API Service
*/
import { api } from '@/services/api';
import type {
PredictionPackage,
PurchaseWithPackage,
PredictionWithOutcome,
PredictionStats,
RequestPredictionInput,
PurchasePackageInput,
PredictionFilters,
} from '../types';
const PREDICTIONS_BASE_URL = import.meta.env.VITE_PREDICTIONS_SERVICE_URL || 'http://localhost:3094';
export const predictionsApi = {
// Packages
async getPackages(filters?: { predictionType?: string; assetClass?: string }): Promise<PredictionPackage[]> {
const { data } = await api.get(`${PREDICTIONS_BASE_URL}/api/v1/packages`, { params: filters });
return data.data;
},
async getPackage(packageId: string): Promise<PredictionPackage> {
const { data } = await api.get(`${PREDICTIONS_BASE_URL}/api/v1/packages/${packageId}`);
return data.data;
},
// Purchases
async purchasePackage(input: PurchasePackageInput): Promise<PurchaseWithPackage> {
const { data } = await api.post(`${PREDICTIONS_BASE_URL}/api/v1/purchases`, input);
return data.data;
},
async getUserPurchases(activeOnly = true): Promise<PurchaseWithPackage[]> {
const { data } = await api.get(`${PREDICTIONS_BASE_URL}/api/v1/users/me/purchases`, {
params: { activeOnly },
});
return data.data;
},
async getPurchase(purchaseId: string): Promise<PurchaseWithPackage> {
const { data } = await api.get(`${PREDICTIONS_BASE_URL}/api/v1/purchases/${purchaseId}`);
return data.data;
},
// Predictions
async requestPrediction(input: RequestPredictionInput): Promise<PredictionWithOutcome> {
const { data } = await api.post(`${PREDICTIONS_BASE_URL}/api/v1/predictions`, input);
return data.data;
},
async getPredictions(filters?: PredictionFilters): Promise<PredictionWithOutcome[]> {
const { data } = await api.get(`${PREDICTIONS_BASE_URL}/api/v1/predictions`, {
params: { userId: 'me', ...filters },
});
return data.data;
},
async getPrediction(predictionId: string): Promise<PredictionWithOutcome> {
const { data } = await api.get(`${PREDICTIONS_BASE_URL}/api/v1/predictions/${predictionId}`);
return data.data;
},
// Stats
async getUserStats(): Promise<PredictionStats> {
const { data } = await api.get(`${PREDICTIONS_BASE_URL}/api/v1/users/me/prediction-stats`);
return data.data;
},
};

View File

@ -0,0 +1,97 @@
/**
* Predictions Zustand Store
*/
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import type {
PredictionPackage,
PurchaseWithPackage,
PredictionWithOutcome,
PredictionStats,
PredictionFilters,
} from '../types';
interface PredictionsState {
// State
packages: PredictionPackage[];
purchases: PurchaseWithPackage[];
predictions: PredictionWithOutcome[];
stats: PredictionStats | null;
filters: PredictionFilters;
isLoading: boolean;
error: string | null;
// Actions
setPackages: (packages: PredictionPackage[]) => void;
setPurchases: (purchases: PurchaseWithPackage[]) => void;
setPredictions: (predictions: PredictionWithOutcome[]) => void;
appendPrediction: (prediction: PredictionWithOutcome) => void;
setStats: (stats: PredictionStats | null) => void;
setFilters: (filters: Partial<PredictionFilters>) => void;
setLoading: (isLoading: boolean) => void;
setError: (error: string | null) => void;
reset: () => void;
}
const initialState = {
packages: [],
purchases: [],
predictions: [],
stats: null,
filters: {
limit: 20,
offset: 0,
},
isLoading: false,
error: null,
};
export const usePredictionsStore = create<PredictionsState>()(
devtools(
persist(
(set) => ({
...initialState,
setPackages: (packages) => set({ packages }, false, 'setPackages'),
setPurchases: (purchases) => set({ purchases }, false, 'setPurchases'),
setPredictions: (predictions) => set({ predictions }, false, 'setPredictions'),
appendPrediction: (prediction) =>
set(
(state) => ({
predictions: [prediction, ...state.predictions],
}),
false,
'appendPrediction'
),
setStats: (stats) => set({ stats }, false, 'setStats'),
setFilters: (filters) =>
set(
(state) => ({
filters: { ...state.filters, ...filters },
}),
false,
'setFilters'
),
setLoading: (isLoading) => set({ isLoading }, false, 'setLoading'),
setError: (error) => set({ error }, false, 'setError'),
reset: () => set(initialState, false, 'reset'),
}),
{
name: 'predictions-storage',
partialize: (state) => ({
filters: state.filters,
}),
}
),
{ name: 'PredictionsStore' }
)
);

View File

@ -0,0 +1,119 @@
/**
* Predictions Module Types
*/
export type PredictionType = 'AMD' | 'RANGE' | 'TPSL' | 'ICT_SMC' | 'STRATEGY_ENSEMBLE';
export type AssetClass = 'FOREX' | 'CRYPTO' | 'INDICES' | 'COMMODITIES';
// PredictionStatus matches DDL: ml.prediction_status
export type PredictionStatus = 'pending' | 'delivered' | 'expired' | 'validated' | 'invalidated';
// OutcomeResult matches DDL: ml.outcome_status
export type OutcomeResult = 'pending' | 'win' | 'loss' | 'partial' | 'expired' | 'cancelled';
export type PurchaseStatus = 'PENDING' | 'COMPLETED' | 'FAILED' | 'REFUNDED';
export interface PredictionPackage {
id: string;
name: string;
description: string | null;
predictionType: PredictionType;
assetClasses: AssetClass[];
predictionsCount: number;
priceCredits: number;
validityDays: number;
vipTierRequired: string | null;
isActive: boolean;
}
export interface PackagePurchase {
id: string;
packageId: string;
status: PurchaseStatus;
amountPaid: number;
predictionsRemaining: number;
predictionsUsed: number;
expiresAt: string;
createdAt: string;
}
export interface PurchaseWithPackage extends PackagePurchase {
packageName: string;
predictionType: PredictionType;
totalPredictions: number;
}
export interface Prediction {
id: string;
purchaseId: string;
predictionType: PredictionType;
asset: string;
assetClass: AssetClass;
timeframe: string;
direction: 'LONG' | 'SHORT' | 'NEUTRAL';
entryPrice: number | null;
targetPrice: number | null;
stopLoss: number | null;
confidence: number;
status: PredictionStatus;
expiresAt: string;
deliveredAt: string | null;
predictionData: Record<string, unknown>;
createdAt: string;
}
export interface PredictionOutcome {
id: string;
predictionId: string;
result: OutcomeResult;
actualPrice: number | null;
pnlPercent: number | null;
pnlAbsolute: number | null;
verifiedAt: string;
verificationSource: string;
notes: string | null;
}
export interface PredictionWithOutcome extends Prediction {
outcome?: PredictionOutcome;
}
export interface PredictionStats {
totalPredictions: number;
winCount: number;
lossCount: number;
partialCount: number;
expiredCount: number;
winRate: number;
averagePnlPercent: number;
byType: {
predictionType: PredictionType;
total: number;
wins: number;
winRate: number;
}[];
byAssetClass: {
assetClass: AssetClass;
total: number;
wins: number;
winRate: number;
}[];
}
export interface RequestPredictionInput {
purchaseId: string;
asset: string;
assetClass: AssetClass;
timeframe: string;
}
export interface PurchasePackageInput {
packageId: string;
walletId: string;
}
export interface PredictionFilters {
predictionType?: PredictionType;
assetClass?: AssetClass;
status?: PredictionStatus;
asset?: string;
limit?: number;
offset?: number;
}

View File

@ -0,0 +1,180 @@
/**
* Cart Sidebar Component - STC Theme (Gold/Black)
*/
import { FC } from 'react';
import { Trash2, Plus, Minus, Loader2 } from 'lucide-react';
import { useProductsStore, selectCartTotal, selectCartItemCount } from '../stores/products.store';
import { usePurchaseProduct } from '../hooks/useProducts';
import { useWalletStore } from '@/modules/wallet/stores/wallet.store';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
interface CartSidebarProps {
isOpen: boolean;
onClose: () => void;
}
export const CartSidebar: FC<CartSidebarProps> = ({ isOpen, onClose }) => {
const { cart, removeFromCart, updateCartQuantity, clearCart } = useProductsStore();
const cartTotal = useProductsStore(selectCartTotal);
const itemCount = useProductsStore(selectCartItemCount);
const { wallet } = useWalletStore();
const purchaseMutation = usePurchaseProduct();
const canAfford = wallet && wallet.balance >= cartTotal;
const handleCheckout = async () => {
if (!wallet || !canAfford) return;
// Process each cart item
for (const item of cart) {
for (let i = 0; i < item.quantity; i++) {
await purchaseMutation.mutateAsync({
productId: item.product.id,
walletId: wallet.id,
quantity: 1,
});
}
}
clearCart();
onClose();
};
return (
<Sheet open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
<SheetContent className="bg-primary-800 border-l border-primary-700 w-full sm:max-w-md flex flex-col p-0">
{/* Header */}
<SheetHeader className="p-4 border-b border-primary-700">
<SheetTitle className="text-white">
Cart ({itemCount})
</SheetTitle>
</SheetHeader>
{/* Cart Items */}
<div className="flex-1 overflow-y-auto p-4">
{cart.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-500 text-4xl mb-3">🛒</div>
<p className="text-gray-400">Your cart is empty</p>
</div>
) : (
<div className="space-y-4">
{cart.map((item) => (
<div
key={item.product.id}
className="bg-primary-700/50 rounded-lg p-4 flex gap-4"
>
{/* Image */}
<div className="w-16 h-16 bg-primary-700 rounded-lg flex items-center justify-center text-2xl flex-shrink-0">
{item.product.imageUrl ? (
<img
src={item.product.imageUrl}
alt={item.product.name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
'📦'
)}
</div>
{/* Details */}
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium truncate">{item.product.name}</h4>
<p className="text-gold font-bold">${item.product.priceCredits}</p>
{/* Quantity Controls */}
<div className="flex items-center gap-2 mt-2">
<Button
onClick={() => updateCartQuantity(item.product.id, item.quantity - 1)}
variant="ghost"
size="icon"
className="w-7 h-7 bg-primary-600 hover:bg-primary-500 text-white"
>
<Minus className="w-4 h-4" />
</Button>
<span className="text-white w-8 text-center">{item.quantity}</span>
<Button
onClick={() => updateCartQuantity(item.product.id, item.quantity + 1)}
variant="ghost"
size="icon"
className="w-7 h-7 bg-primary-600 hover:bg-primary-500 text-white"
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Remove */}
<Button
onClick={() => removeFromCart(item.product.id)}
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-400"
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Footer */}
{cart.length > 0 && (
<div className="border-t border-primary-700 p-4 space-y-4">
{/* Balance */}
<div className="flex justify-between text-sm">
<span className="text-gray-400">Your Balance</span>
<span className="text-white">${wallet?.balance.toFixed(2) || '0.00'}</span>
</div>
{/* Total */}
<div className="flex justify-between">
<span className="text-gray-300 font-medium">Total</span>
<span className="text-2xl font-bold text-gold">${cartTotal.toFixed(2)}</span>
</div>
{/* Insufficient Funds Warning */}
{!canAfford && (
<p className="text-red-400 text-sm text-center">
Insufficient balance. Please deposit more credits.
</p>
)}
{/* Checkout Button */}
<Button
onClick={handleCheckout}
disabled={!canAfford || purchaseMutation.isPending}
variant="secondary"
className="w-full"
>
{purchaseMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
'Checkout'
)}
</Button>
<Button
onClick={clearCart}
variant="ghost"
className="w-full text-gray-400 hover:text-white"
>
Clear Cart
</Button>
</div>
)}
</SheetContent>
</Sheet>
);
};

View File

@ -0,0 +1,126 @@
/**
* Product Card Component - STC Theme (Gold/Black)
*/
import { FC } from 'react';
import { Link } from 'react-router-dom';
import { Star } from 'lucide-react';
import type { Product, ProductCategory, ProductType } from '../types';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
interface ProductCardProps {
product: Product;
onAddToCart?: (product: Product) => void;
}
const categoryIcons: Record<ProductCategory, string> = {
PREDICTION: '🎯',
EDUCATION: '📚',
CONSULTING: '💼',
AGENT_ACCESS: '🤖',
SIGNAL_PACK: '📊',
API_ACCESS: '🔌',
PREMIUM_FEATURE: '⭐',
};
const typeLabels: Record<ProductType, { label: string; color: string }> = {
ONE_TIME: { label: 'One-time', color: 'bg-blue-500/20 text-blue-400 border-blue-500/50' },
SUBSCRIPTION: { label: 'Subscription', color: 'bg-gold/20 text-gold border-gold/50' },
VIP: { label: 'VIP', color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50' },
};
export const ProductCard: FC<ProductCardProps> = ({ product, onAddToCart }) => {
const hasDiscount = product.originalPrice && product.originalPrice > product.priceCredits;
const discountPercent = hasDiscount
? Math.round(((product.originalPrice! - product.priceCredits) / product.originalPrice!) * 100)
: 0;
return (
<Card className="bg-primary-800 border-primary-700 overflow-hidden hover:border-gold/50 transition-colors group">
{/* Image */}
<div className="relative aspect-video bg-primary-700">
{product.imageUrl ? (
<img
src={product.imageUrl}
alt={product.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-6xl">
{categoryIcons[product.category]}
</div>
)}
{/* Featured Badge */}
{product.isFeatured && (
<Badge className="absolute top-2 left-2 bg-gold text-primary-900">
Featured
</Badge>
)}
{/* Discount Badge */}
{hasDiscount && (
<Badge className="absolute top-2 right-2 bg-red-500 text-white">
-{discountPercent}%
</Badge>
)}
</div>
{/* Content */}
<CardContent className="p-4">
{/* Category & Type */}
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-400">{product.category}</span>
<Badge variant="outline" className={typeLabels[product.type].color}>
{typeLabels[product.type].label}
</Badge>
</div>
{/* Title */}
<Link to={`/products/${product.id}`}>
<h3 className="text-lg font-semibold text-white group-hover:text-gold transition-colors line-clamp-2">
{product.name}
</h3>
</Link>
{/* Description */}
{product.shortDescription && (
<p className="text-gray-400 text-sm mt-2 line-clamp-2">{product.shortDescription}</p>
)}
{/* Rating */}
{product.rating !== null && (
<div className="flex items-center gap-1 mt-2">
<Star className="w-4 h-4 text-gold fill-gold" />
<span className="text-white text-sm">{product.rating.toFixed(1)}</span>
<span className="text-gray-500 text-sm">({product.reviewsCount})</span>
</div>
)}
{/* Price & Action */}
<div className="flex items-center justify-between mt-4">
<div>
<p className="text-2xl font-bold text-gold">${product.priceCredits}</p>
{hasDiscount && (
<p className="text-sm text-gray-500 line-through">${product.originalPrice}</p>
)}
</div>
<Button
onClick={() => onAddToCart?.(product)}
variant="secondary"
size="sm"
>
Add to Cart
</Button>
</div>
{/* Sales Count */}
{product.totalSold > 0 && (
<p className="text-gray-500 text-xs mt-2">{product.totalSold} sold</p>
)}
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,56 @@
/**
* Product Grid Component - STC Theme (Gold/Black)
*/
import { FC } from 'react';
import { ProductCard } from './ProductCard';
import { Skeleton } from '@/components/ui/skeleton';
import type { Product } from '../types';
interface ProductGridProps {
products: Product[];
isLoading?: boolean;
onAddToCart?: (product: Product) => void;
}
export const ProductGrid: FC<ProductGridProps> = ({ products, isLoading, onAddToCart }) => {
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="bg-primary-800 rounded-xl border border-primary-700 overflow-hidden">
<Skeleton className="aspect-video bg-primary-700" />
<div className="p-4 space-y-3">
<Skeleton className="h-4 w-1/3 bg-primary-700" />
<Skeleton className="h-6 w-3/4 bg-primary-700" />
<Skeleton className="h-4 w-full bg-primary-700" />
<Skeleton className="h-4 w-2/3 bg-primary-700" />
<div className="flex justify-between items-center">
<Skeleton className="h-8 w-20 bg-primary-700" />
<Skeleton className="h-10 w-28 bg-primary-700" />
</div>
</div>
</div>
))}
</div>
);
}
if (products.length === 0) {
return (
<div className="text-center py-12">
<div className="text-gray-500 text-6xl mb-4">🛒</div>
<h3 className="text-xl font-semibold text-white mb-2">No products found</h3>
<p className="text-gray-400">Try adjusting your filters or search terms</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={onAddToCart} />
))}
</div>
);
};

View File

@ -0,0 +1,7 @@
/**
* Products Components Index
*/
export { ProductCard } from './ProductCard';
export { ProductGrid } from './ProductGrid';
export { CartSidebar } from './CartSidebar';

View File

@ -0,0 +1,121 @@
/**
* Products Hooks
*/
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';
import { productsApi } from '../services/products.api';
import { useProductsStore } from '../stores/products.store';
import type { ProductFilters, PurchaseInput } from '../types';
// Query keys
export const productKeys = {
all: ['products'] as const,
lists: () => [...productKeys.all, 'list'] as const,
list: (filters?: ProductFilters) => [...productKeys.lists(), filters] as const,
featured: () => [...productKeys.all, 'featured'] as const,
category: (category: string) => [...productKeys.all, 'category', category] as const,
detail: (id: string) => [...productKeys.all, 'detail', id] as const,
purchases: () => [...productKeys.all, 'purchases'] as const,
purchase: (id: string) => [...productKeys.all, 'purchase', id] as const,
};
// List products
export function useProducts(filters?: ProductFilters) {
const { setProducts } = useProductsStore();
return useQuery({
queryKey: productKeys.list(filters),
queryFn: async () => {
const products = await productsApi.getProducts(filters);
setProducts(products);
return products;
},
placeholderData: keepPreviousData,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Featured products
export function useFeaturedProducts() {
const { setFeaturedProducts } = useProductsStore();
return useQuery({
queryKey: productKeys.featured(),
queryFn: async () => {
const products = await productsApi.getFeaturedProducts();
setFeaturedProducts(products);
return products;
},
staleTime: 5 * 60 * 1000,
});
}
// Products by category
export function useProductsByCategory(category: string) {
return useQuery({
queryKey: productKeys.category(category),
queryFn: () => productsApi.getProductsByCategory(category),
staleTime: 5 * 60 * 1000,
enabled: !!category,
});
}
// Single product
export function useProduct(productId: string) {
const { setSelectedProduct } = useProductsStore();
return useQuery({
queryKey: productKeys.detail(productId),
queryFn: async () => {
const product = await productsApi.getProduct(productId);
setSelectedProduct(product);
return product;
},
enabled: !!productId,
});
}
// User purchases
export function useUserPurchases() {
return useQuery({
queryKey: productKeys.purchases(),
queryFn: productsApi.getUserPurchases,
staleTime: 60 * 1000, // 1 minute
});
}
// Single purchase
export function usePurchase(purchaseId: string) {
return useQuery({
queryKey: productKeys.purchase(purchaseId),
queryFn: () => productsApi.getPurchase(purchaseId),
enabled: !!purchaseId,
});
}
// Purchase mutation
export function usePurchaseProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: PurchaseInput) => productsApi.purchaseProduct(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productKeys.purchases() });
queryClient.invalidateQueries({ queryKey: ['wallet'] }); // Refresh wallet balance
toast.success('Purchase successful!');
},
onError: (error: Error) => {
toast.error(error.message || 'Purchase failed');
},
});
}
// Check ownership
export function useProductOwnership(productId: string) {
return useQuery({
queryKey: [...productKeys.detail(productId), 'ownership'],
queryFn: () => productsApi.checkOwnership(productId),
enabled: !!productId,
staleTime: 30 * 1000,
});
}

View File

@ -0,0 +1,18 @@
/**
* Products Module Index
*/
// Components
export * from './components';
// Pages
export { ProductsPage } from './pages/ProductsPage';
// Hooks
export * from './hooks/useProducts';
// Store
export { useProductsStore, selectCartTotal, selectCartItemCount } from './stores/products.store';
// Types
export * from './types';

View File

@ -0,0 +1,230 @@
/**
* Products Page - STC Theme (Gold/Black)
*/
import { FC, useState } from 'react';
import { ShoppingCart } from 'lucide-react';
import { useProducts, useFeaturedProducts } from '../hooks/useProducts';
import { useProductsStore, selectCartItemCount } from '../stores/products.store';
import { ProductGrid, CartSidebar } from '../components';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ProductCategory, ProductType } from '../types';
const CATEGORIES: { value: ProductCategory | ''; label: string }[] = [
{ value: '', label: 'All Categories' },
{ value: 'PREDICTION', label: 'Predictions' },
{ value: 'EDUCATION', label: 'Education' },
{ value: 'CONSULTING', label: 'Consulting' },
{ value: 'AGENT_ACCESS', label: 'Agent Access' },
{ value: 'SIGNAL_PACK', label: 'Signal Packs' },
{ value: 'API_ACCESS', label: 'API Access' },
{ value: 'PREMIUM_FEATURE', label: 'Premium Features' },
];
const TYPES: { value: ProductType | ''; label: string }[] = [
{ value: '', label: 'All Types' },
{ value: 'ONE_TIME', label: 'One-time' },
{ value: 'SUBSCRIPTION', label: 'Subscription' },
{ value: 'VIP', label: 'VIP' },
];
const SORT_OPTIONS = [
{ value: 'newest', label: 'Newest' },
{ value: 'price', label: 'Price: Low to High' },
{ value: 'price-desc', label: 'Price: High to Low' },
{ value: 'rating', label: 'Best Rated' },
{ value: 'sales', label: 'Best Selling' },
];
export const ProductsPage: FC = () => {
const [showCart, setShowCart] = useState(false);
const [search, setSearch] = useState('');
const { filters, setFilters, addToCart } = useProductsStore();
const cartItemCount = useProductsStore(selectCartItemCount);
const { data: products, isLoading } = useProducts({
...filters,
search: search || undefined,
});
const { data: featuredProducts } = useFeaturedProducts();
const handleCategoryChange = (category: string) => {
setFilters({ category: (category || undefined) as ProductCategory | undefined, offset: 0 });
};
const handleTypeChange = (type: string) => {
setFilters({ type: (type || undefined) as ProductType | undefined, offset: 0 });
};
const handleSortChange = (value: string) => {
if (value === 'price-desc') {
setFilters({ sortBy: 'price', sortOrder: 'desc' });
} else if (value === 'price') {
setFilters({ sortBy: 'price', sortOrder: 'asc' });
} else {
setFilters({ sortBy: value as 'newest' | 'price' | 'rating' | 'sales', sortOrder: 'desc' });
}
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<div className="bg-primary-800 border-b border-primary-700">
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-white">
<span className="text-gold">Marketplace</span>
</h1>
<p className="text-gray-400 mt-1">Browse products, tools, and services</p>
</div>
<Button
onClick={() => setShowCart(true)}
variant="secondary"
className="relative"
>
<ShoppingCart className="w-5 h-5 mr-2" />
Cart
{cartItemCount > 0 && (
<Badge className="absolute -top-2 -right-2 bg-red-500 text-white h-5 w-5 flex items-center justify-center p-0">
{cartItemCount}
</Badge>
)}
</Button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-6">
{/* Featured Products */}
{featuredProducts && featuredProducts.length > 0 && !filters.category && !search && (
<div className="mb-8">
<h2 className="text-xl font-semibold text-gold mb-4">Featured</h2>
<ProductGrid
products={featuredProducts.slice(0, 3)}
onAddToCart={addToCart}
/>
</div>
)}
{/* Filters */}
<Card className="bg-primary-800 border-primary-700 mb-6">
<CardContent className="p-4">
<div className="flex flex-wrap gap-4">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search products..."
className="bg-primary-700 border-primary-600 text-white placeholder:text-gray-500"
/>
</div>
{/* Category */}
<Select
value={filters.category || ''}
onValueChange={handleCategoryChange}
>
<SelectTrigger className="w-[180px] bg-primary-700 border-primary-600 text-white">
<SelectValue placeholder="All Categories" />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{CATEGORIES.map(({ value, label }) => (
<SelectItem
key={value || 'all'}
value={value || 'all'}
className="text-white hover:bg-primary-700 focus:bg-primary-700"
>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Type */}
<Select
value={filters.type || ''}
onValueChange={handleTypeChange}
>
<SelectTrigger className="w-[150px] bg-primary-700 border-primary-600 text-white">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{TYPES.map(({ value, label }) => (
<SelectItem
key={value || 'all'}
value={value || 'all'}
className="text-white hover:bg-primary-700 focus:bg-primary-700"
>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Sort */}
<Select
value={
filters.sortBy === 'price' && filters.sortOrder === 'desc'
? 'price-desc'
: filters.sortBy || 'newest'
}
onValueChange={handleSortChange}
>
<SelectTrigger className="w-[180px] bg-primary-700 border-primary-600 text-white">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent className="bg-primary-800 border-primary-700">
{SORT_OPTIONS.map(({ value, label }) => (
<SelectItem
key={value}
value={value}
className="text-white hover:bg-primary-700 focus:bg-primary-700"
>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Products Grid */}
<ProductGrid
products={products || []}
isLoading={isLoading}
onAddToCart={addToCart}
/>
{/* Load More */}
{products && products.length >= (filters.limit || 20) && (
<div className="text-center mt-8">
<Button
onClick={() => setFilters({ offset: (filters.offset || 0) + (filters.limit || 20) })}
variant="outline"
className="border-primary-600 text-white hover:bg-primary-700"
>
Load More
</Button>
</div>
)}
</div>
{/* Cart Sidebar */}
<CartSidebar isOpen={showCart} onClose={() => setShowCart(false)} />
</div>
);
};

View File

@ -0,0 +1,62 @@
/**
* Products API Service
*/
import { api } from '@/services/api';
import type { Product, ProductPurchase, PurchaseWithProduct, ProductFilters, PurchaseInput } from '../types';
const PRODUCTS_BASE_URL = import.meta.env.VITE_PRODUCTS_SERVICE_URL || 'http://localhost:3091';
export const productsApi = {
// List products
async getProducts(filters?: ProductFilters): Promise<Product[]> {
const { data } = await api.get(`${PRODUCTS_BASE_URL}/api/v1/products`, { params: filters });
return data.data;
},
// Get single product
async getProduct(productId: string): Promise<Product> {
const { data } = await api.get(`${PRODUCTS_BASE_URL}/api/v1/products/${productId}`);
return data.data;
},
// Get featured products
async getFeaturedProducts(): Promise<Product[]> {
const { data } = await api.get(`${PRODUCTS_BASE_URL}/api/v1/products/featured`);
return data.data;
},
// Get products by category
async getProductsByCategory(category: string): Promise<Product[]> {
const { data } = await api.get(`${PRODUCTS_BASE_URL}/api/v1/categories/${category}/products`);
return data.data;
},
// Purchase product
async purchaseProduct(input: PurchaseInput): Promise<ProductPurchase> {
const { data } = await api.post(`${PRODUCTS_BASE_URL}/api/v1/purchases`, input);
return data.data;
},
// Get user purchases
async getUserPurchases(): Promise<PurchaseWithProduct[]> {
const { data } = await api.get(`${PRODUCTS_BASE_URL}/api/v1/users/me/purchases`);
return data.data;
},
// Get single purchase
async getPurchase(purchaseId: string): Promise<PurchaseWithProduct> {
const { data } = await api.get(`${PRODUCTS_BASE_URL}/api/v1/purchases/${purchaseId}`);
return data.data;
},
// Check if user owns product
async checkOwnership(productId: string): Promise<boolean> {
try {
const { data } = await api.get(`${PRODUCTS_BASE_URL}/api/v1/users/me/products/${productId}/ownership`);
return data.data.owned;
} catch {
return false;
}
},
};

View File

@ -0,0 +1,138 @@
/**
* Products Zustand Store
*/
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import type { Product, CartItem, ProductFilters } from '../types';
interface ProductsState {
// State
products: Product[];
featuredProducts: Product[];
selectedProduct: Product | null;
cart: CartItem[];
filters: ProductFilters;
isLoading: boolean;
error: string | null;
// Actions
setProducts: (products: Product[]) => void;
setFeaturedProducts: (products: Product[]) => void;
setSelectedProduct: (product: Product | null) => void;
addToCart: (product: Product, quantity?: number) => void;
removeFromCart: (productId: string) => void;
updateCartQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
setFilters: (filters: Partial<ProductFilters>) => void;
setLoading: (isLoading: boolean) => void;
setError: (error: string | null) => void;
reset: () => void;
}
const initialState = {
products: [],
featuredProducts: [],
selectedProduct: null,
cart: [],
filters: {
sortBy: 'newest' as const,
sortOrder: 'desc' as const,
limit: 20,
offset: 0,
},
isLoading: false,
error: null,
};
export const useProductsStore = create<ProductsState>()(
devtools(
persist(
(set) => ({
...initialState,
setProducts: (products) => set({ products }, false, 'setProducts'),
setFeaturedProducts: (featuredProducts) =>
set({ featuredProducts }, false, 'setFeaturedProducts'),
setSelectedProduct: (selectedProduct) =>
set({ selectedProduct }, false, 'setSelectedProduct'),
addToCart: (product, quantity = 1) =>
set(
(state) => {
const existing = state.cart.find((item) => item.product.id === product.id);
if (existing) {
return {
cart: state.cart.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
),
};
}
return { cart: [...state.cart, { product, quantity }] };
},
false,
'addToCart'
),
removeFromCart: (productId) =>
set(
(state) => ({
cart: state.cart.filter((item) => item.product.id !== productId),
}),
false,
'removeFromCart'
),
updateCartQuantity: (productId, quantity) =>
set(
(state) => ({
cart:
quantity <= 0
? state.cart.filter((item) => item.product.id !== productId)
: state.cart.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
),
}),
false,
'updateCartQuantity'
),
clearCart: () => set({ cart: [] }, false, 'clearCart'),
setFilters: (filters) =>
set(
(state) => ({
filters: { ...state.filters, ...filters },
}),
false,
'setFilters'
),
setLoading: (isLoading) => set({ isLoading }, false, 'setLoading'),
setError: (error) => set({ error }, false, 'setError'),
reset: () => set(initialState, false, 'reset'),
}),
{
name: 'products-storage',
partialize: (state) => ({
cart: state.cart,
filters: state.filters,
}),
}
),
{ name: 'ProductsStore' }
)
);
// Selectors
export const selectCartTotal = (state: ProductsState) =>
state.cart.reduce((total, item) => total + item.product.priceCredits * item.quantity, 0);
export const selectCartItemCount = (state: ProductsState) =>
state.cart.reduce((count, item) => count + item.quantity, 0);

View File

@ -0,0 +1,83 @@
/**
* Products Module Types
*/
// Alineado con backend mcp-products/src/types/product.types.ts (2026-01-13)
export type ProductType = 'ONE_TIME' | 'SUBSCRIPTION' | 'VIP';
export type ProductCategory =
| 'PREDICTION'
| 'EDUCATION'
| 'CONSULTING'
| 'AGENT_ACCESS'
| 'SIGNAL_PACK'
| 'API_ACCESS'
| 'PREMIUM_FEATURE';
export type PurchaseStatus = 'pending' | 'completed' | 'failed' | 'refunded' | 'expired';
export type DeliveryStatus = 'pending' | 'processing' | 'delivered' | 'failed' | 'refunded';
export interface Product {
id: string;
name: string;
description: string | null;
shortDescription: string | null;
type: ProductType;
category: ProductCategory;
priceCredits: number;
originalPrice: number | null;
isActive: boolean;
isFeatured: boolean;
sortOrder: number;
maxQuantityPerUser: number | null;
totalSold: number;
rating: number | null;
reviewsCount: number;
imageUrl: string | null;
features: string[];
metadata: Record<string, unknown>;
createdAt: string;
}
export interface ProductPurchase {
id: string;
productId: string;
userId: string;
walletId: string;
status: PurchaseStatus;
quantity: number;
unitPrice: number;
totalAmount: number;
walletTransactionId: string | null;
deliveryStatus: DeliveryStatus;
deliveredAt: string | null;
expiresAt: string | null;
deliveryData: Record<string, unknown> | null;
createdAt: string;
}
export interface PurchaseWithProduct extends ProductPurchase {
product: Product;
}
export interface ProductFilters {
category?: ProductCategory;
type?: ProductType;
minPrice?: number;
maxPrice?: number;
isFeatured?: boolean;
search?: string;
sortBy?: 'price' | 'rating' | 'sales' | 'newest';
sortOrder?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
export interface CartItem {
product: Product;
quantity: number;
}
export interface PurchaseInput {
productId: string;
walletId: string;
quantity?: number;
}

View File

@ -0,0 +1,123 @@
/**
* PermissionGate - Conditional rendering based on permissions
*/
import React from 'react';
import { usePermission, useAnyPermission, useAllPermissions, useRole } from '../hooks/useRbac';
interface PermissionGateProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface SinglePermissionProps extends PermissionGateProps {
permission: string;
}
interface MultiPermissionProps extends PermissionGateProps {
permissions: string[];
require?: 'any' | 'all';
}
interface RoleGateProps extends PermissionGateProps {
role: string;
}
/**
* Gate that requires a single permission
*/
export function RequirePermission({ permission, children, fallback = null }: SinglePermissionProps) {
const hasPermission = usePermission(permission);
if (!hasPermission) {
return <>{fallback}</>;
}
return <>{children}</>;
}
/**
* Gate that requires multiple permissions (any or all)
*/
export function RequirePermissions({
permissions,
require = 'any',
children,
fallback = null,
}: MultiPermissionProps) {
const hasAny = useAnyPermission(permissions);
const hasAll = useAllPermissions(permissions);
const hasAccess = require === 'any' ? hasAny : hasAll;
if (!hasAccess) {
return <>{fallback}</>;
}
return <>{children}</>;
}
/**
* Gate that requires a specific role
*/
export function RequireRole({ role, children, fallback = null }: RoleGateProps) {
const hasRole = useRole(role);
if (!hasRole) {
return <>{fallback}</>;
}
return <>{children}</>;
}
/**
* Combined permission gate with flexible options
*/
interface PermissionGateCombinedProps extends PermissionGateProps {
permission?: string;
permissions?: string[];
require?: 'any' | 'all';
role?: string;
}
export function PermissionGate({
permission,
permissions,
require = 'any',
role,
children,
fallback = null,
}: PermissionGateCombinedProps) {
const hasSinglePermission = usePermission(permission || '');
const hasAnyPermissions = useAnyPermission(permissions || []);
const hasAllPermissions = useAllPermissions(permissions || []);
const hasRole = useRole(role || '');
// Determine if user has access
let hasAccess = true;
if (permission) {
hasAccess = hasAccess && hasSinglePermission;
}
if (permissions && permissions.length > 0) {
if (require === 'any') {
hasAccess = hasAccess && hasAnyPermissions;
} else {
hasAccess = hasAccess && hasAllPermissions;
}
}
if (role) {
hasAccess = hasAccess && hasRole;
}
if (!hasAccess) {
return <>{fallback}</>;
}
return <>{children}</>;
}
// Export default for convenience
export default PermissionGate;

View File

@ -0,0 +1,10 @@
/**
* RBAC Components
*/
export {
PermissionGate,
RequirePermission,
RequirePermissions,
RequireRole,
} from './PermissionGate';

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