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:
parent
bcd0737ba9
commit
737303d177
17
.env
Normal file
17
.env
Normal 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
17
.env.example
Normal 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
31
Dockerfile
Normal 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;"]
|
||||
79
README.md
79
README.md
@ -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
589
docs/MASTER_INVENTORY.yml
Normal 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
300
docs/PROXIMA-ACCION.md
Normal 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`
|
||||
299
docs/auditorias/AUDITORIA-MIGRACION-STC-THEME.md
Normal file
299
docs/auditorias/AUDITORIA-MIGRACION-STC-THEME.md
Normal 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
17
index.html
Normal 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
92
nginx.conf
Normal 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
8849
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
73
package.json
Normal file
73
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
69
src/App.tsx
Normal file
69
src/App.tsx
Normal 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
71
src/__tests__/setup.ts
Normal 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
244
src/components/Layout.tsx
Normal 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>© {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>
|
||||
);
|
||||
};
|
||||
57
src/components/ProtectedRoute.tsx
Normal file
57
src/components/ProtectedRoute.tsx
Normal 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}</>;
|
||||
};
|
||||
269
src/components/shared/NotionContentViewer.tsx
Normal file
269
src/components/shared/NotionContentViewer.tsx
Normal 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;
|
||||
55
src/components/ui/accordion.tsx
Normal file
55
src/components/ui/accordion.tsx
Normal 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 }
|
||||
47
src/components/ui/avatar.tsx
Normal file
47
src/components/ui/avatar.tsx
Normal 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 }
|
||||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal 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 }
|
||||
47
src/components/ui/button.tsx
Normal file
47
src/components/ui/button.tsx
Normal 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 }
|
||||
67
src/components/ui/card.tsx
Normal file
67
src/components/ui/card.tsx
Normal 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 }
|
||||
27
src/components/ui/checkbox.tsx
Normal file
27
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
119
src/components/ui/dialog.tsx
Normal file
119
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
197
src/components/ui/dropdown-menu.tsx
Normal file
197
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
29
src/components/ui/index.ts
Normal file
29
src/components/ui/index.ts
Normal 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'
|
||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal 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 }
|
||||
23
src/components/ui/label.tsx
Normal file
23
src/components/ui/label.tsx
Normal 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 }
|
||||
30
src/components/ui/popover.tsx
Normal file
30
src/components/ui/popover.tsx
Normal 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 }
|
||||
29
src/components/ui/progress.tsx
Normal file
29
src/components/ui/progress.tsx
Normal 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 }
|
||||
45
src/components/ui/scroll-area.tsx
Normal file
45
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
157
src/components/ui/select.tsx
Normal file
157
src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal 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
140
src/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal 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 }
|
||||
26
src/components/ui/switch.tsx
Normal file
26
src/components/ui/switch.tsx
Normal 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
119
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
52
src/components/ui/tabs.tsx
Normal file
52
src/components/ui/tabs.tsx
Normal 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 }
|
||||
23
src/components/ui/textarea.tsx
Normal file
23
src/components/ui/textarea.tsx
Normal 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 }
|
||||
27
src/components/ui/tooltip.tsx
Normal file
27
src/components/ui/tooltip.tsx
Normal 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
52
src/hooks/useTheme.ts
Normal 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
239
src/index.css
Normal 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
6
src/lib/utils.ts
Normal 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
14
src/main.tsx
Normal 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>
|
||||
);
|
||||
262
src/modules/auth/__tests__/auth.store.test.ts
Normal file
262
src/modules/auth/__tests__/auth.store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
247
src/modules/auth/__tests__/auth.types.test.ts
Normal file
247
src/modules/auth/__tests__/auth.types.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
6
src/modules/auth/components/index.ts
Normal file
6
src/modules/auth/components/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Auth Components - Re-exports
|
||||
*/
|
||||
|
||||
// No additional components yet
|
||||
// Future: SessionList, PasswordStrengthIndicator, etc.
|
||||
240
src/modules/auth/hooks/useAuth.ts
Normal file
240
src/modules/auth/hooks/useAuth.ts
Normal 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
28
src/modules/auth/index.ts
Normal 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';
|
||||
168
src/modules/auth/pages/ForgotPasswordPage.tsx
Normal file
168
src/modules/auth/pages/ForgotPasswordPage.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} Trading Platform. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
159
src/modules/auth/pages/LoginPage.tsx
Normal file
159
src/modules/auth/pages/LoginPage.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} Trading Platform. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
343
src/modules/auth/pages/RegisterPage.tsx
Normal file
343
src/modules/auth/pages/RegisterPage.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} Trading Platform. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
234
src/modules/auth/pages/ResetPasswordPage.tsx
Normal file
234
src/modules/auth/pages/ResetPasswordPage.tsx
Normal 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">
|
||||
© {new Date().getFullYear()} Trading Platform. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
src/modules/auth/pages/index.ts
Normal file
8
src/modules/auth/pages/index.ts
Normal 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';
|
||||
190
src/modules/auth/services/auth.api.ts
Normal file
190
src/modules/auth/services/auth.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
103
src/modules/auth/stores/auth.store.ts
Normal file
103
src/modules/auth/stores/auth.store.ts
Normal 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);
|
||||
156
src/modules/auth/types/index.ts
Normal file
156
src/modules/auth/types/index.ts
Normal 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;
|
||||
}
|
||||
5
src/modules/dashboard/index.ts
Normal file
5
src/modules/dashboard/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Dashboard Module Exports
|
||||
*/
|
||||
|
||||
export { DashboardPage } from './pages/DashboardPage';
|
||||
350
src/modules/dashboard/pages/DashboardPage.tsx
Normal file
350
src/modules/dashboard/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
385
src/modules/education/components/CourseCard.tsx
Normal file
385
src/modules/education/components/CourseCard.tsx
Normal 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;
|
||||
2
src/modules/education/components/index.ts
Normal file
2
src/modules/education/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { CourseCard } from './CourseCard';
|
||||
export { default as CourseCardDefault } from './CourseCard';
|
||||
171
src/modules/education/hooks/useEducation.ts
Normal file
171
src/modules/education/hooks/useEducation.ts
Normal 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
|
||||
});
|
||||
}
|
||||
29
src/modules/education/index.ts
Normal file
29
src/modules/education/index.ts
Normal 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';
|
||||
619
src/modules/education/pages/CourseDetailPage.tsx
Normal file
619
src/modules/education/pages/CourseDetailPage.tsx
Normal 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;
|
||||
428
src/modules/education/pages/CoursesPage.tsx
Normal file
428
src/modules/education/pages/CoursesPage.tsx
Normal 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;
|
||||
148
src/modules/education/services/education.api.ts
Normal file
148
src/modules/education/services/education.api.ts
Normal 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;
|
||||
60
src/modules/education/types/index.ts
Normal file
60
src/modules/education/types/index.ts
Normal 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;
|
||||
}
|
||||
120
src/modules/investment/components/AgentCard.tsx
Normal file
120
src/modules/investment/components/AgentCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
119
src/modules/investment/components/AllocationCard.tsx
Normal file
119
src/modules/investment/components/AllocationCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
200
src/modules/investment/components/AllocationModal.tsx
Normal file
200
src/modules/investment/components/AllocationModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
7
src/modules/investment/components/index.ts
Normal file
7
src/modules/investment/components/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Investment Components Index
|
||||
*/
|
||||
|
||||
export { AgentCard } from './AgentCard';
|
||||
export { AllocationCard } from './AllocationCard';
|
||||
export { AllocationModal } from './AllocationModal';
|
||||
178
src/modules/investment/hooks/useInvestment.ts
Normal file
178
src/modules/investment/hooks/useInvestment.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
}
|
||||
18
src/modules/investment/index.ts
Normal file
18
src/modules/investment/index.ts
Normal 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';
|
||||
191
src/modules/investment/pages/InvestmentPage.tsx
Normal file
191
src/modules/investment/pages/InvestmentPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
98
src/modules/investment/services/investment.api.ts
Normal file
98
src/modules/investment/services/investment.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
80
src/modules/investment/stores/investment.store.ts
Normal file
80
src/modules/investment/stores/investment.store.ts
Normal 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' }
|
||||
)
|
||||
);
|
||||
109
src/modules/investment/types/index.ts
Normal file
109
src/modules/investment/types/index.ts
Normal 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;
|
||||
}
|
||||
93
src/modules/predictions/components/PackageCard.tsx
Normal file
93
src/modules/predictions/components/PackageCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
157
src/modules/predictions/components/PredictionCard.tsx
Normal file
157
src/modules/predictions/components/PredictionCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
152
src/modules/predictions/components/StatsOverview.tsx
Normal file
152
src/modules/predictions/components/StatsOverview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
7
src/modules/predictions/components/index.ts
Normal file
7
src/modules/predictions/components/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Predictions Components Index
|
||||
*/
|
||||
|
||||
export { PredictionCard } from './PredictionCard';
|
||||
export { StatsOverview } from './StatsOverview';
|
||||
export { PackageCard } from './PackageCard';
|
||||
128
src/modules/predictions/hooks/usePredictions.ts
Normal file
128
src/modules/predictions/hooks/usePredictions.ts
Normal 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');
|
||||
},
|
||||
});
|
||||
}
|
||||
19
src/modules/predictions/index.ts
Normal file
19
src/modules/predictions/index.ts
Normal 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';
|
||||
354
src/modules/predictions/pages/PredictionHistoryPage.tsx
Normal file
354
src/modules/predictions/pages/PredictionHistoryPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
254
src/modules/predictions/pages/PredictionsPage.tsx
Normal file
254
src/modules/predictions/pages/PredictionsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
src/modules/predictions/services/predictions.api.ts
Normal file
71
src/modules/predictions/services/predictions.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
97
src/modules/predictions/stores/predictions.store.ts
Normal file
97
src/modules/predictions/stores/predictions.store.ts
Normal 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' }
|
||||
)
|
||||
);
|
||||
119
src/modules/predictions/types/index.ts
Normal file
119
src/modules/predictions/types/index.ts
Normal 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;
|
||||
}
|
||||
180
src/modules/products/components/CartSidebar.tsx
Normal file
180
src/modules/products/components/CartSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
126
src/modules/products/components/ProductCard.tsx
Normal file
126
src/modules/products/components/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
56
src/modules/products/components/ProductGrid.tsx
Normal file
56
src/modules/products/components/ProductGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
7
src/modules/products/components/index.ts
Normal file
7
src/modules/products/components/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Products Components Index
|
||||
*/
|
||||
|
||||
export { ProductCard } from './ProductCard';
|
||||
export { ProductGrid } from './ProductGrid';
|
||||
export { CartSidebar } from './CartSidebar';
|
||||
121
src/modules/products/hooks/useProducts.ts
Normal file
121
src/modules/products/hooks/useProducts.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
18
src/modules/products/index.ts
Normal file
18
src/modules/products/index.ts
Normal 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';
|
||||
230
src/modules/products/pages/ProductsPage.tsx
Normal file
230
src/modules/products/pages/ProductsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
62
src/modules/products/services/products.api.ts
Normal file
62
src/modules/products/services/products.api.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
138
src/modules/products/stores/products.store.ts
Normal file
138
src/modules/products/stores/products.store.ts
Normal 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);
|
||||
83
src/modules/products/types/index.ts
Normal file
83
src/modules/products/types/index.ts
Normal 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;
|
||||
}
|
||||
123
src/modules/rbac/components/PermissionGate.tsx
Normal file
123
src/modules/rbac/components/PermissionGate.tsx
Normal 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;
|
||||
10
src/modules/rbac/components/index.ts
Normal file
10
src/modules/rbac/components/index.ts
Normal 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
Loading…
Reference in New Issue
Block a user