[REMEDIATION] feat: Frontend remediation across auth, payments, portfolio, trading, marketplace modules

Enhance SecuritySettings page, PortfolioDetailPage, AgentsPage. Add marketplace
and payment services/types. Fix barrel exports across 8 modules.
Addresses frontend gaps from TASK-2026-02-05 analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-05 23:17:22 -06:00
parent 9e8f69d7f2
commit 67e54d6519
80 changed files with 16904 additions and 163 deletions

View File

@ -26,6 +26,7 @@ const SecuritySettings = lazy(() => import('./modules/auth/pages/SecuritySetting
const Dashboard = lazy(() => import('./modules/dashboard/pages/Dashboard')); const Dashboard = lazy(() => import('./modules/dashboard/pages/Dashboard'));
const Trading = lazy(() => import('./modules/trading/pages/Trading')); const Trading = lazy(() => import('./modules/trading/pages/Trading'));
const TradingAgents = lazy(() => import('./modules/trading/pages/AgentsPage')); const TradingAgents = lazy(() => import('./modules/trading/pages/AgentsPage'));
const BotDetail = lazy(() => import('./modules/trading/pages/BotDetailPage'));
const MLDashboard = lazy(() => import('./modules/ml/pages/MLDashboard')); const MLDashboard = lazy(() => import('./modules/ml/pages/MLDashboard'));
const BacktestingDashboard = lazy(() => import('./modules/backtesting/pages/BacktestingDashboard')); const BacktestingDashboard = lazy(() => import('./modules/backtesting/pages/BacktestingDashboard'));
const Investment = lazy(() => import('./modules/investment/pages/Investment')); const Investment = lazy(() => import('./modules/investment/pages/Investment'));
@ -37,8 +38,11 @@ const InvestmentTransactions = lazy(() => import('./modules/investment/pages/Tra
const InvestmentReports = lazy(() => import('./modules/investment/pages/Reports')); const InvestmentReports = lazy(() => import('./modules/investment/pages/Reports'));
const ProductDetail = lazy(() => import('./modules/investment/pages/ProductDetail')); const ProductDetail = lazy(() => import('./modules/investment/pages/ProductDetail'));
const KYCVerification = lazy(() => import('./modules/investment/pages/KYCVerification')); const KYCVerification = lazy(() => import('./modules/investment/pages/KYCVerification'));
const InvestmentDeposit = lazy(() => import('./modules/investment/pages/Deposit'));
const InvestmentWithdraw = lazy(() => import('./modules/investment/pages/Withdraw'));
const Settings = lazy(() => import('./modules/settings/pages/Settings')); const Settings = lazy(() => import('./modules/settings/pages/Settings'));
const Assistant = lazy(() => import('./modules/assistant/pages/Assistant')); const Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
const AgentSettingsPage = lazy(() => import('./modules/assistant/pages/AgentSettingsPage'));
// Lazy load modules - Portfolio // Lazy load modules - Portfolio
const PortfolioDashboard = lazy(() => import('./modules/portfolio/pages/PortfolioDashboard')); const PortfolioDashboard = lazy(() => import('./modules/portfolio/pages/PortfolioDashboard'));
@ -60,6 +64,9 @@ const Pricing = lazy(() => import('./modules/payments/pages/Pricing'));
const Billing = lazy(() => import('./modules/payments/pages/Billing')); const Billing = lazy(() => import('./modules/payments/pages/Billing'));
const CheckoutSuccess = lazy(() => import('./modules/payments/pages/CheckoutSuccess')); const CheckoutSuccess = lazy(() => import('./modules/payments/pages/CheckoutSuccess'));
const CheckoutCancel = lazy(() => import('./modules/payments/pages/CheckoutCancel')); const CheckoutCancel = lazy(() => import('./modules/payments/pages/CheckoutCancel'));
const InvoicesPage = lazy(() => import('./modules/payments/pages/InvoicesPage'));
const RefundsPage = lazy(() => import('./modules/payments/pages/RefundsPage'));
const PaymentMethodsPage = lazy(() => import('./modules/payments/pages/PaymentMethodsPage'));
// Lazy load modules - Notifications // Lazy load modules - Notifications
const NotificationsPage = lazy(() => import('./modules/notifications/pages/NotificationsPage')); const NotificationsPage = lazy(() => import('./modules/notifications/pages/NotificationsPage'));
@ -74,6 +81,9 @@ const PredictionsPage = lazy(() => import('./modules/admin/pages/PredictionsPage
const MarketplaceCatalog = lazy(() => import('./modules/marketplace/pages/MarketplaceCatalog')); const MarketplaceCatalog = lazy(() => import('./modules/marketplace/pages/MarketplaceCatalog'));
const SignalPackDetail = lazy(() => import('./modules/marketplace/pages/SignalPackDetail')); const SignalPackDetail = lazy(() => import('./modules/marketplace/pages/SignalPackDetail'));
const AdvisoryDetail = lazy(() => import('./modules/marketplace/pages/AdvisoryDetail')); const AdvisoryDetail = lazy(() => import('./modules/marketplace/pages/AdvisoryDetail'));
const CheckoutFlow = lazy(() => import('./modules/marketplace/pages/CheckoutFlow'));
const SellerDashboard = lazy(() => import('./modules/marketplace/pages/SellerDashboard'));
const CreateProductWizard = lazy(() => import('./modules/marketplace/pages/CreateProductWizard'));
function App() { function App() {
return ( return (
@ -104,6 +114,7 @@ function App() {
{/* Trading */} {/* Trading */}
<Route path="/trading" element={<Trading />} /> <Route path="/trading" element={<Trading />} />
<Route path="/trading/agents" element={<TradingAgents />} /> <Route path="/trading/agents" element={<TradingAgents />} />
<Route path="/trading/agents/:botId" element={<BotDetail />} />
<Route path="/ml-dashboard" element={<MLDashboard />} /> <Route path="/ml-dashboard" element={<MLDashboard />} />
<Route path="/backtesting" element={<BacktestingDashboard />} /> <Route path="/backtesting" element={<BacktestingDashboard />} />
<Route path="/investment" element={<Investment />} /> <Route path="/investment" element={<Investment />} />
@ -115,6 +126,8 @@ function App() {
<Route path="/investment/reports" element={<InvestmentReports />} /> <Route path="/investment/reports" element={<InvestmentReports />} />
<Route path="/investment/products/:productId" element={<ProductDetail />} /> <Route path="/investment/products/:productId" element={<ProductDetail />} />
<Route path="/investment/kyc" element={<KYCVerification />} /> <Route path="/investment/kyc" element={<KYCVerification />} />
<Route path="/investment/deposit" element={<InvestmentDeposit />} />
<Route path="/investment/withdraw" element={<InvestmentWithdraw />} />
{/* Portfolio Manager */} {/* Portfolio Manager */}
<Route path="/portfolio" element={<PortfolioDashboard />} /> <Route path="/portfolio" element={<PortfolioDashboard />} />
@ -135,6 +148,9 @@ function App() {
{/* Payments */} {/* Payments */}
<Route path="/pricing" element={<Pricing />} /> <Route path="/pricing" element={<Pricing />} />
<Route path="/billing" element={<Billing />} /> <Route path="/billing" element={<Billing />} />
<Route path="/billing/invoices" element={<InvoicesPage />} />
<Route path="/billing/refunds" element={<RefundsPage />} />
<Route path="/billing/payment-methods" element={<PaymentMethodsPage />} />
<Route path="/payments/success" element={<CheckoutSuccess />} /> <Route path="/payments/success" element={<CheckoutSuccess />} />
<Route path="/payments/cancel" element={<CheckoutCancel />} /> <Route path="/payments/cancel" element={<CheckoutCancel />} />
@ -149,6 +165,7 @@ function App() {
{/* Assistant */} {/* Assistant */}
<Route path="/assistant" element={<Assistant />} /> <Route path="/assistant" element={<Assistant />} />
<Route path="/assistant/settings" element={<AgentSettingsPage />} />
{/* Admin */} {/* Admin */}
<Route path="/admin" element={<AdminDashboard />} /> <Route path="/admin" element={<AdminDashboard />} />
@ -161,6 +178,10 @@ function App() {
<Route path="/marketplace/signals/:slug" element={<SignalPackDetail />} /> <Route path="/marketplace/signals/:slug" element={<SignalPackDetail />} />
<Route path="/marketplace/advisory/:slug" element={<AdvisoryDetail />} /> <Route path="/marketplace/advisory/:slug" element={<AdvisoryDetail />} />
<Route path="/marketplace/courses/:slug" element={<CourseDetail />} /> <Route path="/marketplace/courses/:slug" element={<CourseDetail />} />
<Route path="/marketplace/checkout/:productId" element={<CheckoutFlow />} />
<Route path="/marketplace/seller" element={<SellerDashboard />} />
<Route path="/marketplace/seller/create" element={<CreateProductWizard />} />
<Route path="/marketplace/seller/products/new" element={<CreateProductWizard />} />
</Route> </Route>
{/* Redirects */} {/* Redirects */}

View File

@ -0,0 +1,304 @@
/**
* PaymentMethodsManager Component
* Complete payment methods management with Stripe Elements for adding new cards
* Epic: OQI-005 Pagos y Stripe
*/
import React, { useState, useCallback } from 'react';
import {
CreditCard,
Plus,
X,
Loader2,
AlertCircle,
Shield,
} from 'lucide-react';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { StripeCardElementChangeEvent } from '@stripe/stripe-js';
import PaymentMethodsList from './PaymentMethodsList';
import StripeElementsWrapper from './StripeElementsWrapper';
import {
usePaymentMethods,
useAddPaymentMethod,
} from '../../hooks/usePayments';
// Card Element options for dark theme
const CARD_ELEMENT_OPTIONS = {
style: {
base: {
color: '#f1f5f9',
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: '16px',
fontSmoothing: 'antialiased',
'::placeholder': {
color: '#64748b',
},
},
invalid: {
color: '#ef4444',
iconColor: '#ef4444',
},
},
hidePostalCode: true,
};
// ============================================================================
// Add Payment Method Form (internal, wrapped in Elements)
// ============================================================================
interface AddPaymentMethodFormProps {
onSuccess: () => void;
onCancel: () => void;
}
const AddPaymentMethodForm: React.FC<AddPaymentMethodFormProps> = ({
onSuccess,
onCancel,
}) => {
const stripe = useStripe();
const elements = useElements();
const addPaymentMethod = useAddPaymentMethod();
const [isProcessing, setIsProcessing] = useState(false);
const [cardError, setCardError] = useState<string | null>(null);
const [cardComplete, setCardComplete] = useState(false);
const [setAsDefault, setSetAsDefault] = useState(true);
const handleCardChange = useCallback((event: StripeCardElementChangeEvent) => {
setCardError(event.error?.message || null);
setCardComplete(event.complete);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
return;
}
setIsProcessing(true);
setCardError(null);
try {
// Create payment method with Stripe
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
});
if (error) {
setCardError(error.message || 'Failed to create payment method');
setIsProcessing(false);
return;
}
if (!paymentMethod) {
setCardError('Failed to create payment method');
setIsProcessing(false);
return;
}
// Save to backend
await addPaymentMethod.mutateAsync(paymentMethod.id);
onSuccess();
} catch (err) {
setCardError(err instanceof Error ? err.message : 'Failed to add payment method');
} finally {
setIsProcessing(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Card Element */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Card Details
</label>
<div className="p-4 bg-gray-900 border border-gray-700 rounded-lg focus-within:border-blue-500 transition-colors">
<CardElement options={CARD_ELEMENT_OPTIONS} onChange={handleCardChange} />
</div>
{cardError && (
<p className="text-sm text-red-400 flex items-center gap-1.5">
<AlertCircle className="w-4 h-4" />
{cardError}
</p>
)}
</div>
{/* Set as Default Checkbox */}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={setAsDefault}
onChange={(e) => setSetAsDefault(e.target.checked)}
className="w-4 h-4 rounded border-gray-600 bg-gray-800 text-blue-600 focus:ring-blue-500"
/>
<span className="text-gray-300 text-sm">Set as default payment method</span>
</label>
{/* Security Note */}
<div className="flex items-center gap-2 p-3 bg-gray-900/50 rounded-lg">
<Shield className="w-4 h-4 text-green-400" />
<p className="text-xs text-gray-500">
Your card information is securely processed by Stripe and never stored on our servers.
</p>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onCancel}
disabled={isProcessing}
className="px-4 py-2 text-gray-400 hover:text-white transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={!stripe || !cardComplete || isProcessing}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : (
<>
<Plus className="w-4 h-4" />
Add Card
</>
)}
</button>
</div>
</form>
);
};
// ============================================================================
// Main PaymentMethodsManager Component
// ============================================================================
export interface PaymentMethodsManagerProps {
title?: string;
showTitle?: boolean;
compact?: boolean;
onMethodChange?: () => void;
}
const PaymentMethodsManager: React.FC<PaymentMethodsManagerProps> = ({
title = 'Payment Methods',
showTitle = true,
compact = false,
onMethodChange,
}) => {
const [showAddForm, setShowAddForm] = useState(false);
const { data: methods, isLoading, error, refetch } = usePaymentMethods();
const handleAddSuccess = useCallback(() => {
setShowAddForm(false);
refetch();
onMethodChange?.();
}, [refetch, onMethodChange]);
const handleAddNew = useCallback(() => {
setShowAddForm(true);
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
</div>
);
}
if (error) {
return (
<div className="text-center py-8">
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-gray-400 mb-4">
{error instanceof Error ? error.message : 'Failed to load payment methods'}
</p>
<button
onClick={() => refetch()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm"
>
Retry
</button>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
{showTitle && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-600/20 rounded-lg">
<CreditCard className="w-5 h-5 text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">{title}</h2>
<p className="text-sm text-gray-400">
{methods?.length ?? 0} saved payment method{methods?.length !== 1 ? 's' : ''}
</p>
</div>
</div>
{!showAddForm && (
<button
onClick={handleAddNew}
className="flex items-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm transition-colors"
>
<Plus className="w-4 h-4" />
Add New
</button>
)}
</div>
)}
{/* Add Payment Method Form */}
{showAddForm && (
<div className="p-6 bg-gray-800/50 rounded-xl border border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-white font-medium flex items-center gap-2">
<CreditCard className="w-5 h-5 text-blue-400" />
Add New Payment Method
</h3>
<button
onClick={() => setShowAddForm(false)}
className="p-1 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<StripeElementsWrapper showLoadingState={false}>
<AddPaymentMethodForm
onSuccess={handleAddSuccess}
onCancel={() => setShowAddForm(false)}
/>
</StripeElementsWrapper>
</div>
)}
{/* Payment Methods List */}
<PaymentMethodsList
onAddNew={handleAddNew}
showAddButton={!showAddForm}
compact={compact}
/>
</div>
);
};
export default PaymentMethodsManager;

View File

@ -40,8 +40,8 @@ export interface Refund {
reasonDetails?: string; reasonDetails?: string;
status: RefundStatus; status: RefundStatus;
refundMethod: 'original' | 'wallet'; refundMethod: 'original' | 'wallet';
requestedAt: Date; requestedAt: string;
processedAt?: Date; processedAt?: string;
failureReason?: string; failureReason?: string;
transactionId?: string; transactionId?: string;
} }
@ -103,22 +103,24 @@ const formatCurrency = (amount: number, currency: string = 'USD') => {
}).format(amount); }).format(amount);
}; };
// Date formatter // Date formatter (accepts Date or string)
const formatDate = (date: Date) => { function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
}).format(date); }).format(d);
}; }
// Time formatter // Time formatter (accepts Date or string)
const formatTime = (date: Date) => { function formatTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}).format(date); }).format(d);
}; }
// Status badge component // Status badge component
const StatusBadge: React.FC<{ status: RefundStatus }> = ({ status }) => { const StatusBadge: React.FC<{ status: RefundStatus }> = ({ status }) => {

View File

@ -31,6 +31,8 @@ export type { InvoiceLineItem, InvoiceDiscount, InvoiceTax, InvoicePreviewData }
// Payment Methods Management // Payment Methods Management
export { default as PaymentMethodsList } from './PaymentMethodsList'; export { default as PaymentMethodsList } from './PaymentMethodsList';
export type { PaymentMethod } from './PaymentMethodsList'; export type { PaymentMethod } from './PaymentMethodsList';
export { default as PaymentMethodsManager } from './PaymentMethodsManager';
export type { PaymentMethodsManagerProps } from './PaymentMethodsManager';
// Subscription Management // Subscription Management
export { default as SubscriptionUpgradeFlow } from './SubscriptionUpgradeFlow'; export { default as SubscriptionUpgradeFlow } from './SubscriptionUpgradeFlow';

View File

@ -33,3 +33,22 @@ export type {
// Feature Flags // Feature Flags
export { useFeatureFlags } from './useFeatureFlags'; export { useFeatureFlags } from './useFeatureFlags';
// Payment Hooks (OQI-005)
export {
useInvoices,
useInvoice,
useDownloadInvoice,
usePaymentMethods,
useAddPaymentMethod,
useRemovePaymentMethod,
useSetDefaultPaymentMethod,
useRefunds,
useRefund,
useRefundEligibility,
useRequestRefund,
useCancelRefund,
useBillingData,
paymentKeys,
} from './usePayments';
export type { UseInvoicesOptions, UseRefundsOptions } from './usePayments';

219
src/hooks/usePayments.ts Normal file
View File

@ -0,0 +1,219 @@
/**
* Payment Hooks
* TanStack Query hooks for payment operations
* Epic: OQI-005 Pagos y Stripe
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { paymentService } from '../services/payment.service';
import type { RefundRequest } from '../types/payment.types';
// Query Keys
export const paymentKeys = {
all: ['payments'] as const,
invoices: () => [...paymentKeys.all, 'invoices'] as const,
invoicesList: (limit: number, offset: number) => [...paymentKeys.invoices(), { limit, offset }] as const,
invoice: (id: string) => [...paymentKeys.invoices(), id] as const,
paymentMethods: () => [...paymentKeys.all, 'payment-methods'] as const,
refunds: () => [...paymentKeys.all, 'refunds'] as const,
refundsList: (limit: number, offset: number, status?: string) =>
[...paymentKeys.refunds(), { limit, offset, status }] as const,
refund: (id: string) => [...paymentKeys.refunds(), id] as const,
refundEligibility: (subscriptionId: string) =>
[...paymentKeys.refunds(), 'eligibility', subscriptionId] as const,
};
// ============================================================================
// Invoice Hooks
// ============================================================================
export interface UseInvoicesOptions {
limit?: number;
offset?: number;
enabled?: boolean;
}
export function useInvoices(options: UseInvoicesOptions = {}) {
const { limit = 20, offset = 0, enabled = true } = options;
return useQuery({
queryKey: paymentKeys.invoicesList(limit, offset),
queryFn: () => paymentService.getInvoices(limit, offset),
enabled,
staleTime: 30000, // 30 seconds
});
}
export function useInvoice(invoiceId: string | undefined) {
return useQuery({
queryKey: paymentKeys.invoice(invoiceId ?? ''),
queryFn: () => paymentService.getInvoiceById(invoiceId!),
enabled: !!invoiceId,
staleTime: 60000, // 1 minute
});
}
export function useDownloadInvoice() {
return useMutation({
mutationFn: async (invoiceId: string) => {
const blob = await paymentService.downloadInvoice(invoiceId);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `invoice-${invoiceId}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
return true;
},
});
}
// ============================================================================
// Payment Methods Hooks
// ============================================================================
export function usePaymentMethods(enabled = true) {
return useQuery({
queryKey: paymentKeys.paymentMethods(),
queryFn: () => paymentService.getPaymentMethods(),
enabled,
staleTime: 60000, // 1 minute
});
}
export function useAddPaymentMethod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (paymentMethodId: string) => paymentService.addPaymentMethod(paymentMethodId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.paymentMethods() });
},
});
}
export function useRemovePaymentMethod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (paymentMethodId: string) => paymentService.removePaymentMethod(paymentMethodId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.paymentMethods() });
},
});
}
export function useSetDefaultPaymentMethod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (paymentMethodId: string) => paymentService.setDefaultPaymentMethod(paymentMethodId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.paymentMethods() });
},
});
}
// ============================================================================
// Refund Hooks
// ============================================================================
export interface UseRefundsOptions {
limit?: number;
offset?: number;
status?: string;
enabled?: boolean;
}
export function useRefunds(options: UseRefundsOptions = {}) {
const { limit = 20, offset = 0, status, enabled = true } = options;
return useQuery({
queryKey: paymentKeys.refundsList(limit, offset, status),
queryFn: () => paymentService.getRefunds(limit, offset, status),
enabled,
staleTime: 30000, // 30 seconds
});
}
export function useRefund(refundId: string | undefined) {
return useQuery({
queryKey: paymentKeys.refund(refundId ?? ''),
queryFn: () => paymentService.getRefundById(refundId!),
enabled: !!refundId,
staleTime: 60000, // 1 minute
});
}
export function useRefundEligibility(subscriptionId: string | undefined) {
return useQuery({
queryKey: paymentKeys.refundEligibility(subscriptionId ?? ''),
queryFn: () => paymentService.getRefundEligibility(subscriptionId!),
enabled: !!subscriptionId,
staleTime: 60000, // 1 minute
});
}
export function useRequestRefund() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: RefundRequest) => paymentService.requestRefund(request),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.refunds() });
},
});
}
export function useCancelRefund() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (refundId: string) => paymentService.cancelRefund(refundId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: paymentKeys.refunds() });
},
});
}
// ============================================================================
// Combined Hook for Billing Page
// ============================================================================
export function useBillingData() {
const invoicesQuery = useInvoices({ limit: 5 });
const paymentMethodsQuery = usePaymentMethods();
const refundsQuery = useRefunds({ limit: 5 });
return {
invoices: invoicesQuery.data?.invoices ?? [],
paymentMethods: paymentMethodsQuery.data ?? [],
refunds: refundsQuery.data?.refunds ?? [],
isLoading:
invoicesQuery.isLoading || paymentMethodsQuery.isLoading || refundsQuery.isLoading,
error: invoicesQuery.error || paymentMethodsQuery.error || refundsQuery.error,
refetch: () => {
invoicesQuery.refetch();
paymentMethodsQuery.refetch();
refundsQuery.refetch();
},
};
}
export default {
useInvoices,
useInvoice,
useDownloadInvoice,
usePaymentMethods,
useAddPaymentMethod,
useRemovePaymentMethod,
useSetDefaultPaymentMethod,
useRefunds,
useRefund,
useRefundEligibility,
useRequestRefund,
useCancelRefund,
useBillingData,
};

View File

@ -0,0 +1,465 @@
/**
* AgentModeSelector Component
* Toggle between proactive and reactive agent modes with configuration
* OQI-007: LLM Strategy Agent - Agent Mode Selection UI
*/
import React, { useState, useCallback } from 'react';
import {
BoltIcon,
HandRaisedIcon,
ClockIcon,
BellIcon,
ChartBarIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
Cog6ToothIcon,
ChevronDownIcon,
ChevronUpIcon,
CheckIcon,
PlusIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { BoltIcon as BoltSolidIcon, HandRaisedIcon as HandRaisedSolidIcon } from '@heroicons/react/24/solid';
// ============================================================================
// Types
// ============================================================================
export type AgentMode = 'proactive' | 'reactive';
export interface TriggerCondition {
id: string;
type: 'price_alert' | 'signal_generated' | 'risk_threshold' | 'market_event' | 'schedule';
name: string;
enabled: boolean;
config: Record<string, unknown>;
}
export interface ActivitySchedule {
id: string;
dayOfWeek: number; // 0-6 (Sunday-Saturday)
startTime: string; // HH:MM
endTime: string; // HH:MM
enabled: boolean;
}
export interface AgentModeConfig {
mode: AgentMode;
triggers: TriggerCondition[];
schedules: ActivitySchedule[];
notifyOnAction: boolean;
requireConfirmation: boolean;
maxActionsPerHour: number;
}
interface AgentModeSelectorProps {
config: AgentModeConfig;
onModeChange: (mode: AgentMode) => void;
onUpdateTrigger: (triggerId: string, updates: Partial<TriggerCondition>) => void;
onAddTrigger?: () => void;
onRemoveTrigger?: (triggerId: string) => void;
onUpdateSchedule: (scheduleId: string, updates: Partial<ActivitySchedule>) => void;
onUpdateConfig: (updates: Partial<AgentModeConfig>) => void;
isLoading?: boolean;
}
// ============================================================================
// Constants
// ============================================================================
const TRIGGER_TYPES: Record<string, { label: string; icon: React.ElementType; description: string }> = {
price_alert: {
label: 'Alerta de Precio',
icon: ChartBarIcon,
description: 'Cuando un precio alcanza cierto nivel',
},
signal_generated: {
label: 'Senal Generada',
icon: BoltIcon,
description: 'Cuando se genera una senal de trading',
},
risk_threshold: {
label: 'Umbral de Riesgo',
icon: ExclamationTriangleIcon,
description: 'Cuando se supera un nivel de riesgo',
},
market_event: {
label: 'Evento de Mercado',
icon: BellIcon,
description: 'Noticias o eventos importantes',
},
schedule: {
label: 'Programado',
icon: ClockIcon,
description: 'En horarios especificos',
},
};
const DAYS_OF_WEEK = [
{ value: 0, label: 'Dom' },
{ value: 1, label: 'Lun' },
{ value: 2, label: 'Mar' },
{ value: 3, label: 'Mie' },
{ value: 4, label: 'Jue' },
{ value: 5, label: 'Vie' },
{ value: 6, label: 'Sab' },
];
// ============================================================================
// Sub-Components
// ============================================================================
interface ModeCardProps {
mode: AgentMode;
isSelected: boolean;
onSelect: () => void;
}
const ModeCard: React.FC<ModeCardProps> = ({ mode, isSelected, onSelect }) => {
const isProactive = mode === 'proactive';
const Icon = isProactive ? (isSelected ? BoltSolidIcon : BoltIcon) : (isSelected ? HandRaisedSolidIcon : HandRaisedIcon);
return (
<button
onClick={onSelect}
className={`flex-1 p-4 rounded-xl border-2 transition-all ${
isSelected
? isProactive
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
: 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex flex-col items-center text-center">
<div className={`p-3 rounded-full mb-3 ${
isSelected
? isProactive
? 'bg-amber-500 text-white'
: 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500'
}`}>
<Icon className="w-6 h-6" />
</div>
<h4 className={`font-semibold mb-1 ${
isSelected
? isProactive
? 'text-amber-700 dark:text-amber-300'
: 'text-blue-700 dark:text-blue-300'
: 'text-gray-900 dark:text-white'
}`}>
{isProactive ? 'Proactivo' : 'Reactivo'}
</h4>
<p className="text-xs text-gray-500">
{isProactive
? 'El agente toma acciones automaticamente'
: 'El agente solo responde a tus preguntas'}
</p>
</div>
</button>
);
};
interface TriggerItemProps {
trigger: TriggerCondition;
onToggle: (enabled: boolean) => void;
onRemove?: () => void;
}
const TriggerItem: React.FC<TriggerItemProps> = ({ trigger, onToggle, onRemove }) => {
const typeInfo = TRIGGER_TYPES[trigger.type];
const Icon = typeInfo?.icon || BellIcon;
return (
<div className={`flex items-center gap-3 p-3 rounded-lg border transition-colors ${
trigger.enabled
? 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 opacity-75'
}`}>
<div className={`p-2 rounded-lg ${
trigger.enabled ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-400'
}`}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">{trigger.name}</p>
<p className="text-xs text-gray-500">{typeInfo?.description}</p>
</div>
<div className="flex items-center gap-2">
{onRemove && (
<button
onClick={onRemove}
className="p-1.5 text-gray-400 hover:text-red-500 rounded-lg"
>
<XMarkIcon className="w-4 h-4" />
</button>
)}
<button
onClick={() => onToggle(!trigger.enabled)}
className={`relative w-10 h-6 rounded-full transition-colors ${
trigger.enabled ? 'bg-amber-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
trigger.enabled ? 'translate-x-5' : 'translate-x-1'
}`} />
</button>
</div>
</div>
);
};
interface ScheduleEditorProps {
schedules: ActivitySchedule[];
onUpdate: (scheduleId: string, updates: Partial<ActivitySchedule>) => void;
}
const ScheduleEditor: React.FC<ScheduleEditorProps> = ({ schedules, onUpdate }) => {
return (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Horarios de Actividad</p>
<div className="grid grid-cols-7 gap-1.5">
{DAYS_OF_WEEK.map((day) => {
const schedule = schedules.find((s) => s.dayOfWeek === day.value);
return (
<button
key={day.value}
onClick={() => {
if (schedule) {
onUpdate(schedule.id, { enabled: !schedule.enabled });
}
}}
className={`p-2 rounded-lg text-center transition-colors ${
schedule?.enabled
? 'bg-amber-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<span className="text-xs font-medium">{day.label}</span>
</button>
);
})}
</div>
{schedules.filter((s) => s.enabled).length > 0 && (
<div className="space-y-2">
{schedules.filter((s) => s.enabled).map((schedule) => (
<div key={schedule.id} className="flex items-center gap-3 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
<span className="text-xs text-gray-500 w-12">
{DAYS_OF_WEEK.find((d) => d.value === schedule.dayOfWeek)?.label}
</span>
<input
type="time"
value={schedule.startTime}
onChange={(e) => onUpdate(schedule.id, { startTime: e.target.value })}
className="px-2 py-1 text-xs bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded"
/>
<span className="text-xs text-gray-400">a</span>
<input
type="time"
value={schedule.endTime}
onChange={(e) => onUpdate(schedule.id, { endTime: e.target.value })}
className="px-2 py-1 text-xs bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded"
/>
</div>
))}
</div>
)}
</div>
);
};
// ============================================================================
// Main Component
// ============================================================================
export const AgentModeSelector: React.FC<AgentModeSelectorProps> = ({
config,
onModeChange,
onUpdateTrigger,
onAddTrigger,
onRemoveTrigger,
onUpdateSchedule,
onUpdateConfig,
isLoading = false,
}) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const handleModeChange = useCallback((mode: AgentMode) => {
onModeChange(mode);
}, [onModeChange]);
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 mb-1">
<BoltIcon className="w-5 h-5 text-primary-500" />
<h3 className="font-semibold text-gray-900 dark:text-white">Modo del Agente</h3>
</div>
<p className="text-sm text-gray-500">
Configura como interactua el agente contigo
</p>
</div>
{/* Mode Selection */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex gap-4">
<ModeCard
mode="reactive"
isSelected={config.mode === 'reactive'}
onSelect={() => handleModeChange('reactive')}
/>
<ModeCard
mode="proactive"
isSelected={config.mode === 'proactive'}
onSelect={() => handleModeChange('proactive')}
/>
</div>
</div>
{/* Mode Description */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className={`p-4 rounded-lg ${
config.mode === 'proactive'
? 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800'
: 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
}`}>
<div className="flex items-start gap-3">
<InformationCircleIcon className={`w-5 h-5 flex-shrink-0 ${
config.mode === 'proactive' ? 'text-amber-500' : 'text-blue-500'
}`} />
<div>
<h4 className={`font-medium text-sm ${
config.mode === 'proactive' ? 'text-amber-800 dark:text-amber-200' : 'text-blue-800 dark:text-blue-200'
}`}>
Modo {config.mode === 'proactive' ? 'Proactivo' : 'Reactivo'} Activado
</h4>
<p className={`text-sm mt-1 ${
config.mode === 'proactive' ? 'text-amber-700 dark:text-amber-300' : 'text-blue-700 dark:text-blue-300'
}`}>
{config.mode === 'proactive'
? 'El agente te notificara sobre oportunidades, riesgos y eventos relevantes. Puede sugerir acciones automaticamente basado en los triggers configurados.'
: 'El agente solo respondera cuando le hagas preguntas. No enviara notificaciones ni tomara acciones por su cuenta.'}
</p>
</div>
</div>
</div>
</div>
{/* Proactive Mode Configuration */}
{config.mode === 'proactive' && (
<>
{/* Triggers */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900 dark:text-white">Triggers de Activacion</h4>
{onAddTrigger && (
<button
onClick={onAddTrigger}
className="flex items-center gap-1 text-xs text-primary-500 hover:text-primary-600"
>
<PlusIcon className="w-4 h-4" />
Agregar
</button>
)}
</div>
<div className="space-y-2">
{config.triggers.map((trigger) => (
<TriggerItem
key={trigger.id}
trigger={trigger}
onToggle={(enabled) => onUpdateTrigger(trigger.id, { enabled })}
onRemove={onRemoveTrigger ? () => onRemoveTrigger(trigger.id) : undefined}
/>
))}
</div>
</div>
{/* Schedule */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<ScheduleEditor
schedules={config.schedules}
onUpdate={onUpdateSchedule}
/>
</div>
</>
)}
{/* Advanced Settings */}
<div className="p-4">
<button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center justify-between w-full text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
>
<div className="flex items-center gap-2">
<Cog6ToothIcon className="w-4 h-4" />
<span>Configuracion Avanzada</span>
</div>
{showAdvanced ? <ChevronUpIcon className="w-4 h-4" /> : <ChevronDownIcon className="w-4 h-4" />}
</button>
{showAdvanced && (
<div className="mt-4 space-y-4">
{/* Notify on Action */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Notificar al actuar</p>
<p className="text-xs text-gray-500">Recibir notificacion cuando el agente tome una accion</p>
</div>
<button
onClick={() => onUpdateConfig({ notifyOnAction: !config.notifyOnAction })}
className={`relative w-11 h-6 rounded-full transition-colors ${
config.notifyOnAction ? 'bg-primary-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
config.notifyOnAction ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
{/* Require Confirmation */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Requerir confirmacion</p>
<p className="text-xs text-gray-500">Pedir confirmacion antes de ejecutar acciones criticas</p>
</div>
<button
onClick={() => onUpdateConfig({ requireConfirmation: !config.requireConfirmation })}
className={`relative w-11 h-6 rounded-full transition-colors ${
config.requireConfirmation ? 'bg-primary-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
config.requireConfirmation ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
{/* Max Actions Per Hour */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Max acciones por hora</p>
<span className="text-sm text-gray-500">{config.maxActionsPerHour}</span>
</div>
<input
type="range"
min={1}
max={20}
value={config.maxActionsPerHour}
onChange={(e) => onUpdateConfig({ maxActionsPerHour: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>1</span>
<span>20</span>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default AgentModeSelector;

View File

@ -0,0 +1,451 @@
/**
* ConversationHistoryAdvanced Component
* Enhanced conversation history with date filters, search, and continue functionality
* OQI-007: LLM Strategy Agent - Conversation History UI
*/
import React, { useState, useMemo, useCallback } from 'react';
import { format, formatDistanceToNow, isWithinInterval, startOfDay, endOfDay, subDays } from 'date-fns';
import { es } from 'date-fns/locale';
import {
ChatBubbleLeftRightIcon,
PlusIcon,
TrashIcon,
MagnifyingGlassIcon,
CalendarIcon,
FunnelIcon,
ArrowPathIcon,
ChevronRightIcon,
XMarkIcon,
ArchiveBoxIcon,
ClockIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import { SparklesIcon as SparklesSolidIcon } from '@heroicons/react/24/solid';
import type { ChatSession } from '../../../types/chat.types';
// ============================================================================
// Types
// ============================================================================
type DateFilter = 'all' | 'today' | 'week' | 'month' | 'custom';
interface ConversationHistoryAdvancedProps {
sessions: ChatSession[];
currentSessionId: string | null;
loading: boolean;
onSelectSession: (sessionId: string) => void;
onCreateSession: () => void;
onDeleteSession: (sessionId: string) => void;
onContinueSession?: (sessionId: string) => void;
onArchiveSession?: (sessionId: string) => void;
onExportSession?: (sessionId: string) => void;
}
// ============================================================================
// Helper Functions
// ============================================================================
const getSessionTitle = (session: ChatSession): string => {
const firstUserMessage = session.messages.find((m) => m.role === 'user');
if (firstUserMessage) {
return firstUserMessage.content.slice(0, 50) + (firstUserMessage.content.length > 50 ? '...' : '');
}
return 'Nueva conversacion';
};
const getLastMessagePreview = (session: ChatSession): string => {
const lastMessage = session.messages[session.messages.length - 1];
if (lastMessage) {
const preview = lastMessage.content.slice(0, 60);
return preview + (lastMessage.content.length > 60 ? '...' : '');
}
return 'Sin mensajes';
};
const getDateFilterRange = (filter: DateFilter, customRange?: { start: Date; end: Date }) => {
const now = new Date();
switch (filter) {
case 'today':
return { start: startOfDay(now), end: endOfDay(now) };
case 'week':
return { start: startOfDay(subDays(now, 7)), end: endOfDay(now) };
case 'month':
return { start: startOfDay(subDays(now, 30)), end: endOfDay(now) };
case 'custom':
return customRange || null;
default:
return null;
}
};
// ============================================================================
// Sub-Components
// ============================================================================
interface DateFilterButtonsProps {
selected: DateFilter;
onSelect: (filter: DateFilter) => void;
onCustomClick: () => void;
}
const DateFilterButtons: React.FC<DateFilterButtonsProps> = ({ selected, onSelect, onCustomClick }) => {
const filters: { id: DateFilter; label: string }[] = [
{ id: 'all', label: 'Todo' },
{ id: 'today', label: 'Hoy' },
{ id: 'week', label: '7 dias' },
{ id: 'month', label: '30 dias' },
{ id: 'custom', label: 'Rango' },
];
return (
<div className="flex flex-wrap gap-1.5">
{filters.map((filter) => (
<button
key={filter.id}
onClick={() => filter.id === 'custom' ? onCustomClick() : onSelect(filter.id)}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors ${
selected === filter.id
? 'bg-primary-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{filter.label}
</button>
))}
</div>
);
};
interface SessionListItemProps {
session: ChatSession;
isActive: boolean;
onSelect: () => void;
onDelete: () => void;
onContinue?: () => void;
onArchive?: () => void;
}
const SessionListItem: React.FC<SessionListItemProps> = ({
session,
isActive,
onSelect,
onDelete,
onContinue,
onArchive,
}) => {
const [showActions, setShowActions] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
if (confirmDelete) {
onDelete();
setConfirmDelete(false);
} else {
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
}
};
return (
<div
onClick={onSelect}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => { setShowActions(false); setConfirmDelete(false); }}
className={`group relative p-3 rounded-lg cursor-pointer transition-all ${
isActive
? 'bg-primary-50 dark:bg-primary-900/30 border border-primary-200 dark:border-primary-800'
: 'hover:bg-gray-100 dark:hover:bg-gray-700/50 border border-transparent'
}`}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div className={`flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center ${
isActive
? 'bg-primary-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}>
{isActive ? <SparklesSolidIcon className="w-4 h-4" /> : <SparklesIcon className="w-4 h-4" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={`font-medium text-sm truncate ${
isActive ? 'text-primary-900 dark:text-primary-100' : 'text-gray-900 dark:text-white'
}`}>
{getSessionTitle(session)}
</p>
<p className="text-xs text-gray-500 truncate mt-0.5">
{getLastMessagePreview(session)}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">
{formatDistanceToNow(new Date(session.updatedAt), { addSuffix: true, locale: es })}
</span>
<span className="text-xs text-gray-300 dark:text-gray-600">|</span>
<span className="text-xs text-gray-400">{session.messages.length} msg</span>
</div>
</div>
{/* Actions */}
<div className={`flex items-center gap-1 transition-opacity ${showActions ? 'opacity-100' : 'opacity-0'}`}>
{!confirmDelete ? (
<>
{onContinue && (
<button
onClick={(e) => { e.stopPropagation(); onContinue(); }}
className="p-1.5 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg"
title="Continuar conversacion"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
)}
{onArchive && (
<button
onClick={(e) => { e.stopPropagation(); onArchive(); }}
className="p-1.5 text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg"
title="Archivar"
>
<ArchiveBoxIcon className="w-4 h-4" />
</button>
)}
<button
onClick={handleDelete}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<TrashIcon className="w-4 h-4" />
</button>
</>
) : (
<>
<button
onClick={handleDelete}
className="px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
>
Confirmar
</button>
<button
onClick={(e) => { e.stopPropagation(); setConfirmDelete(false); }}
className="p-1 text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
</div>
);
};
// ============================================================================
// Main Component
// ============================================================================
export const ConversationHistoryAdvanced: React.FC<ConversationHistoryAdvancedProps> = ({
sessions,
currentSessionId,
loading,
onSelectSession,
onCreateSession,
onDeleteSession,
onContinueSession,
onArchiveSession,
onExportSession,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [dateFilter, setDateFilter] = useState<DateFilter>('all');
const [showFilters, setShowFilters] = useState(false);
const [customRange, setCustomRange] = useState<{ start: Date; end: Date } | null>(null);
const [showCustomDatePicker, setShowCustomDatePicker] = useState(false);
const filteredSessions = useMemo(() => {
let filtered = sessions;
// Search filter
if (searchTerm.trim()) {
const query = searchTerm.toLowerCase();
filtered = filtered.filter((session) => {
const title = getSessionTitle(session).toLowerCase();
const hasMatchInMessages = session.messages.some((m) =>
m.content.toLowerCase().includes(query)
);
return title.includes(query) || hasMatchInMessages;
});
}
// Date filter
const range = getDateFilterRange(dateFilter, customRange || undefined);
if (range) {
filtered = filtered.filter((session) => {
const date = new Date(session.updatedAt);
return isWithinInterval(date, { start: range.start, end: range.end });
});
}
// Sort by most recent
return filtered.sort((a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}, [sessions, searchTerm, dateFilter, customRange]);
const handleContinue = useCallback((sessionId: string) => {
onSelectSession(sessionId);
onContinueSession?.(sessionId);
}, [onSelectSession, onContinueSession]);
const totalMessages = sessions.reduce((acc, s) => acc + s.messages.length, 0);
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-800">
{/* Header with New Chat */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<button
onClick={onCreateSession}
disabled={loading}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-600 hover:to-blue-700 text-white rounded-lg transition-all disabled:opacity-50 shadow-md hover:shadow-lg"
>
<PlusIcon className="w-5 h-5" />
<span className="font-medium">Nueva Conversacion</span>
</button>
</div>
{/* Search */}
<div className="px-4 py-3 space-y-3">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Buscar en conversaciones..."
className="w-full pl-9 pr-10 py-2 text-sm bg-gray-100 dark:bg-gray-700 border-0 rounded-lg focus:ring-2 focus:ring-primary-500 text-gray-900 dark:text-white placeholder-gray-500"
/>
<button
onClick={() => setShowFilters(!showFilters)}
className={`absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg transition-colors ${
showFilters || dateFilter !== 'all'
? 'text-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
>
<FunnelIcon className="w-4 h-4" />
</button>
</div>
{/* Filters */}
{showFilters && (
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<CalendarIcon className="w-4 h-4" />
<span>Filtrar por fecha</span>
</div>
<DateFilterButtons
selected={dateFilter}
onSelect={setDateFilter}
onCustomClick={() => setShowCustomDatePicker(true)}
/>
{dateFilter === 'custom' && customRange && (
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{format(customRange.start, 'dd/MM/yyyy', { locale: es })}</span>
<span>-</span>
<span>{format(customRange.end, 'dd/MM/yyyy', { locale: es })}</span>
<button
onClick={() => { setCustomRange(null); setDateFilter('all'); }}
className="ml-auto text-gray-400 hover:text-red-500"
>
<XMarkIcon className="w-4 h-4" />
</button>
</div>
)}
{showCustomDatePicker && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-500 mb-1">Desde</label>
<input
type="date"
onChange={(e) => {
const start = new Date(e.target.value);
setCustomRange((prev) => ({ start, end: prev?.end || new Date() }));
setDateFilter('custom');
}}
className="w-full px-2 py-1.5 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Hasta</label>
<input
type="date"
onChange={(e) => {
const end = new Date(e.target.value);
setCustomRange((prev) => ({ start: prev?.start || subDays(new Date(), 30), end }));
setDateFilter('custom');
}}
className="w-full px-2 py-1.5 text-sm bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded"
/>
</div>
</div>
)}
</div>
)}
</div>
{/* Results Summary */}
{(searchTerm || dateFilter !== 'all') && (
<div className="px-4 pb-2">
<p className="text-xs text-gray-500">
{filteredSessions.length} de {sessions.length} conversaciones
</p>
</div>
)}
{/* Session List */}
<div className="flex-1 overflow-y-auto px-3 py-2">
{loading && sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<ArrowPathIcon className="w-8 h-8 text-primary-500 animate-spin mb-3" />
<p className="text-sm text-gray-500">Cargando conversaciones...</p>
</div>
) : filteredSessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mb-4">
<ChatBubbleLeftRightIcon className="w-8 h-8 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
{searchTerm || dateFilter !== 'all' ? 'No se encontraron resultados' : 'Sin conversaciones'}
</p>
<p className="text-xs text-gray-400">
{searchTerm || dateFilter !== 'all' ? 'Intenta con otros terminos o filtros' : 'Inicia una nueva conversacion'}
</p>
</div>
) : (
<div className="space-y-1">
{filteredSessions.map((session) => (
<SessionListItem
key={session.id}
session={session}
isActive={session.id === currentSessionId}
onSelect={() => onSelectSession(session.id)}
onDelete={() => onDeleteSession(session.id)}
onContinue={onContinueSession ? () => handleContinue(session.id) : undefined}
onArchive={onArchiveSession ? () => onArchiveSession(session.id) : undefined}
/>
))}
</div>
)}
</div>
{/* Footer Stats */}
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{sessions.length} conversaciones</span>
<span>{totalMessages.toLocaleString()} mensajes totales</span>
</div>
</div>
</div>
);
};
export default ConversationHistoryAdvanced;

View File

@ -0,0 +1,453 @@
/**
* MemoryManagerPanel Component
* Enhanced memory management with categories, edit/delete, and API integration
* OQI-007: LLM Strategy Agent - Memory Management UI
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
CircleStackIcon,
PencilIcon,
TrashIcon,
PlusIcon,
FolderIcon,
LightBulbIcon,
ChartBarIcon,
ClockIcon,
CheckIcon,
XMarkIcon,
MagnifyingGlassIcon,
ArrowPathIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
// ============================================================================
// Types
// ============================================================================
export type MemoryCategory = 'preferences' | 'strategies' | 'history' | 'custom';
export interface MemoryItem {
id: string;
category: MemoryCategory;
key: string;
value: string;
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
isPinned?: boolean;
}
export interface MemoryStats {
totalItems: number;
totalTokens: number;
maxTokens: number;
byCategory: Record<MemoryCategory, number>;
}
interface MemoryManagerPanelProps {
memories: MemoryItem[];
stats: MemoryStats;
onAddMemory: (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
onUpdateMemory: (id: string, updates: Partial<MemoryItem>) => Promise<void>;
onDeleteMemory: (id: string) => Promise<void>;
onClearCategory?: (category: MemoryCategory) => Promise<void>;
onRefresh?: () => Promise<void>;
isLoading?: boolean;
}
// ============================================================================
// Constants
// ============================================================================
const CATEGORY_INFO: Record<MemoryCategory, { label: string; icon: React.ElementType; color: string }> = {
preferences: { label: 'Preferencias', icon: LightBulbIcon, color: 'text-amber-500 bg-amber-500/10' },
strategies: { label: 'Estrategias', icon: ChartBarIcon, color: 'text-blue-500 bg-blue-500/10' },
history: { label: 'Historial', icon: ClockIcon, color: 'text-purple-500 bg-purple-500/10' },
custom: { label: 'Personalizado', icon: FolderIcon, color: 'text-gray-500 bg-gray-500/10' },
};
// ============================================================================
// Sub-Components
// ============================================================================
interface MemoryItemCardProps {
memory: MemoryItem;
onEdit: () => void;
onDelete: () => void;
isEditing: boolean;
editValue: string;
onEditChange: (value: string) => void;
onSave: () => void;
onCancel: () => void;
}
const MemoryItemCard: React.FC<MemoryItemCardProps> = ({
memory,
onEdit,
onDelete,
isEditing,
editValue,
onEditChange,
onSave,
onCancel,
}) => {
const [confirmDelete, setConfirmDelete] = useState(false);
const category = CATEGORY_INFO[memory.category];
const CategoryIcon = category.icon;
return (
<div className="group p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700 hover:border-gray-200 dark:hover:border-gray-600 transition-colors">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${category.color}`}>
<CategoryIcon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-gray-500 uppercase">{memory.key}</span>
{memory.isPinned && (
<span className="px-1.5 py-0.5 text-xs bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded">
Fijado
</span>
)}
</div>
{isEditing ? (
<div className="space-y-2">
<textarea
value={editValue}
onChange={(e) => onEditChange(e.target.value)}
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-600 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary-500"
rows={3}
autoFocus
/>
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Cancelar
</button>
<button
onClick={onSave}
className="px-3 py-1.5 text-xs bg-primary-500 text-white rounded-lg hover:bg-primary-600"
>
Guardar
</button>
</div>
</div>
) : (
<p className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{memory.value}</p>
)}
<p className="text-xs text-gray-400 mt-2">
{new Date(memory.updatedAt).toLocaleDateString('es-ES', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
{!isEditing && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!confirmDelete ? (
<>
<button
onClick={onEdit}
className="p-1.5 text-gray-400 hover:text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-lg"
title="Editar"
>
<PencilIcon className="w-4 h-4" />
</button>
<button
onClick={() => setConfirmDelete(true)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Eliminar"
>
<TrashIcon className="w-4 h-4" />
</button>
</>
) : (
<>
<button
onClick={() => { onDelete(); setConfirmDelete(false); }}
className="px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
>
Confirmar
</button>
<button
onClick={() => setConfirmDelete(false)}
className="p-1 text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="w-4 h-4" />
</button>
</>
)}
</div>
)}
</div>
</div>
);
};
interface AddMemoryFormProps {
onAdd: (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void;
}
const AddMemoryForm: React.FC<AddMemoryFormProps> = ({ onAdd, onCancel }) => {
const [category, setCategory] = useState<MemoryCategory>('custom');
const [key, setKey] = useState('');
const [value, setValue] = useState('');
const handleSubmit = () => {
if (key.trim() && value.trim()) {
onAdd({ category, key: key.trim(), value: value.trim() });
}
};
return (
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Categoria</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value as MemoryCategory)}
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-600 rounded-lg"
>
{Object.entries(CATEGORY_INFO).map(([key, info]) => (
<option key={key} value={key}>{info.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Clave</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="ej: risk_tolerance"
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-600 rounded-lg"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Valor</label>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Contenido de la memoria..."
className="w-full px-3 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-600 rounded-lg resize-none"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700">
Cancelar
</button>
<button
onClick={handleSubmit}
disabled={!key.trim() || !value.trim()}
className="px-4 py-2 text-sm bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50"
>
Agregar Memoria
</button>
</div>
</div>
);
};
// ============================================================================
// Main Component
// ============================================================================
export const MemoryManagerPanel: React.FC<MemoryManagerPanelProps> = ({
memories,
stats,
onAddMemory,
onUpdateMemory,
onDeleteMemory,
onClearCategory,
onRefresh,
isLoading = false,
}) => {
const [activeCategory, setActiveCategory] = useState<MemoryCategory | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const filteredMemories = useMemo(() => {
let filtered = memories;
if (activeCategory !== 'all') {
filtered = filtered.filter((m) => m.category === activeCategory);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(m) => m.key.toLowerCase().includes(query) || m.value.toLowerCase().includes(query)
);
}
return filtered.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}, [memories, activeCategory, searchQuery]);
const handleAdd = useCallback(async (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
await onAddMemory(memory);
setShowAddForm(false);
}, [onAddMemory]);
const handleEdit = useCallback((memory: MemoryItem) => {
setEditingId(memory.id);
setEditValue(memory.value);
}, []);
const handleSave = useCallback(async (id: string) => {
await onUpdateMemory(id, { value: editValue });
setEditingId(null);
setEditValue('');
}, [editValue, onUpdateMemory]);
const usagePercent = (stats.totalTokens / stats.maxTokens) * 100;
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<CircleStackIcon className="w-5 h-5 text-primary-500" />
<h3 className="font-semibold text-gray-900 dark:text-white">Memoria del Agente</h3>
</div>
<div className="flex items-center gap-2">
{onRefresh && (
<button
onClick={onRefresh}
disabled={isLoading}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
>
<ArrowPathIcon className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
)}
<button
onClick={() => setShowAddForm(!showAddForm)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-primary-500 text-white rounded-lg hover:bg-primary-600"
>
<PlusIcon className="w-4 h-4" />
Agregar
</button>
</div>
</div>
{/* Usage Bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-500">{stats.totalItems} memorias</span>
<span className={usagePercent > 80 ? 'text-red-500' : 'text-gray-500'}>
{stats.totalTokens.toLocaleString()} / {stats.maxTokens.toLocaleString()} tokens
</span>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${usagePercent > 80 ? 'bg-red-500' : usagePercent > 60 ? 'bg-yellow-500' : 'bg-emerald-500'}`}
style={{ width: `${Math.min(usagePercent, 100)}%` }}
/>
</div>
{usagePercent > 80 && (
<div className="mt-2 p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg flex items-center gap-2 text-xs text-yellow-600 dark:text-yellow-400">
<ExclamationTriangleIcon className="w-4 h-4" />
<span>Memoria casi llena. Considera eliminar memorias antiguas.</span>
</div>
)}
</div>
{/* Category Stats */}
<div className="grid grid-cols-4 gap-2">
{Object.entries(CATEGORY_INFO).map(([key, info]) => {
const Icon = info.icon;
const count = stats.byCategory[key as MemoryCategory] || 0;
return (
<button
key={key}
onClick={() => setActiveCategory(activeCategory === key ? 'all' : key as MemoryCategory)}
className={`p-2 rounded-lg border transition-colors ${
activeCategory === key
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-100 dark:border-gray-700 hover:border-gray-200 dark:hover:border-gray-600'
}`}
>
<Icon className={`w-4 h-4 mx-auto mb-1 ${info.color.split(' ')[0]}`} />
<div className="text-lg font-bold text-gray-900 dark:text-white">{count}</div>
<div className="text-xs text-gray-500 truncate">{info.label}</div>
</button>
);
})}
</div>
</div>
{/* Add Form */}
{showAddForm && (
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<AddMemoryForm onAdd={handleAdd} onCancel={() => setShowAddForm(false)} />
</div>
)}
{/* Search */}
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar memorias..."
className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border-0 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
{/* Memory List */}
<div className="max-h-96 overflow-y-auto p-3 space-y-2">
{filteredMemories.length === 0 ? (
<div className="p-8 text-center">
<CircleStackIcon className="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 text-sm">
{searchQuery ? 'No se encontraron memorias' : 'Sin memorias guardadas'}
</p>
<p className="text-gray-400 text-xs mt-1">
Las memorias ayudan al agente a recordar tus preferencias
</p>
</div>
) : (
filteredMemories.map((memory) => (
<MemoryItemCard
key={memory.id}
memory={memory}
onEdit={() => handleEdit(memory)}
onDelete={() => onDeleteMemory(memory.id)}
isEditing={editingId === memory.id}
editValue={editValue}
onEditChange={setEditValue}
onSave={() => handleSave(memory.id)}
onCancel={() => { setEditingId(null); setEditValue(''); }}
/>
))
)}
</div>
{/* Clear Category Footer */}
{activeCategory !== 'all' && filteredMemories.length > 0 && onClearCategory && (
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<button
onClick={() => onClearCategory(activeCategory)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<TrashIcon className="w-4 h-4" />
Limpiar {CATEGORY_INFO[activeCategory].label} ({filteredMemories.length})
</button>
</div>
)}
</div>
);
};
export default MemoryManagerPanel;

View File

@ -0,0 +1,454 @@
/**
* ToolsConfigPanel Component
* Tool configuration panel with enable/disable, parameters, and descriptions
* OQI-007: LLM Strategy Agent - Tools Configuration UI
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
WrenchScrewdriverIcon,
ChartBarIcon,
CurrencyDollarIcon,
CalculatorIcon,
NewspaperIcon,
ShieldCheckIcon,
BoltIcon,
Cog6ToothIcon,
ChevronDownIcon,
ChevronUpIcon,
MagnifyingGlassIcon,
InformationCircleIcon,
CheckCircleIcon,
XCircleIcon,
} from '@heroicons/react/24/outline';
// ============================================================================
// Types
// ============================================================================
export type ToolCategory = 'analysis' | 'trading' | 'risk' | 'data' | 'utility';
export interface ToolParameter {
name: string;
type: 'string' | 'number' | 'boolean' | 'select';
label: string;
description?: string;
default: string | number | boolean;
options?: { value: string; label: string }[];
min?: number;
max?: number;
}
export interface AgentTool {
id: string;
name: string;
description: string;
category: ToolCategory;
enabled: boolean;
parameters: ToolParameter[];
parameterValues: Record<string, string | number | boolean>;
requiredPermissions?: string[];
lastUsed?: string;
usageCount: number;
isCore?: boolean;
}
interface ToolsConfigPanelProps {
tools: AgentTool[];
onToggleTool: (toolId: string, enabled: boolean) => void;
onUpdateToolParams: (toolId: string, params: Record<string, string | number | boolean>) => void;
onResetTool?: (toolId: string) => void;
onResetAllTools?: () => void;
isLoading?: boolean;
}
// ============================================================================
// Constants
// ============================================================================
const CATEGORY_INFO: Record<ToolCategory, { label: string; icon: React.ElementType; color: string }> = {
analysis: { label: 'Analisis', icon: ChartBarIcon, color: 'text-blue-500 bg-blue-500/10' },
trading: { label: 'Trading', icon: BoltIcon, color: 'text-emerald-500 bg-emerald-500/10' },
risk: { label: 'Riesgo', icon: ShieldCheckIcon, color: 'text-amber-500 bg-amber-500/10' },
data: { label: 'Datos', icon: CurrencyDollarIcon, color: 'text-purple-500 bg-purple-500/10' },
utility: { label: 'Utilidades', icon: WrenchScrewdriverIcon, color: 'text-gray-500 bg-gray-500/10' },
};
// ============================================================================
// Sub-Components
// ============================================================================
interface ParameterFieldProps {
param: ToolParameter;
value: string | number | boolean;
onChange: (value: string | number | boolean) => void;
disabled?: boolean;
}
const ParameterField: React.FC<ParameterFieldProps> = ({ param, value, onChange, disabled }) => {
const baseClasses = "w-full px-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg disabled:opacity-50";
switch (param.type) {
case 'boolean':
return (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{param.label}</p>
{param.description && <p className="text-xs text-gray-500">{param.description}</p>}
</div>
<button
onClick={() => onChange(!value)}
disabled={disabled}
className={`relative w-11 h-6 rounded-full transition-colors disabled:opacity-50 ${
value ? 'bg-primary-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
);
case 'select':
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{param.label}
</label>
<select
value={String(value)}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={baseClasses}
>
{param.options?.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{param.description && <p className="text-xs text-gray-500 mt-1">{param.description}</p>}
</div>
);
case 'number':
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{param.label}
</label>
<input
type="number"
value={Number(value)}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
min={param.min}
max={param.max}
disabled={disabled}
className={baseClasses}
/>
{param.description && <p className="text-xs text-gray-500 mt-1">{param.description}</p>}
</div>
);
default:
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{param.label}
</label>
<input
type="text"
value={String(value)}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={baseClasses}
/>
{param.description && <p className="text-xs text-gray-500 mt-1">{param.description}</p>}
</div>
);
}
};
interface ToolCardProps {
tool: AgentTool;
onToggle: (enabled: boolean) => void;
onUpdateParams: (params: Record<string, string | number | boolean>) => void;
onReset?: () => void;
}
const ToolCard: React.FC<ToolCardProps> = ({ tool, onToggle, onUpdateParams, onReset }) => {
const [isExpanded, setIsExpanded] = useState(false);
const category = CATEGORY_INFO[tool.category];
const CategoryIcon = category.icon;
const handleParamChange = useCallback((name: string, value: string | number | boolean) => {
onUpdateParams({ ...tool.parameterValues, [name]: value });
}, [tool.parameterValues, onUpdateParams]);
const formatLastUsed = (dateStr?: string) => {
if (!dateStr) return 'Nunca usado';
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 3600000) return `Hace ${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `Hace ${Math.floor(diff / 3600000)}h`;
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
};
return (
<div className={`rounded-lg border transition-all ${
tool.enabled
? 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
: 'border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 opacity-75'
}`}>
<div className="p-4">
<div className="flex items-start gap-3">
<div className={`p-2.5 rounded-lg ${category.color}`}>
<CategoryIcon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-gray-900 dark:text-white">{tool.name}</h4>
<span className={`px-2 py-0.5 text-xs rounded ${category.color}`}>
{category.label}
</span>
{tool.isCore && (
<span className="px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 rounded">
Core
</span>
)}
</div>
<p className="text-sm text-gray-500 line-clamp-2">{tool.description}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
<span>{tool.usageCount} usos</span>
<span>{formatLastUsed(tool.lastUsed)}</span>
</div>
</div>
<div className="flex items-center gap-2">
{tool.parameters.length > 0 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
{isExpanded ? <ChevronUpIcon className="w-4 h-4" /> : <ChevronDownIcon className="w-4 h-4" />}
</button>
)}
<button
onClick={() => onToggle(!tool.enabled)}
disabled={tool.isCore}
className={`relative w-12 h-7 rounded-full transition-colors disabled:cursor-not-allowed ${
tool.enabled ? 'bg-primary-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
title={tool.isCore ? 'Herramienta core - no se puede desactivar' : (tool.enabled ? 'Desactivar' : 'Activar')}
>
<div className={`absolute top-1 w-5 h-5 bg-white rounded-full shadow transition-transform ${
tool.enabled ? 'translate-x-6' : 'translate-x-1'
}`}>
{tool.enabled ? (
<CheckCircleIcon className="w-5 h-5 text-primary-500" />
) : (
<XCircleIcon className="w-5 h-5 text-gray-400" />
)}
</div>
</button>
</div>
</div>
{/* Required Permissions */}
{tool.requiredPermissions && tool.requiredPermissions.length > 0 && (
<div className="mt-3 flex items-center gap-2">
<ShieldCheckIcon className="w-4 h-4 text-amber-500" />
<div className="flex flex-wrap gap-1">
{tool.requiredPermissions.map((perm) => (
<span key={perm} className="px-2 py-0.5 text-xs bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 rounded">
{perm}
</span>
))}
</div>
</div>
)}
</div>
{/* Expanded Parameters */}
{isExpanded && tool.parameters.length > 0 && (
<div className="px-4 pb-4 pt-3 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<h5 className="text-sm font-medium text-gray-700 dark:text-gray-300">Configuracion</h5>
{onReset && (
<button
onClick={onReset}
className="text-xs text-gray-500 hover:text-primary-500"
>
Restaurar valores
</button>
)}
</div>
<div className="space-y-3">
{tool.parameters.map((param) => (
<ParameterField
key={param.name}
param={param}
value={tool.parameterValues[param.name] ?? param.default}
onChange={(value) => handleParamChange(param.name, value)}
disabled={!tool.enabled}
/>
))}
</div>
</div>
)}
</div>
);
};
// ============================================================================
// Main Component
// ============================================================================
export const ToolsConfigPanel: React.FC<ToolsConfigPanelProps> = ({
tools,
onToggleTool,
onUpdateToolParams,
onResetTool,
onResetAllTools,
isLoading = false,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<ToolCategory | 'all'>('all');
const [showEnabledOnly, setShowEnabledOnly] = useState(false);
const filteredTools = useMemo(() => {
let filtered = tools;
if (categoryFilter !== 'all') {
filtered = filtered.filter((t) => t.category === categoryFilter);
}
if (showEnabledOnly) {
filtered = filtered.filter((t) => t.enabled);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((t) =>
t.name.toLowerCase().includes(query) || t.description.toLowerCase().includes(query)
);
}
return filtered;
}, [tools, categoryFilter, showEnabledOnly, searchQuery]);
const enabledCount = tools.filter((t) => t.enabled).length;
const categories = [...new Set(tools.map((t) => t.category))];
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Cog6ToothIcon className="w-5 h-5 text-primary-500" />
<h3 className="font-semibold text-gray-900 dark:text-white">Herramientas del Agente</h3>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{enabledCount}/{tools.length} activas</span>
{onResetAllTools && (
<button
onClick={onResetAllTools}
className="text-xs text-gray-500 hover:text-primary-500"
>
Restaurar todo
</button>
)}
</div>
</div>
{/* Category Filters */}
<div className="flex flex-wrap gap-2 mb-3">
<button
onClick={() => setCategoryFilter('all')}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
categoryFilter === 'all'
? 'bg-primary-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
Todas
</button>
{categories.map((cat) => {
const info = CATEGORY_INFO[cat];
const Icon = info.icon;
return (
<button
key={cat}
onClick={() => setCategoryFilter(cat)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg transition-colors ${
categoryFilter === cat
? 'bg-primary-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{info.label}
</button>
);
})}
</div>
{/* Search & Filter */}
<div className="flex items-center gap-3">
<div className="flex-1 relative">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar herramientas..."
className="w-full pl-9 pr-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border-0 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<button
onClick={() => setShowEnabledOnly(!showEnabledOnly)}
className={`flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
showEnabledOnly
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
}`}
>
<CheckCircleIcon className="w-4 h-4" />
Solo activas
</button>
</div>
</div>
{/* Tools List */}
<div className="max-h-[500px] overflow-y-auto p-4 space-y-3">
{filteredTools.length === 0 ? (
<div className="p-8 text-center">
<WrenchScrewdriverIcon className="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" />
<p className="text-gray-500 text-sm">No se encontraron herramientas</p>
</div>
) : (
filteredTools.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
onToggle={(enabled) => onToggleTool(tool.id, enabled)}
onUpdateParams={(params) => onUpdateToolParams(tool.id, params)}
onReset={onResetTool ? () => onResetTool(tool.id) : undefined}
/>
))
)}
</div>
{/* Info Footer */}
<div className="p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-start gap-2 text-xs text-gray-500">
<InformationCircleIcon className="w-4 h-4 flex-shrink-0 mt-0.5" />
<p>
Las herramientas extienden las capacidades del agente. Activa las herramientas que necesites
para analisis de mercado, ejecucion de operaciones, calculo de riesgo y mas.
</p>
</div>
</div>
</div>
);
};
export default ToolsConfigPanel;

View File

@ -89,3 +89,18 @@ export type { LLMTool, ToolParameter, ToolExecution } from './LLMToolsPanel';
// Fine-Tuning Interface (OQI-007 - SUBTASK-008) // Fine-Tuning Interface (OQI-007 - SUBTASK-008)
export { default as FineTuningPanel } from './FineTuningPanel'; export { default as FineTuningPanel } from './FineTuningPanel';
export type { BaseModel, TrainingDataset, FineTunedModel, TrainingConfig } from './FineTuningPanel'; export type { BaseModel, TrainingDataset, FineTunedModel, TrainingConfig } from './FineTuningPanel';
// Memory Manager Panel (OQI-007 - Enhanced)
export { default as MemoryManagerPanel } from './MemoryManagerPanel';
export type { MemoryItem, MemoryStats, MemoryCategory } from './MemoryManagerPanel';
// Tools Configuration Panel (OQI-007)
export { default as ToolsConfigPanel } from './ToolsConfigPanel';
export type { AgentTool, ToolCategory } from './ToolsConfigPanel';
// Advanced Conversation History (OQI-007)
export { default as ConversationHistoryAdvanced } from './ConversationHistoryAdvanced';
// Agent Mode Selector (OQI-007)
export { default as AgentModeSelector } from './AgentModeSelector';
export type { AgentMode, AgentModeConfig, TriggerCondition, ActivitySchedule } from './AgentModeSelector';

View File

@ -1,8 +1,10 @@
/** /**
* Assistant Hooks - Index Export * Assistant Hooks - Index Export
* Custom hooks for LLM assistant functionality * Custom hooks for LLM assistant functionality
* OQI-007: LLM Strategy Agent
*/ */
// Core Chat Hooks
export { default as useChatAssistant } from './useChatAssistant'; export { default as useChatAssistant } from './useChatAssistant';
export type { export type {
ChatAssistantOptions, ChatAssistantOptions,
@ -20,3 +22,24 @@ export type {
StreamOptions, StreamOptions,
UseStreamingChatReturn, UseStreamingChatReturn,
} from './useStreamingChat'; } from './useStreamingChat';
// Agent Memory Hook (OQI-007)
export { default as useAgentMemory } from './useAgentMemory';
// Agent Tools Hook (OQI-007)
export { default as useAgentTools } from './useAgentTools';
// Conversations Hook (OQI-007)
export { default as useConversations } from './useConversations';
// Agent Mode Hook (OQI-007)
export { default as useAgentMode } from './useAgentMode';
// Agent Settings Hook (OQI-007)
export { default as useAgentSettings } from './useAgentSettings';
export type {
NotificationSettings,
OperationLimits,
CommunicationPreferences,
AgentSettings,
} from './useAgentSettings';

View File

@ -0,0 +1,181 @@
/**
* useAgentMemory Hook
* Manages agent memory state with API integration
* OQI-007: LLM Strategy Agent - Memory Hook
*/
import { useState, useCallback, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../lib/apiClient';
import type { MemoryItem, MemoryStats, MemoryCategory } from '../components/MemoryManagerPanel';
// ============================================================================
// Types
// ============================================================================
interface UseAgentMemoryOptions {
enabled?: boolean;
autoRefresh?: boolean;
refreshInterval?: number;
}
interface UseAgentMemoryReturn {
memories: MemoryItem[];
stats: MemoryStats;
isLoading: boolean;
isError: boolean;
error: Error | null;
addMemory: (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
updateMemory: (id: string, updates: Partial<MemoryItem>) => Promise<void>;
deleteMemory: (id: string) => Promise<void>;
clearCategory: (category: MemoryCategory) => Promise<void>;
refresh: () => Promise<void>;
searchMemories: (query: string) => MemoryItem[];
}
// ============================================================================
// API Functions
// ============================================================================
const memoryApi = {
getAll: async (): Promise<{ memories: MemoryItem[]; stats: MemoryStats }> => {
const response = await apiClient.get('/llm/memory');
return response.data;
},
add: async (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>): Promise<MemoryItem> => {
const response = await apiClient.post('/llm/memory', memory);
return response.data;
},
update: async (id: string, updates: Partial<MemoryItem>): Promise<MemoryItem> => {
const response = await apiClient.patch(`/llm/memory/${id}`, updates);
return response.data;
},
delete: async (id: string): Promise<void> => {
await apiClient.delete(`/llm/memory/${id}`);
},
clearCategory: async (category: MemoryCategory): Promise<void> => {
await apiClient.delete(`/llm/memory/category/${category}`);
},
};
// ============================================================================
// Default Values
// ============================================================================
const DEFAULT_STATS: MemoryStats = {
totalItems: 0,
totalTokens: 0,
maxTokens: 8000,
byCategory: {
preferences: 0,
strategies: 0,
history: 0,
custom: 0,
},
};
// ============================================================================
// Hook Implementation
// ============================================================================
export function useAgentMemory(options: UseAgentMemoryOptions = {}): UseAgentMemoryReturn {
const { enabled = true, autoRefresh = false, refreshInterval = 60000 } = options;
const queryClient = useQueryClient();
// Query for memories
const {
data,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ['agent-memory'],
queryFn: memoryApi.getAll,
enabled,
refetchInterval: autoRefresh ? refreshInterval : false,
staleTime: 30000, // 30 seconds
});
// Add mutation
const addMutation = useMutation({
mutationFn: memoryApi.add,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-memory'] });
},
});
// Update mutation
const updateMutation = useMutation({
mutationFn: ({ id, updates }: { id: string; updates: Partial<MemoryItem> }) =>
memoryApi.update(id, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-memory'] });
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: memoryApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-memory'] });
},
});
// Clear category mutation
const clearCategoryMutation = useMutation({
mutationFn: memoryApi.clearCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-memory'] });
},
});
// Handlers
const addMemory = useCallback(async (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
await addMutation.mutateAsync(memory);
}, [addMutation]);
const updateMemory = useCallback(async (id: string, updates: Partial<MemoryItem>) => {
await updateMutation.mutateAsync({ id, updates });
}, [updateMutation]);
const deleteMemory = useCallback(async (id: string) => {
await deleteMutation.mutateAsync(id);
}, [deleteMutation]);
const clearCategory = useCallback(async (category: MemoryCategory) => {
await clearCategoryMutation.mutateAsync(category);
}, [clearCategoryMutation]);
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
const searchMemories = useCallback((query: string): MemoryItem[] => {
if (!data?.memories || !query.trim()) return data?.memories || [];
const lowerQuery = query.toLowerCase();
return data.memories.filter(
(m) => m.key.toLowerCase().includes(lowerQuery) || m.value.toLowerCase().includes(lowerQuery)
);
}, [data?.memories]);
return {
memories: data?.memories || [],
stats: data?.stats || DEFAULT_STATS,
isLoading,
isError,
error: error as Error | null,
addMemory,
updateMemory,
deleteMemory,
clearCategory,
refresh,
searchMemories,
};
}
export default useAgentMemory;

View File

@ -0,0 +1,343 @@
/**
* useAgentMode Hook
* Manages agent mode (proactive/reactive) and related configuration
* OQI-007: LLM Strategy Agent - Agent Mode Hook
*/
import { useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../lib/apiClient';
import type {
AgentMode,
AgentModeConfig,
TriggerCondition,
ActivitySchedule,
} from '../components/AgentModeSelector';
// ============================================================================
// Types
// ============================================================================
interface UseAgentModeOptions {
enabled?: boolean;
}
interface UseAgentModeReturn {
config: AgentModeConfig;
isLoading: boolean;
isError: boolean;
error: Error | null;
// Mode
mode: AgentMode;
isProactive: boolean;
isReactive: boolean;
setMode: (mode: AgentMode) => Promise<void>;
// Triggers
triggers: TriggerCondition[];
enabledTriggers: TriggerCondition[];
updateTrigger: (triggerId: string, updates: Partial<TriggerCondition>) => Promise<void>;
addTrigger: (trigger: Omit<TriggerCondition, 'id'>) => Promise<void>;
removeTrigger: (triggerId: string) => Promise<void>;
// Schedules
schedules: ActivitySchedule[];
activeSchedules: ActivitySchedule[];
updateSchedule: (scheduleId: string, updates: Partial<ActivitySchedule>) => Promise<void>;
// Config
updateConfig: (updates: Partial<AgentModeConfig>) => Promise<void>;
resetConfig: () => Promise<void>;
refresh: () => Promise<void>;
}
// ============================================================================
// API Functions
// ============================================================================
const modeApi = {
getConfig: async (): Promise<AgentModeConfig> => {
const response = await apiClient.get('/llm/agent/mode');
return response.data;
},
setMode: async (mode: AgentMode): Promise<AgentModeConfig> => {
const response = await apiClient.patch('/llm/agent/mode', { mode });
return response.data;
},
updateTrigger: async (triggerId: string, updates: Partial<TriggerCondition>): Promise<TriggerCondition> => {
const response = await apiClient.patch(`/llm/agent/triggers/${triggerId}`, updates);
return response.data;
},
addTrigger: async (trigger: Omit<TriggerCondition, 'id'>): Promise<TriggerCondition> => {
const response = await apiClient.post('/llm/agent/triggers', trigger);
return response.data;
},
removeTrigger: async (triggerId: string): Promise<void> => {
await apiClient.delete(`/llm/agent/triggers/${triggerId}`);
},
updateSchedule: async (scheduleId: string, updates: Partial<ActivitySchedule>): Promise<ActivitySchedule> => {
const response = await apiClient.patch(`/llm/agent/schedules/${scheduleId}`, updates);
return response.data;
},
updateConfig: async (updates: Partial<AgentModeConfig>): Promise<AgentModeConfig> => {
const response = await apiClient.patch('/llm/agent/config', updates);
return response.data;
},
resetConfig: async (): Promise<AgentModeConfig> => {
const response = await apiClient.post('/llm/agent/config/reset');
return response.data;
},
};
// ============================================================================
// Default Config
// ============================================================================
const DEFAULT_CONFIG: AgentModeConfig = {
mode: 'reactive',
triggers: [],
schedules: Array.from({ length: 7 }, (_, i) => ({
id: String(i),
dayOfWeek: i,
startTime: '09:00',
endTime: '17:00',
enabled: i >= 1 && i <= 5,
})),
notifyOnAction: true,
requireConfirmation: true,
maxActionsPerHour: 5,
};
// ============================================================================
// Hook Implementation
// ============================================================================
export function useAgentMode(options: UseAgentModeOptions = {}): UseAgentModeReturn {
const { enabled = true } = options;
const queryClient = useQueryClient();
// Query for config
const {
data: config = DEFAULT_CONFIG,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ['agent-mode-config'],
queryFn: modeApi.getConfig,
enabled,
staleTime: 60000, // 1 minute
});
// Set mode mutation
const setModeMutation = useMutation({
mutationFn: modeApi.setMode,
onMutate: async (newMode) => {
await queryClient.cancelQueries({ queryKey: ['agent-mode-config'] });
const previousConfig = queryClient.getQueryData<AgentModeConfig>(['agent-mode-config']);
queryClient.setQueryData<AgentModeConfig>(['agent-mode-config'], (old) =>
old ? { ...old, mode: newMode } : undefined
);
return { previousConfig };
},
onError: (_err, _vars, context) => {
if (context?.previousConfig) {
queryClient.setQueryData(['agent-mode-config'], context.previousConfig);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-mode-config'] });
},
});
// Update trigger mutation
const updateTriggerMutation = useMutation({
mutationFn: ({ triggerId, updates }: { triggerId: string; updates: Partial<TriggerCondition> }) =>
modeApi.updateTrigger(triggerId, updates),
onMutate: async ({ triggerId, updates }) => {
await queryClient.cancelQueries({ queryKey: ['agent-mode-config'] });
const previousConfig = queryClient.getQueryData<AgentModeConfig>(['agent-mode-config']);
queryClient.setQueryData<AgentModeConfig>(['agent-mode-config'], (old) =>
old
? {
...old,
triggers: old.triggers.map((t) =>
t.id === triggerId ? { ...t, ...updates } : t
),
}
: undefined
);
return { previousConfig };
},
onError: (_err, _vars, context) => {
if (context?.previousConfig) {
queryClient.setQueryData(['agent-mode-config'], context.previousConfig);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-mode-config'] });
},
});
// Add trigger mutation
const addTriggerMutation = useMutation({
mutationFn: modeApi.addTrigger,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-mode-config'] });
},
});
// Remove trigger mutation
const removeTriggerMutation = useMutation({
mutationFn: modeApi.removeTrigger,
onMutate: async (triggerId) => {
await queryClient.cancelQueries({ queryKey: ['agent-mode-config'] });
const previousConfig = queryClient.getQueryData<AgentModeConfig>(['agent-mode-config']);
queryClient.setQueryData<AgentModeConfig>(['agent-mode-config'], (old) =>
old
? { ...old, triggers: old.triggers.filter((t) => t.id !== triggerId) }
: undefined
);
return { previousConfig };
},
onError: (_err, _vars, context) => {
if (context?.previousConfig) {
queryClient.setQueryData(['agent-mode-config'], context.previousConfig);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-mode-config'] });
},
});
// Update schedule mutation
const updateScheduleMutation = useMutation({
mutationFn: ({ scheduleId, updates }: { scheduleId: string; updates: Partial<ActivitySchedule> }) =>
modeApi.updateSchedule(scheduleId, updates),
onMutate: async ({ scheduleId, updates }) => {
await queryClient.cancelQueries({ queryKey: ['agent-mode-config'] });
const previousConfig = queryClient.getQueryData<AgentModeConfig>(['agent-mode-config']);
queryClient.setQueryData<AgentModeConfig>(['agent-mode-config'], (old) =>
old
? {
...old,
schedules: old.schedules.map((s) =>
s.id === scheduleId ? { ...s, ...updates } : s
),
}
: undefined
);
return { previousConfig };
},
onError: (_err, _vars, context) => {
if (context?.previousConfig) {
queryClient.setQueryData(['agent-mode-config'], context.previousConfig);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-mode-config'] });
},
});
// Update config mutation
const updateConfigMutation = useMutation({
mutationFn: modeApi.updateConfig,
onMutate: async (updates) => {
await queryClient.cancelQueries({ queryKey: ['agent-mode-config'] });
const previousConfig = queryClient.getQueryData<AgentModeConfig>(['agent-mode-config']);
queryClient.setQueryData<AgentModeConfig>(['agent-mode-config'], (old) =>
old ? { ...old, ...updates } : undefined
);
return { previousConfig };
},
onError: (_err, _vars, context) => {
if (context?.previousConfig) {
queryClient.setQueryData(['agent-mode-config'], context.previousConfig);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-mode-config'] });
},
});
// Reset config mutation
const resetConfigMutation = useMutation({
mutationFn: modeApi.resetConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-mode-config'] });
},
});
// Computed values
const enabledTriggers = useMemo(() =>
config.triggers.filter((t) => t.enabled),
[config.triggers]
);
const activeSchedules = useMemo(() =>
config.schedules.filter((s) => s.enabled),
[config.schedules]
);
// Handlers
const setMode = useCallback(async (mode: AgentMode) => {
await setModeMutation.mutateAsync(mode);
}, [setModeMutation]);
const updateTrigger = useCallback(async (triggerId: string, updates: Partial<TriggerCondition>) => {
await updateTriggerMutation.mutateAsync({ triggerId, updates });
}, [updateTriggerMutation]);
const addTrigger = useCallback(async (trigger: Omit<TriggerCondition, 'id'>) => {
await addTriggerMutation.mutateAsync(trigger);
}, [addTriggerMutation]);
const removeTrigger = useCallback(async (triggerId: string) => {
await removeTriggerMutation.mutateAsync(triggerId);
}, [removeTriggerMutation]);
const updateSchedule = useCallback(async (scheduleId: string, updates: Partial<ActivitySchedule>) => {
await updateScheduleMutation.mutateAsync({ scheduleId, updates });
}, [updateScheduleMutation]);
const updateConfig = useCallback(async (updates: Partial<AgentModeConfig>) => {
await updateConfigMutation.mutateAsync(updates);
}, [updateConfigMutation]);
const resetConfig = useCallback(async () => {
await resetConfigMutation.mutateAsync();
}, [resetConfigMutation]);
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
return {
config,
isLoading,
isError,
error: error as Error | null,
mode: config.mode,
isProactive: config.mode === 'proactive',
isReactive: config.mode === 'reactive',
setMode,
triggers: config.triggers,
enabledTriggers,
updateTrigger,
addTrigger,
removeTrigger,
schedules: config.schedules,
activeSchedules,
updateSchedule,
updateConfig,
resetConfig,
refresh,
};
}
export default useAgentMode;

View File

@ -0,0 +1,302 @@
/**
* useAgentSettings Hook
* Unified hook for all agent settings including notifications and limits
* OQI-007: LLM Strategy Agent - Settings Hook
*/
import { useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../lib/apiClient';
// ============================================================================
// Types
// ============================================================================
export interface NotificationSettings {
emailNotifications: boolean;
pushNotifications: boolean;
signalAlerts: boolean;
riskAlerts: boolean;
dailyDigest: boolean;
weeklyReport: boolean;
}
export interface OperationLimits {
maxDailyTrades: number;
maxPositionSize: number;
maxDailyLoss: number;
maxDrawdown: number;
pauseOnLossStreak: number;
}
export interface CommunicationPreferences {
responseStyle: 'concise' | 'detailed' | 'technical';
language: 'es' | 'en';
includeCharts: boolean;
includeSignals: boolean;
autoAnalyze: boolean;
}
export interface AgentSettings {
notifications: NotificationSettings;
limits: OperationLimits;
communication: CommunicationPreferences;
timezone: string;
riskProfile: 'conservative' | 'moderate' | 'aggressive';
preferredTimeframes: string[];
preferredSymbols: string[];
}
interface UseAgentSettingsOptions {
enabled?: boolean;
}
interface UseAgentSettingsReturn {
settings: AgentSettings;
isLoading: boolean;
isSaving: boolean;
isError: boolean;
error: Error | null;
// Notifications
notifications: NotificationSettings;
updateNotifications: (updates: Partial<NotificationSettings>) => Promise<void>;
// Limits
limits: OperationLimits;
updateLimits: (updates: Partial<OperationLimits>) => Promise<void>;
// Communication
communication: CommunicationPreferences;
updateCommunication: (updates: Partial<CommunicationPreferences>) => Promise<void>;
// General
updateSettings: (updates: Partial<AgentSettings>) => Promise<void>;
resetSettings: () => Promise<void>;
refresh: () => Promise<void>;
}
// ============================================================================
// API Functions
// ============================================================================
const settingsApi = {
get: async (): Promise<AgentSettings> => {
const response = await apiClient.get('/llm/agent/settings');
return response.data;
},
update: async (updates: Partial<AgentSettings>): Promise<AgentSettings> => {
const response = await apiClient.patch('/llm/agent/settings', updates);
return response.data;
},
updateNotifications: async (updates: Partial<NotificationSettings>): Promise<AgentSettings> => {
const response = await apiClient.patch('/llm/agent/settings/notifications', updates);
return response.data;
},
updateLimits: async (updates: Partial<OperationLimits>): Promise<AgentSettings> => {
const response = await apiClient.patch('/llm/agent/settings/limits', updates);
return response.data;
},
updateCommunication: async (updates: Partial<CommunicationPreferences>): Promise<AgentSettings> => {
const response = await apiClient.patch('/llm/agent/settings/communication', updates);
return response.data;
},
reset: async (): Promise<AgentSettings> => {
const response = await apiClient.post('/llm/agent/settings/reset');
return response.data;
},
};
// ============================================================================
// Default Settings
// ============================================================================
const DEFAULT_SETTINGS: AgentSettings = {
notifications: {
emailNotifications: true,
pushNotifications: true,
signalAlerts: true,
riskAlerts: true,
dailyDigest: false,
weeklyReport: true,
},
limits: {
maxDailyTrades: 10,
maxPositionSize: 2,
maxDailyLoss: 3,
maxDrawdown: 10,
pauseOnLossStreak: 3,
},
communication: {
responseStyle: 'detailed',
language: 'es',
includeCharts: true,
includeSignals: true,
autoAnalyze: false,
},
timezone: 'America/Mexico_City',
riskProfile: 'moderate',
preferredTimeframes: ['H1', 'H4', 'D1'],
preferredSymbols: ['XAUUSD', 'EURUSD', 'BTCUSD'],
};
// ============================================================================
// Hook Implementation
// ============================================================================
export function useAgentSettings(options: UseAgentSettingsOptions = {}): UseAgentSettingsReturn {
const { enabled = true } = options;
const queryClient = useQueryClient();
// Query for settings
const {
data: settings = DEFAULT_SETTINGS,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ['agent-settings'],
queryFn: settingsApi.get,
enabled,
staleTime: 60000, // 1 minute
});
// Generic update mutation
const updateMutation = useMutation({
mutationFn: settingsApi.update,
onMutate: async (updates) => {
await queryClient.cancelQueries({ queryKey: ['agent-settings'] });
const previousSettings = queryClient.getQueryData<AgentSettings>(['agent-settings']);
queryClient.setQueryData<AgentSettings>(['agent-settings'], (old) =>
old ? { ...old, ...updates } : undefined
);
return { previousSettings };
},
onError: (_err, _vars, context) => {
if (context?.previousSettings) {
queryClient.setQueryData(['agent-settings'], context.previousSettings);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-settings'] });
},
});
// Notifications mutation
const updateNotificationsMutation = useMutation({
mutationFn: settingsApi.updateNotifications,
onMutate: async (updates) => {
await queryClient.cancelQueries({ queryKey: ['agent-settings'] });
const previousSettings = queryClient.getQueryData<AgentSettings>(['agent-settings']);
queryClient.setQueryData<AgentSettings>(['agent-settings'], (old) =>
old ? { ...old, notifications: { ...old.notifications, ...updates } } : undefined
);
return { previousSettings };
},
onError: (_err, _vars, context) => {
if (context?.previousSettings) {
queryClient.setQueryData(['agent-settings'], context.previousSettings);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-settings'] });
},
});
// Limits mutation
const updateLimitsMutation = useMutation({
mutationFn: settingsApi.updateLimits,
onMutate: async (updates) => {
await queryClient.cancelQueries({ queryKey: ['agent-settings'] });
const previousSettings = queryClient.getQueryData<AgentSettings>(['agent-settings']);
queryClient.setQueryData<AgentSettings>(['agent-settings'], (old) =>
old ? { ...old, limits: { ...old.limits, ...updates } } : undefined
);
return { previousSettings };
},
onError: (_err, _vars, context) => {
if (context?.previousSettings) {
queryClient.setQueryData(['agent-settings'], context.previousSettings);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-settings'] });
},
});
// Communication mutation
const updateCommunicationMutation = useMutation({
mutationFn: settingsApi.updateCommunication,
onMutate: async (updates) => {
await queryClient.cancelQueries({ queryKey: ['agent-settings'] });
const previousSettings = queryClient.getQueryData<AgentSettings>(['agent-settings']);
queryClient.setQueryData<AgentSettings>(['agent-settings'], (old) =>
old ? { ...old, communication: { ...old.communication, ...updates } } : undefined
);
return { previousSettings };
},
onError: (_err, _vars, context) => {
if (context?.previousSettings) {
queryClient.setQueryData(['agent-settings'], context.previousSettings);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-settings'] });
},
});
// Reset mutation
const resetMutation = useMutation({
mutationFn: settingsApi.reset,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-settings'] });
},
});
// Handlers
const updateSettings = useCallback(async (updates: Partial<AgentSettings>) => {
await updateMutation.mutateAsync(updates);
}, [updateMutation]);
const updateNotifications = useCallback(async (updates: Partial<NotificationSettings>) => {
await updateNotificationsMutation.mutateAsync(updates);
}, [updateNotificationsMutation]);
const updateLimits = useCallback(async (updates: Partial<OperationLimits>) => {
await updateLimitsMutation.mutateAsync(updates);
}, [updateLimitsMutation]);
const updateCommunication = useCallback(async (updates: Partial<CommunicationPreferences>) => {
await updateCommunicationMutation.mutateAsync(updates);
}, [updateCommunicationMutation]);
const resetSettings = useCallback(async () => {
await resetMutation.mutateAsync();
}, [resetMutation]);
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
return {
settings,
isLoading,
isSaving: updateMutation.isPending || updateNotificationsMutation.isPending ||
updateLimitsMutation.isPending || updateCommunicationMutation.isPending,
isError,
error: error as Error | null,
notifications: settings.notifications,
updateNotifications,
limits: settings.limits,
updateLimits,
communication: settings.communication,
updateCommunication,
updateSettings,
resetSettings,
refresh,
};
}
export default useAgentSettings;

View File

@ -0,0 +1,209 @@
/**
* useAgentTools Hook
* Manages agent tools configuration with API integration
* OQI-007: LLM Strategy Agent - Tools Hook
*/
import { useState, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../lib/apiClient';
import type { AgentTool, ToolCategory } from '../components/ToolsConfigPanel';
// ============================================================================
// Types
// ============================================================================
interface UseAgentToolsOptions {
enabled?: boolean;
}
interface UseAgentToolsReturn {
tools: AgentTool[];
enabledTools: AgentTool[];
toolsByCategory: Record<ToolCategory, AgentTool[]>;
isLoading: boolean;
isError: boolean;
error: Error | null;
toggleTool: (toolId: string, enabled: boolean) => Promise<void>;
updateToolParams: (toolId: string, params: Record<string, string | number | boolean>) => Promise<void>;
resetTool: (toolId: string) => Promise<void>;
resetAllTools: () => Promise<void>;
getTool: (toolId: string) => AgentTool | undefined;
refresh: () => Promise<void>;
}
// ============================================================================
// API Functions
// ============================================================================
const toolsApi = {
getAll: async (): Promise<AgentTool[]> => {
const response = await apiClient.get('/llm/tools');
return response.data;
},
toggle: async (toolId: string, enabled: boolean): Promise<AgentTool> => {
const response = await apiClient.patch(`/llm/tools/${toolId}`, { enabled });
return response.data;
},
updateParams: async (toolId: string, params: Record<string, string | number | boolean>): Promise<AgentTool> => {
const response = await apiClient.patch(`/llm/tools/${toolId}/params`, { params });
return response.data;
},
reset: async (toolId: string): Promise<AgentTool> => {
const response = await apiClient.post(`/llm/tools/${toolId}/reset`);
return response.data;
},
resetAll: async (): Promise<void> => {
await apiClient.post('/llm/tools/reset-all');
},
};
// ============================================================================
// Hook Implementation
// ============================================================================
export function useAgentTools(options: UseAgentToolsOptions = {}): UseAgentToolsReturn {
const { enabled = true } = options;
const queryClient = useQueryClient();
// Query for tools
const {
data: tools = [],
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ['agent-tools'],
queryFn: toolsApi.getAll,
enabled,
staleTime: 60000, // 1 minute
});
// Toggle mutation
const toggleMutation = useMutation({
mutationFn: ({ toolId, enabled }: { toolId: string; enabled: boolean }) =>
toolsApi.toggle(toolId, enabled),
onMutate: async ({ toolId, enabled }) => {
// Optimistic update
await queryClient.cancelQueries({ queryKey: ['agent-tools'] });
const previousTools = queryClient.getQueryData<AgentTool[]>(['agent-tools']);
queryClient.setQueryData<AgentTool[]>(['agent-tools'], (old) =>
old?.map((t) => (t.id === toolId ? { ...t, enabled } : t))
);
return { previousTools };
},
onError: (_err, _vars, context) => {
// Rollback on error
if (context?.previousTools) {
queryClient.setQueryData(['agent-tools'], context.previousTools);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-tools'] });
},
});
// Update params mutation
const updateParamsMutation = useMutation({
mutationFn: ({ toolId, params }: { toolId: string; params: Record<string, string | number | boolean> }) =>
toolsApi.updateParams(toolId, params),
onMutate: async ({ toolId, params }) => {
await queryClient.cancelQueries({ queryKey: ['agent-tools'] });
const previousTools = queryClient.getQueryData<AgentTool[]>(['agent-tools']);
queryClient.setQueryData<AgentTool[]>(['agent-tools'], (old) =>
old?.map((t) => (t.id === toolId ? { ...t, parameterValues: params } : t))
);
return { previousTools };
},
onError: (_err, _vars, context) => {
if (context?.previousTools) {
queryClient.setQueryData(['agent-tools'], context.previousTools);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['agent-tools'] });
},
});
// Reset tool mutation
const resetMutation = useMutation({
mutationFn: toolsApi.reset,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-tools'] });
},
});
// Reset all mutation
const resetAllMutation = useMutation({
mutationFn: toolsApi.resetAll,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-tools'] });
},
});
// Computed values
const enabledTools = useMemo(() => tools.filter((t) => t.enabled), [tools]);
const toolsByCategory = useMemo(() => {
const grouped: Record<ToolCategory, AgentTool[]> = {
analysis: [],
trading: [],
risk: [],
data: [],
utility: [],
};
tools.forEach((tool) => {
if (grouped[tool.category]) {
grouped[tool.category].push(tool);
}
});
return grouped;
}, [tools]);
// Handlers
const toggleTool = useCallback(async (toolId: string, enabled: boolean) => {
await toggleMutation.mutateAsync({ toolId, enabled });
}, [toggleMutation]);
const updateToolParams = useCallback(async (toolId: string, params: Record<string, string | number | boolean>) => {
await updateParamsMutation.mutateAsync({ toolId, params });
}, [updateParamsMutation]);
const resetTool = useCallback(async (toolId: string) => {
await resetMutation.mutateAsync(toolId);
}, [resetMutation]);
const resetAllTools = useCallback(async () => {
await resetAllMutation.mutateAsync();
}, [resetAllMutation]);
const getTool = useCallback((toolId: string): AgentTool | undefined => {
return tools.find((t) => t.id === toolId);
}, [tools]);
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
return {
tools,
enabledTools,
toolsByCategory,
isLoading,
isError,
error: error as Error | null,
toggleTool,
updateToolParams,
resetTool,
resetAllTools,
getTool,
refresh,
};
}
export default useAgentTools;

View File

@ -0,0 +1,277 @@
/**
* useConversations Hook
* Enhanced conversation management with filtering and search
* OQI-007: LLM Strategy Agent - Conversations Hook
*/
import { useState, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { isWithinInterval, startOfDay, endOfDay, subDays } from 'date-fns';
import { apiClient } from '../../../lib/apiClient';
import type { ChatSession } from '../../../types/chat.types';
// ============================================================================
// Types
// ============================================================================
type DateFilter = 'all' | 'today' | 'week' | 'month' | 'custom';
interface DateRange {
start: Date;
end: Date;
}
interface UseConversationsOptions {
enabled?: boolean;
autoRefresh?: boolean;
refreshInterval?: number;
}
interface UseConversationsReturn {
sessions: ChatSession[];
filteredSessions: ChatSession[];
currentSessionId: string | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
// Filters
searchQuery: string;
dateFilter: DateFilter;
customDateRange: DateRange | null;
setSearchQuery: (query: string) => void;
setDateFilter: (filter: DateFilter) => void;
setCustomDateRange: (range: DateRange | null) => void;
clearFilters: () => void;
// Actions
selectSession: (sessionId: string) => void;
createSession: () => Promise<string>;
deleteSession: (sessionId: string) => Promise<void>;
archiveSession: (sessionId: string) => Promise<void>;
exportSession: (sessionId: string) => Promise<Blob>;
refresh: () => Promise<void>;
// Stats
totalMessages: number;
sessionCount: number;
}
// ============================================================================
// API Functions
// ============================================================================
const conversationsApi = {
getAll: async (): Promise<ChatSession[]> => {
const response = await apiClient.get('/llm/sessions');
return response.data;
},
create: async (): Promise<{ sessionId: string }> => {
const response = await apiClient.post('/llm/sessions');
return response.data;
},
delete: async (sessionId: string): Promise<void> => {
await apiClient.delete(`/llm/sessions/${sessionId}`);
},
archive: async (sessionId: string): Promise<void> => {
await apiClient.patch(`/llm/sessions/${sessionId}/archive`);
},
export: async (sessionId: string): Promise<Blob> => {
const response = await apiClient.get(`/llm/sessions/${sessionId}/export`, {
responseType: 'blob',
});
return response.data;
},
};
// ============================================================================
// Helper Functions
// ============================================================================
const getDateRange = (filter: DateFilter, customRange?: DateRange | null): DateRange | null => {
const now = new Date();
switch (filter) {
case 'today':
return { start: startOfDay(now), end: endOfDay(now) };
case 'week':
return { start: startOfDay(subDays(now, 7)), end: endOfDay(now) };
case 'month':
return { start: startOfDay(subDays(now, 30)), end: endOfDay(now) };
case 'custom':
return customRange || null;
default:
return null;
}
};
// ============================================================================
// Hook Implementation
// ============================================================================
export function useConversations(options: UseConversationsOptions = {}): UseConversationsReturn {
const { enabled = true, autoRefresh = false, refreshInterval = 30000 } = options;
const queryClient = useQueryClient();
// Local state
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [dateFilter, setDateFilter] = useState<DateFilter>('all');
const [customDateRange, setCustomDateRange] = useState<DateRange | null>(null);
// Query for sessions
const {
data: sessions = [],
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ['conversations'],
queryFn: conversationsApi.getAll,
enabled,
refetchInterval: autoRefresh ? refreshInterval : false,
staleTime: 15000, // 15 seconds
});
// Create mutation
const createMutation = useMutation({
mutationFn: conversationsApi.create,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['conversations'] });
setCurrentSessionId(data.sessionId);
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: conversationsApi.delete,
onMutate: async (sessionId) => {
await queryClient.cancelQueries({ queryKey: ['conversations'] });
const previousSessions = queryClient.getQueryData<ChatSession[]>(['conversations']);
queryClient.setQueryData<ChatSession[]>(['conversations'], (old) =>
old?.filter((s) => s.id !== sessionId)
);
return { previousSessions };
},
onError: (_err, _vars, context) => {
if (context?.previousSessions) {
queryClient.setQueryData(['conversations'], context.previousSessions);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
// Archive mutation
const archiveMutation = useMutation({
mutationFn: conversationsApi.archive,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['conversations'] });
},
});
// Filtered sessions
const filteredSessions = useMemo(() => {
let filtered = [...sessions];
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((session) => {
// Search in messages
const hasMatch = session.messages.some((m) =>
m.content.toLowerCase().includes(query)
);
// Search in first message (title)
const firstUserMsg = session.messages.find((m) => m.role === 'user');
const titleMatch = firstUserMsg?.content.toLowerCase().includes(query);
return hasMatch || titleMatch;
});
}
// Date filter
const range = getDateRange(dateFilter, customDateRange);
if (range) {
filtered = filtered.filter((session) => {
const date = new Date(session.updatedAt);
return isWithinInterval(date, { start: range.start, end: range.end });
});
}
// Sort by most recent
return filtered.sort((a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}, [sessions, searchQuery, dateFilter, customDateRange]);
// Stats
const totalMessages = useMemo(() =>
sessions.reduce((acc, s) => acc + s.messages.length, 0),
[sessions]
);
// Handlers
const selectSession = useCallback((sessionId: string) => {
setCurrentSessionId(sessionId);
localStorage.setItem('currentChatSessionId', sessionId);
}, []);
const createSession = useCallback(async (): Promise<string> => {
const result = await createMutation.mutateAsync();
return result.sessionId;
}, [createMutation]);
const deleteSession = useCallback(async (sessionId: string) => {
await deleteMutation.mutateAsync(sessionId);
if (currentSessionId === sessionId) {
setCurrentSessionId(null);
localStorage.removeItem('currentChatSessionId');
}
}, [deleteMutation, currentSessionId]);
const archiveSession = useCallback(async (sessionId: string) => {
await archiveMutation.mutateAsync(sessionId);
}, [archiveMutation]);
const exportSession = useCallback(async (sessionId: string): Promise<Blob> => {
return await conversationsApi.export(sessionId);
}, []);
const clearFilters = useCallback(() => {
setSearchQuery('');
setDateFilter('all');
setCustomDateRange(null);
}, []);
const refresh = useCallback(async () => {
await refetch();
}, [refetch]);
return {
sessions,
filteredSessions,
currentSessionId,
isLoading,
isError,
error: error as Error | null,
searchQuery,
dateFilter,
customDateRange,
setSearchQuery,
setDateFilter,
setCustomDateRange,
clearFilters,
selectSession,
createSession,
deleteSession,
archiveSession,
exportSession,
refresh,
totalMessages,
sessionCount: sessions.length,
};
}
export default useConversations;

View File

@ -0,0 +1,498 @@
/**
* AgentSettingsPage
* Full page for configuring the LLM trading agent
* OQI-007: LLM Strategy Agent - Settings Page
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowLeftIcon,
Cog6ToothIcon,
CircleStackIcon,
WrenchScrewdriverIcon,
BoltIcon,
BellIcon,
ShieldCheckIcon,
ArrowPathIcon,
CheckIcon,
ExclamationCircleIcon,
} from '@heroicons/react/24/outline';
// Components
import MemoryManagerPanel, { MemoryItem, MemoryStats, MemoryCategory } from '../components/MemoryManagerPanel';
import ToolsConfigPanel, { AgentTool } from '../components/ToolsConfigPanel';
import AgentModeSelector, { AgentModeConfig, AgentMode, TriggerCondition, ActivitySchedule } from '../components/AgentModeSelector';
// ============================================================================
// Types
// ============================================================================
type SettingsTab = 'mode' | 'memory' | 'tools' | 'notifications' | 'limits';
interface NotificationSettings {
emailNotifications: boolean;
pushNotifications: boolean;
signalAlerts: boolean;
riskAlerts: boolean;
dailyDigest: boolean;
weeklyReport: boolean;
}
interface OperationLimits {
maxDailyTrades: number;
maxPositionSize: number;
maxDailyLoss: number;
maxDrawdown: number;
pauseOnLossStreak: number;
}
// ============================================================================
// Constants
// ============================================================================
const TABS: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'mode', label: 'Modo', icon: BoltIcon },
{ id: 'memory', label: 'Memoria', icon: CircleStackIcon },
{ id: 'tools', label: 'Herramientas', icon: WrenchScrewdriverIcon },
{ id: 'notifications', label: 'Notificaciones', icon: BellIcon },
{ id: 'limits', label: 'Limites', icon: ShieldCheckIcon },
];
// Mock data - would come from API
const MOCK_MEMORIES: MemoryItem[] = [
{
id: '1',
category: 'preferences',
key: 'risk_tolerance',
value: 'Prefiero operaciones conservadoras con stop loss ajustado. Maximo 1% de riesgo por operacion.',
createdAt: '2026-02-01T10:00:00Z',
updatedAt: '2026-02-03T15:30:00Z',
isPinned: true,
},
{
id: '2',
category: 'strategies',
key: 'preferred_setup',
value: 'Setup favorito: Ruptura de estructura en H4 con confirmacion en M15. Buscar FVG y OB.',
createdAt: '2026-02-02T09:00:00Z',
updatedAt: '2026-02-02T09:00:00Z',
},
{
id: '3',
category: 'history',
key: 'last_analysis',
value: 'Ultimo analisis de XAUUSD mostro fase de distribucion. Se identifico zona de demanda en 2040.',
createdAt: '2026-02-03T08:00:00Z',
updatedAt: '2026-02-03T08:00:00Z',
},
];
const MOCK_STATS: MemoryStats = {
totalItems: 15,
totalTokens: 4500,
maxTokens: 8000,
byCategory: { preferences: 5, strategies: 4, history: 4, custom: 2 },
};
const MOCK_TOOLS: AgentTool[] = [
{
id: 'market_analysis',
name: 'Analisis de Mercado',
description: 'Analiza datos de mercado en tiempo real incluyendo precio, volumen y estructura.',
category: 'analysis',
enabled: true,
isCore: true,
parameters: [],
parameterValues: {},
usageCount: 156,
lastUsed: '2026-02-04T10:30:00Z',
},
{
id: 'order_execution',
name: 'Ejecucion de Ordenes',
description: 'Permite al agente sugerir y preparar ordenes de trading.',
category: 'trading',
enabled: true,
parameters: [
{ name: 'require_confirmation', type: 'boolean', label: 'Requerir confirmacion', default: true },
{ name: 'default_risk', type: 'number', label: 'Riesgo por defecto (%)', default: 1, min: 0.1, max: 5 },
],
parameterValues: { require_confirmation: true, default_risk: 1 },
requiredPermissions: ['trading:execute'],
usageCount: 42,
lastUsed: '2026-02-03T18:00:00Z',
},
{
id: 'risk_calculator',
name: 'Calculadora de Riesgo',
description: 'Calcula tamano de posicion y riesgo basado en tu configuracion.',
category: 'risk',
enabled: true,
isCore: true,
parameters: [],
parameterValues: {},
usageCount: 89,
lastUsed: '2026-02-04T09:15:00Z',
},
{
id: 'news_analyzer',
name: 'Analizador de Noticias',
description: 'Analiza noticias y eventos del mercado para identificar impacto potencial.',
category: 'data',
enabled: false,
parameters: [
{ name: 'sources', type: 'select', label: 'Fuentes', default: 'all', options: [
{ value: 'all', label: 'Todas' },
{ value: 'forex', label: 'Solo Forex' },
{ value: 'crypto', label: 'Solo Crypto' },
]},
],
parameterValues: { sources: 'all' },
usageCount: 23,
lastUsed: '2026-02-01T12:00:00Z',
},
{
id: 'backtester',
name: 'Backtester',
description: 'Ejecuta backtests sobre estrategias y configuraciones.',
category: 'analysis',
enabled: true,
parameters: [
{ name: 'default_period', type: 'select', label: 'Periodo por defecto', default: '90', options: [
{ value: '30', label: '30 dias' },
{ value: '90', label: '90 dias' },
{ value: '180', label: '180 dias' },
{ value: '365', label: '1 ano' },
]},
],
parameterValues: { default_period: '90' },
usageCount: 12,
lastUsed: '2026-02-02T14:00:00Z',
},
];
const MOCK_MODE_CONFIG: AgentModeConfig = {
mode: 'reactive',
triggers: [
{ id: '1', type: 'signal_generated', name: 'Senal de alta confianza', enabled: true, config: { minConfidence: 0.85 } },
{ id: '2', type: 'risk_threshold', name: 'Alerta de drawdown', enabled: true, config: { threshold: 5 } },
{ id: '3', type: 'price_alert', name: 'Precio objetivo alcanzado', enabled: false, config: {} },
],
schedules: TABS.map((_, i) => ({
id: String(i),
dayOfWeek: i,
startTime: '09:00',
endTime: '17:00',
enabled: i >= 1 && i <= 5,
})),
notifyOnAction: true,
requireConfirmation: true,
maxActionsPerHour: 5,
};
// ============================================================================
// Main Component
// ============================================================================
const AgentSettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<SettingsTab>('mode');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
// State
const [memories, setMemories] = useState<MemoryItem[]>(MOCK_MEMORIES);
const [memoryStats, setMemoryStats] = useState<MemoryStats>(MOCK_STATS);
const [tools, setTools] = useState<AgentTool[]>(MOCK_TOOLS);
const [modeConfig, setModeConfig] = useState<AgentModeConfig>(MOCK_MODE_CONFIG);
const [notifications, setNotifications] = useState<NotificationSettings>({
emailNotifications: true,
pushNotifications: true,
signalAlerts: true,
riskAlerts: true,
dailyDigest: false,
weeklyReport: true,
});
const [limits, setLimits] = useState<OperationLimits>({
maxDailyTrades: 10,
maxPositionSize: 2,
maxDailyLoss: 3,
maxDrawdown: 10,
pauseOnLossStreak: 3,
});
// Handlers - Memory
const handleAddMemory = useCallback(async (memory: Omit<MemoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
const newMemory: MemoryItem = {
...memory,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
setMemories((prev) => [newMemory, ...prev]);
setMemoryStats((prev) => ({
...prev,
totalItems: prev.totalItems + 1,
byCategory: { ...prev.byCategory, [memory.category]: (prev.byCategory[memory.category] || 0) + 1 },
}));
}, []);
const handleUpdateMemory = useCallback(async (id: string, updates: Partial<MemoryItem>) => {
setMemories((prev) => prev.map((m) =>
m.id === id ? { ...m, ...updates, updatedAt: new Date().toISOString() } : m
));
}, []);
const handleDeleteMemory = useCallback(async (id: string) => {
const memory = memories.find((m) => m.id === id);
if (memory) {
setMemories((prev) => prev.filter((m) => m.id !== id));
setMemoryStats((prev) => ({
...prev,
totalItems: prev.totalItems - 1,
byCategory: { ...prev.byCategory, [memory.category]: prev.byCategory[memory.category] - 1 },
}));
}
}, [memories]);
// Handlers - Tools
const handleToggleTool = useCallback((toolId: string, enabled: boolean) => {
setTools((prev) => prev.map((t) => t.id === toolId ? { ...t, enabled } : t));
}, []);
const handleUpdateToolParams = useCallback((toolId: string, params: Record<string, string | number | boolean>) => {
setTools((prev) => prev.map((t) => t.id === toolId ? { ...t, parameterValues: params } : t));
}, []);
// Handlers - Mode
const handleModeChange = useCallback((mode: AgentMode) => {
setModeConfig((prev) => ({ ...prev, mode }));
}, []);
const handleUpdateTrigger = useCallback((triggerId: string, updates: Partial<TriggerCondition>) => {
setModeConfig((prev) => ({
...prev,
triggers: prev.triggers.map((t) => t.id === triggerId ? { ...t, ...updates } : t),
}));
}, []);
const handleUpdateSchedule = useCallback((scheduleId: string, updates: Partial<ActivitySchedule>) => {
setModeConfig((prev) => ({
...prev,
schedules: prev.schedules.map((s) => s.id === scheduleId ? { ...s, ...updates } : s),
}));
}, []);
const handleUpdateModeConfig = useCallback((updates: Partial<AgentModeConfig>) => {
setModeConfig((prev) => ({ ...prev, ...updates }));
}, []);
// Save all settings
const handleSaveAll = useCallback(async () => {
setIsSaving(true);
setError(null);
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} catch (err) {
setError('Error al guardar la configuracion');
} finally {
setIsSaving(false);
}
}, []);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-4">
<Link
to="/assistant"
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
<ArrowLeftIcon className="w-5 h-5" />
</Link>
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-r from-cyan-500 to-blue-500 rounded-lg">
<Cog6ToothIcon className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="font-bold text-gray-900 dark:text-white">Configuracion del Agente</h1>
<p className="text-xs text-gray-500">Personaliza tu asistente de trading</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{saveSuccess && (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400">
<CheckIcon className="w-4 h-4" />
Guardado
</div>
)}
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<ExclamationCircleIcon className="w-4 h-4" />
{error}
</div>
)}
<button
onClick={handleSaveAll}
disabled={isSaving}
className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 disabled:opacity-50 transition-colors"
>
{isSaving ? (
<ArrowPathIcon className="w-4 h-4 animate-spin" />
) : (
<CheckIcon className="w-4 h-4" />
)}
Guardar Cambios
</button>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex gap-8">
{/* Sidebar Tabs */}
<div className="w-56 flex-shrink-0">
<nav className="space-y-1">
{TABS.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${
activeTab === tab.id
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
<Icon className="w-5 h-5" />
{tab.label}
</button>
);
})}
</nav>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{activeTab === 'mode' && (
<AgentModeSelector
config={modeConfig}
onModeChange={handleModeChange}
onUpdateTrigger={handleUpdateTrigger}
onUpdateSchedule={handleUpdateSchedule}
onUpdateConfig={handleUpdateModeConfig}
/>
)}
{activeTab === 'memory' && (
<MemoryManagerPanel
memories={memories}
stats={memoryStats}
onAddMemory={handleAddMemory}
onUpdateMemory={handleUpdateMemory}
onDeleteMemory={handleDeleteMemory}
isLoading={isLoading}
/>
)}
{activeTab === 'tools' && (
<ToolsConfigPanel
tools={tools}
onToggleTool={handleToggleTool}
onUpdateToolParams={handleUpdateToolParams}
isLoading={isLoading}
/>
)}
{activeTab === 'notifications' && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-2 mb-6">
<BellIcon className="w-5 h-5 text-primary-500" />
<h3 className="font-semibold text-gray-900 dark:text-white">Notificaciones</h3>
</div>
<div className="space-y-4">
{Object.entries(notifications).map(([key, value]) => (
<div key={key} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{key.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())}
</p>
</div>
<button
onClick={() => setNotifications((prev) => ({ ...prev, [key]: !value }))}
className={`relative w-11 h-6 rounded-full transition-colors ${
value ? 'bg-primary-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
value ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
))}
</div>
</div>
)}
{activeTab === 'limits' && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-2 mb-6">
<ShieldCheckIcon className="w-5 h-5 text-primary-500" />
<h3 className="font-semibold text-gray-900 dark:text-white">Limites de Operacion</h3>
</div>
<div className="space-y-6">
{[
{ key: 'maxDailyTrades', label: 'Max operaciones por dia', min: 1, max: 50, unit: '' },
{ key: 'maxPositionSize', label: 'Max tamano posicion', min: 0.5, max: 10, unit: '%' },
{ key: 'maxDailyLoss', label: 'Max perdida diaria', min: 1, max: 10, unit: '%' },
{ key: 'maxDrawdown', label: 'Max drawdown total', min: 5, max: 30, unit: '%' },
{ key: 'pauseOnLossStreak', label: 'Pausar tras perdidas consecutivas', min: 2, max: 10, unit: '' },
].map((item) => (
<div key={item.key}>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{item.label}</p>
<span className="text-sm text-primary-500 font-medium">
{limits[item.key as keyof OperationLimits]}{item.unit}
</span>
</div>
<input
type="range"
min={item.min}
max={item.max}
step={item.key === 'maxPositionSize' ? 0.5 : 1}
value={limits[item.key as keyof OperationLimits]}
onChange={(e) => setLimits((prev) => ({
...prev,
[item.key]: parseFloat(e.target.value),
}))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>{item.min}{item.unit}</span>
<span>{item.max}{item.unit}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default AgentSettingsPage;

View File

@ -1,7 +1,7 @@
# Módulo Auth # Módulo Auth
**Epic:** OQI-001 - Fundamentos Auth **Epic:** OQI-001 - Fundamentos Auth
**Progreso:** 70% **Progreso:** 95%
**Responsable:** Backend + Frontend Teams **Responsable:** Backend + Frontend Teams
## Descripción ## Descripción
@ -34,18 +34,22 @@ Este módulo es crítico para toda la plataforma, ya que controla el acceso a to
``` ```
modules/auth/ modules/auth/
├── components/ ├── components/
│ ├── PhoneLoginForm.tsx │ ├── PhoneLoginForm.tsx - Phone number login with SMS verification
│ ├── SocialLoginButtons.tsx │ ├── SocialLoginButtons.tsx - Google, Facebook, Apple OAuth buttons
│ ├── DeviceCard.tsx │ ├── DeviceCard.tsx - Session/device display card with revoke
│ └── SessionsList.tsx │ ├── SessionsList.tsx - Active sessions list with management
│ ├── TwoFactorSetup.tsx - 2FA setup wizard (QR, verify, backup codes)
│ └── TwoFactorVerifyModal.tsx - 2FA verification during login
├── hooks/
│ └── use2FA.ts - React Query hooks for 2FA operations
├── pages/ ├── pages/
│ ├── Login.tsx │ ├── Login.tsx - Main login page with 2FA inline support
│ ├── Register.tsx │ ├── Register.tsx - User registration
│ ├── ForgotPassword.tsx │ ├── ForgotPassword.tsx - Password recovery email request
│ ├── ResetPassword.tsx │ ├── ResetPassword.tsx - Set new password with token
│ ├── VerifyEmail.tsx │ ├── VerifyEmail.tsx - Email verification confirmation
│ ├── AuthCallback.tsx │ ├── AuthCallback.tsx - OAuth callback handler
│ └── SecuritySettings.tsx │ └── SecuritySettings.tsx - Security hub (sessions, password, 2FA)
└── README.md └── README.md
``` ```
@ -67,6 +71,15 @@ modules/auth/
| `/auth/logout` | POST | Cerrar sesión y revocar token | | `/auth/logout` | POST | Cerrar sesión y revocar token |
| `/auth/social/:provider` | GET | Iniciar flujo OAuth con proveedor social | | `/auth/social/:provider` | GET | Iniciar flujo OAuth con proveedor social |
| `/auth/social/callback` | GET | Callback de OAuth providers | | `/auth/social/callback` | GET | Callback de OAuth providers |
| `/auth/change-password` | POST | Change user password |
| `/auth/2fa/status` | GET | Get 2FA status for current user |
| `/auth/2fa/setup` | POST | Generate QR code and secret for 2FA setup |
| `/auth/2fa/enable` | POST | Enable 2FA with verification code |
| `/auth/2fa/disable` | POST | Disable 2FA with verification code |
| `/auth/2fa/backup-codes` | POST | Regenerate backup codes |
| `/auth/sessions` | GET | List all active sessions |
| `/auth/sessions/:id` | DELETE | Revoke a specific session |
| `/auth/logout-all` | POST | Revoke all sessions except current |
## Uso Rápido ## Uso Rápido
@ -139,8 +152,13 @@ npm run test:e2e auth
## Roadmap ## Roadmap
### Completados (P0)
- [x] **2FA Implementation** - Autenticación de dos factores con TOTP (setup wizard, verify modal, backup codes)
- [x] **Session Management** - Lista de sesiones activas con revocación individual y masiva
- [x] **Password Change** - Cambio de contraseña desde SecuritySettings
- [x] **Password Recovery** - Flujo completo de recuperación (forgot + reset)
### Pendientes - Alta Prioridad (P0) ### Pendientes - Alta Prioridad (P0)
- [ ] **2FA Implementation** (45h) - Autenticación de dos factores con TOTP
- [ ] **Auto-refresh Tokens** (60h) - Renovación automática de JWT sin logout forzado - [ ] **Auto-refresh Tokens** (60h) - Renovación automática de JWT sin logout forzado
- [ ] **CSRF Protection** (16h) - Protección contra Cross-Site Request Forgery - [ ] **CSRF Protection** (16h) - Protección contra Cross-Site Request Forgery

View File

@ -31,8 +31,8 @@ export default function AuthCallback() {
// Redirect after a brief delay // Redirect after a brief delay
setTimeout(() => { setTimeout(() => {
if (isNewUser) { if (isNewUser) {
// New user - redirect to onboarding // New user - redirect to dashboard (onboarding not yet implemented)
navigate('/onboarding'); navigate('/dashboard');
} else { } else {
navigate(returnUrl); navigate(returnUrl);
} }

View File

@ -1,11 +1,14 @@
/** /**
* SecuritySettings Page * SecuritySettings Page
* Security settings including active sessions management * Security settings including active sessions management, password change, and 2FA
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { SessionsList } from '../components/SessionsList'; import { SessionsList } from '../components/SessionsList';
import { TwoFactorSetup } from '../components/TwoFactorSetup';
import { use2FAStatus, useDisable2FA, useRegenerateBackupCodes } from '../hooks/use2FA';
import { apiClient } from '../../../lib/apiClient';
// ============================================================================ // ============================================================================
// Icons // Icons
@ -59,6 +62,83 @@ export default function SecuritySettings() {
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<SecurityTab>('sessions'); const [activeTab, setActiveTab] = useState<SecurityTab>('sessions');
// 2FA state
const [showSetup2FA, setShowSetup2FA] = useState(false);
const [showDisable2FAConfirm, setShowDisable2FAConfirm] = useState(false);
const [disableCode, setDisableCode] = useState('');
const [showRegenBackupCodes, setShowRegenBackupCodes] = useState(false);
const [regenCode, setRegenCode] = useState('');
const [newBackupCodes, setNewBackupCodes] = useState<string[] | null>(null);
// 2FA hooks
const { data: twoFAStatus, isLoading: is2FALoading, refetch: refetch2FAStatus } = use2FAStatus();
const disable2FAMutation = useDisable2FA();
const regenBackupCodesMutation = useRegenerateBackupCodes();
// Password change state
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
const [passwordLoading, setPasswordLoading] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [passwordSuccess, setPasswordSuccess] = useState(false);
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError(null);
setPasswordSuccess(false);
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setPasswordError('New passwords do not match');
return;
}
if (passwordForm.newPassword.length < 8) {
setPasswordError('Password must be at least 8 characters');
return;
}
setPasswordLoading(true);
try {
await apiClient.post('/auth/change-password', {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
});
setPasswordSuccess(true);
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (err: unknown) {
const errorMessage = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
setPasswordError(errorMessage || 'Failed to change password');
} finally {
setPasswordLoading(false);
}
};
const handleDisable2FA = async () => {
if (disableCode.length !== 6) return;
try {
await disable2FAMutation.mutateAsync(disableCode);
setShowDisable2FAConfirm(false);
setDisableCode('');
refetch2FAStatus();
} catch {
// Error handled by mutation
}
};
const handleRegenBackupCodes = async () => {
if (regenCode.length !== 6) return;
try {
const result = await regenBackupCodesMutation.mutateAsync(regenCode);
setNewBackupCodes(result.backupCodes);
setRegenCode('');
} catch {
// Error handled by mutation
}
};
const tabs = [ const tabs = [
{ id: 'sessions' as const, name: 'Active Sessions', icon: DevicesIcon }, { id: 'sessions' as const, name: 'Active Sessions', icon: DevicesIcon },
{ id: 'password' as const, name: 'Change Password', icon: KeyIcon }, { id: 'password' as const, name: 'Change Password', icon: KeyIcon },
@ -144,15 +224,30 @@ export default function SecuritySettings() {
</p> </p>
</div> </div>
<form className="space-y-4 max-w-md"> {passwordSuccess && (
<div className="p-4 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<p className="text-sm text-emerald-400">Password changed successfully!</p>
</div>
)}
{passwordError && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30">
<p className="text-sm text-red-400">{passwordError}</p>
</div>
)}
<form onSubmit={handlePasswordChange} className="space-y-4 max-w-md">
<div> <div>
<label className="block text-sm font-medium text-slate-300 mb-2"> <label className="block text-sm font-medium text-slate-300 mb-2">
Current Password Current Password
</label> </label>
<input <input
type="password" type="password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter current password" placeholder="Enter current password"
required
/> />
</div> </div>
@ -162,8 +257,12 @@ export default function SecuritySettings() {
</label> </label>
<input <input
type="password" type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter new password" placeholder="Enter new password"
required
minLength={8}
/> />
</div> </div>
@ -173,16 +272,30 @@ export default function SecuritySettings() {
</label> </label>
<input <input
type="password" type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-4 py-2.5 bg-slate-900 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Confirm new password" placeholder="Confirm new password"
required
/> />
</div> </div>
<button <button
type="submit" type="submit"
className="px-6 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors" disabled={passwordLoading}
className="px-6 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
> >
Update Password {passwordLoading ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Updating...
</>
) : (
'Update Password'
)}
</button> </button>
</form> </form>
</div> </div>
@ -198,73 +311,268 @@ export default function SecuritySettings() {
</p> </p>
</div> </div>
<div className="p-4 rounded-lg bg-amber-500/10 border border-amber-500/30"> {is2FALoading ? (
<div className="flex items-start gap-3"> <div className="flex items-center justify-center py-8">
<svg className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-8 h-8 text-blue-500 animate-spin" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg> </svg>
<div>
<p className="text-sm font-medium text-amber-400">
Two-Factor Authentication is not enabled
</p>
<p className="text-sm text-slate-400 mt-1">
Enable 2FA to add an extra layer of security to your account.
You&apos;ll need to enter a code from your authenticator app when signing in.
</p>
</div>
</div> </div>
</div> ) : twoFAStatus?.enabled ? (
<>
<div className="space-y-4"> {/* 2FA Enabled Status */}
<h4 className="font-medium text-white">Available Methods</h4> <div className="p-4 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<div className="flex items-start gap-3">
{/* Authenticator App Option */} <svg className="w-5 h-5 text-emerald-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div className="flex items-center justify-between p-4 rounded-lg bg-slate-800 border border-slate-700">
<div className="flex items-center gap-4">
<div className="p-2 bg-slate-700 rounded-lg">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" /> d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg> </svg>
</div> <div>
<div> <p className="text-sm font-medium text-emerald-400">
<p className="font-medium text-white">Authenticator App</p> Two-Factor Authentication is enabled
<p className="text-sm text-slate-400">Use an app like Google Authenticator or Authy</p> </p>
<p className="text-sm text-slate-400 mt-1">
Your account is protected with {twoFAStatus.method === '2fa_totp' ? 'TOTP authenticator app' : '2FA'}.
{twoFAStatus.backupCodesRemaining !== undefined && (
<span className="block mt-1">
Backup codes remaining: {twoFAStatus.backupCodesRemaining}
</span>
)}
</p>
</div>
</div> </div>
</div> </div>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-blue-400 bg-blue-500/10 rounded-lg hover:bg-blue-500/20 transition-colors"
>
Setup
</button>
</div>
{/* SMS Option */} {/* Actions when 2FA is enabled */}
<div className="flex items-center justify-between p-4 rounded-lg bg-slate-800 border border-slate-700"> <div className="space-y-4">
<div className="flex items-center gap-4"> <h4 className="font-medium text-white">Manage 2FA</h4>
<div className="p-2 bg-slate-700 rounded-lg">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> {/* Regenerate Backup Codes */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} <div className="flex items-center justify-between p-4 rounded-lg bg-slate-800 border border-slate-700">
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /> <div className="flex items-center gap-4">
</svg> <div className="p-2 bg-slate-700 rounded-lg">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div>
<p className="font-medium text-white">Backup Codes</p>
<p className="text-sm text-slate-400">Generate new backup codes</p>
</div>
</div>
<button
type="button"
onClick={() => setShowRegenBackupCodes(true)}
className="px-4 py-2 text-sm font-medium text-blue-400 bg-blue-500/10 rounded-lg hover:bg-blue-500/20 transition-colors"
>
Regenerate
</button>
</div> </div>
<div>
<p className="font-medium text-white">SMS</p> {/* Disable 2FA */}
<p className="text-sm text-slate-400">Receive codes via text message</p> <div className="flex items-center justify-between p-4 rounded-lg bg-slate-800 border border-slate-700">
<div className="flex items-center gap-4">
<div className="p-2 bg-red-900/30 rounded-lg">
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
</div>
<div>
<p className="font-medium text-white">Disable 2FA</p>
<p className="text-sm text-slate-400">Remove two-factor authentication</p>
</div>
</div>
<button
type="button"
onClick={() => setShowDisable2FAConfirm(true)}
className="px-4 py-2 text-sm font-medium text-red-400 bg-red-500/10 rounded-lg hover:bg-red-500/20 transition-colors"
>
Disable
</button>
</div> </div>
</div> </div>
<button
type="button" {/* Regenerate Backup Codes Dialog */}
className="px-4 py-2 text-sm font-medium text-blue-400 bg-blue-500/10 rounded-lg hover:bg-blue-500/20 transition-colors" {showRegenBackupCodes && (
> <div className="p-4 rounded-lg bg-slate-800 border border-slate-700 space-y-4">
Setup <h4 className="font-medium text-white">Regenerate Backup Codes</h4>
</button> {newBackupCodes ? (
</div> <div className="space-y-4">
</div> <p className="text-sm text-slate-400">
Save these codes in a secure location. Each code can only be used once.
</p>
<div className="grid grid-cols-2 gap-2">
{newBackupCodes.map((code, idx) => (
<div key={idx} className="px-3 py-2 bg-slate-900 rounded font-mono text-sm text-white">
{code}
</div>
))}
</div>
<button
type="button"
onClick={() => { setShowRegenBackupCodes(false); setNewBackupCodes(null); refetch2FAStatus(); }}
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
>
Done
</button>
</div>
) : (
<>
<p className="text-sm text-slate-400">
Enter your current TOTP code to generate new backup codes. Your old codes will be invalidated.
</p>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={regenCode}
onChange={(e) => setRegenCode(e.target.value.replace(/\D/g, ''))}
placeholder="Enter 6-digit code"
className="w-full px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-center font-mono text-lg tracking-widest focus:outline-none focus:border-blue-500"
/>
{regenBackupCodesMutation.isError && (
<p className="text-sm text-red-400">Invalid code. Please try again.</p>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => { setShowRegenBackupCodes(false); setRegenCode(''); }}
className="flex-1 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleRegenBackupCodes}
disabled={regenCode.length !== 6 || regenBackupCodesMutation.isPending}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors disabled:opacity-50"
>
{regenBackupCodesMutation.isPending ? 'Generating...' : 'Generate'}
</button>
</div>
</>
)}
</div>
)}
{/* Disable 2FA Dialog */}
{showDisable2FAConfirm && (
<div className="p-4 rounded-lg bg-red-900/20 border border-red-500/30 space-y-4">
<h4 className="font-medium text-white">Confirm Disable 2FA</h4>
<p className="text-sm text-slate-400">
This will remove two-factor authentication from your account. Enter your current TOTP code to confirm.
</p>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
placeholder="Enter 6-digit code"
className="w-full px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-center font-mono text-lg tracking-widest focus:outline-none focus:border-blue-500"
/>
{disable2FAMutation.isError && (
<p className="text-sm text-red-400">Invalid code. Please try again.</p>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => { setShowDisable2FAConfirm(false); setDisableCode(''); }}
className="flex-1 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleDisable2FA}
disabled={disableCode.length !== 6 || disable2FAMutation.isPending}
className="flex-1 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors disabled:opacity-50"
>
{disable2FAMutation.isPending ? 'Disabling...' : 'Disable 2FA'}
</button>
</div>
</div>
)}
</>
) : (
<>
{/* 2FA Not Enabled Status */}
<div className="p-4 rounded-lg bg-amber-500/10 border border-amber-500/30">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p className="text-sm font-medium text-amber-400">
Two-Factor Authentication is not enabled
</p>
<p className="text-sm text-slate-400 mt-1">
Enable 2FA to add an extra layer of security to your account.
You&apos;ll need to enter a code from your authenticator app when signing in.
</p>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="font-medium text-white">Available Methods</h4>
{/* Authenticator App Option */}
<div className="flex items-center justify-between p-4 rounded-lg bg-slate-800 border border-slate-700">
<div className="flex items-center gap-4">
<div className="p-2 bg-slate-700 rounded-lg">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<div>
<p className="font-medium text-white">Authenticator App</p>
<p className="text-sm text-slate-400">Use an app like Google Authenticator or Authy</p>
</div>
</div>
<button
type="button"
onClick={() => setShowSetup2FA(true)}
className="px-4 py-2 text-sm font-medium text-blue-400 bg-blue-500/10 rounded-lg hover:bg-blue-500/20 transition-colors"
>
Setup
</button>
</div>
{/* SMS Option - Coming Soon */}
<div className="flex items-center justify-between p-4 rounded-lg bg-slate-800 border border-slate-700 opacity-60">
<div className="flex items-center gap-4">
<div className="p-2 bg-slate-700 rounded-lg">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div>
<p className="font-medium text-white">SMS</p>
<p className="text-sm text-slate-400">Receive codes via text message</p>
</div>
</div>
<span className="px-3 py-1 text-xs font-medium text-slate-400 bg-slate-700 rounded-full">
Coming Soon
</span>
</div>
</div>
</>
)}
</div> </div>
)} )}
{/* 2FA Setup Modal */}
<TwoFactorSetup
isOpen={showSetup2FA}
onClose={() => setShowSetup2FA(false)}
onSuccess={() => refetch2FAStatus()}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,132 @@
/**
* ContinueLearningCard Component
* Displays continue learning suggestion with course progress
* OQI-002: Modulo Educativo
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { Play, BookOpen, Clock, TrendingUp, Loader2 } from 'lucide-react';
import { useContinueLearning } from '../hooks';
interface ContinueLearningCardProps {
className?: string;
}
const ContinueLearningCard: React.FC<ContinueLearningCardProps> = ({ className = '' }) => {
const { data: continueLearning, isLoading, error } = useContinueLearning();
// Loading state
if (isLoading) {
return (
<div className={`bg-gradient-to-r from-blue-600/20 to-purple-600/20 rounded-xl border border-blue-500/30 p-6 ${className}`}>
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
</div>
</div>
);
}
// No continue learning data
if (!continueLearning || error) {
return (
<div className={`bg-gray-800 rounded-xl border border-gray-700 p-6 ${className}`}>
<div className="text-center">
<BookOpen className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<h3 className="font-medium text-white mb-2">Comienza tu viaje de aprendizaje</h3>
<p className="text-sm text-gray-400 mb-4">
Inscribete en un curso para empezar
</p>
<Link
to="/education/courses"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors"
>
<BookOpen className="w-4 h-4" />
Explorar Cursos
</Link>
</div>
</div>
);
}
const formatTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) return 'Hace unos minutos';
if (diffHours < 24) return `Hace ${diffHours}h`;
if (diffDays === 1) return 'Ayer';
if (diffDays < 7) return `Hace ${diffDays} dias`;
return date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' });
};
return (
<div className={`bg-gradient-to-r from-blue-600/20 to-purple-600/20 rounded-xl border border-blue-500/30 overflow-hidden ${className}`}>
<div className="flex flex-col md:flex-row">
{/* Thumbnail */}
<div className="relative md:w-48 lg:w-56 flex-shrink-0">
{continueLearning.courseThumbnail ? (
<img
src={continueLearning.courseThumbnail}
alt={continueLearning.courseTitle}
className="w-full h-32 md:h-full object-cover"
/>
) : (
<div className="w-full h-32 md:h-full bg-gradient-to-br from-blue-600/30 to-purple-600/30 flex items-center justify-center">
<BookOpen className="w-12 h-12 text-blue-400/50" />
</div>
)}
{/* Progress overlay */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gray-900/50">
<div
className="h-full bg-blue-500"
style={{ width: `${continueLearning.progressPercentage}%` }}
/>
</div>
</div>
{/* Content */}
<div className="flex-1 p-4 md:p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm text-blue-400 font-medium mb-1">Continua donde lo dejaste</p>
<h3 className="font-semibold text-white text-lg mb-2 line-clamp-1">
{continueLearning.courseTitle}
</h3>
<p className="text-sm text-gray-400 mb-3 line-clamp-1">
<span className="text-gray-500">Siguiente:</span>{' '}
{continueLearning.lastLessonTitle}
</p>
{/* Stats */}
<div className="flex items-center gap-4 text-xs text-gray-400">
<span className="flex items-center gap-1">
<TrendingUp className="w-3.5 h-3.5" />
{continueLearning.progressPercentage}% completado
</span>
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{formatTime(continueLearning.lastAccessedAt)}
</span>
</div>
</div>
{/* Action Button */}
<Link
to={`/education/courses/${continueLearning.courseSlug}/lesson/${continueLearning.lastLessonId}`}
className="flex-shrink-0 flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors"
>
<Play className="w-4 h-4" />
<span className="hidden sm:inline">Continuar</span>
</Link>
</div>
</div>
</div>
</div>
);
};
export default ContinueLearningCard;

View File

@ -0,0 +1,176 @@
/**
* CourseReviewsSection Component
* Combines CourseReviews display with ReviewForm
* OQI-002: Modulo Educativo
*/
import React, { useState, useCallback } from 'react';
import { MessageSquare, Loader2 } from 'lucide-react';
import CourseReviews from './CourseReviews';
import ReviewForm from './ReviewForm';
import {
useCourseReviews,
useInfiniteReviews,
useMyReview,
useSubmitReview,
useMarkHelpful,
useReportReview,
} from '../hooks';
import type { RatingSummary, Review as ReviewType } from '../hooks/useCourseReviews';
interface CourseReviewsSectionProps {
courseId: string;
isEnrolled?: boolean;
hasCompleted?: boolean;
}
const CourseReviewsSection: React.FC<CourseReviewsSectionProps> = ({
courseId,
isEnrolled = false,
hasCompleted = false,
}) => {
const [filterRating, setFilterRating] = useState<number | null>(null);
// Queries
const {
data: reviewsData,
isLoading: loadingReviews,
} = useCourseReviews(courseId, filterRating ?? undefined);
const {
data: infiniteData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteReviews(courseId, 10, filterRating ?? undefined);
const { data: myReview, isLoading: loadingMyReview } = useMyReview(courseId);
// Mutations
const submitReviewMutation = useSubmitReview();
const markHelpfulMutation = useMarkHelpful();
const reportReviewMutation = useReportReview();
// Get all reviews from infinite query
const allReviews: ReviewType[] = infiniteData?.pages.flatMap((page) => page.reviews) ?? [];
// Get summary from first page
const ratingSummary: RatingSummary = reviewsData?.summary ?? {
average: 0,
total: 0,
distribution: { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 },
};
// Handlers
const handleSubmitReview = useCallback(
async (data: { rating: number; title?: string; comment: string }) => {
await submitReviewMutation.mutateAsync({ courseId, data });
},
[courseId, submitReviewMutation]
);
const handleMarkHelpful = useCallback(
(reviewId: string) => {
markHelpfulMutation.mutate({ courseId, reviewId });
},
[courseId, markHelpfulMutation]
);
const handleReport = useCallback(
(reviewId: string) => {
// In a real app, you'd open a modal for the reason
const reason = 'Inappropriate content';
reportReviewMutation.mutate({ courseId, reviewId, reason });
},
[courseId, reportReviewMutation]
);
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// Can user leave a review?
const canReview = isEnrolled && hasCompleted && !myReview;
if (loadingReviews && loadingMyReview) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
</div>
);
}
return (
<div className="space-y-8">
{/* Section Header */}
<div className="flex items-center gap-3">
<MessageSquare className="w-6 h-6 text-blue-400" />
<h2 className="text-xl font-bold text-white">Resenas del Curso</h2>
</div>
{/* Review Form (if eligible) */}
{canReview && (
<ReviewForm
courseId={courseId}
onSubmit={handleSubmitReview}
disabled={submitReviewMutation.isPending}
/>
)}
{/* Show existing user review */}
{myReview && (
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4">
<p className="text-sm text-blue-400 mb-2">Tu resena:</p>
<div className="flex items-center gap-2 mb-2">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-lg ${
star <= myReview.rating ? 'text-yellow-400' : 'text-gray-600'
}`}
>
</span>
))}
</div>
{myReview.title && <p className="font-medium text-white mb-1">{myReview.title}</p>}
<p className="text-gray-300 text-sm">{myReview.comment}</p>
</div>
)}
{/* Prompt for non-enrolled users */}
{!isEnrolled && (
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6 text-center">
<p className="text-gray-400">
Inscribete y completa el curso para dejar tu resena
</p>
</div>
)}
{/* Prompt for enrolled but not completed */}
{isEnrolled && !hasCompleted && !myReview && (
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6 text-center">
<p className="text-gray-400">
Completa el curso para dejar tu resena
</p>
</div>
)}
{/* Reviews List */}
<CourseReviews
courseId={courseId}
reviews={allReviews}
ratingSummary={ratingSummary}
onLoadMore={handleLoadMore}
onMarkHelpful={handleMarkHelpful}
onReport={handleReport}
hasMore={hasNextPage ?? false}
loading={isFetchingNextPage}
/>
</div>
);
};
export default CourseReviewsSection;

View File

@ -0,0 +1,344 @@
/**
* LessonProgress Component
* Visual lesson navigation with progress indicators
* OQI-002: Modulo Educativo
*/
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
CheckCircle,
Circle,
Play,
FileText,
Award,
Zap,
Lock,
ChevronRight,
Clock,
} from 'lucide-react';
import type { CourseModule, ContentType } from '../../../types/education.types';
interface LessonInfo {
id: string;
title: string;
contentType: ContentType;
durationMinutes: number;
isCompleted?: boolean;
isFree?: boolean;
isLocked?: boolean;
}
interface LessonProgressProps {
modules: CourseModule[];
currentLessonId: string;
courseSlug: string;
compact?: boolean;
onLessonClick?: (lessonId: string) => void;
}
const contentTypeIcons: Record<ContentType, React.ReactNode> = {
video: <Play className="w-4 h-4" />,
text: <FileText className="w-4 h-4" />,
quiz: <Award className="w-4 h-4" />,
exercise: <Zap className="w-4 h-4" />,
};
const LessonProgress: React.FC<LessonProgressProps> = ({
modules,
currentLessonId,
courseSlug,
compact = false,
onLessonClick,
}) => {
// Flatten all lessons with module info
const allLessons = useMemo(() => {
const lessons: (LessonInfo & { moduleTitle: string; moduleIndex: number })[] = [];
modules.forEach((module, moduleIndex) => {
module.lessons.forEach((lesson) => {
lessons.push({
id: lesson.id,
title: lesson.title,
contentType: lesson.contentType,
durationMinutes: lesson.durationMinutes,
isCompleted: lesson.isCompleted,
isFree: lesson.isFree,
isLocked: module.isLocked,
moduleTitle: module.title,
moduleIndex,
});
});
});
return lessons;
}, [modules]);
// Find current lesson index
const currentIndex = useMemo(() => {
return allLessons.findIndex((l) => l.id === currentLessonId);
}, [allLessons, currentLessonId]);
// Calculate progress stats
const stats = useMemo(() => {
const total = allLessons.length;
const completed = allLessons.filter((l) => l.isCompleted).length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, percentage };
}, [allLessons]);
const formatTime = (minutes: number) => {
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
// Compact horizontal view
if (compact) {
return (
<div className="bg-gray-800/50 rounded-xl border border-gray-700 p-4">
{/* Progress Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">
Leccion {currentIndex + 1} de {stats.total}
</span>
<span className="text-xs text-gray-500">
({stats.completed} completadas)
</span>
</div>
<span className="text-sm font-medium text-blue-400">{stats.percentage}%</span>
</div>
{/* Progress Dots */}
<div className="flex items-center gap-1 overflow-x-auto pb-2">
{allLessons.map((lesson, index) => {
const isCurrent = lesson.id === currentLessonId;
const isPast = index < currentIndex;
return (
<Link
key={lesson.id}
to={lesson.isLocked ? '#' : `/education/courses/${courseSlug}/lesson/${lesson.id}`}
onClick={(e) => {
if (lesson.isLocked) {
e.preventDefault();
return;
}
onLessonClick?.(lesson.id);
}}
title={lesson.title}
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition-all ${
isCurrent
? 'bg-blue-600 text-white ring-2 ring-blue-400 ring-offset-2 ring-offset-gray-800'
: lesson.isCompleted
? 'bg-green-500/20 text-green-400'
: lesson.isLocked
? 'bg-gray-800 text-gray-600 cursor-not-allowed'
: 'bg-gray-700 text-gray-400 hover:bg-gray-600'
}`}
>
{lesson.isCompleted ? (
<CheckCircle className="w-4 h-4" />
) : lesson.isLocked ? (
<Lock className="w-3 h-3" />
) : (
<span className="text-xs font-medium">{index + 1}</span>
)}
</Link>
);
})}
</div>
{/* Current Lesson Info */}
{currentIndex >= 0 && allLessons[currentIndex] && (
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/20 rounded-lg text-blue-400">
{contentTypeIcons[allLessons[currentIndex].contentType]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{allLessons[currentIndex].title}
</p>
<p className="text-xs text-gray-500">
{allLessons[currentIndex].moduleTitle}
</p>
</div>
{allLessons[currentIndex].durationMinutes > 0 && (
<span className="text-xs text-gray-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(allLessons[currentIndex].durationMinutes)}
</span>
)}
</div>
</div>
)}
</div>
);
}
// Full vertical view with module sections
return (
<div className="bg-gray-800/50 rounded-xl border border-gray-700 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-white">Contenido del Curso</h3>
<span className="text-sm text-blue-400">{stats.percentage}% completado</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-600 to-green-500 rounded-full transition-all"
style={{ width: `${stats.percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-2">
{stats.completed} de {stats.total} lecciones completadas
</p>
</div>
{/* Module List */}
<div className="max-h-[400px] overflow-y-auto">
{modules.map((module, moduleIndex) => (
<ModuleSection
key={module.id}
module={module}
moduleIndex={moduleIndex}
currentLessonId={currentLessonId}
courseSlug={courseSlug}
onLessonClick={onLessonClick}
/>
))}
</div>
</div>
);
};
// Module Section Component
interface ModuleSectionProps {
module: CourseModule;
moduleIndex: number;
currentLessonId: string;
courseSlug: string;
onLessonClick?: (lessonId: string) => void;
}
const ModuleSection: React.FC<ModuleSectionProps> = ({
module,
moduleIndex,
currentLessonId,
courseSlug,
onLessonClick,
}) => {
const [isExpanded, setIsExpanded] = React.useState(
module.lessons.some((l) => l.id === currentLessonId)
);
const completedLessons = module.lessons.filter((l) => l.isCompleted).length;
const totalLessons = module.lessons.length;
const moduleProgress = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
return (
<div className="border-b border-gray-700/50 last:border-b-0">
{/* Module Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 hover:bg-gray-700/30 transition-colors"
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
moduleProgress === 100
? 'bg-green-500/20 text-green-400'
: 'bg-gray-700 text-gray-400'
}`}
>
{moduleProgress === 100 ? (
<CheckCircle className="w-4 h-4" />
) : (
moduleIndex + 1
)}
</div>
<div className="text-left">
<p className="font-medium text-white text-sm">{module.title}</p>
<p className="text-xs text-gray-500">
{completedLessons}/{totalLessons} lecciones
</p>
</div>
</div>
<div className="flex items-center gap-3">
{module.isLocked && <Lock className="w-4 h-4 text-gray-500" />}
<ChevronRight
className={`w-4 h-4 text-gray-400 transition-transform ${
isExpanded ? 'rotate-90' : ''
}`}
/>
</div>
</button>
{/* Module Lessons */}
{isExpanded && (
<div className="pb-2">
{module.lessons.map((lesson, lessonIndex) => {
const isCurrent = lesson.id === currentLessonId;
return (
<Link
key={lesson.id}
to={module.isLocked ? '#' : `/education/courses/${courseSlug}/lesson/${lesson.id}`}
onClick={(e) => {
if (module.isLocked) {
e.preventDefault();
return;
}
onLessonClick?.(lesson.id);
}}
className={`flex items-center gap-3 px-4 py-2.5 mx-2 rounded-lg transition-colors ${
isCurrent
? 'bg-blue-600 text-white'
: lesson.isCompleted
? 'text-green-400 hover:bg-gray-700/30'
: module.isLocked
? 'text-gray-600 cursor-not-allowed'
: 'text-gray-400 hover:bg-gray-700/30 hover:text-white'
}`}
>
{/* Status Icon */}
<div className="flex-shrink-0">
{lesson.isCompleted ? (
<CheckCircle className="w-4 h-4" />
) : module.isLocked ? (
<Lock className="w-4 h-4" />
) : (
contentTypeIcons[lesson.contentType]
)}
</div>
{/* Lesson Info */}
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{lesson.title}</p>
</div>
{/* Duration */}
{lesson.durationMinutes > 0 && (
<span className="text-xs opacity-60 flex-shrink-0">
{lesson.durationMinutes}m
</span>
)}
{/* Free Badge */}
{lesson.isFree && !lesson.isCompleted && !isCurrent && (
<span className="px-1.5 py-0.5 bg-green-500/20 text-green-400 text-xs rounded flex-shrink-0">
Gratis
</span>
)}
</Link>
);
})}
</div>
)}
</div>
);
};
export default LessonProgress;

View File

@ -0,0 +1,204 @@
/**
* ReviewForm Component
* Form for submitting course reviews with rating and comment
* OQI-002: Modulo Educativo
*/
import React, { useState } from 'react';
import { Star, Send, Loader2, AlertCircle } from 'lucide-react';
interface ReviewFormProps {
courseId: string;
onSubmit: (data: { rating: number; title?: string; comment: string }) => Promise<void>;
disabled?: boolean;
maxCommentLength?: number;
}
const ReviewForm: React.FC<ReviewFormProps> = ({
courseId,
onSubmit,
disabled = false,
maxCommentLength = 1000,
}) => {
const [rating, setRating] = useState(0);
const [hoverRating, setHoverRating] = useState(0);
const [title, setTitle] = useState('');
const [comment, setComment] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const ratingLabels: Record<number, string> = {
1: 'Muy malo',
2: 'Malo',
3: 'Regular',
4: 'Bueno',
5: 'Excelente',
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (rating === 0) {
setError('Por favor selecciona una calificacion');
return;
}
if (comment.trim().length < 10) {
setError('El comentario debe tener al menos 10 caracteres');
return;
}
setSubmitting(true);
try {
await onSubmit({
rating,
title: title.trim() || undefined,
comment: comment.trim(),
});
setSuccess(true);
setRating(0);
setTitle('');
setComment('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Error al enviar la resena');
} finally {
setSubmitting(false);
}
};
if (success) {
return (
<div className="bg-green-500/10 border border-green-500/30 rounded-xl p-6 text-center">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-500/20 mb-4">
<Star className="w-6 h-6 text-green-400 fill-green-400" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Gracias por tu resena</h3>
<p className="text-gray-400 text-sm mb-4">
Tu opinion ayuda a otros estudiantes a tomar mejores decisiones
</p>
<button
onClick={() => setSuccess(false)}
className="text-sm text-blue-400 hover:text-blue-300"
>
Escribir otra resena
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<h3 className="text-lg font-bold text-white mb-6">Deja tu Resena</h3>
{/* Rating Stars */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-400 mb-3">
Calificacion *
</label>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
disabled={disabled || submitting}
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 transition-transform hover:scale-110 disabled:cursor-not-allowed"
>
<Star
className={`w-8 h-8 transition-colors ${
star <= (hoverRating || rating)
? 'text-yellow-400 fill-yellow-400'
: 'text-gray-600'
}`}
/>
</button>
))}
</div>
{(hoverRating || rating) > 0 && (
<span className="text-sm text-gray-400 ml-2">
{ratingLabels[hoverRating || rating]}
</span>
)}
</div>
</div>
{/* Title (Optional) */}
<div className="mb-4">
<label htmlFor="review-title" className="block text-sm font-medium text-gray-400 mb-2">
Titulo (opcional)
</label>
<input
id="review-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={disabled || submitting}
placeholder="Resume tu experiencia en una frase"
maxLength={100}
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none disabled:opacity-50"
/>
</div>
{/* Comment */}
<div className="mb-4">
<label htmlFor="review-comment" className="block text-sm font-medium text-gray-400 mb-2">
Tu opinion *
</label>
<textarea
id="review-comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={disabled || submitting}
placeholder="Comparte tu experiencia con este curso. Que te gusto? Que podria mejorar?"
maxLength={maxCommentLength}
rows={4}
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none resize-none disabled:opacity-50"
/>
<div className="flex justify-end mt-1">
<span className={`text-xs ${comment.length > maxCommentLength * 0.9 ? 'text-yellow-400' : 'text-gray-500'}`}>
{comment.length}/{maxCommentLength}
</span>
</div>
</div>
{/* Error Message */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg mb-4">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
<span className="text-sm text-red-400">{error}</span>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={disabled || submitting || rating === 0}
className="w-full flex items-center justify-center gap-2 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Enviando...
</>
) : (
<>
<Send className="w-5 h-5" />
Enviar Resena
</>
)}
</button>
<p className="text-xs text-gray-500 text-center mt-4">
Tu resena sera visible publicamente para otros estudiantes
</p>
</form>
);
};
export default ReviewForm;

View File

@ -39,3 +39,9 @@ export { default as QuizHistoryCard } from './QuizHistoryCard';
export type { QuizAttemptHistory } from './QuizHistoryCard'; export type { QuizAttemptHistory } from './QuizHistoryCard';
export { default as EarnedCertificates } from './EarnedCertificates'; export { default as EarnedCertificates } from './EarnedCertificates';
export type { EarnedCertificate } from './EarnedCertificates'; export type { EarnedCertificate } from './EarnedCertificates';
// Review & Lesson Progress Components (OQI-002)
export { default as ReviewForm } from './ReviewForm';
export { default as LessonProgress } from './LessonProgress';
export { default as CourseReviewsSection } from './CourseReviewsSection';
export { default as ContinueLearningCard } from './ContinueLearningCard';

View File

@ -0,0 +1,63 @@
/**
* Education Hooks Index
* Export all education-related TanStack Query hooks
* OQI-002: Modulo Educativo
*/
// Course Progress Hooks
export {
default as useCourseProgress,
useContinueLearning,
useDownloadProgressReport,
courseProgressKeys,
type ContinueLearning,
} from './useCourseProgress';
// Quiz Hooks
export {
default as useQuiz,
useQuizByLesson,
useCourseQuizzes,
useQuizAttempts,
useQuizResult,
useQuizStats,
useUserQuizStats,
useStartQuiz,
useSubmitQuiz,
quizKeys,
type QuizStats,
type UserQuizStats,
type StartQuizResponse,
} from './useQuiz';
// Certificate Hooks
export {
default as useCertificates,
useCertificate,
useCourseCertificate,
useVerifyCertificate,
useDownloadCertificate,
useShareCertificate,
certificateKeys,
type CertificateDetail,
type SharePlatform,
type VerificationResult,
} from './useCertificates';
// Course Reviews Hooks
export {
default as useCourseReviews,
useInfiniteReviews,
useRatingSummary,
useMyReview,
useSubmitReview,
useUpdateReview,
useDeleteReview,
useMarkHelpful,
useReportReview,
reviewKeys,
type Review,
type RatingSummary,
type ReviewsResponse,
type CreateReviewData,
} from './useCourseReviews';

View File

@ -0,0 +1,200 @@
/**
* useCertificates Hooks
* TanStack Query hooks for certificate functionality
* OQI-002: Modulo Educativo
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient as api } from '../../../lib/apiClient';
import type { ApiResponse } from '../../../types/education.types';
import type { EarnedCertificate } from '../components/EarnedCertificates';
// ============================================================================
// Query Keys
// ============================================================================
export const certificateKeys = {
all: ['certificates'] as const,
list: () => [...certificateKeys.all, 'list'] as const,
detail: (id: string) => [...certificateKeys.all, id] as const,
byCourse: (courseId: string) => [...certificateKeys.all, 'course', courseId] as const,
verify: (credentialId: string) => [...certificateKeys.all, 'verify', credentialId] as const,
};
// ============================================================================
// Types
// ============================================================================
export interface CertificateDetail extends EarnedCertificate {
courseDescription?: string;
modules?: { title: string; lessonsCompleted: number }[];
verificationQRCode?: string;
}
export interface SharePlatform {
platform: 'linkedin' | 'twitter' | 'email' | 'copy';
}
export interface VerificationResult {
isValid: boolean;
certificate?: {
credentialId: string;
courseTitle: string;
studentName: string;
issuedAt: string;
grade?: number;
};
message?: string;
}
// ============================================================================
// API Functions
// ============================================================================
async function fetchMyCertificates(): Promise<EarnedCertificate[]> {
const response = await api.get<ApiResponse<EarnedCertificate[]>>('/education/my/certificates');
return response.data.data;
}
async function fetchCertificateById(id: string): Promise<CertificateDetail> {
const response = await api.get<ApiResponse<CertificateDetail>>(`/education/certificates/${id}`);
return response.data.data;
}
async function fetchCertificateByCourse(courseId: string): Promise<EarnedCertificate | null> {
try {
const response = await api.get<ApiResponse<EarnedCertificate>>(
`/education/courses/${courseId}/certificate`
);
return response.data.data;
} catch {
return null;
}
}
async function verifyCertificate(credentialId: string): Promise<VerificationResult> {
const response = await api.get<ApiResponse<VerificationResult>>(
`/education/certificates/verify/${credentialId}`
);
return response.data.data;
}
async function downloadCertificate(id: string): Promise<Blob> {
const response = await api.get(`/education/certificates/${id}/download`, {
responseType: 'blob',
});
return response.data;
}
async function shareCertificate(
id: string,
platform: SharePlatform['platform']
): Promise<{ shareUrl: string }> {
const response = await api.post<ApiResponse<{ shareUrl: string }>>(
`/education/certificates/${id}/share`,
{ platform }
);
return response.data.data;
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to fetch all user certificates
*/
export function useCertificates() {
return useQuery<EarnedCertificate[], Error>({
queryKey: certificateKeys.list(),
queryFn: fetchMyCertificates,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch a certificate by ID
*/
export function useCertificate(id: string) {
return useQuery<CertificateDetail, Error>({
queryKey: certificateKeys.detail(id),
queryFn: () => fetchCertificateById(id),
enabled: !!id,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook to fetch certificate for a specific course
*/
export function useCourseCertificate(courseId: string) {
return useQuery<EarnedCertificate | null, Error>({
queryKey: certificateKeys.byCourse(courseId),
queryFn: () => fetchCertificateByCourse(courseId),
enabled: !!courseId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Hook to verify a certificate
*/
export function useVerifyCertificate(credentialId: string) {
return useQuery<VerificationResult, Error>({
queryKey: certificateKeys.verify(credentialId),
queryFn: () => verifyCertificate(credentialId),
enabled: !!credentialId,
staleTime: Infinity, // Verification results don't change
});
}
/**
* Hook to download a certificate as PDF
*/
export function useDownloadCertificate() {
return useMutation<Blob, Error, string>({
mutationFn: downloadCertificate,
onSuccess: (blob, id) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `certificate-${id}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
});
}
/**
* Hook to share a certificate
*/
export function useShareCertificate() {
return useMutation<
{ shareUrl: string },
Error,
{ id: string; platform: SharePlatform['platform'] }
>({
mutationFn: ({ id, platform }) => shareCertificate(id, platform),
onSuccess: ({ shareUrl }, { platform }) => {
if (platform === 'copy') {
navigator.clipboard.writeText(shareUrl);
} else if (platform === 'linkedin') {
window.open(
`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`,
'_blank'
);
} else if (platform === 'twitter') {
window.open(
`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent('I just earned a certificate!')}`,
'_blank'
);
} else if (platform === 'email') {
window.location.href = `mailto:?subject=My Certificate&body=${encodeURIComponent(shareUrl)}`;
}
},
});
}
export default useCertificates;

View File

@ -0,0 +1,114 @@
/**
* useCourseProgress Hook
* TanStack Query hook for course progress tracking
* OQI-002: Modulo Educativo
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient as api } from '../../../lib/apiClient';
import type { CourseProgressData, ModuleProgress } from '../components/CourseProgressTracker';
import type { ApiResponse } from '../../../types/education.types';
// ============================================================================
// Query Keys
// ============================================================================
export const courseProgressKeys = {
all: ['course-progress'] as const,
detail: (courseId: string) => [...courseProgressKeys.all, courseId] as const,
stats: (courseId: string) => [...courseProgressKeys.all, courseId, 'stats'] as const,
modules: (courseId: string) => [...courseProgressKeys.all, courseId, 'modules'] as const,
continue: () => [...courseProgressKeys.all, 'continue'] as const,
};
// ============================================================================
// Types
// ============================================================================
export interface ContinueLearning {
courseId: string;
courseTitle: string;
courseSlug: string;
courseThumbnail?: string;
lastLessonId: string;
lastLessonTitle: string;
progressPercentage: number;
lastAccessedAt: string;
}
// ============================================================================
// API Functions
// ============================================================================
async function fetchCourseProgress(courseId: string): Promise<CourseProgressData> {
const response = await api.get<ApiResponse<CourseProgressData>>(
`/education/courses/${courseId}/progress`
);
return response.data.data;
}
async function fetchContinueLearning(): Promise<ContinueLearning | null> {
try {
const response = await api.get<ApiResponse<ContinueLearning>>('/education/my/continue');
return response.data.data;
} catch {
return null;
}
}
async function downloadProgressReport(courseId: string): Promise<Blob> {
const response = await api.get(`/education/courses/${courseId}/progress/report`, {
responseType: 'blob',
});
return response.data;
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to fetch course progress data
*/
export function useCourseProgress(courseId: string) {
return useQuery<CourseProgressData, Error>({
queryKey: courseProgressKeys.detail(courseId),
queryFn: () => fetchCourseProgress(courseId),
enabled: !!courseId,
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch continue learning suggestion
*/
export function useContinueLearning() {
return useQuery<ContinueLearning | null, Error>({
queryKey: courseProgressKeys.continue(),
queryFn: fetchContinueLearning,
staleTime: 30 * 1000, // 30 seconds
});
}
/**
* Hook to download progress report
*/
export function useDownloadProgressReport() {
return useMutation<Blob, Error, string>({
mutationFn: downloadProgressReport,
onSuccess: (blob, courseId) => {
// Create download link
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `progress-report-${courseId}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
},
});
}
export default useCourseProgress;

View File

@ -0,0 +1,288 @@
/**
* useCourseReviews Hooks
* TanStack Query hooks for course reviews functionality
* OQI-002: Modulo Educativo
*/
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
import { apiClient as api } from '../../../lib/apiClient';
import type { ApiResponse } from '../../../types/education.types';
// ============================================================================
// Query Keys
// ============================================================================
export const reviewKeys = {
all: ['course-reviews'] as const,
list: (courseId: string) => [...reviewKeys.all, courseId] as const,
summary: (courseId: string) => [...reviewKeys.all, courseId, 'summary'] as const,
myReview: (courseId: string) => [...reviewKeys.all, courseId, 'my-review'] as const,
infinite: (courseId: string) => [...reviewKeys.all, courseId, 'infinite'] as const,
};
// ============================================================================
// Types
// ============================================================================
export interface Review {
id: string;
userId: string;
userName: string;
userAvatar?: string;
rating: number;
title?: string;
comment: string;
helpful: number;
createdAt: string;
verified?: boolean;
}
export interface RatingSummary {
average: number;
total: number;
distribution: {
5: number;
4: number;
3: number;
2: number;
1: number;
};
}
export interface ReviewsResponse {
reviews: Review[];
summary: RatingSummary;
hasMore: boolean;
total: number;
}
export interface CreateReviewData {
rating: number;
title?: string;
comment: string;
}
// ============================================================================
// API Functions
// ============================================================================
async function fetchReviews(
courseId: string,
page: number = 1,
pageSize: number = 10,
rating?: number
): Promise<ReviewsResponse> {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
});
if (rating) {
params.append('rating', String(rating));
}
const response = await api.get<ApiResponse<ReviewsResponse>>(
`/education/courses/${courseId}/reviews?${params.toString()}`
);
return response.data.data;
}
async function fetchRatingSummary(courseId: string): Promise<RatingSummary> {
const response = await api.get<ApiResponse<RatingSummary>>(
`/education/courses/${courseId}/reviews/summary`
);
return response.data.data;
}
async function fetchMyReview(courseId: string): Promise<Review | null> {
try {
const response = await api.get<ApiResponse<Review>>(
`/education/courses/${courseId}/reviews/my`
);
return response.data.data;
} catch {
return null;
}
}
async function createReview(courseId: string, data: CreateReviewData): Promise<Review> {
const response = await api.post<ApiResponse<Review>>(
`/education/courses/${courseId}/reviews`,
data
);
return response.data.data;
}
async function updateReview(
courseId: string,
reviewId: string,
data: Partial<CreateReviewData>
): Promise<Review> {
const response = await api.patch<ApiResponse<Review>>(
`/education/courses/${courseId}/reviews/${reviewId}`,
data
);
return response.data.data;
}
async function deleteReview(courseId: string, reviewId: string): Promise<void> {
await api.delete(`/education/courses/${courseId}/reviews/${reviewId}`);
}
async function markReviewHelpful(courseId: string, reviewId: string): Promise<{ helpful: number }> {
const response = await api.post<ApiResponse<{ helpful: number }>>(
`/education/courses/${courseId}/reviews/${reviewId}/helpful`
);
return response.data.data;
}
async function reportReview(
courseId: string,
reviewId: string,
reason: string
): Promise<void> {
await api.post(`/education/courses/${courseId}/reviews/${reviewId}/report`, { reason });
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to fetch course reviews with pagination
*/
export function useCourseReviews(courseId: string, rating?: number) {
return useQuery<ReviewsResponse, Error>({
queryKey: [...reviewKeys.list(courseId), { rating }],
queryFn: () => fetchReviews(courseId, 1, 10, rating),
enabled: !!courseId,
staleTime: 60 * 1000, // 1 minute
});
}
/**
* Hook to fetch reviews with infinite scroll
*/
export function useInfiniteReviews(courseId: string, pageSize: number = 10, rating?: number) {
return useInfiniteQuery<ReviewsResponse, Error>({
queryKey: [...reviewKeys.infinite(courseId), { rating }],
queryFn: ({ pageParam = 1 }) => fetchReviews(courseId, pageParam as number, pageSize, rating),
getNextPageParam: (lastPage, allPages) => {
if (lastPage.hasMore) {
return allPages.length + 1;
}
return undefined;
},
initialPageParam: 1,
enabled: !!courseId,
staleTime: 60 * 1000,
});
}
/**
* Hook to fetch rating summary only
*/
export function useRatingSummary(courseId: string) {
return useQuery<RatingSummary, Error>({
queryKey: reviewKeys.summary(courseId),
queryFn: () => fetchRatingSummary(courseId),
enabled: !!courseId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch user's review for a course
*/
export function useMyReview(courseId: string) {
return useQuery<Review | null, Error>({
queryKey: reviewKeys.myReview(courseId),
queryFn: () => fetchMyReview(courseId),
enabled: !!courseId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Hook to create a new review
*/
export function useSubmitReview() {
const queryClient = useQueryClient();
return useMutation<Review, Error, { courseId: string; data: CreateReviewData }>({
mutationFn: ({ courseId, data }) => createReview(courseId, data),
onSuccess: (review, { courseId }) => {
// Update my review cache
queryClient.setQueryData(reviewKeys.myReview(courseId), review);
// Invalidate reviews list and summary
queryClient.invalidateQueries({ queryKey: reviewKeys.list(courseId) });
queryClient.invalidateQueries({ queryKey: reviewKeys.summary(courseId) });
queryClient.invalidateQueries({ queryKey: reviewKeys.infinite(courseId) });
},
});
}
/**
* Hook to update an existing review
*/
export function useUpdateReview() {
const queryClient = useQueryClient();
return useMutation<
Review,
Error,
{ courseId: string; reviewId: string; data: Partial<CreateReviewData> }
>({
mutationFn: ({ courseId, reviewId, data }) => updateReview(courseId, reviewId, data),
onSuccess: (review, { courseId }) => {
queryClient.setQueryData(reviewKeys.myReview(courseId), review);
queryClient.invalidateQueries({ queryKey: reviewKeys.list(courseId) });
queryClient.invalidateQueries({ queryKey: reviewKeys.summary(courseId) });
queryClient.invalidateQueries({ queryKey: reviewKeys.infinite(courseId) });
},
});
}
/**
* Hook to delete a review
*/
export function useDeleteReview() {
const queryClient = useQueryClient();
return useMutation<void, Error, { courseId: string; reviewId: string }>({
mutationFn: ({ courseId, reviewId }) => deleteReview(courseId, reviewId),
onSuccess: (_, { courseId }) => {
queryClient.setQueryData(reviewKeys.myReview(courseId), null);
queryClient.invalidateQueries({ queryKey: reviewKeys.list(courseId) });
queryClient.invalidateQueries({ queryKey: reviewKeys.summary(courseId) });
queryClient.invalidateQueries({ queryKey: reviewKeys.infinite(courseId) });
},
});
}
/**
* Hook to mark a review as helpful
*/
export function useMarkHelpful() {
const queryClient = useQueryClient();
return useMutation<{ helpful: number }, Error, { courseId: string; reviewId: string }>({
mutationFn: ({ courseId, reviewId }) => markReviewHelpful(courseId, reviewId),
onSuccess: (_, { courseId }) => {
// Invalidate to refresh helpful counts
queryClient.invalidateQueries({ queryKey: reviewKeys.list(courseId) });
queryClient.invalidateQueries({ queryKey: reviewKeys.infinite(courseId) });
},
});
}
/**
* Hook to report a review
*/
export function useReportReview() {
return useMutation<void, Error, { courseId: string; reviewId: string; reason: string }>({
mutationFn: ({ courseId, reviewId, reason }) => reportReview(courseId, reviewId, reason),
});
}
export default useCourseReviews;

View File

@ -0,0 +1,182 @@
/**
* useQuiz Hooks
* TanStack Query hooks for quiz functionality
* OQI-002: Modulo Educativo
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { educationService } from '../../../services/education.service';
import type { Quiz, QuizAttempt, QuizResult, QuizAnswer } from '../../../types/education.types';
// ============================================================================
// Query Keys
// ============================================================================
export const quizKeys = {
all: ['quizzes'] as const,
detail: (quizId: string) => [...quizKeys.all, quizId] as const,
byLesson: (lessonId: string) => [...quizKeys.all, 'lesson', lessonId] as const,
byCourse: (courseId: string) => [...quizKeys.all, 'course', courseId] as const,
attempts: (quizId: string) => [...quizKeys.all, quizId, 'attempts'] as const,
result: (attemptId: string) => [...quizKeys.all, 'result', attemptId] as const,
stats: (quizId: string) => [...quizKeys.all, quizId, 'stats'] as const,
userStats: () => [...quizKeys.all, 'user-stats'] as const,
};
// ============================================================================
// Types
// ============================================================================
export interface QuizStats {
totalAttempts: number;
passRate: number;
averageScore: number;
}
export interface UserQuizStats {
totalAttempts: number;
quizzesPassed: number;
averageScore: number;
bestScore: number;
}
export interface StartQuizResponse {
attemptId: string;
quiz: Quiz;
startedAt: string;
expiresAt?: string;
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to fetch a quiz by ID
*/
export function useQuiz(quizId: string) {
return useQuery<Quiz, Error>({
queryKey: quizKeys.detail(quizId),
queryFn: () => educationService.getQuizById(quizId),
enabled: !!quizId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch quiz by lesson ID
*/
export function useQuizByLesson(lessonId: string) {
return useQuery<Quiz | null, Error>({
queryKey: quizKeys.byLesson(lessonId),
queryFn: () => educationService.getQuizByLessonId(lessonId),
enabled: !!lessonId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Hook to fetch all quizzes for a course
*/
export function useCourseQuizzes(courseId: string) {
return useQuery<Quiz[], Error>({
queryKey: quizKeys.byCourse(courseId),
queryFn: () => educationService.getCourseQuizzes(courseId),
enabled: !!courseId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Hook to fetch user's quiz attempts
*/
export function useQuizAttempts(quizId: string) {
return useQuery<QuizAttempt[], Error>({
queryKey: quizKeys.attempts(quizId),
queryFn: () => educationService.getUserQuizAttempts(quizId),
enabled: !!quizId,
staleTime: 30 * 1000, // 30 seconds
});
}
/**
* Hook to fetch quiz result
*/
export function useQuizResult(attemptId: string) {
return useQuery<QuizResult, Error>({
queryKey: quizKeys.result(attemptId),
queryFn: () => educationService.getQuizResults(attemptId),
enabled: !!attemptId,
staleTime: Infinity, // Results don't change
});
}
/**
* Hook to fetch quiz statistics
*/
export function useQuizStats(quizId: string) {
return useQuery<QuizStats, Error>({
queryKey: quizKeys.stats(quizId),
queryFn: () => educationService.getQuizStatistics(quizId),
enabled: !!quizId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Hook to fetch user's overall quiz stats
*/
export function useUserQuizStats() {
return useQuery<UserQuizStats, Error>({
queryKey: quizKeys.userStats(),
queryFn: educationService.getUserQuizStats,
staleTime: 60 * 1000, // 1 minute
});
}
/**
* Hook to start a quiz attempt
*/
export function useStartQuiz() {
const queryClient = useQueryClient();
return useMutation<
StartQuizResponse,
Error,
{ quizId: string; enrollmentId?: string }
>({
mutationFn: ({ quizId, enrollmentId }) =>
educationService.startQuizAttempt(quizId, enrollmentId),
onSuccess: (_, { quizId }) => {
// Invalidate attempts list
queryClient.invalidateQueries({ queryKey: quizKeys.attempts(quizId) });
},
});
}
/**
* Hook to submit quiz answers
*/
export function useSubmitQuiz() {
const queryClient = useQueryClient();
return useMutation<
QuizResult,
Error,
{ attemptId: string; answers: { questionId: string; answer: string | string[] }[] }
>({
mutationFn: ({ attemptId, answers }) =>
educationService.submitQuizAttempt(attemptId, answers),
onSuccess: (result) => {
// Cache the result
queryClient.setQueryData(quizKeys.result(result.attempt.id), result);
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: quizKeys.attempts(result.attempt.quizId) });
queryClient.invalidateQueries({ queryKey: quizKeys.stats(result.attempt.quizId) });
queryClient.invalidateQueries({ queryKey: quizKeys.userStats() });
},
});
}
export default useQuiz;

View File

@ -0,0 +1,347 @@
/**
* ProductCard Component
* Enhanced card for displaying investment product information
* Epic: OQI-004 Cuentas de Inversion
*/
import React from 'react';
import { Link } from 'react-router-dom';
import {
Shield,
TrendingUp,
Zap,
Clock,
AlertTriangle,
ChevronRight,
Star,
Users,
} from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
export type ProductStatus = 'open' | 'closed' | 'coming_soon' | 'limited';
export interface ProductCardProps {
id: string;
code: string;
name: string;
description: string;
riskProfile: 'conservative' | 'moderate' | 'aggressive';
targetReturnMin: number;
targetReturnMax: number;
maxDrawdown: number;
minInvestment: number;
managementFee?: number;
performanceFee?: number;
status?: ProductStatus;
historicalReturn?: number;
totalInvestors?: number;
aum?: number;
isNew?: boolean;
isFeatured?: boolean;
onInvest?: (productId: string) => void;
compact?: boolean;
}
// ============================================================================
// Subcomponents
// ============================================================================
const RiskBadge: React.FC<{ risk: string; size?: 'sm' | 'md' }> = ({
risk,
size = 'md',
}) => {
const config = {
conservative: {
color: 'text-emerald-400',
bg: 'bg-emerald-500/20',
border: 'border-emerald-500/30',
label: 'Conservador',
icon: Shield,
},
moderate: {
color: 'text-amber-400',
bg: 'bg-amber-500/20',
border: 'border-amber-500/30',
label: 'Moderado',
icon: TrendingUp,
},
aggressive: {
color: 'text-red-400',
bg: 'bg-red-500/20',
border: 'border-red-500/30',
label: 'Agresivo',
icon: Zap,
},
};
const c = config[risk as keyof typeof config] || config.moderate;
const Icon = c.icon;
const sizeClasses = size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-sm';
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full font-medium ${sizeClasses} ${c.bg} ${c.color} border ${c.border}`}
>
<Icon className={size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} />
{c.label}
</span>
);
};
const StatusBadge: React.FC<{ status: ProductStatus }> = ({ status }) => {
const config = {
open: {
color: 'text-emerald-400',
bg: 'bg-emerald-500/20',
label: 'Abierto',
dot: 'bg-emerald-400',
},
closed: {
color: 'text-gray-400',
bg: 'bg-gray-500/20',
label: 'Cerrado',
dot: 'bg-gray-400',
},
coming_soon: {
color: 'text-blue-400',
bg: 'bg-blue-500/20',
label: 'Proximamente',
dot: 'bg-blue-400',
},
limited: {
color: 'text-amber-400',
bg: 'bg-amber-500/20',
label: 'Cupo Limitado',
dot: 'bg-amber-400',
},
};
const c = config[status];
return (
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.color}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot} animate-pulse`} />
{c.label}
</span>
);
};
// ============================================================================
// Main Component
// ============================================================================
export const ProductCard: React.FC<ProductCardProps> = ({
id,
code,
name,
description,
riskProfile,
targetReturnMin,
targetReturnMax,
maxDrawdown,
minInvestment,
managementFee,
performanceFee,
status = 'open',
historicalReturn,
totalInvestors,
aum,
isNew,
isFeatured,
onInvest,
compact = false,
}) => {
const icons: Record<string, React.ReactNode> = {
atlas: <Shield className="w-6 h-6 text-blue-400" />,
orion: <TrendingUp className="w-6 h-6 text-purple-400" />,
nova: <Zap className="w-6 h-6 text-amber-400" />,
};
const isAvailable = status === 'open' || status === 'limited';
if (compact) {
return (
<Link
to={`/investment/products/${id}`}
className="flex items-center gap-4 p-4 rounded-xl bg-slate-800/50 border border-slate-700 hover:border-slate-600 transition-all group"
>
<div className="w-12 h-12 rounded-lg bg-slate-700 flex items-center justify-center">
{icons[code] || <TrendingUp className="w-6 h-6 text-slate-400" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-white truncate">{name}</h3>
{isNew && (
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400">
Nuevo
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1">
<RiskBadge risk={riskProfile} size="sm" />
<span className="text-sm text-emerald-400 font-medium">
{targetReturnMin}-{targetReturnMax}%
</span>
</div>
</div>
<ChevronRight className="w-5 h-5 text-slate-500 group-hover:text-blue-400 transition-colors" />
</Link>
);
}
return (
<div
className={`relative rounded-2xl border overflow-hidden transition-all ${
isFeatured
? 'bg-gradient-to-br from-blue-900/30 to-purple-900/30 border-blue-500/30'
: 'bg-slate-800/50 border-slate-700 hover:border-slate-600'
}`}
>
{/* Featured/New Badges */}
{(isFeatured || isNew) && (
<div className="absolute top-4 right-4 flex gap-2">
{isFeatured && (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-amber-500/20 text-amber-400 border border-amber-500/30">
<Star className="w-3 h-3" />
Destacado
</span>
)}
{isNew && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-500/20 text-blue-400 border border-blue-500/30">
Nuevo
</span>
)}
</div>
)}
{/* Header */}
<div className="p-6">
<div className="flex items-start gap-4 mb-4">
<div className="w-14 h-14 rounded-xl bg-slate-700 flex items-center justify-center">
{icons[code] || <TrendingUp className="w-7 h-7 text-slate-400" />}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-xl font-bold text-white">{name}</h3>
<StatusBadge status={status} />
</div>
<RiskBadge risk={riskProfile} />
</div>
</div>
<p className="text-slate-400 text-sm mb-6 line-clamp-2">{description}</p>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-3 rounded-lg bg-slate-900/50">
<p className="text-xs text-slate-500 mb-1">Target Mensual</p>
<p className="text-xl font-bold text-emerald-400">
{targetReturnMin}-{targetReturnMax}%
</p>
</div>
<div className="p-3 rounded-lg bg-slate-900/50">
<p className="text-xs text-slate-500 mb-1">Max Drawdown</p>
<p className="text-xl font-bold text-red-400">{maxDrawdown}%</p>
</div>
</div>
{/* Additional Info */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Inversion Minima</span>
<span className="font-medium text-white">${minInvestment.toLocaleString()}</span>
</div>
{historicalReturn !== undefined && (
<div className="flex justify-between">
<span className="text-slate-500">Rendimiento Historico</span>
<span
className={`font-medium ${
historicalReturn >= 0 ? 'text-emerald-400' : 'text-red-400'
}`}
>
{historicalReturn >= 0 ? '+' : ''}
{historicalReturn.toFixed(1)}%
</span>
</div>
)}
{performanceFee !== undefined && (
<div className="flex justify-between">
<span className="text-slate-500">Performance Fee</span>
<span className="font-medium text-white">{performanceFee}%</span>
</div>
)}
{totalInvestors !== undefined && (
<div className="flex justify-between">
<span className="text-slate-500 flex items-center gap-1">
<Users className="w-3 h-3" />
Inversores
</span>
<span className="font-medium text-white">{totalInvestors.toLocaleString()}</span>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="p-4 border-t border-slate-700/50 bg-slate-900/30">
<div className="flex gap-3">
<Link
to={`/investment/products/${id}`}
className="flex-1 py-2.5 text-center text-sm font-medium text-slate-300 bg-slate-700 hover:bg-slate-600 rounded-lg transition-colors"
>
Ver Detalles
</Link>
{isAvailable ? (
onInvest ? (
<button
onClick={() => onInvest(id)}
className="flex-1 py-2.5 text-center text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors"
>
Invertir Ahora
</button>
) : (
<Link
to={`/investment/products/${id}`}
className="flex-1 py-2.5 text-center text-sm font-medium text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors"
>
Invertir Ahora
</Link>
)
) : (
<button
disabled
className="flex-1 py-2.5 text-center text-sm font-medium text-slate-500 bg-slate-800 rounded-lg cursor-not-allowed"
>
{status === 'closed' ? 'No Disponible' : 'Proximamente'}
</button>
)}
</div>
{status === 'limited' && (
<div className="mt-3 flex items-center justify-center gap-2 text-xs text-amber-400">
<Clock className="w-3 h-3" />
<span>Cupo limitado - actua rapido</span>
</div>
)}
</div>
{/* Risk Warning */}
<div className="px-4 pb-4">
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-900/20 border border-amber-800/30">
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-400/80">
El trading conlleva riesgos. Los rendimientos pasados no garantizan
rendimientos futuros.
</p>
</div>
</div>
</div>
);
};
export default ProductCard;

View File

@ -34,3 +34,10 @@ export type { RiskMetrics, RiskScore, RiskRecommendation } from './RiskAnalysisP
export { default as PortfolioOptimizerWidget } from './PortfolioOptimizerWidget'; export { default as PortfolioOptimizerWidget } from './PortfolioOptimizerWidget';
export type { AccountAllocation, OptimizationResult, PortfolioSimulation, OptimizationStrategy } from './PortfolioOptimizerWidget'; export type { AccountAllocation, OptimizationResult, PortfolioSimulation, OptimizationStrategy } from './PortfolioOptimizerWidget';
// KYC Components (OQI-004)
export { KYCStatusBadge, type KYCStatus } from './KYCStatusBadge';
export { KYCVerificationPanel } from './KYCVerificationPanel';
// Product Components (OQI-004)
export { ProductCard, type ProductCardProps, type ProductStatus } from './ProductCard';

View File

@ -0,0 +1,45 @@
/**
* Investment Module Hooks
* TanStack Query hooks for investment operations
* Epic: OQI-004 Cuentas de Inversion
*/
// Account hooks
export {
useInvestmentAccounts,
useAccountSummary,
useAccountDetail,
useCreateAccount,
useCloseAccount,
investmentAccountKeys,
} from './useInvestmentAccounts';
// Product hooks
export {
useInvestmentProducts,
useProductDetail,
useProductPerformance,
investmentProductKeys,
} from './useInvestmentProducts';
// Deposit hooks
export { useDeposit, type DepositParams } from './useDeposit';
// Withdrawal hooks
export {
useWithdrawals,
useWithdraw,
withdrawalKeys,
type WithdrawParams,
} from './useWithdraw';
// KYC hooks
export {
useKYCStatus,
useSubmitKYC,
useIsKYCApproved,
useIsKYCPending,
kycKeys,
type KYCData,
type KYCSubmitData,
} from './useKYCStatus';

View File

@ -0,0 +1,44 @@
/**
* useDeposit Hook
* TanStack Query hook for deposit operations
* Epic: OQI-004 Cuentas de Inversion
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createDeposit, type Transaction } from '../../../services/investment.service';
import { investmentAccountKeys } from './useInvestmentAccounts';
// ============================================================================
// Types
// ============================================================================
export interface DepositParams {
accountId: string;
amount: number;
}
// ============================================================================
// Hook
// ============================================================================
/**
* Hook to create a deposit to an investment account
* Invalidates account queries on success
*/
export function useDeposit() {
const queryClient = useQueryClient();
return useMutation<Transaction, Error, DepositParams>({
mutationFn: ({ accountId, amount }) => createDeposit(accountId, amount),
onSuccess: (_, { accountId }) => {
// Invalidate accounts list, summary, and the specific account detail
queryClient.invalidateQueries({ queryKey: investmentAccountKeys.lists() });
queryClient.invalidateQueries({ queryKey: investmentAccountKeys.summary() });
queryClient.invalidateQueries({
queryKey: investmentAccountKeys.detail(accountId),
});
},
});
}
export default useDeposit;

View File

@ -0,0 +1,108 @@
/**
* useInvestmentAccounts Hook
* TanStack Query hook for fetching investment accounts
* Epic: OQI-004 Cuentas de Inversion
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getUserAccounts,
getAccountSummary,
getAccountById,
createAccount,
closeAccount,
type InvestmentAccount,
type AccountSummary,
type AccountDetail,
} from '../../../services/investment.service';
// ============================================================================
// Query Keys
// ============================================================================
export const investmentAccountKeys = {
all: ['investment-accounts'] as const,
lists: () => [...investmentAccountKeys.all, 'list'] as const,
summary: () => [...investmentAccountKeys.all, 'summary'] as const,
detail: (id: string) => [...investmentAccountKeys.all, 'detail', id] as const,
};
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to fetch all user investment accounts
*/
export function useInvestmentAccounts() {
return useQuery<InvestmentAccount[], Error>({
queryKey: investmentAccountKeys.lists(),
queryFn: getUserAccounts,
staleTime: 30 * 1000, // 30 seconds
});
}
/**
* Hook to fetch account summary (total balance, earnings, etc.)
*/
export function useAccountSummary() {
return useQuery<AccountSummary, Error>({
queryKey: investmentAccountKeys.summary(),
queryFn: getAccountSummary,
staleTime: 30 * 1000,
});
}
/**
* Hook to fetch a single account by ID
*/
export function useAccountDetail(accountId: string) {
return useQuery<AccountDetail, Error>({
queryKey: investmentAccountKeys.detail(accountId),
queryFn: () => getAccountById(accountId),
enabled: !!accountId,
staleTime: 30 * 1000,
});
}
/**
* Hook to create a new investment account
*/
export function useCreateAccount() {
const queryClient = useQueryClient();
return useMutation<
InvestmentAccount,
Error,
{ productId: string; initialDeposit: number }
>({
mutationFn: ({ productId, initialDeposit }) =>
createAccount(productId, initialDeposit),
onSuccess: () => {
// Invalidate accounts list and summary
queryClient.invalidateQueries({ queryKey: investmentAccountKeys.lists() });
queryClient.invalidateQueries({ queryKey: investmentAccountKeys.summary() });
},
});
}
/**
* Hook to close an investment account
*/
export function useCloseAccount() {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: closeAccount,
onSuccess: (_, accountId) => {
// Invalidate accounts list, summary, and detail
queryClient.invalidateQueries({ queryKey: investmentAccountKeys.lists() });
queryClient.invalidateQueries({ queryKey: investmentAccountKeys.summary() });
queryClient.invalidateQueries({
queryKey: investmentAccountKeys.detail(accountId),
});
},
});
}
export default useInvestmentAccounts;

View File

@ -0,0 +1,74 @@
/**
* useInvestmentProducts Hook
* TanStack Query hook for fetching investment products
* Epic: OQI-004 Cuentas de Inversion
*/
import { useQuery } from '@tanstack/react-query';
import {
getProducts,
getProductById,
getProductPerformance,
type Product,
type ProductPerformance,
} from '../../../services/investment.service';
// ============================================================================
// Query Keys
// ============================================================================
export const investmentProductKeys = {
all: ['investment-products'] as const,
lists: () => [...investmentProductKeys.all, 'list'] as const,
listByRisk: (riskProfile: string) =>
[...investmentProductKeys.all, 'list', { riskProfile }] as const,
detail: (id: string) => [...investmentProductKeys.all, 'detail', id] as const,
performance: (id: string, period: string) =>
[...investmentProductKeys.all, 'performance', id, period] as const,
};
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to fetch all investment products
*/
export function useInvestmentProducts(riskProfile?: string) {
return useQuery<Product[], Error>({
queryKey: riskProfile
? investmentProductKeys.listByRisk(riskProfile)
: investmentProductKeys.lists(),
queryFn: () => getProducts(riskProfile),
staleTime: 5 * 60 * 1000, // 5 minutes - products don't change often
});
}
/**
* Hook to fetch a single product by ID
*/
export function useProductDetail(productId: string) {
return useQuery<Product, Error>({
queryKey: investmentProductKeys.detail(productId),
queryFn: () => getProductById(productId),
enabled: !!productId,
staleTime: 5 * 60 * 1000,
});
}
/**
* Hook to fetch product performance data
*/
export function useProductPerformance(
productId: string,
period: 'week' | 'month' | '3months' | 'year' = 'month'
) {
return useQuery<ProductPerformance[], Error>({
queryKey: investmentProductKeys.performance(productId, period),
queryFn: () => getProductPerformance(productId, period),
enabled: !!productId,
staleTime: 60 * 1000, // 1 minute
});
}
export default useInvestmentProducts;

View File

@ -0,0 +1,150 @@
/**
* useKYCStatus Hook
* TanStack Query hooks for KYC verification status
* Epic: OQI-004 Cuentas de Inversion
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../../../lib/apiClient';
import type { KYCStatus } from '../components/KYCStatusBadge';
// ============================================================================
// Query Keys
// ============================================================================
export const kycKeys = {
all: ['kyc'] as const,
status: () => [...kycKeys.all, 'status'] as const,
};
// ============================================================================
// Types
// ============================================================================
export interface KYCData {
status: KYCStatus;
personalData?: {
fullName: string;
dateOfBirth: string;
nationality: string;
address: string;
city: string;
state: string;
postalCode: string;
country: string;
occupation: string;
incomeSource: string;
};
documents?: Array<{
id: string;
type: 'id_front' | 'id_back' | 'proof_of_address' | 'selfie';
fileName: string;
status: 'pending' | 'approved' | 'rejected';
rejectionReason?: string;
}>;
rejectionReason?: string;
submittedAt?: string;
reviewedAt?: string;
}
export interface KYCSubmitData {
personalData: {
fullName: string;
dateOfBirth: string;
nationality: string;
address: string;
city: string;
state: string;
postalCode: string;
country: string;
occupation: string;
incomeSource: string;
};
documents: File[];
}
// ============================================================================
// API Functions
// ============================================================================
async function getKYCStatus(): Promise<KYCData> {
try {
const response = await apiClient.get('/investment/kyc/status');
return response.data.data || { status: 'not_started' };
} catch (error: unknown) {
const axiosError = error as { response?: { status: number } };
if (axiosError.response?.status === 404) {
return { status: 'not_started' };
}
throw error;
}
}
async function submitKYC(data: KYCSubmitData): Promise<KYCData> {
const formData = new FormData();
formData.append('personalData', JSON.stringify(data.personalData));
data.documents.forEach((file, index) => {
formData.append(`document_${index}`, file);
});
const response = await apiClient.post('/investment/kyc/submit', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data.data;
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to fetch KYC verification status
*/
export function useKYCStatus() {
return useQuery<KYCData, Error>({
queryKey: kycKeys.status(),
queryFn: getKYCStatus,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: (failureCount, error) => {
// Don't retry on 404
const axiosError = error as { response?: { status: number } };
if (axiosError.response?.status === 404) return false;
return failureCount < 3;
},
});
}
/**
* Hook to submit KYC verification
*/
export function useSubmitKYC() {
const queryClient = useQueryClient();
return useMutation<KYCData, Error, KYCSubmitData>({
mutationFn: submitKYC,
onSuccess: (data) => {
// Update the KYC status in the cache
queryClient.setQueryData(kycKeys.status(), data);
},
});
}
/**
* Check if KYC is complete (approved)
*/
export function useIsKYCApproved(): boolean {
const { data } = useKYCStatus();
return data?.status === 'approved';
}
/**
* Check if KYC is in progress (pending or in_review)
*/
export function useIsKYCPending(): boolean {
const { data } = useKYCStatus();
return data?.status === 'pending' || data?.status === 'in_review';
}
export default useKYCStatus;

View File

@ -0,0 +1,84 @@
/**
* useWithdraw Hook
* TanStack Query hooks for withdrawal operations
* Epic: OQI-004 Cuentas de Inversion
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getWithdrawals,
createWithdrawal,
type Withdrawal,
} from '../../../services/investment.service';
import { investmentAccountKeys } from './useInvestmentAccounts';
// ============================================================================
// Query Keys
// ============================================================================
export const withdrawalKeys = {
all: ['withdrawals'] as const,
lists: () => [...withdrawalKeys.all, 'list'] as const,
listByStatus: (status: string) =>
[...withdrawalKeys.all, 'list', { status }] as const,
};
// ============================================================================
// Types
// ============================================================================
export interface WithdrawParams {
accountId: string;
amount: number;
bankInfo?: {
bankName: string;
accountNumber: string;
routingNumber: string;
};
cryptoInfo?: {
network: string;
address: string;
};
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook to fetch all user withdrawals
*/
export function useWithdrawals(status?: string) {
return useQuery<Withdrawal[], Error>({
queryKey: status
? withdrawalKeys.listByStatus(status)
: withdrawalKeys.lists(),
queryFn: () => getWithdrawals(status),
staleTime: 30 * 1000, // 30 seconds
});
}
/**
* Hook to create a withdrawal request
* Invalidates account and withdrawal queries on success
*/
export function useWithdraw() {
const queryClient = useQueryClient();
return useMutation<Withdrawal, Error, WithdrawParams>({
mutationFn: ({ accountId, amount, bankInfo, cryptoInfo }) =>
createWithdrawal(accountId, amount, { bankInfo, cryptoInfo }),
onSuccess: (_, { accountId }) => {
// Invalidate withdrawals list
queryClient.invalidateQueries({ queryKey: withdrawalKeys.all });
// Invalidate accounts to update available balance
queryClient.invalidateQueries({ queryKey: investmentAccountKeys.lists() });
queryClient.invalidateQueries({ queryKey: investmentAccountKeys.summary() });
queryClient.invalidateQueries({
queryKey: investmentAccountKeys.detail(accountId),
});
},
});
}
export default useWithdraw;

View File

@ -0,0 +1,231 @@
/**
* Deposit Page
* Standalone page for depositing funds to investment accounts
* Epic: OQI-004 Cuentas de Inversion
*/
import React, { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowLeft, AlertCircle, Wallet, TrendingUp, Shield } from 'lucide-react';
import { DepositForm } from '../components/DepositForm';
import { useInvestmentAccounts, useAccountSummary } from '../hooks';
// ============================================================================
// Types
// ============================================================================
interface AccountOption {
id: string;
accountNumber: string;
productName: string;
currentBalance: number;
}
// ============================================================================
// Main Component
// ============================================================================
export const Deposit: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preselectedAccountId = searchParams.get('accountId');
const { data: accounts, isLoading: loadingAccounts, error: accountsError } = useInvestmentAccounts();
const { data: summary } = useAccountSummary();
const [accountOptions, setAccountOptions] = useState<AccountOption[]>([]);
useEffect(() => {
if (accounts) {
const options = accounts
.filter(a => a.status === 'active')
.map(a => ({
id: a.id,
accountNumber: a.accountNumber,
productName: a.product.name,
currentBalance: a.balance,
}));
setAccountOptions(options);
}
}, [accounts]);
const handleSuccess = (transactionId: string) => {
// Navigate to portfolio with success message
navigate('/investment/portfolio', {
state: { depositSuccess: true, transactionId },
});
};
const handleCancel = () => {
navigate('/investment/portfolio');
};
if (loadingAccounts) {
return (
<div className="flex items-center justify-center min-h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (accountsError) {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6 text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-red-600 dark:text-red-400 mb-2">
Error al cargar cuentas
</h3>
<p className="text-red-500 dark:text-red-400 mb-4">
{accountsError.message}
</p>
<Link
to="/investment/portfolio"
className="inline-block px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Volver al Portfolio
</Link>
</div>
</div>
);
}
if (accountOptions.length === 0) {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-center gap-4 mb-8">
<Link
to="/investment/portfolio"
className="p-2 rounded-lg bg-slate-800 border border-slate-700 hover:border-slate-600"
>
<ArrowLeft className="w-5 h-5 text-slate-400" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white">Depositar Fondos</h1>
<p className="text-slate-400">Agrega fondos a tus cuentas de inversion</p>
</div>
</div>
<div className="text-center py-16 bg-slate-800/50 border border-slate-700 rounded-xl">
<Wallet className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h3 className="text-xl font-bold text-white mb-2">
No tienes cuentas activas
</h3>
<p className="text-slate-400 mb-6">
Primero necesitas abrir una cuenta de inversion para poder depositar
</p>
<Link
to="/investment/products"
className="inline-block px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
Ver Productos de Inversion
</Link>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
to="/investment/portfolio"
className="p-2 rounded-lg bg-slate-800 border border-slate-700 hover:border-slate-600"
>
<ArrowLeft className="w-5 h-5 text-slate-400" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white">Depositar Fondos</h1>
<p className="text-slate-400">Agrega fondos a tus cuentas de inversion</p>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-8">
{/* Form Section */}
<div className="lg:col-span-2">
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-6">
Detalles del Deposito
</h2>
<DepositForm
accounts={accountOptions}
onSuccess={handleSuccess}
onCancel={handleCancel}
/>
</div>
</div>
{/* Info Sidebar */}
<div className="space-y-6">
{/* Portfolio Summary */}
{summary && (
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-400 mb-4">Tu Portfolio</h3>
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Wallet className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-sm text-slate-400">Balance Total</p>
<p className="text-xl font-bold text-white">
${summary.totalBalance.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-emerald-400" />
</div>
<div>
<p className="text-sm text-slate-400">Rendimiento</p>
<p className={`text-lg font-bold ${summary.overallReturnPercent >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{summary.overallReturnPercent >= 0 ? '+' : ''}{summary.overallReturnPercent.toFixed(2)}%
</p>
</div>
</div>
</div>
</div>
)}
{/* Security Info */}
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Shield className="w-5 h-5 text-blue-400" />
<h3 className="text-sm font-medium text-white">Pago Seguro</h3>
</div>
<ul className="space-y-3 text-sm text-slate-400">
<li className="flex items-start gap-2">
<span className="text-emerald-400"></span>
<span>Procesado por Stripe, lider en pagos seguros</span>
</li>
<li className="flex items-start gap-2">
<span className="text-emerald-400"></span>
<span>Encriptacion SSL de 256 bits</span>
</li>
<li className="flex items-start gap-2">
<span className="text-emerald-400"></span>
<span>No almacenamos datos de tarjeta</span>
</li>
<li className="flex items-start gap-2">
<span className="text-emerald-400"></span>
<span>Fondos acreditados al instante</span>
</li>
</ul>
</div>
{/* Help */}
<div className="bg-amber-900/20 border border-amber-800/50 rounded-xl p-4">
<p className="text-sm text-amber-400">
<strong>Nota:</strong> Los depositos se acreditan inmediatamente despues
de la confirmacion del pago. Si tienes problemas, contacta a soporte.
</p>
</div>
</div>
</div>
</div>
);
};
export default Deposit;

View File

@ -486,14 +486,14 @@ export default function Investment() {
icon={<Plus className="w-6 h-6 text-blue-400" />} icon={<Plus className="w-6 h-6 text-blue-400" />}
label="Depositar" label="Depositar"
description="Agregar fondos a tus cuentas" description="Agregar fondos a tus cuentas"
to="/investment/portfolio" to="/investment/deposit"
color="bg-blue-500/20" color="bg-blue-500/20"
/> />
<QuickActionButton <QuickActionButton
icon={<ArrowDownRight className="w-6 h-6 text-purple-400" />} icon={<ArrowDownRight className="w-6 h-6 text-purple-400" />}
label="Retirar" label="Retirar"
description="Solicitar retiro de fondos" description="Solicitar retiro de fondos"
to="/investment/withdrawals" to="/investment/withdraw"
color="bg-purple-500/20" color="bg-purple-500/20"
/> />
<QuickActionButton <QuickActionButton

View File

@ -0,0 +1,301 @@
/**
* Withdraw Page
* Standalone page for withdrawing funds from investment accounts
* Epic: OQI-004 Cuentas de Inversion
*/
import React, { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import {
ArrowLeft,
AlertCircle,
Wallet,
Shield,
Clock,
AlertTriangle,
} from 'lucide-react';
import { WithdrawForm } from '../components/WithdrawForm';
import { useInvestmentAccounts, useAccountSummary, useKYCStatus } from '../hooks';
import { KYCStatusBadge } from '../components/KYCStatusBadge';
// ============================================================================
// Types
// ============================================================================
interface AccountOption {
id: string;
accountNumber: string;
productName: string;
currentBalance: number;
}
// ============================================================================
// Main Component
// ============================================================================
export const Withdraw: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const preselectedAccountId = searchParams.get('accountId');
const { data: accounts, isLoading: loadingAccounts, error: accountsError } = useInvestmentAccounts();
const { data: summary } = useAccountSummary();
const { data: kycData, isLoading: loadingKYC } = useKYCStatus();
const [accountOptions, setAccountOptions] = useState<AccountOption[]>([]);
useEffect(() => {
if (accounts) {
const options = accounts
.filter(a => a.status === 'active' && a.balance > 0)
.map(a => ({
id: a.id,
accountNumber: a.accountNumber,
productName: a.product.name,
currentBalance: a.balance,
}));
setAccountOptions(options);
}
}, [accounts]);
const handleSuccess = (withdrawalId: string) => {
// Navigate to withdrawals list with success message
navigate('/investment/withdrawals', {
state: { withdrawalSuccess: true, withdrawalId },
});
};
const handleCancel = () => {
navigate('/investment/portfolio');
};
const isLoading = loadingAccounts || loadingKYC;
const isKYCApproved = kycData?.status === 'approved';
const isKYCPending = kycData?.status === 'pending' || kycData?.status === 'in_review';
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (accountsError) {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6 text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-red-600 dark:text-red-400 mb-2">
Error al cargar cuentas
</h3>
<p className="text-red-500 dark:text-red-400 mb-4">
{accountsError.message}
</p>
<Link
to="/investment/portfolio"
className="inline-block px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Volver al Portfolio
</Link>
</div>
</div>
);
}
// KYC not approved - show verification required
if (!isKYCApproved) {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-center gap-4 mb-8">
<Link
to="/investment/portfolio"
className="p-2 rounded-lg bg-slate-800 border border-slate-700 hover:border-slate-600"
>
<ArrowLeft className="w-5 h-5 text-slate-400" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white">Retirar Fondos</h1>
<p className="text-slate-400">Solicita un retiro de tus cuentas de inversion</p>
</div>
</div>
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-8 text-center">
<Shield className="w-16 h-16 text-amber-400 mx-auto mb-4" />
<h3 className="text-xl font-bold text-white mb-2">
Verificacion de Identidad Requerida
</h3>
<p className="text-slate-400 mb-6 max-w-md mx-auto">
Para poder realizar retiros, necesitas completar la verificacion de identidad (KYC).
Este proceso ayuda a proteger tu cuenta y cumplir con regulaciones.
</p>
{kycData && (
<div className="mb-6">
<KYCStatusBadge status={kycData.status} size="lg" />
</div>
)}
{isKYCPending ? (
<div className="bg-blue-900/20 border border-blue-800/50 rounded-lg p-4 mb-6">
<div className="flex items-center justify-center gap-2 text-blue-400">
<Clock className="w-5 h-5" />
<span>Tu verificacion esta en proceso. Te notificaremos cuando este lista.</span>
</div>
</div>
) : (
<Link
to="/investment/kyc"
className="inline-block px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
Iniciar Verificacion KYC
</Link>
)}
</div>
</div>
);
}
// No accounts with balance
if (accountOptions.length === 0) {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-center gap-4 mb-8">
<Link
to="/investment/portfolio"
className="p-2 rounded-lg bg-slate-800 border border-slate-700 hover:border-slate-600"
>
<ArrowLeft className="w-5 h-5 text-slate-400" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white">Retirar Fondos</h1>
<p className="text-slate-400">Solicita un retiro de tus cuentas de inversion</p>
</div>
</div>
<div className="text-center py-16 bg-slate-800/50 border border-slate-700 rounded-xl">
<Wallet className="w-16 h-16 text-slate-600 mx-auto mb-4" />
<h3 className="text-xl font-bold text-white mb-2">
No hay fondos disponibles para retiro
</h3>
<p className="text-slate-400 mb-6">
No tienes cuentas activas con balance disponible para retirar
</p>
<Link
to="/investment/portfolio"
className="inline-block px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
>
Volver al Portfolio
</Link>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
to="/investment/portfolio"
className="p-2 rounded-lg bg-slate-800 border border-slate-700 hover:border-slate-600"
>
<ArrowLeft className="w-5 h-5 text-slate-400" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white">Retirar Fondos</h1>
<p className="text-slate-400">Solicita un retiro de tus cuentas de inversion</p>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-8">
{/* Form Section */}
<div className="lg:col-span-2">
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<h2 className="text-lg font-semibold text-white mb-6">
Solicitud de Retiro
</h2>
<WithdrawForm
accounts={accountOptions}
onSuccess={handleSuccess}
onCancel={handleCancel}
/>
</div>
</div>
{/* Info Sidebar */}
<div className="space-y-6">
{/* Available Balance */}
{summary && (
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-400 mb-4">Disponible para Retiro</h3>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<Wallet className="w-5 h-5 text-emerald-400" />
</div>
<div>
<p className="text-sm text-slate-400">Balance Total</p>
<p className="text-xl font-bold text-white">
${summary.totalBalance.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p>
</div>
</div>
</div>
)}
{/* Process Info */}
<div className="bg-slate-800/50 border border-slate-700 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Clock className="w-5 h-5 text-blue-400" />
<h3 className="text-sm font-medium text-white">Proceso de Retiro</h3>
</div>
<ul className="space-y-3 text-sm text-slate-400">
<li className="flex items-start gap-2">
<span className="text-blue-400 font-medium">1.</span>
<span>Solicita tu retiro completando el formulario</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-400 font-medium">2.</span>
<span>Verifica con tu codigo 2FA</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-400 font-medium">3.</span>
<span>Nuestro equipo revisa la solicitud (24h)</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-400 font-medium">4.</span>
<span>Fondos enviados (1-3 dias habiles)</span>
</li>
</ul>
</div>
{/* Important Notice */}
<div className="bg-amber-900/20 border border-amber-800/50 rounded-xl p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-slate-400">
<p className="font-medium text-amber-400 mb-1">Importante</p>
<ul className="space-y-1">
<li>- Limite diario: $10,000</li>
<li>- Minimo por retiro: $50</li>
<li>- Se requiere verificacion 2FA</li>
<li>- Los retiros estan sujetos a revision</li>
</ul>
</div>
</div>
</div>
{/* View History Link */}
<Link
to="/investment/withdrawals"
className="block text-center py-3 px-4 bg-slate-700 hover:bg-slate-600 text-white font-medium rounded-lg transition-colors"
>
Ver Historial de Retiros
</Link>
</div>
</div>
</div>
);
};
export default Withdraw;

View File

@ -0,0 +1,508 @@
/**
* Marketplace Filters Component
* Advanced filtering for marketplace products with category, price, rating, and sorting
*/
import React, { useState, useCallback, useMemo } from 'react';
import {
Search,
SlidersHorizontal,
X,
ChevronDown,
ChevronUp,
Star,
Check,
} from 'lucide-react';
import type { ProductCategory, MarketplaceFilters as FilterTypes } from '../../../types/marketplace.types';
// ============================================================================
// Types
// ============================================================================
interface MarketplaceFiltersProps {
filters: FilterTypes;
onFiltersChange: (filters: FilterTypes) => void;
onSearch: (term: string) => void;
totalResults?: number;
isLoading?: boolean;
compact?: boolean;
}
interface CategoryOption {
value: ProductCategory | '';
label: string;
icon: string;
color: string;
}
interface SortOption {
value: FilterTypes['sortBy'];
label: string;
}
// ============================================================================
// Constants
// ============================================================================
const CATEGORY_OPTIONS: CategoryOption[] = [
{ value: '', label: 'All Products', icon: '📦', color: 'bg-gray-500/20 text-gray-400' },
{ value: 'signals', label: 'Signal Packs', icon: '📊', color: 'bg-blue-500/20 text-blue-400' },
{ value: 'courses', label: 'Courses', icon: '📚', color: 'bg-green-500/20 text-green-400' },
{ value: 'advisory', label: 'Advisory', icon: '👨‍💼', color: 'bg-purple-500/20 text-purple-400' },
{ value: 'tools', label: 'Tools', icon: '🛠️', color: 'bg-orange-500/20 text-orange-400' },
];
const SORT_OPTIONS: SortOption[] = [
{ value: 'newest', label: 'Newest' },
{ value: 'popular', label: 'Most Popular' },
{ value: 'rating', label: 'Top Rated' },
{ value: 'price_asc', label: 'Price: Low to High' },
{ value: 'price_desc', label: 'Price: High to Low' },
];
const RATING_OPTIONS = [
{ value: 4.5, label: '4.5+' },
{ value: 4.0, label: '4.0+' },
{ value: 3.5, label: '3.5+' },
{ value: 3.0, label: '3.0+' },
];
const PRICE_RANGES = [
{ min: 0, max: 50, label: 'Under $50' },
{ min: 50, max: 100, label: '$50 - $100' },
{ min: 100, max: 200, label: '$100 - $200' },
{ min: 200, max: undefined, label: '$200+' },
];
// ============================================================================
// Component
// ============================================================================
export const MarketplaceFilters: React.FC<MarketplaceFiltersProps> = ({
filters,
onFiltersChange,
onSearch,
totalResults,
isLoading = false,
compact = false,
}) => {
const [searchTerm, setSearchTerm] = useState(filters.search || '');
const [showAdvanced, setShowAdvanced] = useState(false);
const [priceRange, setPriceRange] = useState<{ min?: number; max?: number }>({
min: filters.minPrice,
max: filters.maxPrice,
});
// Handle search submit
const handleSearchSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
onSearch(searchTerm);
onFiltersChange({ ...filters, search: searchTerm || undefined, page: 1 });
},
[searchTerm, filters, onSearch, onFiltersChange]
);
// Handle category change
const handleCategoryChange = useCallback(
(category: ProductCategory | '') => {
onFiltersChange({
...filters,
category: category || undefined,
page: 1,
});
},
[filters, onFiltersChange]
);
// Handle sort change
const handleSortChange = useCallback(
(sortBy: FilterTypes['sortBy']) => {
onFiltersChange({ ...filters, sortBy, page: 1 });
},
[filters, onFiltersChange]
);
// Handle rating change
const handleRatingChange = useCallback(
(rating: number | undefined) => {
onFiltersChange({
...filters,
minRating: filters.minRating === rating ? undefined : rating,
page: 1,
});
},
[filters, onFiltersChange]
);
// Handle price range change
const handlePriceRangeSelect = useCallback(
(min: number, max?: number) => {
const isCurrentRange = filters.minPrice === min && filters.maxPrice === max;
if (isCurrentRange) {
setPriceRange({ min: undefined, max: undefined });
onFiltersChange({ ...filters, minPrice: undefined, maxPrice: undefined, page: 1 });
} else {
setPriceRange({ min, max });
onFiltersChange({ ...filters, minPrice: min, maxPrice: max, page: 1 });
}
},
[filters, onFiltersChange]
);
// Handle verified toggle
const handleVerifiedToggle = useCallback(() => {
onFiltersChange({
...filters,
verified: filters.verified ? undefined : true,
page: 1,
});
}, [filters, onFiltersChange]);
// Handle free trial toggle
const handleFreeTrialToggle = useCallback(() => {
onFiltersChange({
...filters,
hasFreeTrial: filters.hasFreeTrial ? undefined : true,
page: 1,
});
}, [filters, onFiltersChange]);
// Clear all filters
const handleClearFilters = useCallback(() => {
setSearchTerm('');
setPriceRange({ min: undefined, max: undefined });
onFiltersChange({
page: 1,
pageSize: filters.pageSize,
sortBy: 'popular',
});
onSearch('');
}, [filters.pageSize, onFiltersChange, onSearch]);
// Check if any filters are active
const hasActiveFilters = useMemo(() => {
return !!(
filters.search ||
filters.category ||
filters.minRating ||
filters.minPrice !== undefined ||
filters.maxPrice !== undefined ||
filters.verified ||
filters.hasFreeTrial ||
(filters.sortBy && filters.sortBy !== 'popular')
);
}, [filters]);
// Count active filters
const activeFilterCount = useMemo(() => {
let count = 0;
if (filters.category) count++;
if (filters.minRating) count++;
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) count++;
if (filters.verified) count++;
if (filters.hasFreeTrial) count++;
return count;
}, [filters]);
return (
<div className="space-y-4">
{/* Search and Sort Bar */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-4">
<form onSubmit={handleSearchSubmit} className="flex flex-col lg:flex-row gap-4">
{/* Search Input */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products, providers, topics..."
className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm('');
onSearch('');
onFiltersChange({ ...filters, search: undefined, page: 1 });
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="flex flex-wrap gap-3">
{/* Sort Dropdown */}
<div className="relative">
<select
value={filters.sortBy || 'popular'}
onChange={(e) => handleSortChange(e.target.value as FilterTypes['sortBy'])}
className="appearance-none px-4 py-2.5 pr-10 bg-gray-900 border border-gray-700 rounded-lg text-white focus:border-blue-500 focus:outline-none cursor-pointer min-w-[160px]"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
{/* Advanced Filters Toggle */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className={`px-4 py-2.5 rounded-lg border font-medium transition-colors flex items-center gap-2 ${
showAdvanced || activeFilterCount > 0
? 'bg-blue-500/20 border-blue-500/50 text-blue-400'
: 'bg-gray-900 border-gray-700 text-gray-400 hover:text-white'
}`}
>
<SlidersHorizontal className="w-4 h-4" />
Filters
{activeFilterCount > 0 && (
<span className="px-1.5 py-0.5 bg-blue-500 text-white text-xs rounded-full">
{activeFilterCount}
</span>
)}
{showAdvanced ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
{/* Clear Filters */}
{hasActiveFilters && (
<button
type="button"
onClick={handleClearFilters}
className="px-4 py-2.5 text-gray-400 hover:text-white flex items-center gap-2 transition-colors"
>
<X className="w-4 h-4" />
Clear All
</button>
)}
</div>
</form>
{/* Category Pills */}
{!compact && (
<div className="mt-4 flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map((cat) => (
<button
key={cat.value}
onClick={() => handleCategoryChange(cat.value as ProductCategory | '')}
className={`px-4 py-2 rounded-full font-medium transition-colors flex items-center gap-2 ${
(filters.category || '') === cat.value
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-400 hover:text-white hover:bg-gray-600'
}`}
>
<span>{cat.icon}</span>
<span>{cat.label}</span>
</button>
))}
</div>
)}
{/* Advanced Filters Panel */}
{showAdvanced && (
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Price Range */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-3">
Price Range
</label>
<div className="space-y-2">
{PRICE_RANGES.map((range) => {
const isSelected =
filters.minPrice === range.min &&
filters.maxPrice === range.max;
return (
<button
key={range.label}
onClick={() => handlePriceRangeSelect(range.min, range.max)}
className={`w-full px-3 py-2 rounded-lg text-sm text-left transition-colors flex items-center justify-between ${
isSelected
? 'bg-blue-500/20 text-blue-400 border border-blue-500/50'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:text-white hover:border-gray-600'
}`}
>
{range.label}
{isSelected && <Check className="w-4 h-4" />}
</button>
);
})}
</div>
</div>
{/* Rating Filter */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-3">
Minimum Rating
</label>
<div className="space-y-2">
{RATING_OPTIONS.map((rating) => (
<button
key={rating.value}
onClick={() => handleRatingChange(rating.value)}
className={`w-full px-3 py-2 rounded-lg text-sm text-left transition-colors flex items-center gap-2 ${
filters.minRating === rating.value
? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/50'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:text-white hover:border-gray-600'
}`}
>
<Star
className={`w-4 h-4 ${
filters.minRating === rating.value
? 'text-yellow-400 fill-yellow-400'
: 'text-gray-500'
}`}
/>
{rating.label} Stars
{filters.minRating === rating.value && (
<Check className="w-4 h-4 ml-auto" />
)}
</button>
))}
</div>
</div>
{/* Quick Filters */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-3">
Quick Filters
</label>
<div className="space-y-2">
<button
onClick={handleVerifiedToggle}
className={`w-full px-3 py-2 rounded-lg text-sm text-left transition-colors flex items-center gap-2 ${
filters.verified
? 'bg-blue-500/20 text-blue-400 border border-blue-500/50'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:text-white hover:border-gray-600'
}`}
>
<div
className={`w-4 h-4 rounded border flex items-center justify-center ${
filters.verified
? 'bg-blue-500 border-blue-500'
: 'border-gray-600'
}`}
>
{filters.verified && <Check className="w-3 h-3 text-white" />}
</div>
Verified Providers Only
</button>
<button
onClick={handleFreeTrialToggle}
className={`w-full px-3 py-2 rounded-lg text-sm text-left transition-colors flex items-center gap-2 ${
filters.hasFreeTrial
? 'bg-green-500/20 text-green-400 border border-green-500/50'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:text-white hover:border-gray-600'
}`}
>
<div
className={`w-4 h-4 rounded border flex items-center justify-center ${
filters.hasFreeTrial
? 'bg-green-500 border-green-500'
: 'border-gray-600'
}`}
>
{filters.hasFreeTrial && <Check className="w-3 h-3 text-white" />}
</div>
Free Trial Available
</button>
</div>
</div>
{/* Active Filters Summary */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-3">
Active Filters
</label>
<div className="bg-gray-900 rounded-lg p-3 border border-gray-700">
{hasActiveFilters ? (
<div className="flex flex-wrap gap-2">
{filters.category && (
<span className="px-2 py-1 bg-gray-700 text-gray-300 text-xs rounded-full flex items-center gap-1">
{CATEGORY_OPTIONS.find((c) => c.value === filters.category)?.label}
<button
onClick={() => handleCategoryChange('')}
className="hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{filters.minRating && (
<span className="px-2 py-1 bg-gray-700 text-gray-300 text-xs rounded-full flex items-center gap-1">
{filters.minRating}+ Stars
<button
onClick={() => handleRatingChange(undefined)}
className="hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{(filters.minPrice !== undefined || filters.maxPrice !== undefined) && (
<span className="px-2 py-1 bg-gray-700 text-gray-300 text-xs rounded-full flex items-center gap-1">
${filters.minPrice || 0}
{filters.maxPrice ? ` - $${filters.maxPrice}` : '+'}
<button
onClick={() => handlePriceRangeSelect(0, undefined)}
className="hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{filters.verified && (
<span className="px-2 py-1 bg-gray-700 text-gray-300 text-xs rounded-full flex items-center gap-1">
Verified
<button onClick={handleVerifiedToggle} className="hover:text-white">
<X className="w-3 h-3" />
</button>
</span>
)}
{filters.hasFreeTrial && (
<span className="px-2 py-1 bg-gray-700 text-gray-300 text-xs rounded-full flex items-center gap-1">
Free Trial
<button onClick={handleFreeTrialToggle} className="hover:text-white">
<X className="w-3 h-3" />
</button>
</span>
)}
</div>
) : (
<p className="text-gray-500 text-sm">No filters applied</p>
)}
</div>
</div>
</div>
</div>
)}
{/* Results Count */}
<div className="mt-4 flex items-center justify-between text-sm text-gray-400">
<span>
{isLoading
? 'Searching...'
: totalResults !== undefined
? `${totalResults.toLocaleString()} products found`
: 'Loading...'}
</span>
</div>
</div>
</div>
);
};
export default MarketplaceFilters;

View File

@ -7,3 +7,4 @@ export { AdvisoryCard } from './AdvisoryCard';
export { CourseProductCard } from './CourseProductCard'; export { CourseProductCard } from './CourseProductCard';
export { ProductCard } from './ProductCard'; export { ProductCard } from './ProductCard';
export { ReviewCard } from './ReviewCard'; export { ReviewCard } from './ReviewCard';
export { MarketplaceFilters } from './MarketplaceFilters';

View File

@ -0,0 +1,11 @@
/**
* Marketplace Hooks Index
* TanStack Query hooks for marketplace operations
*/
export * from './useMarketplaceProducts';
export * from './useProductDetail';
export * from './useCheckout';
export * from './useSellerProducts';
export * from './useSellerStats';
export * from './useCreateProduct';

View File

@ -0,0 +1,208 @@
/**
* useCheckout Hook
* TanStack Query hooks for checkout and subscription operations
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { marketplaceService } from '../../../services/marketplace.service';
import type {
UserSubscription,
ConsultationBooking,
} from '../../../types/marketplace.types';
// ============================================================================
// Query Keys
// ============================================================================
export const checkoutKeys = {
all: ['checkout'] as const,
subscriptions: () => [...checkoutKeys.all, 'subscriptions'] as const,
subscription: (id: string) => [...checkoutKeys.subscriptions(), id] as const,
bookings: () => [...checkoutKeys.all, 'bookings'] as const,
booking: (id: string) => [...checkoutKeys.bookings(), id] as const,
};
// ============================================================================
// Subscription Hooks
// ============================================================================
/**
* Hook for fetching user's subscriptions
*/
export function useMySubscriptions() {
return useQuery<UserSubscription[], Error>({
queryKey: checkoutKeys.subscriptions(),
queryFn: () => marketplaceService.getMySubscriptions(),
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for fetching a single subscription
*/
export function useSubscription(id: string) {
return useQuery<UserSubscription, Error>({
queryKey: checkoutKeys.subscription(id),
queryFn: () => marketplaceService.getSubscriptionById(id),
enabled: !!id,
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for subscribing to a signal pack
*/
export function useSubscribeToSignalPack() {
const queryClient = useQueryClient();
return useMutation<
UserSubscription,
Error,
{ packId: string; pricingId: string }
>({
mutationFn: ({ packId, pricingId }) =>
marketplaceService.subscribeToSignalPack(packId, pricingId),
onSuccess: () => {
// Invalidate subscriptions cache
queryClient.invalidateQueries({ queryKey: checkoutKeys.subscriptions() });
},
});
}
/**
* Hook for cancelling a subscription
*/
export function useCancelSubscription() {
const queryClient = useQueryClient();
return useMutation<UserSubscription, Error, string>({
mutationFn: (id) => marketplaceService.cancelSubscription(id),
onSuccess: (data, id) => {
// Update cache with cancelled subscription
queryClient.setQueryData<UserSubscription[]>(
checkoutKeys.subscriptions(),
(old) => old?.map((s) => (s.id === id ? data : s))
);
},
});
}
/**
* Hook for renewing a subscription
*/
export function useRenewSubscription() {
const queryClient = useQueryClient();
return useMutation<
UserSubscription,
Error,
{ id: string; pricingId: string }
>({
mutationFn: ({ id, pricingId }) =>
marketplaceService.renewSubscription(id, pricingId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: checkoutKeys.subscriptions() });
},
});
}
// ============================================================================
// Booking Hooks
// ============================================================================
/**
* Hook for fetching user's consultation bookings
*/
export function useMyBookings() {
return useQuery<ConsultationBooking[], Error>({
queryKey: checkoutKeys.bookings(),
queryFn: () => marketplaceService.getMyBookings(),
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for fetching a single booking
*/
export function useBooking(id: string) {
return useQuery<ConsultationBooking, Error>({
queryKey: checkoutKeys.booking(id),
queryFn: () => marketplaceService.getBookingById(id),
enabled: !!id,
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for booking a consultation
*/
export function useBookConsultation() {
const queryClient = useQueryClient();
return useMutation<
ConsultationBooking,
Error,
{
advisoryId: string;
data: {
serviceTypeId: string;
scheduledAt: string;
notes?: string;
};
}
>({
mutationFn: ({ advisoryId, data }) =>
marketplaceService.bookConsultation(advisoryId, data),
onSuccess: () => {
// Invalidate bookings cache
queryClient.invalidateQueries({ queryKey: checkoutKeys.bookings() });
},
});
}
/**
* Hook for cancelling a booking
*/
export function useCancelBooking() {
const queryClient = useQueryClient();
return useMutation<ConsultationBooking, Error, string>({
mutationFn: (id) => marketplaceService.cancelBooking(id),
onSuccess: (data, id) => {
// Update cache with cancelled booking
queryClient.setQueryData<ConsultationBooking[]>(
checkoutKeys.bookings(),
(old) => old?.map((b) => (b.id === id ? data : b))
);
},
});
}
// ============================================================================
// Checkout State Hook
// ============================================================================
interface CheckoutState {
productId: string | null;
productType: 'signals' | 'advisory' | 'courses' | null;
pricingId: string | null;
couponCode: string | null;
discountAmount: number;
}
/**
* Custom hook for managing checkout state
* Uses React state internally - could be moved to context if needed across components
*/
export function useCheckoutState(initialState?: Partial<CheckoutState>) {
const defaultState: CheckoutState = {
productId: null,
productType: null,
pricingId: null,
couponCode: null,
discountAmount: 0,
...initialState,
};
return defaultState;
}

View File

@ -0,0 +1,133 @@
/**
* useCreateProduct Hook
* TanStack Query hooks for creating and managing seller products
*/
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { marketplaceService } from '../../../services/marketplace.service';
import { sellerProductKeys } from './useSellerProducts';
import type {
CreateProductData,
SellerProduct,
ProductDraft,
} from '../../../types/marketplace.types';
// ============================================================================
// Query Keys
// ============================================================================
export const createProductKeys = {
drafts: () => ['seller', 'drafts'] as const,
draft: (id: string) => ['seller', 'drafts', id] as const,
};
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook for creating a new product
*/
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation<SellerProduct, Error, CreateProductData>({
mutationFn: (data) => marketplaceService.createProduct(data),
onSuccess: () => {
// Invalidate seller products list to include new product
queryClient.invalidateQueries({ queryKey: sellerProductKeys.all });
},
});
}
/**
* Hook for saving a product draft
*/
export function useSaveDraft() {
const queryClient = useQueryClient();
return useMutation<ProductDraft, Error, Partial<CreateProductData>>({
mutationFn: (data) => marketplaceService.saveProductDraft(data),
onSuccess: () => {
// Invalidate drafts
queryClient.invalidateQueries({ queryKey: createProductKeys.drafts() });
// Also invalidate products list (drafts appear there)
queryClient.invalidateQueries({ queryKey: sellerProductKeys.all });
},
});
}
/**
* Hook for updating an existing draft
*/
export function useUpdateDraft() {
const queryClient = useQueryClient();
return useMutation<
ProductDraft,
Error,
{ id: string; data: Partial<CreateProductData> }
>({
mutationFn: ({ id, data }) => marketplaceService.updateProductDraft(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: createProductKeys.draft(variables.id) });
queryClient.invalidateQueries({ queryKey: sellerProductKeys.all });
},
});
}
/**
* Hook for publishing a draft
*/
export function usePublishDraft() {
const queryClient = useQueryClient();
return useMutation<SellerProduct, Error, string>({
mutationFn: (draftId) => marketplaceService.publishDraft(draftId),
onSuccess: (_, draftId) => {
// Remove draft from cache
queryClient.removeQueries({ queryKey: createProductKeys.draft(draftId) });
// Invalidate all product lists
queryClient.invalidateQueries({ queryKey: sellerProductKeys.all });
},
});
}
/**
* Hook for deleting a draft
*/
export function useDeleteDraft() {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => marketplaceService.deleteProductDraft(id),
onSuccess: (_, id) => {
queryClient.removeQueries({ queryKey: createProductKeys.draft(id) });
queryClient.invalidateQueries({ queryKey: createProductKeys.drafts() });
queryClient.invalidateQueries({ queryKey: sellerProductKeys.all });
},
});
}
/**
* Hook for uploading product images
*/
export function useUploadProductImage() {
return useMutation<{ url: string }, Error, File>({
mutationFn: (file) => marketplaceService.uploadProductImage(file),
});
}
/**
* Hook for validating coupon code
*/
export function useValidateCoupon() {
return useMutation<
{ isValid: boolean; discountPercent?: number; discountAmount?: number; message?: string },
Error,
{ code: string; productId?: string }
>({
mutationFn: ({ code, productId }) =>
marketplaceService.validateCoupon(code, productId),
});
}

View File

@ -0,0 +1,99 @@
/**
* useMarketplaceProducts Hook
* TanStack Query hook for fetching marketplace products with filters
*/
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { marketplaceService } from '../../../services/marketplace.service';
import type {
MarketplaceProductListItem,
MarketplaceFilters,
PaginatedResponse,
} from '../../../types/marketplace.types';
// ============================================================================
// Query Keys
// ============================================================================
export const marketplaceKeys = {
all: ['marketplace'] as const,
products: () => [...marketplaceKeys.all, 'products'] as const,
productsList: (filters: MarketplaceFilters) =>
[...marketplaceKeys.products(), filters] as const,
productsInfinite: (filters: MarketplaceFilters) =>
[...marketplaceKeys.products(), 'infinite', filters] as const,
featured: () => [...marketplaceKeys.all, 'featured'] as const,
popular: () => [...marketplaceKeys.all, 'popular'] as const,
related: (productId: string) =>
[...marketplaceKeys.all, 'related', productId] as const,
};
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook for fetching paginated marketplace products
*/
export function useMarketplaceProducts(filters: MarketplaceFilters = {}) {
return useQuery<PaginatedResponse<MarketplaceProductListItem>, Error>({
queryKey: marketplaceKeys.productsList(filters),
queryFn: () => marketplaceService.getProducts(filters),
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
/**
* Hook for infinite scroll products loading
*/
export function useMarketplaceProductsInfinite(
filters: Omit<MarketplaceFilters, 'page'>
) {
return useInfiniteQuery({
queryKey: marketplaceKeys.productsInfinite(filters),
queryFn: ({ pageParam = 1 }) =>
marketplaceService.getProducts({ ...filters, page: pageParam as number }),
getNextPageParam: (lastPage) => {
if (lastPage.page < lastPage.totalPages) {
return lastPage.page + 1;
}
return undefined;
},
initialPageParam: 1,
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for fetching featured products
*/
export function useFeaturedProducts(limit = 6) {
return useQuery<MarketplaceProductListItem[], Error>({
queryKey: [...marketplaceKeys.featured(), limit],
queryFn: () => marketplaceService.getFeaturedProducts(limit),
staleTime: 1000 * 60 * 10, // 10 minutes
});
}
/**
* Hook for fetching popular products
*/
export function usePopularProducts(limit = 6) {
return useQuery<MarketplaceProductListItem[], Error>({
queryKey: [...marketplaceKeys.popular(), limit],
queryFn: () => marketplaceService.getPopularProducts(limit),
staleTime: 1000 * 60 * 10,
});
}
/**
* Hook for fetching related products
*/
export function useRelatedProducts(productId: string, limit = 4) {
return useQuery<MarketplaceProductListItem[], Error>({
queryKey: [...marketplaceKeys.related(productId), limit],
queryFn: () => marketplaceService.getRelatedProducts(productId, limit),
enabled: !!productId,
staleTime: 1000 * 60 * 10,
});
}

View File

@ -0,0 +1,196 @@
/**
* useProductDetail Hook
* TanStack Query hooks for fetching product details by type
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { marketplaceService } from '../../../services/marketplace.service';
import type {
SignalPack,
AdvisoryService,
CourseProduct,
ProductReview,
ProductCategory,
PaginatedResponse,
} from '../../../types/marketplace.types';
// ============================================================================
// Query Keys
// ============================================================================
export const productDetailKeys = {
all: ['product-detail'] as const,
signalPack: (idOrSlug: string) =>
[...productDetailKeys.all, 'signals', idOrSlug] as const,
advisory: (idOrSlug: string) =>
[...productDetailKeys.all, 'advisory', idOrSlug] as const,
course: (idOrSlug: string) =>
[...productDetailKeys.all, 'courses', idOrSlug] as const,
reviews: (productId: string, productType: ProductCategory) =>
[...productDetailKeys.all, 'reviews', productType, productId] as const,
};
// ============================================================================
// Signal Pack Hooks
// ============================================================================
/**
* Hook for fetching signal pack by ID
*/
export function useSignalPackById(id: string) {
return useQuery<SignalPack, Error>({
queryKey: productDetailKeys.signalPack(id),
queryFn: () => marketplaceService.getSignalPackById(id),
enabled: !!id,
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for fetching signal pack by slug
*/
export function useSignalPackBySlug(slug: string) {
return useQuery<SignalPack, Error>({
queryKey: productDetailKeys.signalPack(slug),
queryFn: () => marketplaceService.getSignalPackBySlug(slug),
enabled: !!slug,
staleTime: 1000 * 60 * 5,
});
}
// ============================================================================
// Advisory Service Hooks
// ============================================================================
/**
* Hook for fetching advisory service by ID
*/
export function useAdvisoryServiceById(id: string) {
return useQuery<AdvisoryService, Error>({
queryKey: productDetailKeys.advisory(id),
queryFn: () => marketplaceService.getAdvisoryServiceById(id),
enabled: !!id,
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for fetching advisory service by slug
*/
export function useAdvisoryServiceBySlug(slug: string) {
return useQuery<AdvisoryService, Error>({
queryKey: productDetailKeys.advisory(slug),
queryFn: () => marketplaceService.getAdvisoryServiceBySlug(slug),
enabled: !!slug,
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for fetching advisor availability
*/
export function useAdvisorAvailability(advisoryId: string, date: string) {
return useQuery<{ slots: string[]; timezone: string }, Error>({
queryKey: ['advisor-availability', advisoryId, date],
queryFn: () => marketplaceService.getAdvisorAvailability(advisoryId, date),
enabled: !!(advisoryId && date),
staleTime: 1000 * 60 * 2, // 2 minutes - availability changes frequently
});
}
// ============================================================================
// Course Product Hooks
// ============================================================================
/**
* Hook for fetching course product by ID
*/
export function useCourseProductById(id: string) {
return useQuery<CourseProduct, Error>({
queryKey: productDetailKeys.course(id),
queryFn: () => marketplaceService.getCourseProductById(id),
enabled: !!id,
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for fetching course product by slug
*/
export function useCourseProductBySlug(slug: string) {
return useQuery<CourseProduct, Error>({
queryKey: productDetailKeys.course(slug),
queryFn: () => marketplaceService.getCourseProductBySlug(slug),
enabled: !!slug,
staleTime: 1000 * 60 * 5,
});
}
// ============================================================================
// Reviews Hooks
// ============================================================================
interface ReviewsParams {
page?: number;
pageSize?: number;
sortBy?: 'newest' | 'helpful' | 'rating';
}
/**
* Hook for fetching product reviews
*/
export function useProductReviews(
productId: string,
productType: ProductCategory,
params: ReviewsParams = {}
) {
return useQuery<PaginatedResponse<ProductReview>, Error>({
queryKey: [...productDetailKeys.reviews(productId, productType), params],
queryFn: () => marketplaceService.getProductReviews(productId, productType, params),
enabled: !!(productId && productType),
staleTime: 1000 * 60 * 2,
});
}
/**
* Hook for creating a product review
*/
export function useCreateReview() {
const queryClient = useQueryClient();
return useMutation<
ProductReview,
Error,
{
productId: string;
productType: ProductCategory;
data: { rating: number; title?: string; comment: string };
}
>({
mutationFn: ({ productId, productType, data }) =>
marketplaceService.createReview(productId, productType, data),
onSuccess: (_, variables) => {
// Invalidate reviews cache
queryClient.invalidateQueries({
queryKey: productDetailKeys.reviews(variables.productId, variables.productType),
});
},
});
}
/**
* Hook for marking a review as helpful
*/
export function useMarkReviewHelpful() {
const queryClient = useQueryClient();
return useMutation<void, Error, { reviewId: string; productId: string; productType: ProductCategory }>({
mutationFn: ({ reviewId }) => marketplaceService.markReviewHelpful(reviewId),
onSuccess: (_, variables) => {
// Invalidate reviews cache
queryClient.invalidateQueries({
queryKey: productDetailKeys.reviews(variables.productId, variables.productType),
});
},
});
}

View File

@ -0,0 +1,112 @@
/**
* useSellerProducts Hook
* TanStack Query hooks for managing seller's products
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { marketplaceService } from '../../../services/marketplace.service';
import type {
SellerProduct,
SellerProductFilters,
PaginatedResponse,
} from '../../../types/marketplace.types';
// ============================================================================
// Query Keys
// ============================================================================
export const sellerProductKeys = {
all: ['seller', 'products'] as const,
list: (filters?: SellerProductFilters) => [...sellerProductKeys.all, 'list', filters] as const,
detail: (id: string) => [...sellerProductKeys.all, 'detail', id] as const,
};
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook for fetching seller's products
*/
export function useSellerProducts(filters?: SellerProductFilters) {
return useQuery<PaginatedResponse<SellerProduct>, Error>({
queryKey: sellerProductKeys.list(filters),
queryFn: () => marketplaceService.getSellerProducts(filters),
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
/**
* Hook for fetching a single seller product
*/
export function useSellerProduct(id: string) {
return useQuery<SellerProduct, Error>({
queryKey: sellerProductKeys.detail(id),
queryFn: () => marketplaceService.getSellerProductById(id),
enabled: !!id,
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for updating a seller product
*/
export function useUpdateSellerProduct() {
const queryClient = useQueryClient();
return useMutation<
SellerProduct,
Error,
{ id: string; data: Partial<SellerProduct> }
>({
mutationFn: ({ id, data }) => marketplaceService.updateSellerProduct(id, data),
onSuccess: (data, variables) => {
// Update cache with updated product
queryClient.setQueryData<SellerProduct>(
sellerProductKeys.detail(variables.id),
data
);
// Invalidate list to reflect changes
queryClient.invalidateQueries({ queryKey: sellerProductKeys.list() });
},
});
}
/**
* Hook for toggling product status (active/inactive)
*/
export function useToggleProductStatus() {
const queryClient = useQueryClient();
return useMutation<
SellerProduct,
Error,
{ id: string; status: 'active' | 'inactive' }
>({
mutationFn: ({ id, status }) => marketplaceService.toggleProductStatus(id, status),
onSuccess: (data, variables) => {
queryClient.setQueryData<SellerProduct>(
sellerProductKeys.detail(variables.id),
data
);
queryClient.invalidateQueries({ queryKey: sellerProductKeys.list() });
},
});
}
/**
* Hook for deleting a seller product
*/
export function useDeleteSellerProduct() {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (id) => marketplaceService.deleteSellerProduct(id),
onSuccess: (_, id) => {
// Remove from cache
queryClient.removeQueries({ queryKey: sellerProductKeys.detail(id) });
// Invalidate list
queryClient.invalidateQueries({ queryKey: sellerProductKeys.list() });
},
});
}

View File

@ -0,0 +1,76 @@
/**
* useSellerStats Hook
* TanStack Query hooks for fetching seller statistics and analytics
*/
import { useQuery } from '@tanstack/react-query';
import { marketplaceService } from '../../../services/marketplace.service';
import type {
SellerStats,
SellerSalesData,
SellerPayoutInfo,
} from '../../../types/marketplace.types';
// ============================================================================
// Query Keys
// ============================================================================
export const sellerStatsKeys = {
all: ['seller', 'stats'] as const,
overview: () => [...sellerStatsKeys.all, 'overview'] as const,
sales: (period: string) => [...sellerStatsKeys.all, 'sales', period] as const,
payouts: () => [...sellerStatsKeys.all, 'payouts'] as const,
earnings: (period: string) => [...sellerStatsKeys.all, 'earnings', period] as const,
};
// ============================================================================
// Hooks
// ============================================================================
/**
* Hook for fetching seller's overview statistics
*/
export function useSellerStats() {
return useQuery<SellerStats, Error>({
queryKey: sellerStatsKeys.overview(),
queryFn: () => marketplaceService.getSellerStats(),
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
/**
* Hook for fetching seller's sales data with period filter
*/
export function useSellerSales(period: '7d' | '30d' | '90d' | '1y' = '30d') {
return useQuery<SellerSalesData[], Error>({
queryKey: sellerStatsKeys.sales(period),
queryFn: () => marketplaceService.getSellerSales(period),
staleTime: 1000 * 60 * 10, // 10 minutes
});
}
/**
* Hook for fetching seller's payout information
*/
export function useSellerPayouts() {
return useQuery<SellerPayoutInfo, Error>({
queryKey: sellerStatsKeys.payouts(),
queryFn: () => marketplaceService.getSellerPayouts(),
staleTime: 1000 * 60 * 5,
});
}
/**
* Hook for fetching earnings breakdown by period
*/
export function useSellerEarnings(period: '7d' | '30d' | '90d' | '1y' = '30d') {
return useQuery<{
total: number;
byProduct: { productId: string; productName: string; earnings: number }[];
trend: number;
}, Error>({
queryKey: sellerStatsKeys.earnings(period),
queryFn: () => marketplaceService.getSellerEarnings(period),
staleTime: 1000 * 60 * 10,
});
}

View File

@ -0,0 +1,689 @@
/**
* Checkout Flow Page
* Multi-step checkout for marketplace products with Stripe integration
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
ArrowRight,
CreditCard,
Shield,
Check,
Loader2,
Tag,
AlertCircle,
Lock,
Percent,
} from 'lucide-react';
import { loadStripe } from '@stripe/stripe-js';
import {
Elements,
CardElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { useMarketplaceStore } from '../../../stores/marketplaceStore';
import type { MarketplaceProductListItem, SignalPackPricing } from '../../../types/marketplace.types';
// Initialize Stripe
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
// ============================================================================
// Types
// ============================================================================
interface CheckoutStep {
id: number;
title: string;
description: string;
}
interface CouponData {
code: string;
discountPercent: number;
discountAmount?: number;
isValid: boolean;
}
// ============================================================================
// Constants
// ============================================================================
const CHECKOUT_STEPS: CheckoutStep[] = [
{ id: 1, title: 'Review', description: 'Confirm your selection' },
{ id: 2, title: 'Payment', description: 'Enter payment details' },
{ id: 3, title: 'Confirm', description: 'Complete purchase' },
];
// ============================================================================
// Payment Form Component (uses Stripe Elements)
// ============================================================================
interface PaymentFormProps {
onSubmit: (paymentMethodId: string) => Promise<void>;
isProcessing: boolean;
amount: number;
}
const PaymentForm: React.FC<PaymentFormProps> = ({ onSubmit, isProcessing, amount }) => {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!stripe || !elements) {
return;
}
const cardElement = elements.getElement(CardElement);
if (!cardElement) {
return;
}
const { error: stripeError, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
});
if (stripeError) {
setError(stripeError.message || 'Payment failed');
return;
}
if (paymentMethod) {
await onSubmit(paymentMethod.id);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-400 mb-3">
Card Details
</label>
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: '#ffffff',
'::placeholder': {
color: '#6b7280',
},
},
invalid: {
color: '#ef4444',
},
},
}}
/>
</div>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
<button
type="submit"
disabled={!stripe || isProcessing}
className="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isProcessing ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Processing...
</>
) : (
<>
<Lock className="w-5 h-5" />
Pay ${amount.toFixed(2)}
</>
)}
</button>
</form>
);
};
// ============================================================================
// Main Checkout Component
// ============================================================================
export default function CheckoutFlow() {
const { productId } = useParams<{ productId: string }>();
const navigate = useNavigate();
// State
const [currentStep, setCurrentStep] = useState(1);
const [product, setProduct] = useState<MarketplaceProductListItem | null>(null);
const [selectedPricing, setSelectedPricing] = useState<SignalPackPricing | null>(null);
const [couponCode, setCouponCode] = useState('');
const [appliedCoupon, setAppliedCoupon] = useState<CouponData | null>(null);
const [isValidatingCoupon, setIsValidatingCoupon] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
// Store
const {
currentSignalPack,
loadingDetail,
fetchSignalPackById,
subscribeToSignalPack,
} = useMarketplaceStore();
// Fetch product data
useEffect(() => {
if (productId) {
fetchSignalPackById(productId);
}
}, [productId, fetchSignalPackById]);
// Set product and default pricing when data loads
useEffect(() => {
if (currentSignalPack) {
setProduct({
id: currentSignalPack.id,
name: currentSignalPack.name,
slug: currentSignalPack.slug,
shortDescription: currentSignalPack.shortDescription,
thumbnailUrl: currentSignalPack.thumbnailUrl,
productType: 'signals',
providerName: currentSignalPack.provider.name,
providerAvatar: currentSignalPack.provider.avatarUrl,
providerVerified: currentSignalPack.provider.verified,
priceUsd: currentSignalPack.pricing[0]?.priceUsd || 0,
hasFreeTrial: currentSignalPack.hasFreeTrial,
rating: currentSignalPack.rating,
totalReviews: currentSignalPack.totalReviews,
totalSubscribers: currentSignalPack.totalSubscribers,
tags: [],
createdAt: currentSignalPack.createdAt,
});
const defaultPricing =
currentSignalPack.pricing.find((p) => p.period === 'monthly') ||
currentSignalPack.pricing[0];
setSelectedPricing(defaultPricing);
}
}, [currentSignalPack]);
// Calculate final price
const finalPrice = useMemo(() => {
if (!selectedPricing) return 0;
let price = selectedPricing.priceUsd;
if (appliedCoupon?.isValid) {
if (appliedCoupon.discountAmount) {
price -= appliedCoupon.discountAmount;
} else if (appliedCoupon.discountPercent) {
price -= price * (appliedCoupon.discountPercent / 100);
}
}
return Math.max(0, price);
}, [selectedPricing, appliedCoupon]);
// Validate coupon
const handleApplyCoupon = async () => {
if (!couponCode.trim()) return;
setIsValidatingCoupon(true);
try {
// Simulated coupon validation - replace with actual API call
await new Promise((resolve) => setTimeout(resolve, 1000));
// Mock validation logic
if (couponCode.toUpperCase() === 'SAVE20') {
setAppliedCoupon({
code: couponCode.toUpperCase(),
discountPercent: 20,
isValid: true,
});
} else if (couponCode.toUpperCase() === 'NEWUSER') {
setAppliedCoupon({
code: couponCode.toUpperCase(),
discountPercent: 15,
isValid: true,
});
} else {
setAppliedCoupon({
code: couponCode,
discountPercent: 0,
isValid: false,
});
}
} finally {
setIsValidatingCoupon(false);
}
};
// Remove coupon
const handleRemoveCoupon = () => {
setAppliedCoupon(null);
setCouponCode('');
};
// Process payment
const handlePayment = async (paymentMethodId: string) => {
if (!product || !selectedPricing) return;
setIsProcessing(true);
setCheckoutError(null);
try {
await subscribeToSignalPack(product.id, selectedPricing.id);
setIsComplete(true);
setCurrentStep(3);
} catch (error) {
setCheckoutError(
error instanceof Error ? error.message : 'Payment failed. Please try again.'
);
} finally {
setIsProcessing(false);
}
};
// Navigate between steps
const handleNextStep = () => {
if (currentStep < 3) {
setCurrentStep(currentStep + 1);
}
};
const handlePreviousStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
// Loading state
if (loadingDetail) {
return (
<div className="flex items-center justify-center min-h-96">
<Loader2 className="w-10 h-10 text-blue-500 animate-spin" />
</div>
);
}
// Error state
if (!product && !loadingDetail) {
return (
<div className="flex flex-col items-center justify-center min-h-96">
<AlertCircle className="w-12 h-12 text-red-400 mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Product Not Found</h2>
<p className="text-gray-400 mb-4">The requested product does not exist.</p>
<Link
to="/marketplace"
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg"
>
Back to Marketplace
</Link>
</div>
);
}
// Success state
if (isComplete) {
return (
<div className="max-w-2xl mx-auto">
<div className="bg-gray-800 rounded-xl border border-gray-700 p-8 text-center">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<Check className="w-8 h-8 text-green-400" />
</div>
<h1 className="text-2xl font-bold text-white mb-2">Purchase Complete!</h1>
<p className="text-gray-400 mb-6">
Thank you for your purchase. You now have access to {product?.name}.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
to="/marketplace"
className="px-6 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Continue Shopping
</Link>
<Link
to="/dashboard"
className="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
Go to Dashboard
</Link>
</div>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* Back link */}
<Link
to={`/marketplace/signals/${product?.slug}`}
className="inline-flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Product
</Link>
{/* Progress Steps */}
<div className="flex items-center justify-between">
{CHECKOUT_STEPS.map((step, index) => (
<React.Fragment key={step.id}>
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-colors ${
currentStep >= step.id
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-400'
}`}
>
{currentStep > step.id ? <Check className="w-5 h-5" /> : step.id}
</div>
<div className="hidden sm:block">
<p className="font-medium text-white">{step.title}</p>
<p className="text-xs text-gray-500">{step.description}</p>
</div>
</div>
{index < CHECKOUT_STEPS.length - 1 && (
<div
className={`flex-1 h-1 mx-4 rounded ${
currentStep > step.id ? 'bg-blue-600' : 'bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column - Step Content */}
<div className="lg:col-span-2">
{/* Step 1: Review */}
{currentStep === 1 && product && currentSignalPack && (
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6 space-y-6">
<h2 className="text-xl font-semibold text-white">Review Your Selection</h2>
{/* Product Info */}
<div className="flex items-start gap-4 p-4 bg-gray-900/50 rounded-lg">
<div className="w-16 h-16 rounded-lg bg-gray-700 flex items-center justify-center flex-shrink-0">
{product.thumbnailUrl ? (
<img
src={product.thumbnailUrl}
alt={product.name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<CreditCard className="w-8 h-8 text-gray-500" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-white">{product.name}</h3>
<p className="text-sm text-gray-400">{product.providerName}</p>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{product.shortDescription}
</p>
</div>
</div>
{/* Pricing Options */}
<div>
<h3 className="text-sm font-medium text-gray-400 mb-3">
Select Subscription Plan
</h3>
<div className="space-y-3">
{currentSignalPack.pricing.map((pricing) => (
<button
key={pricing.id}
onClick={() => setSelectedPricing(pricing)}
className={`w-full p-4 rounded-lg border-2 transition-colors text-left ${
selectedPricing?.id === pricing.id
? 'border-blue-500 bg-blue-500/10'
: 'border-gray-700 hover:border-gray-600 bg-gray-900/50'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedPricing?.id === pricing.id
? 'border-blue-500 bg-blue-500'
: 'border-gray-600'
}`}
>
{selectedPricing?.id === pricing.id && (
<Check className="w-3 h-3 text-white" />
)}
</div>
<div>
<p className="font-medium text-white capitalize">
{pricing.period}
</p>
{pricing.discountPercent && (
<p className="text-sm text-green-400">
Save {pricing.discountPercent}%
</p>
)}
</div>
</div>
<div className="text-right">
<p className="text-xl font-bold text-white">
${pricing.priceUsd}
</p>
{pricing.originalPriceUsd && (
<p className="text-sm text-gray-500 line-through">
${pricing.originalPriceUsd}
</p>
)}
</div>
</div>
{pricing.isPopular && (
<span className="inline-block mt-2 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
Most Popular
</span>
)}
</button>
))}
</div>
</div>
{/* Coupon Code */}
<div>
<h3 className="text-sm font-medium text-gray-400 mb-3">
Have a Coupon Code?
</h3>
{appliedCoupon?.isValid ? (
<div className="flex items-center justify-between p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2">
<Percent className="w-5 h-5 text-green-400" />
<span className="text-green-400 font-medium">
{appliedCoupon.code} - {appliedCoupon.discountPercent}% off
</span>
</div>
<button
onClick={handleRemoveCoupon}
className="text-gray-400 hover:text-white"
>
<Tag className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex gap-3">
<input
type="text"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
placeholder="Enter coupon code"
className="flex-1 px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
<button
onClick={handleApplyCoupon}
disabled={isValidatingCoupon || !couponCode.trim()}
className="px-4 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors disabled:opacity-50"
>
{isValidatingCoupon ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
'Apply'
)}
</button>
</div>
)}
{appliedCoupon && !appliedCoupon.isValid && (
<p className="mt-2 text-sm text-red-400">Invalid coupon code</p>
)}
</div>
{/* Continue Button */}
<button
onClick={handleNextStep}
disabled={!selectedPricing}
className="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white font-medium rounded-lg transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
Continue to Payment
<ArrowRight className="w-5 h-5" />
</button>
</div>
)}
{/* Step 2: Payment */}
{currentStep === 2 && (
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">Payment Details</h2>
<button
onClick={handlePreviousStep}
className="text-gray-400 hover:text-white flex items-center gap-1"
>
<ArrowLeft className="w-4 h-4" />
Back
</button>
</div>
{checkoutError && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{checkoutError}
</div>
)}
<Elements stripe={stripePromise}>
<PaymentForm
onSubmit={handlePayment}
isProcessing={isProcessing}
amount={finalPrice}
/>
</Elements>
{/* Security Notice */}
<div className="flex items-center gap-3 p-3 bg-gray-900/50 rounded-lg">
<Shield className="w-5 h-5 text-green-400 flex-shrink-0" />
<p className="text-sm text-gray-400">
Your payment is secured with 256-bit SSL encryption
</p>
</div>
</div>
)}
</div>
{/* Right Column - Order Summary */}
<div className="lg:col-span-1">
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6 sticky top-6">
<h3 className="text-lg font-semibold text-white mb-4">Order Summary</h3>
{product && (
<>
{/* Product */}
<div className="flex items-start gap-3 pb-4 border-b border-gray-700">
<div className="w-12 h-12 rounded-lg bg-gray-700 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-white text-sm truncate">
{product.name}
</p>
<p className="text-xs text-gray-500">
{selectedPricing?.period || 'Monthly'} subscription
</p>
</div>
</div>
{/* Price Breakdown */}
<div className="py-4 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-400">Subtotal</span>
<span className="text-white">
${selectedPricing?.priceUsd.toFixed(2) || '0.00'}
</span>
</div>
{appliedCoupon?.isValid && (
<div className="flex justify-between text-sm">
<span className="text-green-400">
Discount ({appliedCoupon.discountPercent}%)
</span>
<span className="text-green-400">
-$
{(
(selectedPricing?.priceUsd || 0) *
(appliedCoupon.discountPercent / 100)
).toFixed(2)}
</span>
</div>
)}
{selectedPricing?.originalPriceUsd && (
<div className="flex justify-between text-sm">
<span className="text-gray-400">Plan Savings</span>
<span className="text-green-400">
-$
{(
selectedPricing.originalPriceUsd - selectedPricing.priceUsd
).toFixed(2)}
</span>
</div>
)}
</div>
{/* Total */}
<div className="pt-4 border-t border-gray-700">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-white">Total</span>
<span className="text-2xl font-bold text-white">
${finalPrice.toFixed(2)}
</span>
</div>
{selectedPricing?.period !== 'lifetime' && (
<p className="text-xs text-gray-500 mt-1">
Billed {selectedPricing?.period}
</p>
)}
</div>
</>
)}
{/* Trust Badges */}
<div className="mt-6 pt-6 border-t border-gray-700 space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-400">
<Shield className="w-4 h-4 text-green-400" />
<span>30-day money-back guarantee</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Lock className="w-4 h-4 text-blue-400" />
<span>Secure SSL encryption</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Check className="w-4 h-4 text-purple-400" />
<span>Cancel anytime</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,782 @@
/**
* Create Product Wizard
* Multi-step wizard for creating new marketplace products
*/
import React, { useState, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
ArrowRight,
Check,
Loader2,
AlertCircle,
Image,
DollarSign,
FileText,
Settings,
Eye,
X,
Plus,
Upload,
} from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
type ProductType = 'signals' | 'courses' | 'advisory';
type PricingPeriod = 'monthly' | 'quarterly' | 'yearly' | 'lifetime';
interface ProductFormData {
type: ProductType;
name: string;
description: string;
shortDescription: string;
tags: string[];
thumbnailUrl: string;
pricing: PricingOption[];
hasFreeTrial: boolean;
freeTrialDays: number;
// Signal-specific
riskLevel?: 'low' | 'medium' | 'high';
symbolsCovered?: string[];
// Course-specific
difficultyLevel?: 'beginner' | 'intermediate' | 'advanced';
// Advisory-specific
specializations?: string[];
sessionDuration?: number;
}
interface PricingOption {
id: string;
period: PricingPeriod;
price: number;
originalPrice?: number;
isPopular: boolean;
}
interface WizardStep {
id: number;
title: string;
icon: React.ReactNode;
}
// ============================================================================
// Constants
// ============================================================================
const WIZARD_STEPS: WizardStep[] = [
{ id: 1, title: 'Product Type', icon: <Settings className="w-5 h-5" /> },
{ id: 2, title: 'Details', icon: <FileText className="w-5 h-5" /> },
{ id: 3, title: 'Pricing', icon: <DollarSign className="w-5 h-5" /> },
{ id: 4, title: 'Preview', icon: <Eye className="w-5 h-5" /> },
];
const PRODUCT_TYPES: { type: ProductType; title: string; description: string; icon: string }[] = [
{
type: 'signals',
title: 'Signal Pack',
description: 'Share your trading signals with subscribers',
icon: '📊',
},
{
type: 'courses',
title: 'Course',
description: 'Create educational content and lessons',
icon: '📚',
},
{
type: 'advisory',
title: 'Advisory Service',
description: 'Offer 1-on-1 consultations and coaching',
icon: '👨‍💼',
},
];
const INITIAL_PRICING: PricingOption[] = [
{ id: '1', period: 'monthly', price: 29.99, isPopular: false },
{ id: '2', period: 'quarterly', price: 79.99, originalPrice: 89.97, isPopular: true },
{ id: '3', period: 'yearly', price: 249.99, originalPrice: 359.88, isPopular: false },
];
// ============================================================================
// Step Components
// ============================================================================
// Step 1: Product Type Selection
interface TypeStepProps {
selectedType: ProductType | null;
onSelect: (type: ProductType) => void;
}
const TypeStep: React.FC<TypeStepProps> = ({ selectedType, onSelect }) => (
<div className="space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">What would you like to create?</h2>
<p className="text-gray-400">Choose the type of product you want to sell</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{PRODUCT_TYPES.map((product) => (
<button
key={product.type}
onClick={() => onSelect(product.type)}
className={`p-6 rounded-xl border-2 text-left transition-all ${
selectedType === product.type
? 'border-blue-500 bg-blue-500/10'
: 'border-gray-700 hover:border-gray-600 bg-gray-800'
}`}
>
<div className="text-4xl mb-4">{product.icon}</div>
<h3 className="text-lg font-semibold text-white mb-2">{product.title}</h3>
<p className="text-sm text-gray-400">{product.description}</p>
{selectedType === product.type && (
<div className="mt-4 flex items-center gap-2 text-blue-400 text-sm">
<Check className="w-4 h-4" />
Selected
</div>
)}
</button>
))}
</div>
</div>
);
// Step 2: Product Details
interface DetailsStepProps {
data: ProductFormData;
onChange: (field: keyof ProductFormData, value: unknown) => void;
}
const DetailsStep: React.FC<DetailsStepProps> = ({ data, onChange }) => {
const [newTag, setNewTag] = useState('');
const handleAddTag = () => {
if (newTag.trim() && !data.tags.includes(newTag.trim())) {
onChange('tags', [...data.tags, newTag.trim()]);
setNewTag('');
}
};
const handleRemoveTag = (tag: string) => {
onChange('tags', data.tags.filter((t) => t !== tag));
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Product Details</h2>
<p className="text-gray-400">Provide information about your product</p>
</div>
<div className="max-w-2xl mx-auto space-y-6">
{/* Product Name */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Product Name <span className="text-red-400">*</span>
</label>
<input
type="text"
value={data.name}
onChange={(e) => onChange('name', e.target.value)}
placeholder="e.g., Pro Trading Signals"
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
</div>
{/* Short Description */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Short Description <span className="text-red-400">*</span>
</label>
<input
type="text"
value={data.shortDescription}
onChange={(e) => onChange('shortDescription', e.target.value)}
placeholder="Brief summary (max 150 characters)"
maxLength={150}
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
<p className="text-xs text-gray-500 mt-1">
{data.shortDescription.length}/150 characters
</p>
</div>
{/* Full Description */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Full Description <span className="text-red-400">*</span>
</label>
<textarea
value={data.description}
onChange={(e) => onChange('description', e.target.value)}
placeholder="Detailed description of your product..."
rows={6}
className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none resize-none"
/>
</div>
{/* Thumbnail */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Thumbnail Image
</label>
<div className="border-2 border-dashed border-gray-700 rounded-lg p-8 text-center hover:border-gray-600 transition-colors cursor-pointer">
{data.thumbnailUrl ? (
<div className="relative inline-block">
<img
src={data.thumbnailUrl}
alt="Thumbnail"
className="w-32 h-32 object-cover rounded-lg"
/>
<button
onClick={() => onChange('thumbnailUrl', '')}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center"
>
<X className="w-4 h-4 text-white" />
</button>
</div>
) : (
<>
<Upload className="w-10 h-10 text-gray-500 mx-auto mb-3" />
<p className="text-gray-400 text-sm">
Drag and drop or click to upload
</p>
<p className="text-gray-500 text-xs mt-1">
PNG, JPG up to 2MB (recommended: 800x600)
</p>
</>
)}
</div>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">Tags</label>
<div className="flex flex-wrap gap-2 mb-3">
{data.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm flex items-center gap-1"
>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
placeholder="Add a tag..."
className="flex-1 px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none"
/>
<button
onClick={handleAddTag}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
{/* Type-specific fields */}
{data.type === 'signals' && (
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Risk Level
</label>
<div className="flex gap-3">
{(['low', 'medium', 'high'] as const).map((level) => (
<button
key={level}
onClick={() => onChange('riskLevel', level)}
className={`px-4 py-2 rounded-lg capitalize transition-colors ${
data.riskLevel === level
? level === 'low'
? 'bg-green-500/20 text-green-400 border border-green-500/50'
: level === 'medium'
? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/50'
: 'bg-red-500/20 text-red-400 border border-red-500/50'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:border-gray-600'
}`}
>
{level} Risk
</button>
))}
</div>
</div>
)}
{data.type === 'courses' && (
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Difficulty Level
</label>
<div className="flex gap-3">
{(['beginner', 'intermediate', 'advanced'] as const).map((level) => (
<button
key={level}
onClick={() => onChange('difficultyLevel', level)}
className={`px-4 py-2 rounded-lg capitalize transition-colors ${
data.difficultyLevel === level
? 'bg-blue-500/20 text-blue-400 border border-blue-500/50'
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:border-gray-600'
}`}
>
{level}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
};
// Step 3: Pricing Configuration
interface PricingStepProps {
data: ProductFormData;
onChange: (field: keyof ProductFormData, value: unknown) => void;
}
const PricingStep: React.FC<PricingStepProps> = ({ data, onChange }) => {
const updatePricing = (id: string, field: keyof PricingOption, value: unknown) => {
const updated = data.pricing.map((p) =>
p.id === id ? { ...p, [field]: value } : p
);
onChange('pricing', updated);
};
const setPopular = (id: string) => {
const updated = data.pricing.map((p) => ({
...p,
isPopular: p.id === id,
}));
onChange('pricing', updated);
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Set Your Pricing</h2>
<p className="text-gray-400">Configure subscription plans for your product</p>
</div>
<div className="max-w-2xl mx-auto space-y-6">
{/* Pricing Tiers */}
<div className="space-y-4">
{data.pricing.map((pricing) => (
<div
key={pricing.id}
className={`p-4 rounded-lg border ${
pricing.isPopular
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 bg-gray-800'
}`}
>
<div className="flex items-center justify-between mb-4">
<span className="font-medium text-white capitalize">{pricing.period}</span>
<button
onClick={() => setPopular(pricing.id)}
className={`px-3 py-1 rounded-full text-xs transition-colors ${
pricing.isPopular
? 'bg-blue-500 text-white'
: 'bg-gray-700 text-gray-400 hover:text-white'
}`}
>
{pricing.isPopular ? 'Popular' : 'Set as Popular'}
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Price (USD)</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
$
</span>
<input
type="number"
value={pricing.price}
onChange={(e) =>
updatePricing(pricing.id, 'price', parseFloat(e.target.value) || 0)
}
min="0"
step="0.01"
className="w-full pl-8 pr-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
Original Price (optional)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
$
</span>
<input
type="number"
value={pricing.originalPrice || ''}
onChange={(e) =>
updatePricing(
pricing.id,
'originalPrice',
e.target.value ? parseFloat(e.target.value) : undefined
)
}
min="0"
step="0.01"
placeholder="Show savings"
className="w-full pl-8 pr-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-600 focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
</div>
))}
</div>
{/* Free Trial */}
<div className="p-4 rounded-lg border border-gray-700 bg-gray-800">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-medium text-white">Free Trial</h3>
<p className="text-sm text-gray-400">
Let customers try before they buy
</p>
</div>
<button
onClick={() => onChange('hasFreeTrial', !data.hasFreeTrial)}
className={`w-12 h-6 rounded-full transition-colors ${
data.hasFreeTrial ? 'bg-blue-600' : 'bg-gray-700'
}`}
>
<div
className={`w-5 h-5 rounded-full bg-white shadow transform transition-transform ${
data.hasFreeTrial ? 'translate-x-6' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{data.hasFreeTrial && (
<div>
<label className="block text-xs text-gray-500 mb-1">Trial Period</label>
<div className="flex items-center gap-2">
<input
type="number"
value={data.freeTrialDays}
onChange={(e) =>
onChange('freeTrialDays', parseInt(e.target.value) || 0)
}
min="1"
max="30"
className="w-24 px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white focus:border-blue-500 focus:outline-none"
/>
<span className="text-gray-400">days</span>
</div>
</div>
)}
</div>
</div>
</div>
);
};
// Step 4: Preview
interface PreviewStepProps {
data: ProductFormData;
}
const PreviewStep: React.FC<PreviewStepProps> = ({ data }) => {
const typeInfo = PRODUCT_TYPES.find((t) => t.type === data.type);
return (
<div className="space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Preview Your Product</h2>
<p className="text-gray-400">Review how your product will appear in the marketplace</p>
</div>
<div className="max-w-xl mx-auto">
{/* Product Card Preview */}
<div className="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
{/* Thumbnail */}
<div className="h-40 bg-gradient-to-br from-blue-600/20 to-purple-600/20 flex items-center justify-center">
{data.thumbnailUrl ? (
<img
src={data.thumbnailUrl}
alt={data.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-6xl">{typeInfo?.icon}</span>
)}
</div>
{/* Content */}
<div className="p-4">
<h3 className="text-lg font-semibold text-white mb-2">
{data.name || 'Product Name'}
</h3>
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
{data.shortDescription || 'Short description will appear here...'}
</p>
{/* Tags */}
{data.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-4">
{data.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-gray-700 text-gray-300 text-xs rounded"
>
{tag}
</span>
))}
</div>
)}
{/* Price */}
<div className="flex items-center justify-between pt-3 border-t border-gray-700">
<div>
<span className="text-xl font-bold text-white">
${data.pricing.find((p) => p.period === 'monthly')?.price || 0}
</span>
<span className="text-gray-400 text-sm">/mo</span>
</div>
{data.hasFreeTrial && (
<span className="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-full">
{data.freeTrialDays} days free
</span>
)}
</div>
</div>
</div>
{/* Summary */}
<div className="mt-6 p-4 bg-gray-900 rounded-lg">
<h4 className="font-medium text-white mb-3">Product Summary</h4>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-400">Type</dt>
<dd className="text-white capitalize">{data.type}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-400">Pricing Tiers</dt>
<dd className="text-white">{data.pricing.length}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-400">Free Trial</dt>
<dd className="text-white">
{data.hasFreeTrial ? `${data.freeTrialDays} days` : 'No'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-400">Tags</dt>
<dd className="text-white">{data.tags.length}</dd>
</div>
</dl>
</div>
</div>
</div>
);
};
// ============================================================================
// Main Component
// ============================================================================
export default function CreateProductWizard() {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<ProductFormData>({
type: 'signals',
name: '',
description: '',
shortDescription: '',
tags: [],
thumbnailUrl: '',
pricing: INITIAL_PRICING,
hasFreeTrial: false,
freeTrialDays: 7,
riskLevel: 'medium',
difficultyLevel: 'beginner',
specializations: [],
sessionDuration: 60,
});
// Update form field
const handleChange = useCallback(
(field: keyof ProductFormData, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// Validate current step
const validateStep = useCallback(() => {
switch (currentStep) {
case 1:
return !!formData.type;
case 2:
return !!(formData.name && formData.shortDescription && formData.description);
case 3:
return formData.pricing.every((p) => p.price > 0);
default:
return true;
}
}, [currentStep, formData]);
// Navigate steps
const handleNext = () => {
if (validateStep() && currentStep < 4) {
setCurrentStep(currentStep + 1);
}
};
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
// Submit product
const handleSubmit = async () => {
setIsSubmitting(true);
setError(null);
try {
// Simulated API call - replace with actual service
await new Promise((resolve) => setTimeout(resolve, 2000));
navigate('/marketplace/seller');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create product');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* Back Link */}
<Link
to="/marketplace/seller"
className="inline-flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Dashboard
</Link>
{/* Progress Steps */}
<div className="flex items-center justify-between">
{WIZARD_STEPS.map((step, index) => (
<React.Fragment key={step.id}>
<div
className={`flex items-center gap-3 cursor-pointer ${
step.id <= currentStep ? 'opacity-100' : 'opacity-50'
}`}
onClick={() => step.id < currentStep && setCurrentStep(step.id)}
>
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors ${
currentStep >= step.id
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-400'
}`}
>
{currentStep > step.id ? <Check className="w-5 h-5" /> : step.icon}
</div>
<span className="hidden sm:block font-medium text-white">{step.title}</span>
</div>
{index < WIZARD_STEPS.length - 1 && (
<div
className={`flex-1 h-1 mx-4 rounded ${
currentStep > step.id ? 'bg-blue-600' : 'bg-gray-700'
}`}
/>
)}
</React.Fragment>
))}
</div>
{/* Step Content */}
<div className="bg-gray-800 rounded-xl border border-gray-700 p-8">
{error && (
<div className="mb-6 flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{currentStep === 1 && (
<TypeStep
selectedType={formData.type}
onSelect={(type) => handleChange('type', type)}
/>
)}
{currentStep === 2 && <DetailsStep data={formData} onChange={handleChange} />}
{currentStep === 3 && <PricingStep data={formData} onChange={handleChange} />}
{currentStep === 4 && <PreviewStep data={formData} />}
{/* Navigation Buttons */}
<div className="mt-8 pt-6 border-t border-gray-700 flex items-center justify-between">
<button
onClick={handlePrevious}
disabled={currentStep === 1}
className="px-6 py-2.5 text-gray-400 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<ArrowLeft className="w-5 h-5" />
Back
</button>
{currentStep < 4 ? (
<button
onClick={handleNext}
disabled={!validateStep()}
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
Continue
<ArrowRight className="w-5 h-5" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="px-6 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Creating...
</>
) : (
<>
<Check className="w-5 h-5" />
Create Product
</>
)}
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,595 @@
/**
* Seller Dashboard Page
* Dashboard for product creators to manage their listings and track sales
*/
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
Plus,
Package,
DollarSign,
Users,
TrendingUp,
BarChart2,
Eye,
Edit,
Trash2,
MoreVertical,
Star,
Clock,
ChevronRight,
ArrowUpRight,
ArrowDownRight,
Loader2,
AlertCircle,
Filter,
} from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
interface SellerProduct {
id: string;
name: string;
type: 'signals' | 'courses' | 'advisory';
status: 'active' | 'draft' | 'inactive';
price: number;
subscribers: number;
rating: number;
reviews: number;
revenue: number;
views: number;
createdAt: string;
thumbnailUrl?: string;
}
interface SellerStats {
totalRevenue: number;
revenueChange: number;
totalSubscribers: number;
subscribersChange: number;
activeProducts: number;
averageRating: number;
pendingPayouts: number;
nextPayoutDate: string;
}
interface SalesData {
date: string;
revenue: number;
subscribers: number;
}
// ============================================================================
// Mock Data (replace with API calls)
// ============================================================================
const MOCK_STATS: SellerStats = {
totalRevenue: 12450.00,
revenueChange: 15.3,
totalSubscribers: 342,
subscribersChange: 8.2,
activeProducts: 5,
averageRating: 4.7,
pendingPayouts: 2340.00,
nextPayoutDate: '2026-02-15',
};
const MOCK_PRODUCTS: SellerProduct[] = [
{
id: '1',
name: 'Pro Trading Signals',
type: 'signals',
status: 'active',
price: 49.99,
subscribers: 156,
rating: 4.8,
reviews: 42,
revenue: 7798.44,
views: 2340,
createdAt: '2025-10-15',
},
{
id: '2',
name: 'Forex Masterclass',
type: 'courses',
status: 'active',
price: 199.00,
subscribers: 89,
rating: 4.6,
reviews: 28,
revenue: 3982.11,
views: 1890,
createdAt: '2025-11-20',
},
{
id: '3',
name: '1-on-1 Trading Coaching',
type: 'advisory',
status: 'active',
price: 150.00,
subscribers: 12,
rating: 5.0,
reviews: 8,
revenue: 1800.00,
views: 456,
createdAt: '2025-12-01',
},
{
id: '4',
name: 'Crypto Signals (Beta)',
type: 'signals',
status: 'draft',
price: 39.99,
subscribers: 0,
rating: 0,
reviews: 0,
revenue: 0,
views: 0,
createdAt: '2026-01-28',
},
];
// ============================================================================
// Stats Card Component
// ============================================================================
interface StatsCardProps {
title: string;
value: string | number;
change?: number;
icon: React.ReactNode;
iconBg: string;
prefix?: string;
suffix?: string;
}
const StatsCard: React.FC<StatsCardProps> = ({
title,
value,
change,
icon,
iconBg,
prefix,
suffix,
}) => (
<div className="bg-gray-800 rounded-xl border border-gray-700 p-6">
<div className="flex items-start justify-between mb-4">
<div className={`w-12 h-12 rounded-lg ${iconBg} flex items-center justify-center`}>
{icon}
</div>
{change !== undefined && (
<div
className={`flex items-center gap-1 text-sm ${
change >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{change >= 0 ? (
<ArrowUpRight className="w-4 h-4" />
) : (
<ArrowDownRight className="w-4 h-4" />
)}
{Math.abs(change)}%
</div>
)}
</div>
<p className="text-2xl font-bold text-white">
{prefix}
{typeof value === 'number' ? value.toLocaleString() : value}
{suffix}
</p>
<p className="text-sm text-gray-400 mt-1">{title}</p>
</div>
);
// ============================================================================
// Product Row Component
// ============================================================================
interface ProductRowProps {
product: SellerProduct;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onView: (id: string) => void;
}
const ProductRow: React.FC<ProductRowProps> = ({ product, onEdit, onDelete, onView }) => {
const [showMenu, setShowMenu] = useState(false);
const statusColors = {
active: 'bg-green-500/20 text-green-400',
draft: 'bg-yellow-500/20 text-yellow-400',
inactive: 'bg-gray-500/20 text-gray-400',
};
const typeIcons = {
signals: '📊',
courses: '📚',
advisory: '👨‍💼',
};
return (
<tr className="border-b border-gray-700 hover:bg-gray-800/50">
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-700 flex items-center justify-center text-lg flex-shrink-0">
{typeIcons[product.type]}
</div>
<div className="min-w-0">
<p className="font-medium text-white truncate">{product.name}</p>
<p className="text-sm text-gray-500 capitalize">{product.type}</p>
</div>
</div>
</td>
<td className="px-4 py-4">
<span
className={`px-2.5 py-1 rounded-full text-xs font-medium capitalize ${
statusColors[product.status]
}`}
>
{product.status}
</span>
</td>
<td className="px-4 py-4 text-white">${product.price.toFixed(2)}</td>
<td className="px-4 py-4 text-white">{product.subscribers}</td>
<td className="px-4 py-4">
{product.rating > 0 ? (
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-white">{product.rating.toFixed(1)}</span>
<span className="text-gray-500 text-sm">({product.reviews})</span>
</div>
) : (
<span className="text-gray-500">No reviews</span>
)}
</td>
<td className="px-4 py-4 text-green-400 font-medium">
${product.revenue.toLocaleString()}
</td>
<td className="px-4 py-4">
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 text-gray-400 hover:text-white rounded-lg hover:bg-gray-700 transition-colors"
>
<MoreVertical className="w-4 h-4" />
</button>
{showMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowMenu(false)}
/>
<div className="absolute right-0 mt-1 w-40 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-20">
<button
onClick={() => {
onView(product.id);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-300 hover:bg-gray-700 flex items-center gap-2"
>
<Eye className="w-4 h-4" />
View
</button>
<button
onClick={() => {
onEdit(product.id);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-300 hover:bg-gray-700 flex items-center gap-2"
>
<Edit className="w-4 h-4" />
Edit
</button>
<button
onClick={() => {
onDelete(product.id);
setShowMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
</>
)}
</div>
</td>
</tr>
);
};
// ============================================================================
// Main Component
// ============================================================================
export default function SellerDashboard() {
const [stats, setStats] = useState<SellerStats | null>(null);
const [products, setProducts] = useState<SellerProduct[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'draft' | 'inactive'>('all');
const [typeFilter, setTypeFilter] = useState<'all' | 'signals' | 'courses' | 'advisory'>('all');
// Fetch seller data
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
// Simulated API call - replace with actual service
await new Promise((resolve) => setTimeout(resolve, 1000));
setStats(MOCK_STATS);
setProducts(MOCK_PRODUCTS);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
// Filter products
const filteredProducts = products.filter((product) => {
if (statusFilter !== 'all' && product.status !== statusFilter) return false;
if (typeFilter !== 'all' && product.type !== typeFilter) return false;
return true;
});
// Handlers
const handleEditProduct = (id: string) => {
console.log('Edit product:', id);
// Navigate to edit page
};
const handleDeleteProduct = (id: string) => {
if (window.confirm('Are you sure you want to delete this product?')) {
setProducts(products.filter((p) => p.id !== id));
}
};
const handleViewProduct = (id: string) => {
const product = products.find((p) => p.id === id);
if (product) {
window.open(`/marketplace/${product.type}/${id}`, '_blank');
}
};
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-96">
<Loader2 className="w-10 h-10 text-blue-500 animate-spin" />
</div>
);
}
// Error state
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-96">
<AlertCircle className="w-12 h-12 text-red-400 mb-4" />
<h2 className="text-xl font-semibold text-white mb-2">Failed to Load</h2>
<p className="text-gray-400">{error}</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Seller Dashboard</h1>
<p className="text-gray-400">Manage your products and track sales</p>
</div>
<Link
to="/marketplace/seller/create"
className="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium rounded-lg transition-colors"
>
<Plus className="w-5 h-5" />
Create Product
</Link>
</div>
{/* Stats Grid */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Total Revenue"
value={stats.totalRevenue.toFixed(2)}
change={stats.revenueChange}
icon={<DollarSign className="w-6 h-6 text-green-400" />}
iconBg="bg-green-500/20"
prefix="$"
/>
<StatsCard
title="Total Subscribers"
value={stats.totalSubscribers}
change={stats.subscribersChange}
icon={<Users className="w-6 h-6 text-blue-400" />}
iconBg="bg-blue-500/20"
/>
<StatsCard
title="Active Products"
value={stats.activeProducts}
icon={<Package className="w-6 h-6 text-purple-400" />}
iconBg="bg-purple-500/20"
/>
<StatsCard
title="Average Rating"
value={stats.averageRating.toFixed(1)}
icon={<Star className="w-6 h-6 text-yellow-400" />}
iconBg="bg-yellow-500/20"
/>
</div>
)}
{/* Payout Notice */}
{stats && stats.pendingPayouts > 0 && (
<div className="bg-gray-800 rounded-xl border border-gray-700 p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<DollarSign className="w-5 h-5 text-green-400" />
</div>
<div>
<p className="font-medium text-white">
Pending Payout: ${stats.pendingPayouts.toFixed(2)}
</p>
<p className="text-sm text-gray-400">
Next payout scheduled for{' '}
{new Date(stats.nextPayoutDate).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
</div>
<Link
to="/billing"
className="text-blue-400 hover:text-blue-300 flex items-center gap-1 text-sm"
>
View Details
<ChevronRight className="w-4 h-4" />
</Link>
</div>
)}
{/* Products Table */}
<div className="bg-gray-800 rounded-xl border border-gray-700">
{/* Table Header */}
<div className="p-4 border-b border-gray-700 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 className="text-lg font-semibold text-white">Your Products</h2>
<div className="flex flex-wrap gap-3">
{/* Status Filter */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-gray-300 text-sm focus:border-blue-500 focus:outline-none"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="draft">Draft</option>
<option value="inactive">Inactive</option>
</select>
{/* Type Filter */}
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as typeof typeFilter)}
className="px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-gray-300 text-sm focus:border-blue-500 focus:outline-none"
>
<option value="all">All Types</option>
<option value="signals">Signals</option>
<option value="courses">Courses</option>
<option value="advisory">Advisory</option>
</select>
</div>
</div>
{/* Table */}
{filteredProducts.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-sm text-gray-400 border-b border-gray-700">
<th className="px-4 py-3 font-medium">Product</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Price</th>
<th className="px-4 py-3 font-medium">Subscribers</th>
<th className="px-4 py-3 font-medium">Rating</th>
<th className="px-4 py-3 font-medium">Revenue</th>
<th className="px-4 py-3 font-medium w-12"></th>
</tr>
</thead>
<tbody>
{filteredProducts.map((product) => (
<ProductRow
key={product.id}
product={product}
onEdit={handleEditProduct}
onDelete={handleDeleteProduct}
onView={handleViewProduct}
/>
))}
</tbody>
</table>
</div>
) : (
<div className="p-12 text-center">
<Package className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No products found</h3>
<p className="text-gray-400 mb-4">
{products.length === 0
? "You haven't created any products yet."
: 'No products match the selected filters.'}
</p>
{products.length === 0 && (
<Link
to="/marketplace/seller/create"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Create Your First Product
</Link>
)}
</div>
)}
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link
to="/marketplace/seller/create"
className="bg-gray-800 rounded-xl border border-gray-700 p-6 hover:border-gray-600 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-blue-500/20 flex items-center justify-center group-hover:bg-blue-500/30 transition-colors">
<Plus className="w-6 h-6 text-blue-400" />
</div>
<div>
<h3 className="font-medium text-white">Create New Product</h3>
<p className="text-sm text-gray-400">Add a new listing to marketplace</p>
</div>
</div>
</Link>
<Link
to="/billing"
className="bg-gray-800 rounded-xl border border-gray-700 p-6 hover:border-gray-600 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-green-500/20 flex items-center justify-center group-hover:bg-green-500/30 transition-colors">
<DollarSign className="w-6 h-6 text-green-400" />
</div>
<div>
<h3 className="font-medium text-white">Payout Settings</h3>
<p className="text-sm text-gray-400">Manage payment methods</p>
</div>
</div>
</Link>
<Link
to="/settings"
className="bg-gray-800 rounded-xl border border-gray-700 p-6 hover:border-gray-600 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-purple-500/20 flex items-center justify-center group-hover:bg-purple-500/30 transition-colors">
<BarChart2 className="w-6 h-6 text-purple-400" />
</div>
<div>
<h3 className="font-medium text-white">Analytics</h3>
<p className="text-sm text-gray-400">View detailed performance</p>
</div>
</div>
</Link>
</div>
</div>
);
}

View File

@ -5,3 +5,6 @@
export { default as MarketplaceCatalog } from './MarketplaceCatalog'; export { default as MarketplaceCatalog } from './MarketplaceCatalog';
export { default as SignalPackDetail } from './SignalPackDetail'; export { default as SignalPackDetail } from './SignalPackDetail';
export { default as AdvisoryDetail } from './AdvisoryDetail'; export { default as AdvisoryDetail } from './AdvisoryDetail';
export { default as CheckoutFlow } from './CheckoutFlow';
export { default as SellerDashboard } from './SellerDashboard';
export { default as CreateProductWizard } from './CreateProductWizard';

View File

@ -1,7 +1,14 @@
/** /**
* Payments Module * Payments Module
* Export all payment-related pages and components * Export all payment-related pages and components
* Epic: OQI-005 Pagos y Stripe
*/ */
// Pages
export { default as Pricing } from './pages/Pricing'; export { default as Pricing } from './pages/Pricing';
export { default as Billing } from './pages/Billing'; export { default as Billing } from './pages/Billing';
export { default as CheckoutSuccess } from './pages/CheckoutSuccess';
export { default as CheckoutCancel } from './pages/CheckoutCancel';
export { default as InvoicesPage } from './pages/InvoicesPage';
export { default as RefundsPage } from './pages/RefundsPage';
export { default as PaymentMethodsPage } from './pages/PaymentMethodsPage';

View File

@ -0,0 +1,253 @@
/**
* InvoicesPage
* Standalone page for viewing and managing invoices
* Route: /billing/invoices
* Epic: OQI-005 Pagos y Stripe
*/
import React, { useState, useCallback } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
FileText,
ArrowLeft,
RefreshCw,
Loader2,
AlertCircle,
Calendar,
Filter,
X,
} from 'lucide-react';
import { useInvoices, useDownloadInvoice } from '../../../hooks/usePayments';
import InvoiceList from '../../../components/payments/InvoiceList';
import InvoiceDetail from '../../../components/payments/InvoiceDetail';
import type { Invoice } from '../../../components/payments/InvoiceList';
const ITEMS_PER_PAGE = 10;
export default function InvoicesPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [currentPage, setCurrentPage] = useState(1);
const [selectedInvoiceId, setSelectedInvoiceId] = useState<string | null>(
searchParams.get('invoiceId')
);
const [statusFilter, setStatusFilter] = useState<'all' | 'paid' | 'pending' | 'failed'>('all');
const [dateRange, setDateRange] = useState<{ start?: string; end?: string }>({});
const [showFilters, setShowFilters] = useState(false);
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
const { data, isLoading, error, refetch } = useInvoices({
limit: ITEMS_PER_PAGE,
offset,
});
const downloadInvoice = useDownloadInvoice();
const handleInvoiceClick = useCallback((invoice: Invoice) => {
setSelectedInvoiceId(invoice.id);
setSearchParams({ invoiceId: invoice.id });
}, [setSearchParams]);
const handleCloseDetail = useCallback(() => {
setSelectedInvoiceId(null);
setSearchParams({});
}, [setSearchParams]);
const handleDownload = useCallback((invoice: Invoice) => {
downloadInvoice.mutate(invoice.id);
}, [downloadInvoice]);
// Page change handler - reserved for future custom pagination
const _handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
}, []);
const clearFilters = useCallback(() => {
setStatusFilter('all');
setDateRange({});
}, []);
const hasActiveFilters = statusFilter !== 'all' || dateRange.start || dateRange.end;
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/billing"
className="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="p-2 bg-blue-600/20 rounded-lg">
<FileText className="w-6 h-6 text-blue-400" />
</div>
Invoices
</h1>
<p className="text-gray-400 mt-1">
View and download your billing history
</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
showFilters || hasActiveFilters
? 'bg-blue-600/20 text-blue-400 border border-blue-600/50'
: 'bg-gray-800 text-gray-400 hover:text-white border border-gray-700'
}`}
>
<Filter className="w-4 h-4" />
Filters
{hasActiveFilters && (
<span className="w-2 h-2 bg-blue-400 rounded-full" />
)}
</button>
<button
onClick={() => refetch()}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-white rounded-lg transition-colors border border-gray-700 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{/* Filters Panel */}
{showFilters && (
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-white font-medium">Filter Invoices</h3>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
Clear filters
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Status Filter */}
<div>
<label className="block text-sm text-gray-400 mb-2">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:border-blue-500 focus:outline-none"
>
<option value="all">All Statuses</option>
<option value="paid">Paid</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
</select>
</div>
{/* Date Range */}
<div>
<label className="block text-sm text-gray-400 mb-2">From Date</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="date"
value={dateRange.start || ''}
onChange={(e) => setDateRange((prev) => ({ ...prev, start: e.target.value }))}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-3 py-2 text-white focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">To Date</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="date"
value={dateRange.end || ''}
onChange={(e) => setDateRange((prev) => ({ ...prev, end: e.target.value }))}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-3 py-2 text-white focus:border-blue-500 focus:outline-none"
/>
</div>
</div>
</div>
</div>
)}
{/* Summary Stats */}
{data && !isLoading && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4">
<p className="text-gray-400 text-sm">Total Invoices</p>
<p className="text-2xl font-bold text-white mt-1">{data.total}</p>
</div>
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4">
<p className="text-gray-400 text-sm">Paid</p>
<p className="text-2xl font-bold text-green-400 mt-1">
{data.invoices.filter((i) => i.status === 'paid').length}
</p>
</div>
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4">
<p className="text-gray-400 text-sm">Pending</p>
<p className="text-2xl font-bold text-yellow-400 mt-1">
{data.invoices.filter((i) => i.status === 'open' || i.status === 'draft').length}
</p>
</div>
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4">
<p className="text-gray-400 text-sm">Total Amount</p>
<p className="text-2xl font-bold text-white mt-1">
${data.invoices.reduce((sum, i) => sum + i.amount, 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}
</p>
</div>
</div>
)}
{/* Invoice List */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl">
{error ? (
<div className="text-center py-12">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
<p className="text-gray-400 mb-4">
{error instanceof Error ? error.message : 'Failed to load invoices'}
</p>
<button
onClick={() => refetch()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm"
>
Retry
</button>
</div>
) : (
<InvoiceList
onInvoiceClick={handleInvoiceClick}
onDownload={handleDownload}
itemsPerPage={ITEMS_PER_PAGE}
showFilters={false}
compact={false}
/>
)}
</div>
{/* Invoice Detail Modal */}
{selectedInvoiceId && (
<InvoiceDetail
invoiceId={selectedInvoiceId}
onClose={handleCloseDetail}
/>
)}
{/* Download Loading Indicator */}
{downloadInvoice.isPending && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg shadow-xl">
<Loader2 className="w-4 h-4 text-blue-400 animate-spin" />
<span className="text-white text-sm">Downloading invoice...</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,151 @@
/**
* PaymentMethodsPage
* Standalone page for managing payment methods
* Route: /billing/payment-methods or /settings/billing
* Epic: OQI-005 Pagos y Stripe
*/
import React from 'react';
import { Link } from 'react-router-dom';
import {
CreditCard,
ArrowLeft,
ExternalLink,
Shield,
Info,
} from 'lucide-react';
import { usePaymentStore } from '../../../stores/paymentStore';
import PaymentMethodsManager from '../../../components/payments/PaymentMethodsManager';
import StripeElementsWrapper from '../../../components/payments/StripeElementsWrapper';
export default function PaymentMethodsPage() {
const { openBillingPortal, currentSubscription } = usePaymentStore();
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/billing"
className="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="p-2 bg-blue-600/20 rounded-lg">
<CreditCard className="w-6 h-6 text-blue-400" />
</div>
Payment Methods
</h1>
<p className="text-gray-400 mt-1">
Manage your saved payment methods
</p>
</div>
</div>
<button
onClick={openBillingPortal}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-white rounded-lg transition-colors border border-gray-700"
>
<ExternalLink className="w-4 h-4" />
Stripe Portal
</button>
</div>
{/* Security Notice */}
<div className="flex items-start gap-3 p-4 bg-gray-800/50 border border-gray-700 rounded-xl">
<Shield className="w-5 h-5 text-green-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-white font-medium">Secure Payment Processing</p>
<p className="text-gray-400 text-sm mt-1">
All payment information is securely processed by Stripe. We never store your full card
details on our servers. Your data is protected with bank-level encryption.
</p>
</div>
</div>
{/* Subscription Info (if active) */}
{currentSubscription && (
<div className="flex items-start gap-3 p-4 bg-blue-900/20 border border-blue-800/50 rounded-xl">
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-blue-400 font-medium">Active Subscription</p>
<p className="text-gray-400 text-sm mt-1">
Your {currentSubscription.plan?.name ?? currentSubscription.planName} subscription
will automatically renew using your default payment method.
Next billing date:{' '}
{new Date(currentSubscription.currentPeriodEnd).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
)}
{/* Payment Methods Manager */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<StripeElementsWrapper>
<PaymentMethodsManager
showTitle={false}
onMethodChange={() => {
// Could trigger a notification here
}}
/>
</StripeElementsWrapper>
</div>
{/* Help Section */}
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-6">
<h3 className="text-white font-medium mb-4">Frequently Asked Questions</h3>
<div className="space-y-4">
<div>
<h4 className="text-gray-300 font-medium">
What payment methods do you accept?
</h4>
<p className="text-gray-500 text-sm mt-1">
We accept all major credit and debit cards (Visa, Mastercard, American Express,
Discover), as well as regional payment methods in select countries.
</p>
</div>
<div>
<h4 className="text-gray-300 font-medium">How do I update my default payment method?</h4>
<p className="text-gray-500 text-sm mt-1">
Click the star icon next to any saved payment method to set it as your default. Your
subscription will automatically use the default method for future payments.
</p>
</div>
<div>
<h4 className="text-gray-300 font-medium">Is my payment information secure?</h4>
<p className="text-gray-500 text-sm mt-1">
Yes! We use Stripe for payment processing, which is PCI-DSS Level 1 certified - the
highest level of security certification. Your card details are encrypted and never
stored on our servers.
</p>
</div>
<div>
<h4 className="text-gray-300 font-medium">
What happens if my payment fails?
</h4>
<p className="text-gray-500 text-sm mt-1">
If a payment fails, we will notify you via email and retry the payment automatically.
You can update your payment method at any time to prevent service interruption.
</p>
</div>
</div>
</div>
{/* Need Help */}
<div className="text-center py-6">
<p className="text-gray-500 text-sm">
Need help with your billing?{' '}
<Link to="/support" className="text-blue-400 hover:text-blue-300">
Contact Support
</Link>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,349 @@
/**
* RefundsPage
* Standalone page for viewing and managing refund requests
* Route: /billing/refunds
* Epic: OQI-005 Pagos y Stripe
*/
import React, { useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
RotateCcw,
ArrowLeft,
RefreshCw,
Loader2,
AlertCircle,
Filter,
X,
Clock,
CheckCircle,
XCircle,
} from 'lucide-react';
import {
useRefunds,
useCancelRefund,
useRefundEligibility,
useRequestRefund,
} from '../../../hooks/usePayments';
import { usePaymentStore } from '../../../stores/paymentStore';
import RefundList from '../../../components/payments/RefundList';
import RefundRequestModal from '../../../components/payments/RefundRequestModal';
import type { RefundStatus, RefundReason } from '../../../types/payment.types';
import type { Refund } from '../../../components/payments/RefundList';
const ITEMS_PER_PAGE = 10;
const STATUS_OPTIONS: { value: RefundStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'All Statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'processing', label: 'Processing' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'canceled', label: 'Canceled' },
];
export default function RefundsPage() {
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<RefundStatus | 'all'>('all');
const [showFilters, setShowFilters] = useState(false);
const [showRefundModal, setShowRefundModal] = useState(false);
const { currentSubscription } = usePaymentStore();
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
const { data, isLoading, error, refetch } = useRefunds({
limit: ITEMS_PER_PAGE,
offset,
status: statusFilter !== 'all' ? statusFilter : undefined,
});
const { data: eligibility } = useRefundEligibility(currentSubscription?.id);
const cancelRefund = useCancelRefund();
const requestRefund = useRequestRefund();
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
}, []);
const handleCancelRefund = useCallback(async (refundId: string) => {
if (!confirm('Are you sure you want to cancel this refund request?')) return;
try {
await cancelRefund.mutateAsync(refundId);
} catch (err) {
console.error('Failed to cancel refund:', err);
}
}, [cancelRefund]);
const handleRetryRefund = useCallback((refundId: string) => {
// For now, just show a message - in production this would re-process
alert(`Retry refund ${refundId} - This would re-process the refund request`);
}, []);
const handleViewInvoice = useCallback((invoiceId: string) => {
window.location.href = `/billing/invoices?invoiceId=${invoiceId}`;
}, []);
const handleRequestRefund = useCallback(async (data: {
subscriptionId: string;
amount: number;
reason: RefundReason;
reasonDetails?: string;
refundMethod: 'original' | 'wallet';
}) => {
await requestRefund.mutateAsync({
subscriptionId: data.subscriptionId,
amount: data.amount,
reason: data.reason,
reasonDetails: data.reasonDetails,
refundMethod: data.refundMethod,
});
}, [requestRefund]);
const clearFilters = useCallback(() => {
setStatusFilter('all');
setCurrentPage(1);
}, []);
const hasActiveFilters = statusFilter !== 'all';
// Calculate stats from current page data
const stats = {
total: data?.total ?? 0,
pending: data?.refunds.filter((r) => r.status === 'pending').length ?? 0,
completed: data?.refunds.filter((r) => r.status === 'completed').length ?? 0,
totalAmount: data?.refunds.reduce((sum, r) => sum + r.amount, 0) ?? 0,
};
// Map refunds to component format (already strings, just need to ensure correct shape)
const mappedRefunds: Refund[] = (data?.refunds ?? []).map((r) => ({
id: r.id,
subscriptionId: r.subscriptionId,
subscriptionName: r.subscriptionName,
invoiceId: r.invoiceId,
amount: r.amount,
currency: r.currency,
reason: r.reason,
reasonDetails: r.reasonDetails,
status: r.status,
refundMethod: r.refundMethod,
requestedAt: r.requestedAt,
processedAt: r.processedAt,
failureReason: r.failureReason,
transactionId: r.transactionId,
}));
return (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/billing"
className="p-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<div className="p-2 bg-blue-600/20 rounded-lg">
<RotateCcw className="w-6 h-6 text-blue-400" />
</div>
Refunds
</h1>
<p className="text-gray-400 mt-1">
View and manage your refund requests
</p>
</div>
</div>
<div className="flex items-center gap-3">
{eligibility?.eligible && currentSubscription && (
<button
onClick={() => setShowRefundModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
<RotateCcw className="w-4 h-4" />
Request Refund
</button>
)}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
showFilters || hasActiveFilters
? 'bg-blue-600/20 text-blue-400 border border-blue-600/50'
: 'bg-gray-800 text-gray-400 hover:text-white border border-gray-700'
}`}
>
<Filter className="w-4 h-4" />
Filters
{hasActiveFilters && (
<span className="w-2 h-2 bg-blue-400 rounded-full" />
)}
</button>
<button
onClick={() => refetch()}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-white rounded-lg transition-colors border border-gray-700 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{/* Filters Panel */}
{showFilters && (
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-white font-medium">Filter Refunds</h3>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
Clear filters
</button>
)}
</div>
<div className="flex flex-wrap gap-2">
{STATUS_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => {
setStatusFilter(option.value as RefundStatus | 'all');
setCurrentPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
statusFilter === option.value
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white border border-gray-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)}
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4">
<div className="flex items-center gap-2 text-gray-400 text-sm mb-1">
<RotateCcw className="w-4 h-4" />
Total Requests
</div>
<p className="text-2xl font-bold text-white">{stats.total}</p>
</div>
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4">
<div className="flex items-center gap-2 text-amber-400 text-sm mb-1">
<Clock className="w-4 h-4" />
Pending
</div>
<p className="text-2xl font-bold text-amber-400">{stats.pending}</p>
</div>
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4">
<div className="flex items-center gap-2 text-green-400 text-sm mb-1">
<CheckCircle className="w-4 h-4" />
Completed
</div>
<p className="text-2xl font-bold text-green-400">{stats.completed}</p>
</div>
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-4">
<p className="text-gray-400 text-sm">Total Refunded</p>
<p className="text-2xl font-bold text-white mt-1">
${stats.totalAmount.toLocaleString('en-US', { minimumFractionDigits: 2 })}
</p>
</div>
</div>
{/* Eligibility Notice */}
{currentSubscription && eligibility && (
<div className={`p-4 rounded-xl border ${
eligibility.eligible
? 'bg-blue-900/20 border-blue-800/50'
: 'bg-gray-800/50 border-gray-700'
}`}>
<div className="flex items-start gap-3">
{eligibility.eligible ? (
<CheckCircle className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
) : (
<XCircle className="w-5 h-5 text-gray-500 flex-shrink-0 mt-0.5" />
)}
<div>
<p className={eligibility.eligible ? 'text-blue-400 font-medium' : 'text-gray-400'}>
{eligibility.eligible
? `You are eligible for a refund of up to $${eligibility.refundableAmount.toFixed(2)}`
: 'You are not currently eligible for a refund'}
</p>
{eligibility.eligible && (
<p className="text-gray-500 text-sm mt-1">
{eligibility.daysRemaining} days remaining in the {eligibility.maxRefundDays}-day refund window
</p>
)}
{!eligibility.eligible && eligibility.reason && (
<p className="text-gray-500 text-sm mt-1">{eligibility.reason}</p>
)}
</div>
</div>
</div>
)}
{/* Refund List */}
{error ? (
<div className="bg-gray-800/50 border border-gray-700 rounded-xl p-12 text-center">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
<p className="text-gray-400 mb-4">
{error instanceof Error ? error.message : 'Failed to load refunds'}
</p>
<button
onClick={() => refetch()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm"
>
Retry
</button>
</div>
) : (
<RefundList
refunds={mappedRefunds}
totalCount={data?.total ?? 0}
currentPage={currentPage}
pageSize={ITEMS_PER_PAGE}
onPageChange={handlePageChange}
onViewInvoice={handleViewInvoice}
onRetryRefund={handleRetryRefund}
onCancelRefund={handleCancelRefund}
isLoading={isLoading}
showFilters={false}
/>
)}
{/* Refund Request Modal */}
{showRefundModal && currentSubscription && eligibility && (
<RefundRequestModal
isOpen={showRefundModal}
onClose={() => setShowRefundModal(false)}
subscriptionId={currentSubscription.id}
subscriptionName={currentSubscription.plan?.name ?? currentSubscription.planName}
eligibility={eligibility}
onSubmit={handleRequestRefund}
onSuccess={() => {
setShowRefundModal(false);
refetch();
}}
/>
)}
{/* Loading indicator for mutations */}
{(cancelRefund.isPending || requestRefund.isPending) && (
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg shadow-xl">
<Loader2 className="w-4 h-4 text-blue-400 animate-spin" />
<span className="text-white text-sm">
{cancelRefund.isPending ? 'Canceling refund...' : 'Processing refund request...'}
</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,839 @@
/**
* Allocation Optimizer Component
* Portfolio optimization using Modern Portfolio Theory
* with efficient frontier visualization and allocation suggestions
*/
import React, { useState, useMemo, useCallback } from 'react';
import {
SparklesIcon,
PlayIcon,
ArrowPathIcon,
AdjustmentsHorizontalIcon,
ChartBarIcon,
ArrowTrendingUpIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
InformationCircleIcon,
} from '@heroicons/react/24/solid';
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Cell,
} from 'recharts';
import type { PortfolioAllocation } from '../../../services/portfolio.service';
// ============================================================================
// Types
// ============================================================================
interface AllocationOptimizerProps {
portfolioId: string;
allocations: PortfolioAllocation[];
totalValue: number;
compact?: boolean;
}
interface OptimizationConstraints {
riskTolerance: number;
minWeight: number;
maxWeight: number;
includeStablecoins: boolean;
}
interface OptimalAllocation {
asset: string;
currentWeight: number;
optimalWeight: number;
difference: number;
expectedReturn: number;
volatility: number;
}
interface EfficientFrontierPoint {
risk: number;
return: number;
sharpeRatio: number;
isOptimal?: boolean;
isCurrent?: boolean;
}
interface OptimizationResult {
allocations: OptimalAllocation[];
expectedReturn: number;
expectedVolatility: number;
sharpeRatio: number;
currentReturn: number;
currentVolatility: number;
currentSharpe: number;
}
// ============================================================================
// Mock Data and Calculations
// ============================================================================
const ASSET_METRICS: Record<string, { expectedReturn: number; volatility: number }> = {
BTC: { expectedReturn: 0.35, volatility: 0.65 },
ETH: { expectedReturn: 0.40, volatility: 0.75 },
SOL: { expectedReturn: 0.55, volatility: 0.90 },
LINK: { expectedReturn: 0.30, volatility: 0.70 },
AVAX: { expectedReturn: 0.45, volatility: 0.85 },
ADA: { expectedReturn: 0.25, volatility: 0.80 },
DOT: { expectedReturn: 0.30, volatility: 0.75 },
MATIC: { expectedReturn: 0.35, volatility: 0.80 },
UNI: { expectedReturn: 0.28, volatility: 0.72 },
USDT: { expectedReturn: 0.05, volatility: 0.01 },
USDC: { expectedReturn: 0.05, volatility: 0.01 },
};
const DEFAULT_METRICS = { expectedReturn: 0.20, volatility: 0.50 };
function getAssetMetrics(asset: string) {
return ASSET_METRICS[asset] || DEFAULT_METRICS;
}
function runOptimization(
allocations: PortfolioAllocation[],
constraints: OptimizationConstraints
): OptimizationResult {
const { riskTolerance, minWeight, maxWeight, includeStablecoins } = constraints;
const riskFactor = riskTolerance / 100;
// Filter assets
const stablecoins = ['USDT', 'USDC', 'BUSD', 'DAI'];
const filteredAllocations = includeStablecoins
? allocations
: allocations.filter((a) => !stablecoins.includes(a.asset));
// Score each asset based on risk-adjusted returns
const scoredAssets = filteredAllocations.map((alloc) => {
const metrics = getAssetMetrics(alloc.asset);
// Higher risk tolerance favors higher returns, lower risk tolerance favors lower volatility
const score =
metrics.expectedReturn * (0.3 + riskFactor * 0.7) -
metrics.volatility * (1 - riskFactor) * 0.4;
return {
asset: alloc.asset,
currentWeight: alloc.currentPercent / 100,
metrics,
score: Math.max(0, score),
};
});
// Calculate optimal weights
const totalScore = scoredAssets.reduce((sum, a) => sum + a.score, 0);
let optimalAllocations: OptimalAllocation[] = scoredAssets.map((asset) => {
const rawWeight = totalScore > 0 ? asset.score / totalScore : 1 / scoredAssets.length;
const optimalWeight = Math.max(minWeight, Math.min(maxWeight, rawWeight));
return {
asset: asset.asset,
currentWeight: asset.currentWeight,
optimalWeight,
difference: optimalWeight - asset.currentWeight,
expectedReturn: asset.metrics.expectedReturn,
volatility: asset.metrics.volatility,
};
});
// Normalize weights
const totalWeight = optimalAllocations.reduce((sum, a) => sum + a.optimalWeight, 0);
optimalAllocations = optimalAllocations.map((a) => ({
...a,
optimalWeight: a.optimalWeight / totalWeight,
difference: a.optimalWeight / totalWeight - a.currentWeight,
}));
// Calculate portfolio metrics
const expectedReturn = optimalAllocations.reduce(
(sum, a) => sum + a.optimalWeight * a.expectedReturn,
0
);
const expectedVolatility = Math.sqrt(
optimalAllocations.reduce(
(sum, a) => sum + Math.pow(a.optimalWeight * a.volatility, 2),
0
) * 0.7 // Simplified correlation adjustment
);
const currentReturn = optimalAllocations.reduce(
(sum, a) => sum + a.currentWeight * a.expectedReturn,
0
);
const currentVolatility = Math.sqrt(
optimalAllocations.reduce(
(sum, a) => sum + Math.pow(a.currentWeight * a.volatility, 2),
0
) * 0.7
);
const riskFreeRate = 0.05;
const sharpeRatio = expectedVolatility > 0
? (expectedReturn - riskFreeRate) / expectedVolatility
: 0;
const currentSharpe = currentVolatility > 0
? (currentReturn - riskFreeRate) / currentVolatility
: 0;
return {
allocations: optimalAllocations.sort((a, b) => b.optimalWeight - a.optimalWeight),
expectedReturn,
expectedVolatility,
sharpeRatio,
currentReturn,
currentVolatility,
currentSharpe,
};
}
function generateEfficientFrontier(
result: OptimizationResult
): EfficientFrontierPoint[] {
const points: EfficientFrontierPoint[] = [];
const riskFreeRate = 0.05;
// Generate frontier curve
for (let i = 0; i <= 20; i++) {
const risk = 0.05 + (i / 20) * 0.55; // 5% to 60% volatility
// Simplified efficient frontier formula
const returnValue = riskFreeRate + risk * 0.45 + Math.pow(risk, 0.8) * 0.1;
points.push({
risk: risk * 100,
return: returnValue * 100,
sharpeRatio: (returnValue - riskFreeRate) / risk,
});
}
// Add current portfolio point
points.push({
risk: result.currentVolatility * 100,
return: result.currentReturn * 100,
sharpeRatio: result.currentSharpe,
isCurrent: true,
});
// Add optimal portfolio point
points.push({
risk: result.expectedVolatility * 100,
return: result.expectedReturn * 100,
sharpeRatio: result.sharpeRatio,
isOptimal: true,
});
return points;
}
// ============================================================================
// Subcomponents
// ============================================================================
interface AllocationComparisonBarProps {
asset: string;
current: number;
optimal: number;
color: string;
}
const AllocationComparisonBar: React.FC<AllocationComparisonBarProps> = ({
asset,
current,
optimal,
color,
}) => {
const diff = optimal - current;
const maxPercent = Math.max(current, optimal, 30);
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-gray-900 dark:text-white">{asset}</span>
<div className="flex items-center gap-2">
<span className="text-gray-500 dark:text-gray-400">
{(current * 100).toFixed(1)}% / {(optimal * 100).toFixed(1)}%
</span>
<span
className={`text-xs font-medium px-1.5 py-0.5 rounded ${
Math.abs(diff) < 0.02
? 'bg-gray-100 dark:bg-gray-700 text-gray-600'
: diff > 0
? 'bg-green-100 dark:bg-green-900/30 text-green-600'
: 'bg-red-100 dark:bg-red-900/30 text-red-600'
}`}
>
{diff > 0 ? '+' : ''}{(diff * 100).toFixed(1)}%
</span>
</div>
</div>
<div className="relative h-3 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
{/* Current bar */}
<div
className="absolute top-0 left-0 h-full rounded-full opacity-50"
style={{
width: `${(current / maxPercent) * 100}%`,
backgroundColor: color,
}}
/>
{/* Optimal bar (outlined) */}
<div
className="absolute top-0 left-0 h-full rounded-full border-2"
style={{
width: `${(optimal / maxPercent) * 100}%`,
borderColor: color,
}}
/>
</div>
</div>
);
};
const ASSET_COLORS: Record<string, string> = {
BTC: '#F7931A',
ETH: '#627EEA',
SOL: '#9945FF',
LINK: '#2A5ADA',
AVAX: '#E84142',
ADA: '#0033AD',
DOT: '#E6007A',
MATIC: '#8247E5',
UNI: '#FF007A',
USDT: '#26A17B',
USDC: '#2775CA',
};
// ============================================================================
// Main Component
// ============================================================================
export const AllocationOptimizer: React.FC<AllocationOptimizerProps> = ({
portfolioId,
allocations,
totalValue,
compact = false,
}) => {
const [constraints, setConstraints] = useState<OptimizationConstraints>({
riskTolerance: 50,
minWeight: 0.02,
maxWeight: 0.40,
includeStablecoins: true,
});
const [isOptimizing, setIsOptimizing] = useState(false);
const [result, setResult] = useState<OptimizationResult | null>(null);
const [showSettings, setShowSettings] = useState(false);
// Run optimization
const handleOptimize = useCallback(() => {
setIsOptimizing(true);
// Simulate async operation
setTimeout(() => {
const optimizationResult = runOptimization(allocations, constraints);
setResult(optimizationResult);
setIsOptimizing(false);
}, 800);
}, [allocations, constraints]);
// Generate efficient frontier data
const efficientFrontier = useMemo(() => {
if (!result) return [];
return generateEfficientFrontier(result);
}, [result]);
// Improvement metrics
const improvements = useMemo(() => {
if (!result) return null;
return {
returnImprovement: result.expectedReturn - result.currentReturn,
riskReduction: result.currentVolatility - result.expectedVolatility,
sharpeImprovement: result.sharpeRatio - result.currentSharpe,
};
}, [result]);
if (compact) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<SparklesIcon className="w-5 h-5 text-amber-600" />
</div>
<h3 className="font-bold text-gray-900 dark:text-white">Optimizador</h3>
</div>
<button
onClick={handleOptimize}
disabled={isOptimizing}
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-700 disabled:bg-amber-400 text-white text-sm font-medium rounded-lg transition-colors flex items-center gap-1"
>
{isOptimizing ? (
<ArrowPathIcon className="w-4 h-4 animate-spin" />
) : (
<PlayIcon className="w-4 h-4" />
)}
Optimizar
</button>
</div>
{result ? (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg text-center">
<p className="text-xs text-gray-500 dark:text-gray-400">Sharpe Actual</p>
<p className="text-lg font-bold text-gray-900 dark:text-white">
{result.currentSharpe.toFixed(2)}
</p>
</div>
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-center">
<p className="text-xs text-amber-600">Sharpe Optimo</p>
<p className="text-lg font-bold text-amber-600">
{result.sharpeRatio.toFixed(2)}
</p>
</div>
</div>
{improvements && improvements.sharpeImprovement > 0 && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircleIcon className="w-4 h-4" />
<span>+{(improvements.sharpeImprovement * 100).toFixed(0)}% mejora potencial</span>
</div>
)}
</div>
) : (
<div className="text-center py-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
Ejecuta el optimizador para ver sugerencias
</p>
</div>
)}
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<SparklesIcon className="w-6 h-6 text-amber-600" />
</div>
<div>
<h3 className="font-bold text-gray-900 dark:text-white">
Optimizador de Allocacion
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Teoria Moderna de Portafolios (MPT)
</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-2 rounded-lg transition-colors ${
showSettings
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-600'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<AdjustmentsHorizontalIcon className="w-5 h-5" />
</button>
<button
onClick={handleOptimize}
disabled={isOptimizing}
className="px-4 py-2 bg-amber-600 hover:bg-amber-700 disabled:bg-amber-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
>
{isOptimizing ? (
<>
<ArrowPathIcon className="w-5 h-5 animate-spin" />
Optimizando...
</>
) : (
<>
<PlayIcon className="w-5 h-5" />
Optimizar
</>
)}
</button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div className="p-6 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-gray-900 dark:text-white mb-4">
Restricciones de Optimizacion
</h4>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Risk Tolerance */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tolerancia al Riesgo: {constraints.riskTolerance}%
</label>
<input
type="range"
min="0"
max="100"
value={constraints.riskTolerance}
onChange={(e) =>
setConstraints({ ...constraints, riskTolerance: Number(e.target.value) })
}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Conservador</span>
<span>Agresivo</span>
</div>
</div>
{/* Min Weight */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Peso Minimo: {(constraints.minWeight * 100).toFixed(0)}%
</label>
<input
type="range"
min="0"
max="20"
value={constraints.minWeight * 100}
onChange={(e) =>
setConstraints({ ...constraints, minWeight: Number(e.target.value) / 100 })
}
className="w-full"
/>
</div>
{/* Max Weight */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Peso Maximo: {(constraints.maxWeight * 100).toFixed(0)}%
</label>
<input
type="range"
min="20"
max="100"
value={constraints.maxWeight * 100}
onChange={(e) =>
setConstraints({ ...constraints, maxWeight: Number(e.target.value) / 100 })
}
className="w-full"
/>
</div>
{/* Include Stablecoins */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Incluir Stablecoins
</label>
<button
onClick={() =>
setConstraints({ ...constraints, includeStablecoins: !constraints.includeStablecoins })
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
constraints.includeStablecoins ? 'bg-amber-600' : 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
constraints.includeStablecoins ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
)}
{/* Content */}
<div className="p-6">
{result ? (
<>
{/* Improvement Summary */}
{improvements && (
<div className="grid md:grid-cols-3 gap-4 mb-6">
<div className={`p-4 rounded-lg border ${
improvements.returnImprovement > 0
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600'
}`}>
<div className="flex items-center gap-2 mb-1">
<ArrowTrendingUpIcon className={`w-5 h-5 ${
improvements.returnImprovement > 0 ? 'text-green-500' : 'text-gray-400'
}`} />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Retorno Esperado
</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{(result.expectedReturn * 100).toFixed(1)}%
</p>
{improvements.returnImprovement > 0 && (
<p className="text-xs text-green-600 mt-1">
+{(improvements.returnImprovement * 100).toFixed(1)}% vs actual
</p>
)}
</div>
<div className={`p-4 rounded-lg border ${
improvements.riskReduction > 0
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600'
}`}>
<div className="flex items-center gap-2 mb-1">
<ExclamationTriangleIcon className={`w-5 h-5 ${
improvements.riskReduction > 0 ? 'text-blue-500' : 'text-gray-400'
}`} />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Volatilidad
</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{(result.expectedVolatility * 100).toFixed(1)}%
</p>
{improvements.riskReduction > 0 && (
<p className="text-xs text-blue-600 mt-1">
-{(improvements.riskReduction * 100).toFixed(1)}% vs actual
</p>
)}
</div>
<div className={`p-4 rounded-lg border ${
improvements.sharpeImprovement > 0
? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'
: 'bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600'
}`}>
<div className="flex items-center gap-2 mb-1">
<ChartBarIcon className={`w-5 h-5 ${
improvements.sharpeImprovement > 0 ? 'text-amber-500' : 'text-gray-400'
}`} />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Sharpe Ratio
</span>
</div>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{result.sharpeRatio.toFixed(2)}
</p>
{improvements.sharpeImprovement > 0 && (
<p className="text-xs text-amber-600 mt-1">
+{improvements.sharpeImprovement.toFixed(2)} vs actual ({result.currentSharpe.toFixed(2)})
</p>
)}
</div>
</div>
)}
{/* Efficient Frontier Chart */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Frontera Eficiente
</h4>
<div className="h-64 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 30, bottom: 10, left: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />
<XAxis
dataKey="risk"
type="number"
domain={[0, 60]}
name="Riesgo"
unit="%"
stroke="#9CA3AF"
fontSize={12}
/>
<YAxis
dataKey="return"
type="number"
domain={[0, 50]}
name="Retorno"
unit="%"
stroke="#9CA3AF"
fontSize={12}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1F2937',
border: 'none',
borderRadius: '8px',
color: '#fff',
}}
formatter={(value: number, name: string) => [
`${value.toFixed(1)}%`,
name === 'risk' ? 'Riesgo' : 'Retorno',
]}
/>
{/* Frontier Line */}
<Scatter
data={efficientFrontier.filter((p) => !p.isCurrent && !p.isOptimal)}
fill="#6366F1"
line={{ stroke: '#6366F1', strokeWidth: 2 }}
shape="circle"
/>
{/* Current Portfolio */}
<Scatter
data={efficientFrontier.filter((p) => p.isCurrent)}
fill="#EF4444"
shape="star"
>
{efficientFrontier
.filter((p) => p.isCurrent)
.map((_, index) => (
<Cell key={index} fill="#EF4444" />
))}
</Scatter>
{/* Optimal Portfolio */}
<Scatter
data={efficientFrontier.filter((p) => p.isOptimal)}
fill="#10B981"
shape="diamond"
>
{efficientFrontier
.filter((p) => p.isOptimal)
.map((_, index) => (
<Cell key={index} fill="#10B981" />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex justify-center gap-6 mt-3 text-xs">
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-indigo-500 rounded-full" />
<span className="text-gray-600 dark:text-gray-400">Frontera Eficiente</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-gray-600 dark:text-gray-400">Portfolio Actual</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-green-500 rounded-full" />
<span className="text-gray-600 dark:text-gray-400">Portfolio Optimo</span>
</div>
</div>
</div>
{/* Allocation Comparison */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
Comparacion de Allocacion (Actual vs Optima)
</h4>
<div className="space-y-4">
{result.allocations.map((alloc) => (
<AllocationComparisonBar
key={alloc.asset}
asset={alloc.asset}
current={alloc.currentWeight}
optimal={alloc.optimalWeight}
color={ASSET_COLORS[alloc.asset] || '#6366F1'}
/>
))}
</div>
</div>
{/* Recommendations Table */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Ajustes Recomendados
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="text-left py-2 px-3 text-gray-500 dark:text-gray-400 font-medium">
Activo
</th>
<th className="text-right py-2 px-3 text-gray-500 dark:text-gray-400 font-medium">
Actual
</th>
<th className="text-right py-2 px-3 text-gray-500 dark:text-gray-400 font-medium">
Optimo
</th>
<th className="text-right py-2 px-3 text-gray-500 dark:text-gray-400 font-medium">
Cambio
</th>
<th className="text-right py-2 px-3 text-gray-500 dark:text-gray-400 font-medium">
Monto USD
</th>
</tr>
</thead>
<tbody>
{result.allocations
.filter((a) => Math.abs(a.difference) >= 0.01)
.map((alloc) => {
const changeUSD = alloc.difference * totalValue;
return (
<tr
key={alloc.asset}
className="border-b border-gray-100 dark:border-gray-700/50"
>
<td className="py-2 px-3 font-medium text-gray-900 dark:text-white">
{alloc.asset}
</td>
<td className="py-2 px-3 text-right text-gray-600 dark:text-gray-400">
{(alloc.currentWeight * 100).toFixed(1)}%
</td>
<td className="py-2 px-3 text-right text-gray-900 dark:text-white">
{(alloc.optimalWeight * 100).toFixed(1)}%
</td>
<td className={`py-2 px-3 text-right font-medium ${
alloc.difference > 0 ? 'text-green-500' : 'text-red-500'
}`}>
{alloc.difference > 0 ? '+' : ''}{(alloc.difference * 100).toFixed(1)}%
</td>
<td className={`py-2 px-3 text-right font-medium ${
changeUSD > 0 ? 'text-green-500' : 'text-red-500'
}`}>
{changeUSD > 0 ? '+' : ''}${Math.abs(changeUSD).toLocaleString(undefined, { maximumFractionDigits: 0 })}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Info Footer */}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium">
Sobre la Optimizacion
</p>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
Esta optimizacion utiliza la Teoria Moderna de Portafolios (Markowitz) para
encontrar la combinacion de activos que maximiza el retorno ajustado por riesgo.
Los resultados son estimaciones basadas en datos historicos y no garantizan
rendimientos futuros.
</p>
</div>
</div>
</div>
</>
) : (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<SparklesIcon className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
Optimiza tu Portfolio
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-md mx-auto mb-4">
El optimizador analiza tu portfolio actual y sugiere una allocacion
optima basada en la Teoria Moderna de Portafolios, maximizando el
ratio de Sharpe segun tu tolerancia al riesgo.
</p>
<button
onClick={handleOptimize}
disabled={isOptimizing}
className="px-6 py-2 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg"
>
Ejecutar Optimizacion
</button>
</div>
)}
</div>
</div>
);
};
export default AllocationOptimizer;

View File

@ -4,6 +4,7 @@
*/ */
export { AllocationChart } from './AllocationChart'; export { AllocationChart } from './AllocationChart';
export { AllocationOptimizer } from './AllocationOptimizer';
export { AllocationTable } from './AllocationTable'; export { AllocationTable } from './AllocationTable';
export { AllocationsCard } from './AllocationsCard'; export { AllocationsCard } from './AllocationsCard';
export { CorrelationMatrix } from './CorrelationMatrix'; export { CorrelationMatrix } from './CorrelationMatrix';

View File

@ -0,0 +1,45 @@
/**
* Portfolio Hooks Index
* Export all portfolio-related hooks
*/
export { useMonteCarloSimulation } from './useMonteCarloSimulation';
export type {
MonteCarloParams,
SimulationResult,
UseMonteCarloSimulationOptions,
} from './useMonteCarloSimulation';
export { useRebalancing } from './useRebalancing';
export type {
RebalanceSettings,
RebalanceHistoryItem,
RebalanceTrade,
UseRebalancingOptions,
} from './useRebalancing';
export { usePortfolioGoals, useGoalDetail } from './usePortfolioGoals';
export type {
GoalStats,
UpdateGoalInput,
UsePortfolioGoalsOptions,
} from './usePortfolioGoals';
export { useRiskMetrics } from './useRiskMetrics';
export type {
RiskLevel,
RiskRating,
DrawdownPeriod,
RiskContribution,
StressTestScenario,
CorrelationPair,
} from './useRiskMetrics';
export { useOptimization } from './useOptimization';
export type {
OptimizationConstraints,
OptimalAllocation,
EfficientFrontierPoint,
OptimizationResult,
UseOptimizationOptions,
} from './useOptimization';

View File

@ -0,0 +1,255 @@
/**
* useMonteCarloSimulation Hook
* Handles Monte Carlo simulation API calls and state management
*/
import { useState, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
// ============================================================================
// Types
// ============================================================================
export interface MonteCarloParams {
numSimulations: number;
timeHorizon: number;
targetReturn: number;
annualReturn?: number;
volatility?: number;
}
export interface SimulationResult {
paths: number[][];
finalValues: number[];
percentiles: {
p5: number;
p10: number;
p25: number;
p50: number;
p75: number;
p90: number;
p95: number;
};
var95: number;
var99: number;
cvar95: number;
expectedValue: number;
medianValue: number;
probabilityOfLoss: number;
probabilityOfTarget: number;
simulationDate: string;
}
export interface UseMonteCarloSimulationOptions {
onSuccess?: (result: SimulationResult) => void;
onError?: (error: Error) => void;
}
// ============================================================================
// API Functions
// ============================================================================
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3080';
async function runMonteCarloSimulation(
portfolioId: string,
params: MonteCarloParams
): Promise<SimulationResult> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/monte-carlo`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(params),
}
);
if (!response.ok) {
throw new Error('Failed to run Monte Carlo simulation');
}
const data = await response.json();
return data.data || data;
}
async function getLastSimulation(portfolioId: string): Promise<SimulationResult | null> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/monte-carlo/latest`,
{ credentials: 'include' }
);
if (!response.ok) {
if (response.status === 404) return null;
throw new Error('Failed to fetch last simulation');
}
const data = await response.json();
return data.data || data;
}
// ============================================================================
// Hook
// ============================================================================
export function useMonteCarloSimulation(
portfolioId: string,
options: UseMonteCarloSimulationOptions = {}
) {
const [localResult, setLocalResult] = useState<SimulationResult | null>(null);
// Fetch last simulation result
const lastSimulationQuery = useQuery({
queryKey: ['monte-carlo', portfolioId, 'latest'],
queryFn: () => getLastSimulation(portfolioId),
enabled: !!portfolioId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Run simulation mutation
const simulationMutation = useMutation({
mutationFn: (params: MonteCarloParams) =>
runMonteCarloSimulation(portfolioId, params),
onSuccess: (result) => {
setLocalResult(result);
options.onSuccess?.(result);
},
onError: (error: Error) => {
options.onError?.(error);
},
});
// Run simulation locally (for faster response without API)
const runLocalSimulation = useCallback(
(
currentValue: number,
params: MonteCarloParams
): SimulationResult => {
const {
numSimulations,
timeHorizon,
targetReturn,
annualReturn = 0.12,
volatility = 0.25,
} = params;
// Daily parameters
const tradingDays = timeHorizon;
const dailyReturn = annualReturn / 252;
const dailyVolatility = volatility / Math.sqrt(252);
const paths: number[][] = [];
const finalValues: number[] = [];
// Run simulations
for (let sim = 0; sim < numSimulations; sim++) {
const path: number[] = [currentValue];
let value = currentValue;
for (let day = 1; day <= tradingDays; day++) {
// Box-Muller transform for normal distribution
const u1 = Math.random();
const u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
// Geometric Brownian Motion
const drift = dailyReturn - 0.5 * dailyVolatility * dailyVolatility;
const diffusion = dailyVolatility * z;
value = value * Math.exp(drift + diffusion);
// Store path points for visualization (first 100 paths only)
if (sim < 100 && day % Math.max(1, Math.floor(tradingDays / 50)) === 0) {
path.push(value);
}
}
if (sim < 100) {
paths.push(path);
}
finalValues.push(value);
}
// Sort for percentile calculations
finalValues.sort((a, b) => a - b);
const getPercentile = (p: number): number => {
const index = Math.floor((p / 100) * finalValues.length);
return finalValues[Math.min(index, finalValues.length - 1)];
};
const percentiles = {
p5: getPercentile(5),
p10: getPercentile(10),
p25: getPercentile(25),
p50: getPercentile(50),
p75: getPercentile(75),
p90: getPercentile(90),
p95: getPercentile(95),
};
// VaR calculations
const var95 = currentValue - getPercentile(5);
const var99 = currentValue - getPercentile(1);
// CVaR (Expected Shortfall)
const var95Index = Math.floor(0.05 * finalValues.length);
const worstValues = finalValues.slice(0, var95Index);
const cvar95 =
currentValue -
(worstValues.reduce((a, b) => a + b, 0) / worstValues.length);
// Statistics
const expectedValue =
finalValues.reduce((a, b) => a + b, 0) / finalValues.length;
const probabilityOfLoss =
(finalValues.filter((v) => v < currentValue).length / finalValues.length) *
100;
const targetValue = currentValue * (1 + targetReturn / 100);
const probabilityOfTarget =
(finalValues.filter((v) => v >= targetValue).length / finalValues.length) *
100;
const result: SimulationResult = {
paths,
finalValues,
percentiles,
var95,
var99,
cvar95,
expectedValue,
medianValue: percentiles.p50,
probabilityOfLoss,
probabilityOfTarget,
simulationDate: new Date().toISOString(),
};
setLocalResult(result);
return result;
},
[]
);
return {
// Data
result: localResult || lastSimulationQuery.data,
lastSimulation: lastSimulationQuery.data,
// Loading states
isLoading: lastSimulationQuery.isLoading,
isSimulating: simulationMutation.isPending,
// Error states
error: lastSimulationQuery.error || simulationMutation.error,
// Actions
runSimulation: simulationMutation.mutate,
runSimulationAsync: simulationMutation.mutateAsync,
runLocalSimulation,
clearResult: () => setLocalResult(null),
// Refetch
refetch: lastSimulationQuery.refetch,
};
}
export default useMonteCarloSimulation;

View File

@ -0,0 +1,363 @@
/**
* useOptimization Hook
* Handles portfolio optimization calculations using Modern Portfolio Theory
*/
import { useState, useCallback, useMemo } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
// ============================================================================
// Types
// ============================================================================
export interface OptimizationConstraints {
riskTolerance: number; // 0-100 scale
minWeight: number; // Minimum weight per asset (0-1)
maxWeight: number; // Maximum weight per asset (0-1)
targetReturn?: number; // Optional target annual return
excludeAssets?: string[]; // Assets to exclude
includeStablecoins?: boolean; // Include stablecoins in optimization
maxAssets?: number; // Maximum number of assets
}
export interface OptimalAllocation {
asset: string;
weight: number;
expectedReturn: number;
volatility: number;
}
export interface EfficientFrontierPoint {
return: number;
risk: number;
sharpeRatio: number;
allocations: OptimalAllocation[];
}
export interface OptimizationResult {
optimalPortfolio: OptimalAllocation[];
expectedReturn: number;
expectedVolatility: number;
sharpeRatio: number;
efficientFrontier: EfficientFrontierPoint[];
currentComparison: {
currentReturn: number;
currentVolatility: number;
currentSharpe: number;
improvementReturn: number;
improvementRisk: number;
improvementSharpe: number;
};
optimizedAt: string;
}
export interface UseOptimizationOptions {
onOptimizeSuccess?: (result: OptimizationResult) => void;
onOptimizeError?: (error: Error) => void;
}
// ============================================================================
// API Functions
// ============================================================================
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3080';
async function runOptimization(
portfolioId: string,
constraints: OptimizationConstraints
): Promise<OptimizationResult> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/optimize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(constraints),
}
);
if (!response.ok) {
throw new Error('Failed to run portfolio optimization');
}
const data = await response.json();
return data.data || data;
}
async function getLastOptimization(
portfolioId: string
): Promise<OptimizationResult | null> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/optimize/latest`,
{ credentials: 'include' }
);
if (!response.ok) {
if (response.status === 404) return null;
throw new Error('Failed to fetch last optimization');
}
const data = await response.json();
return data.data || data;
}
async function getEfficientFrontier(
portfolioId: string,
points: number = 20
): Promise<EfficientFrontierPoint[]> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/efficient-frontier?points=${points}`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch efficient frontier');
}
const data = await response.json();
return data.data || data;
}
// ============================================================================
// Local Optimization (Simplified Mean-Variance)
// ============================================================================
interface AssetData {
asset: string;
expectedReturn: number;
volatility: number;
weight: number;
}
function generateLocalOptimization(
currentAllocations: AssetData[],
constraints: OptimizationConstraints
): OptimizationResult {
const { riskTolerance, minWeight, maxWeight, excludeAssets = [] } = constraints;
// Filter out excluded assets
const filteredAssets = currentAllocations.filter(
(a) => !excludeAssets.includes(a.asset)
);
// Simple optimization based on risk-adjusted returns
const riskFactor = riskTolerance / 100;
// Score each asset: higher expected return and lower volatility is better
const scoredAssets = filteredAssets.map((asset) => ({
...asset,
score:
asset.expectedReturn * (0.5 + riskFactor * 0.5) -
asset.volatility * (1 - riskFactor) * 0.5,
}));
// Sort by score
scoredAssets.sort((a, b) => b.score - a.score);
// Distribute weights
const totalScore = scoredAssets.reduce((sum, a) => sum + Math.max(0, a.score), 0);
let optimalAllocations: OptimalAllocation[] = scoredAssets.map((asset) => {
const rawWeight = totalScore > 0 ? Math.max(0, asset.score) / totalScore : 1 / scoredAssets.length;
const weight = Math.max(minWeight, Math.min(maxWeight, rawWeight));
return {
asset: asset.asset,
weight,
expectedReturn: asset.expectedReturn,
volatility: asset.volatility,
};
});
// Normalize weights to sum to 1
const totalWeight = optimalAllocations.reduce((sum, a) => sum + a.weight, 0);
optimalAllocations = optimalAllocations.map((a) => ({
...a,
weight: a.weight / totalWeight,
}));
// Calculate portfolio metrics
const expectedReturn = optimalAllocations.reduce(
(sum, a) => sum + a.weight * a.expectedReturn,
0
);
// Simplified volatility (ignoring correlations)
const expectedVolatility = Math.sqrt(
optimalAllocations.reduce(
(sum, a) => sum + Math.pow(a.weight * a.volatility, 2),
0
)
);
const riskFreeRate = 0.05; // 5% risk-free rate
const sharpeRatio =
expectedVolatility > 0
? (expectedReturn - riskFreeRate) / expectedVolatility
: 0;
// Calculate current portfolio metrics
const currentReturn = currentAllocations.reduce(
(sum, a) => sum + a.weight * a.expectedReturn,
0
);
const currentVolatility = Math.sqrt(
currentAllocations.reduce(
(sum, a) => sum + Math.pow(a.weight * a.volatility, 2),
0
)
);
const currentSharpe =
currentVolatility > 0
? (currentReturn - riskFreeRate) / currentVolatility
: 0;
// Generate simplified efficient frontier
const efficientFrontier: EfficientFrontierPoint[] = [];
for (let i = 0; i <= 10; i++) {
const targetRisk = (i / 10) * 0.4; // 0% to 40% volatility
const point: EfficientFrontierPoint = {
return: riskFreeRate + targetRisk * 0.5, // Simplified return/risk relationship
risk: targetRisk,
sharpeRatio: targetRisk > 0 ? (riskFreeRate + targetRisk * 0.5 - riskFreeRate) / targetRisk : 0,
allocations: optimalAllocations.map((a) => ({ ...a })),
};
efficientFrontier.push(point);
}
return {
optimalPortfolio: optimalAllocations,
expectedReturn,
expectedVolatility,
sharpeRatio,
efficientFrontier,
currentComparison: {
currentReturn,
currentVolatility,
currentSharpe,
improvementReturn: expectedReturn - currentReturn,
improvementRisk: currentVolatility - expectedVolatility,
improvementSharpe: sharpeRatio - currentSharpe,
},
optimizedAt: new Date().toISOString(),
};
}
// ============================================================================
// Hook
// ============================================================================
export function useOptimization(
portfolioId: string,
options: UseOptimizationOptions = {}
) {
const [localResult, setLocalResult] = useState<OptimizationResult | null>(null);
const [constraints, setConstraints] = useState<OptimizationConstraints>({
riskTolerance: 50,
minWeight: 0.02,
maxWeight: 0.4,
includeStablecoins: true,
});
// Fetch last optimization
const lastOptimizationQuery = useQuery({
queryKey: ['optimization', portfolioId, 'latest'],
queryFn: () => getLastOptimization(portfolioId),
enabled: !!portfolioId,
staleTime: 10 * 60 * 1000, // 10 minutes
});
// Fetch efficient frontier
const efficientFrontierQuery = useQuery({
queryKey: ['optimization', portfolioId, 'frontier'],
queryFn: () => getEfficientFrontier(portfolioId),
enabled: !!portfolioId,
staleTime: 30 * 60 * 1000, // 30 minutes
});
// Run optimization mutation
const optimizationMutation = useMutation({
mutationFn: (params: OptimizationConstraints) =>
runOptimization(portfolioId, params),
onSuccess: (result) => {
setLocalResult(result);
options.onOptimizeSuccess?.(result);
},
onError: (error: Error) => {
options.onOptimizeError?.(error);
},
});
// Run local optimization (for faster response)
const runLocalOptimization = useCallback(
(currentAllocations: AssetData[]): OptimizationResult => {
const result = generateLocalOptimization(currentAllocations, constraints);
setLocalResult(result);
return result;
},
[constraints]
);
// Update constraints
const updateConstraints = useCallback(
(updates: Partial<OptimizationConstraints>) => {
setConstraints((prev) => ({ ...prev, ...updates }));
},
[]
);
// Get active result (local or from API)
const result = useMemo(
() => localResult || lastOptimizationQuery.data,
[localResult, lastOptimizationQuery.data]
);
// Calculate weight differences between current and optimal
const weightDifferences = useCallback(
(currentAllocations: { asset: string; weight: number }[]) => {
if (!result?.optimalPortfolio) return [];
return result.optimalPortfolio.map((optimal) => {
const current = currentAllocations.find((c) => c.asset === optimal.asset);
return {
asset: optimal.asset,
currentWeight: current?.weight || 0,
optimalWeight: optimal.weight,
difference: optimal.weight - (current?.weight || 0),
};
});
},
[result]
);
return {
// Data
result,
lastOptimization: lastOptimizationQuery.data,
efficientFrontier: efficientFrontierQuery.data || result?.efficientFrontier || [],
constraints,
// Computed
weightDifferences,
// Loading states
isLoading: lastOptimizationQuery.isLoading,
isOptimizing: optimizationMutation.isPending,
isLoadingFrontier: efficientFrontierQuery.isLoading,
// Error states
error: lastOptimizationQuery.error || efficientFrontierQuery.error,
optimizationError: optimizationMutation.error,
// Actions
optimize: optimizationMutation.mutate,
optimizeAsync: optimizationMutation.mutateAsync,
runLocalOptimization,
updateConstraints,
clearResult: () => setLocalResult(null),
// Refetch
refetch: lastOptimizationQuery.refetch,
refetchFrontier: efficientFrontierQuery.refetch,
};
}
export default useOptimization;

View File

@ -0,0 +1,352 @@
/**
* usePortfolioGoals Hook
* Handles portfolio goals CRUD operations and state management
*/
import { useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type {
PortfolioGoal,
CreateGoalInput,
GoalProgress,
} from '../../../services/portfolio.service';
// ============================================================================
// Types
// ============================================================================
export interface GoalStats {
total: number;
onTrack: number;
atRisk: number;
behind: number;
totalTarget: number;
totalCurrent: number;
avgProgress: number;
}
export interface UpdateGoalInput {
name?: string;
targetAmount?: number;
targetDate?: string;
monthlyContribution?: number;
}
export interface UsePortfolioGoalsOptions {
onCreateSuccess?: (goal: PortfolioGoal) => void;
onUpdateSuccess?: (goal: PortfolioGoal) => void;
onDeleteSuccess?: (goalId: string) => void;
onError?: (error: Error) => void;
}
// ============================================================================
// API Functions
// ============================================================================
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3080';
async function getGoals(portfolioId?: string): Promise<PortfolioGoal[]> {
const url = portfolioId
? `${API_URL}/api/v1/portfolio/${portfolioId}/goals`
: `${API_URL}/api/v1/portfolio/goals`;
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) {
throw new Error('Failed to fetch goals');
}
const data = await response.json();
return data.data || data;
}
async function getGoal(goalId: string): Promise<PortfolioGoal> {
const response = await fetch(`${API_URL}/api/v1/portfolio/goals/${goalId}`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to fetch goal');
}
const data = await response.json();
return data.data || data;
}
async function getGoalProgress(goalId: string): Promise<GoalProgress> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/goals/${goalId}/progress`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch goal progress');
}
const data = await response.json();
return data.data || data;
}
async function createGoal(input: CreateGoalInput): Promise<PortfolioGoal> {
const response = await fetch(`${API_URL}/api/v1/portfolio/goals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error('Failed to create goal');
}
const data = await response.json();
return data.data || data;
}
async function updateGoal(
goalId: string,
updates: UpdateGoalInput
): Promise<PortfolioGoal> {
const response = await fetch(`${API_URL}/api/v1/portfolio/goals/${goalId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(updates),
});
if (!response.ok) {
throw new Error('Failed to update goal');
}
const data = await response.json();
return data.data || data;
}
async function updateGoalProgress(
goalId: string,
currentAmount: number
): Promise<PortfolioGoal> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/goals/${goalId}/progress`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ currentAmount }),
}
);
if (!response.ok) {
throw new Error('Failed to update goal progress');
}
const data = await response.json();
return data.data || data;
}
async function deleteGoal(goalId: string): Promise<void> {
const response = await fetch(`${API_URL}/api/v1/portfolio/goals/${goalId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to delete goal');
}
}
// ============================================================================
// Hook
// ============================================================================
export function usePortfolioGoals(
portfolioId?: string,
options: UsePortfolioGoalsOptions = {}
) {
const queryClient = useQueryClient();
const queryKey = portfolioId
? ['goals', portfolioId]
: ['goals', 'all'];
// Fetch goals
const goalsQuery = useQuery({
queryKey,
queryFn: () => getGoals(portfolioId),
staleTime: 2 * 60 * 1000, // 2 minutes
});
// Create goal mutation
const createMutation = useMutation({
mutationFn: createGoal,
onSuccess: (goal) => {
queryClient.invalidateQueries({ queryKey: ['goals'] });
options.onCreateSuccess?.(goal);
},
onError: (error: Error) => {
options.onError?.(error);
},
});
// Update goal mutation
const updateMutation = useMutation({
mutationFn: ({ goalId, updates }: { goalId: string; updates: UpdateGoalInput }) =>
updateGoal(goalId, updates),
onSuccess: (goal) => {
queryClient.invalidateQueries({ queryKey: ['goals'] });
queryClient.invalidateQueries({ queryKey: ['goal', goal.id] });
options.onUpdateSuccess?.(goal);
},
onError: (error: Error) => {
options.onError?.(error);
},
});
// Update progress mutation
const updateProgressMutation = useMutation({
mutationFn: ({ goalId, currentAmount }: { goalId: string; currentAmount: number }) =>
updateGoalProgress(goalId, currentAmount),
onSuccess: (goal) => {
queryClient.invalidateQueries({ queryKey: ['goals'] });
queryClient.invalidateQueries({ queryKey: ['goal', goal.id] });
options.onUpdateSuccess?.(goal);
},
onError: (error: Error) => {
options.onError?.(error);
},
});
// Delete goal mutation
const deleteMutation = useMutation({
mutationFn: deleteGoal,
onSuccess: (_, goalId) => {
queryClient.invalidateQueries({ queryKey: ['goals'] });
options.onDeleteSuccess?.(goalId);
},
onError: (error: Error) => {
options.onError?.(error);
},
});
// Calculate stats from goals
const calculateStats = useCallback((): GoalStats => {
const goals = goalsQuery.data || [];
const total = goals.length;
const onTrack = goals.filter((g) => g.status === 'on_track').length;
const atRisk = goals.filter((g) => g.status === 'at_risk').length;
const behind = goals.filter((g) => g.status === 'behind').length;
const totalTarget = goals.reduce((sum, g) => sum + g.targetAmount, 0);
const totalCurrent = goals.reduce((sum, g) => sum + g.currentAmount, 0);
const avgProgress = total > 0 ? (totalCurrent / totalTarget) * 100 : 0;
return {
total,
onTrack,
atRisk,
behind,
totalTarget,
totalCurrent,
avgProgress,
};
}, [goalsQuery.data]);
// Get goal by ID
const getGoalById = useCallback(
(goalId: string) => {
return goalsQuery.data?.find((g) => g.id === goalId);
},
[goalsQuery.data]
);
// Get goals by status
const getGoalsByStatus = useCallback(
(status: PortfolioGoal['status']) => {
return goalsQuery.data?.filter((g) => g.status === status) || [];
},
[goalsQuery.data]
);
// Sort goals by target date
const sortedByTargetDate = useCallback(() => {
if (!goalsQuery.data) return [];
return [...goalsQuery.data].sort(
(a, b) => new Date(a.targetDate).getTime() - new Date(b.targetDate).getTime()
);
}, [goalsQuery.data]);
// Sort goals by progress
const sortedByProgress = useCallback(() => {
if (!goalsQuery.data) return [];
return [...goalsQuery.data].sort((a, b) => b.progress - a.progress);
}, [goalsQuery.data]);
return {
// Data
goals: goalsQuery.data || [],
stats: calculateStats(),
// Helper functions
getGoalById,
getGoalsByStatus,
sortedByTargetDate,
sortedByProgress,
// Loading states
isLoading: goalsQuery.isLoading,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending || updateProgressMutation.isPending,
isDeleting: deleteMutation.isPending,
// Error states
error: goalsQuery.error,
createError: createMutation.error,
updateError: updateMutation.error || updateProgressMutation.error,
deleteError: deleteMutation.error,
// Actions
create: createMutation.mutate,
createAsync: createMutation.mutateAsync,
update: (goalId: string, updates: UpdateGoalInput) =>
updateMutation.mutate({ goalId, updates }),
updateAsync: (goalId: string, updates: UpdateGoalInput) =>
updateMutation.mutateAsync({ goalId, updates }),
updateProgress: (goalId: string, currentAmount: number) =>
updateProgressMutation.mutate({ goalId, currentAmount }),
updateProgressAsync: (goalId: string, currentAmount: number) =>
updateProgressMutation.mutateAsync({ goalId, currentAmount }),
remove: deleteMutation.mutate,
removeAsync: deleteMutation.mutateAsync,
// Refetch
refetch: goalsQuery.refetch,
};
}
// Single goal hook for detail views
export function useGoalDetail(goalId: string) {
const goalQuery = useQuery({
queryKey: ['goal', goalId],
queryFn: () => getGoal(goalId),
enabled: !!goalId,
staleTime: 2 * 60 * 1000,
});
const progressQuery = useQuery({
queryKey: ['goal', goalId, 'progress'],
queryFn: () => getGoalProgress(goalId),
enabled: !!goalId,
staleTime: 60 * 1000,
});
return {
goal: goalQuery.data,
progress: progressQuery.data,
isLoading: goalQuery.isLoading || progressQuery.isLoading,
error: goalQuery.error || progressQuery.error,
refetch: () => {
goalQuery.refetch();
progressQuery.refetch();
},
};
}
export default usePortfolioGoals;

View File

@ -0,0 +1,298 @@
/**
* useRebalancing Hook
* Handles portfolio rebalancing operations and state management
*/
import { useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type {
RebalanceRecommendation,
RebalanceCalculation,
Portfolio,
} from '../../../services/portfolio.service';
// ============================================================================
// Types
// ============================================================================
export interface RebalanceSettings {
threshold: number;
autoRebalance: boolean;
frequency: 'manual' | 'weekly' | 'monthly' | 'quarterly';
notifyOnDrift: boolean;
}
export interface RebalanceHistoryItem {
id: string;
portfolioId: string;
date: string;
tradesCount: number;
totalVolume: number;
driftBefore: number;
driftAfter: number;
status: 'completed' | 'partial' | 'failed';
trades: RebalanceTrade[];
}
export interface RebalanceTrade {
asset: string;
action: 'buy' | 'sell';
quantity: number;
price: number;
value: number;
}
export interface UseRebalancingOptions {
onExecuteSuccess?: (portfolio: Portfolio) => void;
onExecuteError?: (error: Error) => void;
}
// ============================================================================
// API Functions
// ============================================================================
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3080';
async function getRecommendations(portfolioId: string): Promise<RebalanceRecommendation[]> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/rebalance`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch rebalance recommendations');
}
const data = await response.json();
return data.data || data;
}
async function calculateRebalance(portfolioId: string): Promise<RebalanceCalculation> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/rebalance/calculate`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to calculate rebalance');
}
const data = await response.json();
return data.data || data;
}
async function executeRebalance(portfolioId: string): Promise<Portfolio> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/rebalance`,
{
method: 'POST',
credentials: 'include',
}
);
if (!response.ok) {
throw new Error('Failed to execute rebalance');
}
const data = await response.json();
return data.data || data;
}
async function getRebalanceHistory(portfolioId: string): Promise<RebalanceHistoryItem[]> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/rebalance/history`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch rebalance history');
}
const data = await response.json();
return data.data || data;
}
async function getRebalanceSettings(portfolioId: string): Promise<RebalanceSettings> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/rebalance/settings`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch rebalance settings');
}
const data = await response.json();
return data.data || data;
}
async function updateRebalanceSettings(
portfolioId: string,
settings: Partial<RebalanceSettings>
): Promise<RebalanceSettings> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/rebalance/settings`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(settings),
}
);
if (!response.ok) {
throw new Error('Failed to update rebalance settings');
}
const data = await response.json();
return data.data || data;
}
// ============================================================================
// Hook
// ============================================================================
export function useRebalancing(
portfolioId: string,
options: UseRebalancingOptions = {}
) {
const queryClient = useQueryClient();
// Fetch recommendations
const recommendationsQuery = useQuery({
queryKey: ['rebalance', portfolioId, 'recommendations'],
queryFn: () => getRecommendations(portfolioId),
enabled: !!portfolioId,
staleTime: 60 * 1000, // 1 minute
});
// Fetch calculation
const calculationQuery = useQuery({
queryKey: ['rebalance', portfolioId, 'calculation'],
queryFn: () => calculateRebalance(portfolioId),
enabled: !!portfolioId,
staleTime: 60 * 1000,
});
// Fetch history
const historyQuery = useQuery({
queryKey: ['rebalance', portfolioId, 'history'],
queryFn: () => getRebalanceHistory(portfolioId),
enabled: !!portfolioId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Fetch settings
const settingsQuery = useQuery({
queryKey: ['rebalance', portfolioId, 'settings'],
queryFn: () => getRebalanceSettings(portfolioId),
enabled: !!portfolioId,
staleTime: 10 * 60 * 1000, // 10 minutes
});
// Execute rebalance mutation
const executeMutation = useMutation({
mutationFn: () => executeRebalance(portfolioId),
onSuccess: (portfolio) => {
// Invalidate related queries
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
queryClient.invalidateQueries({ queryKey: ['rebalance', portfolioId] });
options.onExecuteSuccess?.(portfolio);
},
onError: (error: Error) => {
options.onExecuteError?.(error);
},
});
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (settings: Partial<RebalanceSettings>) =>
updateRebalanceSettings(portfolioId, settings),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['rebalance', portfolioId, 'settings'],
});
},
});
// Calculate max drift from recommendations
const maxDrift = useCallback(() => {
if (!recommendationsQuery.data) return 0;
return Math.max(
...recommendationsQuery.data.map((r) =>
Math.abs(r.currentPercent - r.targetPercent)
),
0
);
}, [recommendationsQuery.data]);
// Get active recommendations (excluding holds)
const activeRecommendations = useCallback(() => {
if (!recommendationsQuery.data) return [];
return recommendationsQuery.data.filter((r) => r.action !== 'hold');
}, [recommendationsQuery.data]);
// Check if rebalance is needed
const needsRebalance = useCallback(
(threshold: number = 5) => {
return maxDrift() > threshold;
},
[maxDrift]
);
// Calculate total trade volume
const totalTradeVolume = useCallback(() => {
const active = activeRecommendations();
return active.reduce((sum, r) => sum + r.amountUSD, 0);
}, [activeRecommendations]);
return {
// Data
recommendations: recommendationsQuery.data || [],
calculation: calculationQuery.data,
history: historyQuery.data || [],
settings: settingsQuery.data,
// Computed values
maxDrift: maxDrift(),
activeRecommendations: activeRecommendations(),
totalTradeVolume: totalTradeVolume(),
needsRebalance,
// Loading states
isLoading:
recommendationsQuery.isLoading ||
calculationQuery.isLoading ||
settingsQuery.isLoading,
isLoadingHistory: historyQuery.isLoading,
isExecuting: executeMutation.isPending,
isUpdatingSettings: updateSettingsMutation.isPending,
// Error states
error:
recommendationsQuery.error ||
calculationQuery.error ||
settingsQuery.error,
historyError: historyQuery.error,
// Actions
execute: executeMutation.mutate,
executeAsync: executeMutation.mutateAsync,
updateSettings: updateSettingsMutation.mutate,
updateSettingsAsync: updateSettingsMutation.mutateAsync,
// Refetch
refetchRecommendations: recommendationsQuery.refetch,
refetchCalculation: calculationQuery.refetch,
refetchHistory: historyQuery.refetch,
refetchSettings: settingsQuery.refetch,
refetchAll: () => {
recommendationsQuery.refetch();
calculationQuery.refetch();
historyQuery.refetch();
settingsQuery.refetch();
},
};
}
export default useRebalancing;

View File

@ -0,0 +1,381 @@
/**
* useRiskMetrics Hook
* Handles portfolio risk metrics and analysis
*/
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import type {
PortfolioMetrics,
PortfolioAllocation,
} from '../../../services/portfolio.service';
// ============================================================================
// Types
// ============================================================================
export type RiskLevel = 'low' | 'moderate' | 'high' | 'very-high';
export interface RiskRating {
level: RiskLevel;
score: number;
label: string;
color: string;
bgColor: string;
}
export interface DrawdownPeriod {
startDate: string;
endDate: string;
duration: number;
peakValue: number;
troughValue: number;
drawdownPercent: number;
recovered: boolean;
}
export interface RiskContribution {
asset: string;
weight: number;
volatility: number;
contribution: number;
contributionPercent: number;
}
export interface StressTestScenario {
name: string;
description: string;
impact: number;
impactPercent: number;
}
export interface CorrelationPair {
asset1: string;
asset2: string;
correlation: number;
}
// ============================================================================
// API Functions
// ============================================================================
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3080';
async function getPortfolioMetrics(portfolioId: string): Promise<PortfolioMetrics> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/metrics`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch portfolio metrics');
}
const data = await response.json();
return data.data || data;
}
async function getDrawdownHistory(portfolioId: string): Promise<DrawdownPeriod[]> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/drawdowns`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch drawdown history');
}
const data = await response.json();
return data.data || data;
}
async function getRiskContributions(
portfolioId: string
): Promise<RiskContribution[]> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/risk-contributions`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch risk contributions');
}
const data = await response.json();
return data.data || data;
}
async function getStressTests(portfolioId: string): Promise<StressTestScenario[]> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/stress-tests`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch stress tests');
}
const data = await response.json();
return data.data || data;
}
async function getCorrelationMatrix(
portfolioId: string
): Promise<{ assets: string[]; matrix: number[][] }> {
const response = await fetch(
`${API_URL}/api/v1/portfolio/${portfolioId}/correlations`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch correlation matrix');
}
const data = await response.json();
return data.data || data;
}
// ============================================================================
// Helper Functions
// ============================================================================
function calculateRiskRating(metrics: PortfolioMetrics | null): RiskRating {
if (!metrics) {
return {
level: 'moderate',
score: 50,
label: 'Moderado',
color: 'text-yellow-500',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
};
}
let score = 50;
// Volatility impact
if (metrics.volatility > 30) score += 20;
else if (metrics.volatility > 20) score += 10;
else if (metrics.volatility < 10) score -= 15;
// Sharpe ratio impact
if (metrics.sharpeRatio > 2) score -= 20;
else if (metrics.sharpeRatio > 1) score -= 10;
else if (metrics.sharpeRatio < 0.5) score += 15;
// Max drawdown impact
const absDrawdown = Math.abs(metrics.maxDrawdownPercent);
if (absDrawdown > 25) score += 20;
else if (absDrawdown > 15) score += 10;
else if (absDrawdown < 5) score -= 10;
// Beta impact
if (metrics.beta > 1.5) score += 15;
else if (metrics.beta > 1.2) score += 5;
else if (metrics.beta < 0.8) score -= 10;
score = Math.max(0, Math.min(100, score));
if (score >= 70) {
return {
level: 'very-high',
score,
label: 'Muy Alto',
color: 'text-red-600',
bgColor: 'bg-red-100 dark:bg-red-900/30',
};
} else if (score >= 50) {
return {
level: 'high',
score,
label: 'Alto',
color: 'text-orange-500',
bgColor: 'bg-orange-100 dark:bg-orange-900/30',
};
} else if (score >= 30) {
return {
level: 'moderate',
score,
label: 'Moderado',
color: 'text-yellow-500',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
};
} else {
return {
level: 'low',
score,
label: 'Bajo',
color: 'text-green-500',
bgColor: 'bg-green-100 dark:bg-green-900/30',
};
}
}
function getHighCorrelationPairs(
assets: string[],
matrix: number[][],
threshold: number = 0.7
): CorrelationPair[] {
const pairs: CorrelationPair[] = [];
for (let i = 0; i < matrix.length; i++) {
for (let j = i + 1; j < matrix.length; j++) {
if (matrix[i][j] >= threshold) {
pairs.push({
asset1: assets[i],
asset2: assets[j],
correlation: matrix[i][j],
});
}
}
}
return pairs.sort((a, b) => b.correlation - a.correlation);
}
function calculateDiversificationScore(matrix: number[][]): number {
if (matrix.length < 2) return 100;
let totalCorr = 0;
let count = 0;
for (let i = 0; i < matrix.length; i++) {
for (let j = i + 1; j < matrix.length; j++) {
totalCorr += matrix[i][j];
count++;
}
}
const avgCorr = count > 0 ? totalCorr / count : 0;
return Math.max(0, Math.min(100, (1 - avgCorr) * 100));
}
// ============================================================================
// Hook
// ============================================================================
export function useRiskMetrics(portfolioId: string) {
// Fetch metrics
const metricsQuery = useQuery({
queryKey: ['portfolio', portfolioId, 'metrics'],
queryFn: () => getPortfolioMetrics(portfolioId),
enabled: !!portfolioId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Fetch drawdowns
const drawdownsQuery = useQuery({
queryKey: ['portfolio', portfolioId, 'drawdowns'],
queryFn: () => getDrawdownHistory(portfolioId),
enabled: !!portfolioId,
staleTime: 10 * 60 * 1000, // 10 minutes
});
// Fetch risk contributions
const contributionsQuery = useQuery({
queryKey: ['portfolio', portfolioId, 'risk-contributions'],
queryFn: () => getRiskContributions(portfolioId),
enabled: !!portfolioId,
staleTime: 5 * 60 * 1000,
});
// Fetch stress tests
const stressTestsQuery = useQuery({
queryKey: ['portfolio', portfolioId, 'stress-tests'],
queryFn: () => getStressTests(portfolioId),
enabled: !!portfolioId,
staleTime: 30 * 60 * 1000, // 30 minutes
});
// Fetch correlations
const correlationsQuery = useQuery({
queryKey: ['portfolio', portfolioId, 'correlations'],
queryFn: () => getCorrelationMatrix(portfolioId),
enabled: !!portfolioId,
staleTime: 30 * 60 * 1000,
});
// Calculate risk rating
const riskRating = useMemo(
() => calculateRiskRating(metricsQuery.data || null),
[metricsQuery.data]
);
// Calculate diversification score
const diversificationScore = useMemo(() => {
if (!correlationsQuery.data?.matrix) return 0;
return calculateDiversificationScore(correlationsQuery.data.matrix);
}, [correlationsQuery.data]);
// Get high correlation pairs
const highCorrelationPairs = useMemo(() => {
if (!correlationsQuery.data) return [];
return getHighCorrelationPairs(
correlationsQuery.data.assets,
correlationsQuery.data.matrix
);
}, [correlationsQuery.data]);
// Get worst drawdown
const worstDrawdown = useMemo(() => {
if (!drawdownsQuery.data?.length) return null;
return drawdownsQuery.data.reduce((worst, current) =>
current.drawdownPercent < worst.drawdownPercent ? current : worst
);
}, [drawdownsQuery.data]);
// Get top risk contributors
const topRiskContributors = useMemo(() => {
if (!contributionsQuery.data) return [];
return [...contributionsQuery.data]
.sort((a, b) => b.contributionPercent - a.contributionPercent)
.slice(0, 5);
}, [contributionsQuery.data]);
return {
// Data
metrics: metricsQuery.data,
drawdowns: drawdownsQuery.data || [],
contributions: contributionsQuery.data || [],
stressTests: stressTestsQuery.data || [],
correlations: correlationsQuery.data,
// Computed values
riskRating,
diversificationScore,
highCorrelationPairs,
worstDrawdown,
topRiskContributors,
// Loading states
isLoading:
metricsQuery.isLoading ||
drawdownsQuery.isLoading ||
contributionsQuery.isLoading,
isLoadingStressTests: stressTestsQuery.isLoading,
isLoadingCorrelations: correlationsQuery.isLoading,
// Error states
error:
metricsQuery.error ||
drawdownsQuery.error ||
contributionsQuery.error,
stressTestsError: stressTestsQuery.error,
correlationsError: correlationsQuery.error,
// Refetch
refetchMetrics: metricsQuery.refetch,
refetchDrawdowns: drawdownsQuery.refetch,
refetchContributions: contributionsQuery.refetch,
refetchStressTests: stressTestsQuery.refetch,
refetchCorrelations: correlationsQuery.refetch,
refetchAll: () => {
metricsQuery.refetch();
drawdownsQuery.refetch();
contributionsQuery.refetch();
stressTestsQuery.refetch();
correlationsQuery.refetch();
},
};
}
export default useRiskMetrics;

View File

@ -8,3 +8,6 @@ export * from './components';
// Pages // Pages
export * from './pages'; export * from './pages';
// Hooks
export * from './hooks';

View File

@ -18,6 +18,10 @@ import {
ArrowTrendingDownIcon, ArrowTrendingDownIcon,
EllipsisVerticalIcon, EllipsisVerticalIcon,
TrashIcon, TrashIcon,
ShieldExclamationIcon,
FlagIcon,
SparklesIcon,
TableCellsIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
import { import {
@ -32,6 +36,18 @@ import { AllocationsCard } from '../components/AllocationsCard';
import { AllocationTable } from '../components/AllocationTable'; import { AllocationTable } from '../components/AllocationTable';
import { PerformanceMetricsCard } from '../components/PerformanceMetricsCard'; import { PerformanceMetricsCard } from '../components/PerformanceMetricsCard';
import { RebalanceModal } from '../components/RebalanceModal'; import { RebalanceModal } from '../components/RebalanceModal';
import { MonteCarloSimulator } from '../components/MonteCarloSimulator';
import { RebalancingPanel } from '../components/RebalancingPanel';
import { GoalsManager } from '../components/GoalsManager';
import { RiskAnalytics } from '../components/RiskAnalytics';
import { AllocationOptimizer } from '../components/AllocationOptimizer';
import { CorrelationMatrix } from '../components/CorrelationMatrix';
// ============================================================================
// Tab Types
// ============================================================================
type TabId = 'overview' | 'risk' | 'goals' | 'optimize' | 'simulate';
// ============================================================================ // ============================================================================
// Constants // Constants
@ -214,6 +230,38 @@ const PerformanceChartSection: React.FC<PerformanceChartProps> = ({
// Main Component // Main Component
// ============================================================================ // ============================================================================
// Mock goals data for demo
const generateMockGoals = () => [
{
id: 'g1',
userId: 'user-1',
name: 'Fondo de Emergencia',
targetAmount: 10000,
currentAmount: 6500,
targetDate: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000).toISOString(),
monthlyContribution: 500,
progress: 65,
projectedCompletion: new Date(Date.now() + 150 * 24 * 60 * 60 * 1000).toISOString(),
status: 'on_track' as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 'g2',
userId: 'user-1',
name: 'Vacaciones 2027',
targetAmount: 5000,
currentAmount: 1200,
targetDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
monthlyContribution: 300,
progress: 24,
projectedCompletion: new Date(Date.now() + 400 * 24 * 60 * 60 * 1000).toISOString(),
status: 'at_risk' as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
export default function PortfolioDetailPage() { export default function PortfolioDetailPage() {
const { portfolioId } = useParams<{ portfolioId: string }>(); const { portfolioId } = useParams<{ portfolioId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@ -227,6 +275,8 @@ export default function PortfolioDetailPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showRebalanceModal, setShowRebalanceModal] = useState(false); const [showRebalanceModal, setShowRebalanceModal] = useState(false);
const [showActions, setShowActions] = useState(false); const [showActions, setShowActions] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [goals] = useState(generateMockGoals());
// Store // Store
const { fetchPortfolios } = usePortfolioStore(); const { fetchPortfolios } = usePortfolioStore();
@ -398,7 +448,7 @@ export default function PortfolioDetailPage() {
Rebalancear Rebalancear
</button> </button>
<Link <Link
to={`/portfolio/${portfolioId}/add-position`} to={`/portfolio/${portfolioId}/edit`}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
> >
<PlusIcon className="w-5 h-5" /> <PlusIcon className="w-5 h-5" />
@ -471,81 +521,199 @@ export default function PortfolioDetailPage() {
/> />
</div> </div>
{/* Main Content Grid */} {/* Tab Navigation */}
<div className="grid lg:grid-cols-3 gap-8"> <div className="mb-8">
{/* Left Column: Allocations & Positions */} <div className="flex gap-1 bg-gray-100 dark:bg-gray-700 rounded-xl p-1.5 overflow-x-auto">
<div className="lg:col-span-2 space-y-8"> <button
{/* Allocations Chart */} onClick={() => setActiveTab('overview')}
<AllocationsCard className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
allocations={portfolio.allocations} activeTab === 'overview'
showTargetComparison={true} ? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
onRebalance={() => setShowRebalanceModal(true)} : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
maxDriftThreshold={5} }`}
/> >
<ChartBarIcon className="w-4 h-4" />
Resumen
</button>
<button
onClick={() => setActiveTab('risk')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === 'risk'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<ShieldExclamationIcon className="w-4 h-4" />
Riesgo
</button>
<button
onClick={() => setActiveTab('goals')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === 'goals'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<FlagIcon className="w-4 h-4" />
Metas
</button>
<button
onClick={() => setActiveTab('optimize')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === 'optimize'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<SparklesIcon className="w-4 h-4" />
Optimizar
</button>
<button
onClick={() => setActiveTab('simulate')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
activeTab === 'simulate'
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<TableCellsIcon className="w-4 h-4" />
Simular
</button>
</div>
</div>
{/* Positions Table */} {/* Tab Content */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6"> {activeTab === 'overview' && (
<div className="flex items-center justify-between mb-4"> <div className="grid lg:grid-cols-3 gap-8">
<h3 className="font-bold text-gray-900 dark:text-white"> {/* Left Column: Allocations & Positions */}
Detalle de Posiciones <div className="lg:col-span-2 space-y-8">
</h3> {/* Allocations Chart */}
<Link <AllocationsCard
to={`/portfolio/${portfolioId}/edit`}
className="text-sm text-blue-600 hover:text-blue-700"
>
Editar Allocaciones
</Link>
</div>
<AllocationTable
allocations={portfolio.allocations} allocations={portfolio.allocations}
showDeviation={true} showTargetComparison={true}
onRebalance={() => setShowRebalanceModal(true)}
maxDriftThreshold={5}
/>
{/* Positions Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 dark:text-white">
Detalle de Posiciones
</h3>
<Link
to={`/portfolio/${portfolioId}/edit`}
className="text-sm text-blue-600 hover:text-blue-700"
>
Editar Allocaciones
</Link>
</div>
<AllocationTable
allocations={portfolio.allocations}
showDeviation={true}
/>
</div>
</div>
{/* Right Column: Metrics */}
<div className="space-y-8">
<PerformanceMetricsCard
portfolioId={portfolio.id}
compact={false}
/>
{/* Portfolio Info */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<h3 className="font-bold text-gray-900 dark:text-white mb-4">
Informacion del Portfolio
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Perfil de Riesgo</span>
<span className="text-gray-900 dark:text-white">
{riskProfileLabels[portfolio.riskProfile]}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Ultimo Rebalanceo</span>
<span className="text-gray-900 dark:text-white">
{portfolio.lastRebalanced
? new Date(portfolio.lastRebalanced).toLocaleDateString()
: 'Nunca'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Creado</span>
<span className="text-gray-900 dark:text-white">
{new Date(portfolio.createdAt).toLocaleDateString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Actualizado</span>
<span className="text-gray-900 dark:text-white">
{new Date(portfolio.updatedAt).toLocaleString()}
</span>
</div>
</div>
</div>
</div>
</div>
)}
{/* Risk Tab */}
{activeTab === 'risk' && (
<div className="grid lg:grid-cols-2 gap-8">
<RiskAnalytics
portfolioId={portfolio.id}
allocations={portfolio.allocations}
/>
<div className="space-y-8">
<CorrelationMatrix allocations={portfolio.allocations} />
<RebalancingPanel
portfolioId={portfolio.id}
allocations={portfolio.allocations}
onRebalance={() => setShowRebalanceModal(true)}
lastRebalanced={portfolio.lastRebalanced}
/> />
</div> </div>
</div> </div>
)}
{/* Right Column: Metrics */} {/* Goals Tab */}
<div className="space-y-8"> {activeTab === 'goals' && (
<PerformanceMetricsCard <div className="max-w-4xl mx-auto">
portfolioId={portfolio.id} <GoalsManager
compact={false} goals={goals}
onCreateGoal={() => navigate(`/portfolio/${portfolioId}/goals/new`)}
onEditGoal={(goalId) => navigate(`/portfolio/${portfolioId}/goals/${goalId}/edit`)}
onDeleteGoal={(goalId) => console.log('Delete goal:', goalId)}
onUpdateProgress={(goalId, amount) => console.log('Update progress:', goalId, amount)}
/> />
{/* Portfolio Info */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<h3 className="font-bold text-gray-900 dark:text-white mb-4">
Informacion del Portfolio
</h3>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Perfil de Riesgo</span>
<span className="text-gray-900 dark:text-white">
{riskProfileLabels[portfolio.riskProfile]}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Ultimo Rebalanceo</span>
<span className="text-gray-900 dark:text-white">
{portfolio.lastRebalanced
? new Date(portfolio.lastRebalanced).toLocaleDateString()
: 'Nunca'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Creado</span>
<span className="text-gray-900 dark:text-white">
{new Date(portfolio.createdAt).toLocaleDateString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Actualizado</span>
<span className="text-gray-900 dark:text-white">
{new Date(portfolio.updatedAt).toLocaleString()}
</span>
</div>
</div>
</div>
</div> </div>
</div> )}
{/* Optimize Tab */}
{activeTab === 'optimize' && (
<div className="max-w-5xl mx-auto">
<AllocationOptimizer
portfolioId={portfolio.id}
allocations={portfolio.allocations}
totalValue={portfolio.totalValue}
/>
</div>
)}
{/* Simulate Tab */}
{activeTab === 'simulate' && (
<div className="max-w-5xl mx-auto">
<MonteCarloSimulator
portfolioId={portfolio.id}
currentValue={portfolio.totalValue}
annualReturn={0.15}
volatility={0.25}
/>
</div>
)}
{/* Rebalance Modal */} {/* Rebalance Modal */}
<RebalanceModal <RebalanceModal

View File

@ -0,0 +1,566 @@
/**
* CreateBotModal Component
* ========================
* Multi-step wizard for creating a new trading bot
* Steps: 1. Select Template, 2. Configure Symbol, 3. Risk Management, 4. Review & Create
*/
import React, { useState, useCallback, useEffect } from 'react';
import {
X,
ChevronRight,
ChevronLeft,
Shield,
Target,
Rocket,
Settings,
AlertTriangle,
Check,
Loader2,
} from 'lucide-react';
import { useBotTemplates, useCreateBot } from '../../hooks/useBots';
import type {
BotTemplate,
StrategyType,
Timeframe,
CreateBotInput,
} from '../../../../services/bots.service';
interface CreateBotModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
type Step = 1 | 2 | 3 | 4;
interface FormData {
strategyType: StrategyType | null;
name: string;
symbols: string[];
timeframe: Timeframe;
initialCapital: number;
maxPositionSizePct: number;
maxDailyLossPct: number;
maxDrawdownPct: number;
}
const INITIAL_FORM_DATA: FormData = {
strategyType: null,
name: '',
symbols: [],
timeframe: '1h',
initialCapital: 10000,
maxPositionSizePct: 10,
maxDailyLossPct: 3,
maxDrawdownPct: 10,
};
const TIMEFRAME_OPTIONS: { value: Timeframe; label: string }[] = [
{ value: '1m', label: '1 Minute' },
{ value: '5m', label: '5 Minutes' },
{ value: '15m', label: '15 Minutes' },
{ value: '30m', label: '30 Minutes' },
{ value: '1h', label: '1 Hour' },
{ value: '4h', label: '4 Hours' },
{ value: '1d', label: '1 Day' },
];
const POPULAR_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'SOLUSDT', 'XRPUSDT', 'ADAUSDT'];
export const CreateBotModal: React.FC<CreateBotModalProps> = ({ isOpen, onClose, onSuccess }) => {
const [step, setStep] = useState<Step>(1);
const [formData, setFormData] = useState<FormData>(INITIAL_FORM_DATA);
const [symbolInput, setSymbolInput] = useState('');
const { data: templates, isLoading: templatesLoading } = useBotTemplates();
const createBotMutation = useCreateBot();
// Reset form when modal closes
useEffect(() => {
if (!isOpen) {
setStep(1);
setFormData(INITIAL_FORM_DATA);
setSymbolInput('');
}
}, [isOpen]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
const handleSelectTemplate = useCallback((type: StrategyType, template?: BotTemplate) => {
setFormData((prev) => ({
...prev,
strategyType: type,
name: template ? `My ${template.name.split(' - ')[0]} Bot` : 'My Custom Bot',
timeframe: template?.recommendedTimeframes[0] || '1h',
symbols: template?.recommendedSymbols.slice(0, 3) || [],
}));
setStep(2);
}, []);
const handleAddSymbol = useCallback(() => {
const symbol = symbolInput.toUpperCase().trim();
if (symbol && !formData.symbols.includes(symbol)) {
setFormData((prev) => ({
...prev,
symbols: [...prev.symbols, symbol],
}));
setSymbolInput('');
}
}, [symbolInput, formData.symbols]);
const handleRemoveSymbol = useCallback((symbol: string) => {
setFormData((prev) => ({
...prev,
symbols: prev.symbols.filter((s) => s !== symbol),
}));
}, []);
const handleSubmit = useCallback(async () => {
if (!formData.strategyType || formData.symbols.length === 0) return;
const input: CreateBotInput = {
name: formData.name,
symbols: formData.symbols,
timeframe: formData.timeframe,
initialCapital: formData.initialCapital,
strategyType: formData.strategyType,
maxPositionSizePct: formData.maxPositionSizePct,
maxDailyLossPct: formData.maxDailyLossPct,
maxDrawdownPct: formData.maxDrawdownPct,
};
try {
await createBotMutation.mutateAsync(input);
onSuccess?.();
handleClose();
} catch (error) {
console.error('Failed to create bot:', error);
}
}, [formData, createBotMutation, onSuccess, handleClose]);
const canProceedToStep2 = formData.strategyType !== null;
const canProceedToStep3 = canProceedToStep2 && formData.name.trim() && formData.symbols.length > 0;
const canProceedToStep4 = canProceedToStep3 && formData.initialCapital >= 100;
const canSubmit = canProceedToStep4;
if (!isOpen) return null;
const selectedTemplate = templates?.find((t) => t.type === formData.strategyType);
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 rounded-xl shadow-2xl border border-gray-700 w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-700">
<div>
<h2 className="text-xl font-bold text-white">Create Trading Bot</h2>
<p className="text-gray-400 text-sm mt-1">Step {step} of 4</p>
</div>
<button onClick={handleClose} className="text-gray-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
{/* Progress Bar */}
<div className="px-6 pt-4">
<div className="flex gap-2">
{[1, 2, 3, 4].map((s) => (
<div
key={s}
className={`h-1 flex-1 rounded-full transition-colors ${
s <= step ? 'bg-blue-500' : 'bg-gray-700'
}`}
/>
))}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Step 1: Select Template */}
{step === 1 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white mb-4">Select Bot Template</h3>
{templatesLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
</div>
) : (
<div className="grid gap-4">
{templates?.map((template) => (
<TemplateCard
key={template.type}
template={template}
selected={formData.strategyType === template.type}
onClick={() => handleSelectTemplate(template.type, template)}
/>
))}
<TemplateCard
template={{
type: 'custom',
name: 'Custom Strategy',
description: 'Build your own strategy from scratch',
riskLevel: 'medium',
recommendedTimeframes: ['1h'],
recommendedSymbols: ['BTCUSDT'],
defaultConfig: {},
}}
selected={formData.strategyType === 'custom'}
onClick={() => handleSelectTemplate('custom')}
/>
</div>
)}
</div>
)}
{/* Step 2: Configure Symbol */}
{step === 2 && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-white">Configure Trading Pair</h3>
{/* Bot Name */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Bot Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="My Trading Bot"
/>
</div>
{/* Symbols */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Trading Symbols</label>
<div className="flex gap-2 mb-3">
<input
type="text"
value={symbolInput}
onChange={(e) => setSymbolInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddSymbol()}
className="flex-1 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter symbol (e.g., BTCUSDT)"
/>
<button
onClick={handleAddSymbol}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Add
</button>
</div>
{/* Selected Symbols */}
<div className="flex flex-wrap gap-2 mb-4">
{formData.symbols.map((symbol) => (
<span
key={symbol}
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-600/20 text-blue-400 rounded-full text-sm"
>
{symbol}
<button onClick={() => handleRemoveSymbol(symbol)} className="hover:text-blue-300">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
{/* Quick Add */}
<div className="mt-2">
<span className="text-xs text-gray-500">Quick add:</span>
<div className="flex flex-wrap gap-1 mt-1">
{POPULAR_SYMBOLS.filter((s) => !formData.symbols.includes(s)).map((symbol) => (
<button
key={symbol}
onClick={() =>
setFormData((prev) => ({ ...prev, symbols: [...prev.symbols, symbol] }))
}
className="px-2 py-1 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded transition-colors"
>
+{symbol}
</button>
))}
</div>
</div>
</div>
{/* Timeframe */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Timeframe</label>
<select
value={formData.timeframe}
onChange={(e) => setFormData((prev) => ({ ...prev, timeframe: e.target.value as Timeframe }))}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{TIMEFRAME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{selectedTemplate && (
<p className="text-xs text-gray-500 mt-1">
Recommended: {selectedTemplate.recommendedTimeframes.join(', ')}
</p>
)}
</div>
</div>
)}
{/* Step 3: Risk Management */}
{step === 3 && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-white">Risk Management</h3>
{/* Initial Capital */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Initial Capital (USD)</label>
<input
type="number"
min={100}
value={formData.initialCapital}
onChange={(e) =>
setFormData((prev) => ({ ...prev, initialCapital: Number(e.target.value) }))
}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">Minimum: $100</p>
</div>
{/* Max Position Size */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Max Position Size (% of capital)
</label>
<input
type="number"
min={1}
max={100}
value={formData.maxPositionSizePct}
onChange={(e) =>
setFormData((prev) => ({ ...prev, maxPositionSizePct: Number(e.target.value) }))
}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Max Daily Loss */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Max Daily Loss (% of capital)
</label>
<input
type="number"
min={0.5}
max={20}
step={0.5}
value={formData.maxDailyLossPct}
onChange={(e) =>
setFormData((prev) => ({ ...prev, maxDailyLossPct: Number(e.target.value) }))
}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Max Drawdown */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Max Drawdown (% of capital)
</label>
<input
type="number"
min={1}
max={50}
value={formData.maxDrawdownPct}
onChange={(e) =>
setFormData((prev) => ({ ...prev, maxDrawdownPct: Number(e.target.value) }))
}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Risk Warning */}
<div className="flex items-start gap-3 p-4 bg-yellow-900/20 border border-yellow-700/50 rounded-lg">
<AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-400">
<p className="font-semibold">Risk Warning</p>
<p className="text-yellow-500/80 mt-1">
Trading involves substantial risk. Never trade with money you cannot afford to lose.
Past performance does not guarantee future results.
</p>
</div>
</div>
</div>
)}
{/* Step 4: Review */}
{step === 4 && (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-white">Review Configuration</h3>
<div className="space-y-4">
<ReviewItem label="Strategy" value={selectedTemplate?.name || 'Custom'} />
<ReviewItem label="Bot Name" value={formData.name} />
<ReviewItem label="Symbols" value={formData.symbols.join(', ')} />
<ReviewItem
label="Timeframe"
value={TIMEFRAME_OPTIONS.find((t) => t.value === formData.timeframe)?.label || formData.timeframe}
/>
<ReviewItem label="Initial Capital" value={`$${formData.initialCapital.toLocaleString()}`} />
<ReviewItem label="Max Position Size" value={`${formData.maxPositionSizePct}%`} />
<ReviewItem label="Max Daily Loss" value={`${formData.maxDailyLossPct}%`} />
<ReviewItem label="Max Drawdown" value={`${formData.maxDrawdownPct}%`} />
</div>
{createBotMutation.isError && (
<div className="p-4 bg-red-900/20 border border-red-700/50 rounded-lg text-red-400 text-sm">
{createBotMutation.error instanceof Error
? createBotMutation.error.message
: 'Failed to create bot'}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-gray-700">
<button
onClick={() => (step === 1 ? handleClose() : setStep((s) => (s - 1) as Step))}
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white transition-colors"
>
<ChevronLeft className="w-4 h-4" />
{step === 1 ? 'Cancel' : 'Back'}
</button>
{step < 4 ? (
<button
onClick={() => setStep((s) => (s + 1) as Step)}
disabled={
(step === 1 && !canProceedToStep2) ||
(step === 2 && !canProceedToStep3) ||
(step === 3 && !canProceedToStep4)
}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
Next
<ChevronRight className="w-4 h-4" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!canSubmit || createBotMutation.isPending}
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 disabled:text-gray-500 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
{createBotMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating...
</>
) : (
<>
<Check className="w-4 h-4" />
Create Bot
</>
)}
</button>
)}
</div>
</div>
</div>
);
};
// ============================================================================
// Sub-components
// ============================================================================
interface TemplateCardProps {
template: BotTemplate;
selected: boolean;
onClick: () => void;
}
const TemplateCard: React.FC<TemplateCardProps> = ({ template, selected, onClick }) => {
const getIcon = () => {
switch (template.type) {
case 'atlas':
return <Shield className="w-6 h-6" />;
case 'orion':
return <Target className="w-6 h-6" />;
case 'nova':
return <Rocket className="w-6 h-6" />;
default:
return <Settings className="w-6 h-6" />;
}
};
const getColor = () => {
switch (template.type) {
case 'atlas':
return 'bg-blue-600';
case 'orion':
return 'bg-purple-600';
case 'nova':
return 'bg-orange-600';
default:
return 'bg-gray-600';
}
};
const getRiskColor = () => {
switch (template.riskLevel) {
case 'low':
return 'text-green-400';
case 'medium':
return 'text-yellow-400';
case 'high':
return 'text-red-400';
default:
return 'text-gray-400';
}
};
return (
<button
onClick={onClick}
className={`w-full p-4 rounded-lg border transition-all text-left ${
selected
? 'bg-blue-900/30 border-blue-500'
: 'bg-gray-800 border-gray-700 hover:border-gray-600'
}`}
>
<div className="flex items-start gap-4">
<div className={`p-3 rounded-lg ${getColor()}`}>{getIcon()}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-white font-semibold">{template.name}</h4>
<span className={`text-xs ${getRiskColor()}`}>
{template.riskLevel.charAt(0).toUpperCase() + template.riskLevel.slice(1)} Risk
</span>
</div>
<p className="text-gray-400 text-sm mt-1">{template.description}</p>
</div>
{selected && <Check className="w-5 h-5 text-blue-500" />}
</div>
</button>
);
};
interface ReviewItemProps {
label: string;
value: string;
}
const ReviewItem: React.FC<ReviewItemProps> = ({ label, value }) => (
<div className="flex justify-between items-center py-2 border-b border-gray-800">
<span className="text-gray-400">{label}</span>
<span className="text-white font-medium">{value}</span>
</div>
);
export default CreateBotModal;

View File

@ -5,3 +5,4 @@
export { AgentsList } from './AgentsList'; export { AgentsList } from './AgentsList';
export { AgentCard } from './AgentCard'; export { AgentCard } from './AgentCard';
export { BotCard } from './BotCard'; export { BotCard } from './BotCard';
export { CreateBotModal } from './CreateBotModal';

View File

@ -1,11 +1,29 @@
/** /**
* Trading Hooks - Index Export * Trading Hooks - Index Export
* OQI-003: Trading Charts & Agents
* OQI-009: Trading Execution (MT4 Gateway) * OQI-009: Trading Execution (MT4 Gateway)
*/ */
// MT4 WebSocket
export { useMT4WebSocket } from './useMT4WebSocket'; export { useMT4WebSocket } from './useMT4WebSocket';
export type { export type {
MT4AccountInfo, MT4AccountInfo,
MT4Position, MT4Position,
MT4Order, MT4Order,
} from './useMT4WebSocket'; } from './useMT4WebSocket';
// Trading Bots (Atlas, Orion, Nova)
export {
useBots,
useBot,
useBotPerformance,
useBotExecutions,
useBotTemplates,
useBotTemplate,
useCreateBot,
useUpdateBot,
useDeleteBot,
useStartBot,
useStopBot,
botQueryKeys,
} from './useBots';

View File

@ -0,0 +1,203 @@
/**
* useBots Hooks
* =============
* TanStack Query hooks for trading bots management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
botsService,
type TradingBot,
type BotTemplate,
type BotPerformance,
type BotExecution,
type CreateBotInput,
type UpdateBotInput,
type BotFilters,
} from '../../../services/bots.service';
// ============================================================================
// Query Keys
// ============================================================================
export const botQueryKeys = {
all: ['bots'] as const,
lists: () => [...botQueryKeys.all, 'list'] as const,
list: (filters?: BotFilters) => [...botQueryKeys.lists(), filters] as const,
details: () => [...botQueryKeys.all, 'detail'] as const,
detail: (id: string) => [...botQueryKeys.details(), id] as const,
performance: (id: string) => [...botQueryKeys.all, 'performance', id] as const,
executions: (id: string) => [...botQueryKeys.all, 'executions', id] as const,
templates: () => [...botQueryKeys.all, 'templates'] as const,
template: (type: string) => [...botQueryKeys.templates(), type] as const,
};
// ============================================================================
// Query Hooks
// ============================================================================
/**
* Hook to fetch all bots with optional filters
*/
export function useBots(filters?: BotFilters) {
return useQuery({
queryKey: botQueryKeys.list(filters),
queryFn: () => botsService.getBots(filters),
staleTime: 30_000, // 30 seconds
refetchInterval: 60_000, // Refetch every minute for live status updates
});
}
/**
* Hook to fetch a single bot by ID
*/
export function useBot(botId: string, enabled = true) {
return useQuery({
queryKey: botQueryKeys.detail(botId),
queryFn: () => botsService.getBotById(botId),
enabled: enabled && !!botId,
staleTime: 15_000, // 15 seconds
refetchInterval: 30_000, // Refetch every 30 seconds for live updates
});
}
/**
* Hook to fetch bot performance metrics
*/
export function useBotPerformance(botId: string, enabled = true) {
return useQuery({
queryKey: botQueryKeys.performance(botId),
queryFn: () => botsService.getBotPerformance(botId),
enabled: enabled && !!botId,
staleTime: 30_000,
});
}
/**
* Hook to fetch bot execution history
*/
export function useBotExecutions(
botId: string,
options?: { limit?: number; offset?: number },
enabled = true
) {
return useQuery({
queryKey: [...botQueryKeys.executions(botId), options],
queryFn: () => botsService.getBotExecutions(botId, options),
enabled: enabled && !!botId,
staleTime: 30_000,
});
}
/**
* Hook to fetch bot templates
*/
export function useBotTemplates() {
return useQuery({
queryKey: botQueryKeys.templates(),
queryFn: () => botsService.getBotTemplates(),
staleTime: 5 * 60 * 1000, // 5 minutes - templates rarely change
});
}
/**
* Hook to fetch a specific bot template
*/
export function useBotTemplate(type: string, enabled = true) {
return useQuery({
queryKey: botQueryKeys.template(type),
queryFn: () => botsService.getBotTemplate(type as 'atlas' | 'orion' | 'nova' | 'custom'),
enabled: enabled && !!type,
staleTime: 5 * 60 * 1000,
});
}
// ============================================================================
// Mutation Hooks
// ============================================================================
/**
* Hook to create a new bot
*/
export function useCreateBot() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateBotInput) => botsService.createBot(input),
onSuccess: (newBot) => {
// Invalidate bot list to refetch
queryClient.invalidateQueries({ queryKey: botQueryKeys.lists() });
// Optionally add the new bot to cache
queryClient.setQueryData(botQueryKeys.detail(newBot.id), newBot);
},
});
}
/**
* Hook to update a bot
*/
export function useUpdateBot() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ botId, input }: { botId: string; input: UpdateBotInput }) =>
botsService.updateBot(botId, input),
onSuccess: (updatedBot, { botId }) => {
// Update the specific bot in cache
queryClient.setQueryData(botQueryKeys.detail(botId), updatedBot);
// Invalidate bot list
queryClient.invalidateQueries({ queryKey: botQueryKeys.lists() });
},
});
}
/**
* Hook to delete a bot
*/
export function useDeleteBot() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (botId: string) => botsService.deleteBot(botId),
onSuccess: (_, botId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: botQueryKeys.detail(botId) });
// Invalidate bot list
queryClient.invalidateQueries({ queryKey: botQueryKeys.lists() });
},
});
}
/**
* Hook to start a bot
*/
export function useStartBot() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (botId: string) => botsService.startBot(botId),
onSuccess: (updatedBot, botId) => {
// Update the specific bot in cache
queryClient.setQueryData(botQueryKeys.detail(botId), updatedBot);
// Invalidate bot list to update status
queryClient.invalidateQueries({ queryKey: botQueryKeys.lists() });
},
});
}
/**
* Hook to stop a bot
*/
export function useStopBot() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (botId: string) => botsService.stopBot(botId),
onSuccess: (updatedBot, botId) => {
// Update the specific bot in cache
queryClient.setQueryData(botQueryKeys.detail(botId), updatedBot);
// Invalidate bot list to update status
queryClient.invalidateQueries({ queryKey: botQueryKeys.lists() });
},
});
}

View File

@ -1,23 +1,281 @@
/** /**
* Trading Agents Page * Trading Agents Page
* Manage and monitor trading agents within the trading module * Manage and monitor trading agents (Atlas, Orion, Nova)
*/ */
import { AgentsList } from '../components/agents/AgentsList'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, RefreshCw, Loader2, Bot, AlertCircle, Shield, Target, Rocket } from 'lucide-react';
import { useBots, useStartBot, useStopBot } from '../hooks/useBots';
import { BotCard } from '../components/agents/BotCard';
import { CreateBotModal } from '../components/agents/CreateBotModal';
import type { TradingBot as AgentBot } from '../../../types/tradingAgents.types';
import type { TradingBot, BotStatus } from '../../../services/bots.service';
export default function AgentsPage() { export default function AgentsPage() {
const navigate = useNavigate();
const [showCreateModal, setShowCreateModal] = useState(false);
const [statusFilter, setStatusFilter] = useState<BotStatus | 'all'>('all');
const { data: bots, isLoading, error, refetch } = useBots(
statusFilter !== 'all' ? { status: statusFilter } : undefined
);
const startMutation = useStartBot();
const stopMutation = useStopBot();
// Handlers
const handleStartBot = async (botId: string) => {
try {
await startMutation.mutateAsync(botId);
} catch (err) {
console.error('Failed to start bot:', err);
}
};
const handleStopBot = async (botId: string) => {
try {
await stopMutation.mutateAsync(botId);
} catch (err) {
console.error('Failed to stop bot:', err);
}
};
const handleSelectBot = (bot: AgentBot | TradingBot) => {
navigate(`/trading/agents/${bot.id}`);
};
// Map TradingBot to the format expected by BotCard
const mapBotForCard = (bot: TradingBot): AgentBot => ({
id: bot.id,
name: (bot.strategyType as 'atlas' | 'orion' | 'nova') || 'atlas',
displayName: bot.name,
description: `${bot.strategyType || 'Custom'} - ${bot.symbols.join(', ')}`,
status: mapStatus(bot.status),
equity: bot.currentCapital,
positions: 0, // Would need positions data
todayPnl: bot.totalProfitLoss,
metrics: {
total_trades: bot.totalTrades,
winning_trades: bot.winningTrades,
losing_trades: bot.totalTrades - bot.winningTrades,
win_rate: bot.winRate,
total_profit: bot.totalProfitLoss > 0 ? bot.totalProfitLoss : 0,
total_loss: bot.totalProfitLoss < 0 ? Math.abs(bot.totalProfitLoss) : 0,
net_pnl: bot.totalProfitLoss,
max_drawdown: bot.maxDrawdownPct,
current_drawdown: 0,
},
});
const mapStatus = (status: BotStatus): 'running' | 'paused' | 'stopped' | 'error' | 'idle' | 'unavailable' => {
switch (status) {
case 'active': return 'running';
case 'paused': return 'paused';
case 'stopped': return 'stopped';
case 'error': return 'error';
default: return 'stopped';
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl font-bold text-white">Trading Agents</h1> <div>
<p className="text-gray-400 mt-1"> <h1 className="text-2xl font-bold text-white">Trading Agents</h1>
Gestiona tus bots de trading automatizado: Atlas, Orion y Nova <p className="text-gray-400 mt-1">
</p> Manage your automated trading bots: Atlas, Orion, and Nova
</p>
</div>
<div className="flex items-center gap-3">
{/* Status Filter */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as BotStatus | 'all')}
className="px-4 py-2 bg-gray-700 text-white border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="stopped">Stopped</option>
<option value="error">Error</option>
</select>
{/* Refresh Button */}
<button
onClick={() => refetch()}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 text-white rounded-lg transition-colors"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
{/* Create Bot Button */}
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
<span>Create Bot</span>
</button>
</div>
</div> </div>
{/* Main Content */} {/* Strategy Templates Info */}
<AgentsList /> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StrategyInfoCard
icon={<Shield className="w-5 h-5" />}
name="Atlas"
description="Trend Following"
risk="Medium"
color="blue"
/>
<StrategyInfoCard
icon={<Target className="w-5 h-5" />}
name="Orion"
description="Mean Reversion"
risk="Low"
color="purple"
/>
<StrategyInfoCard
icon={<Rocket className="w-5 h-5" />}
name="Nova"
description="Breakout"
risk="High"
color="orange"
/>
</div>
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-3 text-gray-400">
<Loader2 className="w-6 h-6 animate-spin" />
<span>Loading bots...</span>
</div>
</div>
)}
{/* Error State */}
{error && (
<div className="bg-red-900/20 border border-red-700/50 rounded-xl p-6">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-lg font-semibold text-red-400">Error Loading Bots</h3>
<p className="text-red-500/80 text-sm mt-1">
{error instanceof Error ? error.message : 'Failed to load bots'}
</p>
<button
onClick={() => refetch()}
className="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors text-sm"
>
Try Again
</button>
</div>
</div>
</div>
)}
{/* Bots Grid */}
{!isLoading && !error && bots && bots.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{bots.map((bot) => (
<BotCard
key={bot.id}
bot={mapBotForCard(bot)}
onSelect={() => handleSelectBot(bot)}
onStart={() => handleStartBot(bot.id)}
onStop={() => handleStopBot(bot.id)}
loading={startMutation.isPending || stopMutation.isPending}
/>
))}
</div>
)}
{/* Empty State */}
{!isLoading && !error && (!bots || bots.length === 0) && (
<div className="bg-gray-800 rounded-xl p-12 text-center border border-gray-700">
<div className="max-w-md mx-auto">
<div className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
<Bot className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">No Trading Bots</h3>
<p className="text-gray-400 mb-6">
{statusFilter !== 'all'
? `No bots with status "${statusFilter}" found.`
: 'Create your first trading bot to start automated trading with Atlas, Orion, or Nova strategies.'}
</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors inline-flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Create Your First Bot
</button>
</div>
</div>
)}
{/* Create Bot Modal */}
<CreateBotModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
refetch();
setShowCreateModal(false);
}}
/>
</div>
);
}
// ============================================================================
// Sub-components
// ============================================================================
interface StrategyInfoCardProps {
icon: React.ReactNode;
name: string;
description: string;
risk: string;
color: 'blue' | 'purple' | 'orange';
}
function StrategyInfoCard({ icon, name, description, risk, color }: StrategyInfoCardProps) {
const colorClasses = {
blue: 'bg-blue-600/20 border-blue-700/50 text-blue-400',
purple: 'bg-purple-600/20 border-purple-700/50 text-purple-400',
orange: 'bg-orange-600/20 border-orange-700/50 text-orange-400',
};
const iconBgClasses = {
blue: 'bg-blue-600',
purple: 'bg-purple-600',
orange: 'bg-orange-600',
};
const riskColors: Record<string, string> = {
Low: 'text-green-400',
Medium: 'text-yellow-400',
High: 'text-red-400',
};
return (
<div className={`p-4 rounded-lg border ${colorClasses[color]}`}>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${iconBgClasses[color]}`}>
<span className="text-white">{icon}</span>
</div>
<div>
<h4 className="font-semibold text-white">{name}</h4>
<p className="text-sm text-gray-400">{description}</p>
</div>
<div className="ml-auto">
<span className={`text-xs font-medium ${riskColors[risk]}`}>{risk} Risk</span>
</div>
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,470 @@
/**
* Bot Detail Page
* ===============
* Detailed view of a trading bot with performance metrics, execution history,
* and configuration management.
*/
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
Play,
Square,
Edit2,
Trash2,
RefreshCw,
TrendingUp,
TrendingDown,
Activity,
Clock,
DollarSign,
Target,
AlertTriangle,
CheckCircle2,
XCircle,
Loader2,
Shield,
Rocket,
} from 'lucide-react';
import { useBot, useBotPerformance, useBotExecutions, useStartBot, useStopBot, useDeleteBot } from '../hooks/useBots';
import type { TradingBot, BotExecution, BotPerformance, StrategyType } from '../../../services/bots.service';
import { useState } from 'react';
export default function BotDetailPage() {
const { botId } = useParams<{ botId: string }>();
const navigate = useNavigate();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const { data: bot, isLoading: botLoading, error: botError, refetch: refetchBot } = useBot(botId || '');
const { data: performance, isLoading: perfLoading } = useBotPerformance(botId || '', !!bot);
const { data: executions, isLoading: execLoading } = useBotExecutions(botId || '', { limit: 20 }, !!bot);
const startMutation = useStartBot();
const stopMutation = useStopBot();
const deleteMutation = useDeleteBot();
const handleStart = async () => {
if (!botId) return;
try {
await startMutation.mutateAsync(botId);
} catch (error) {
console.error('Failed to start bot:', error);
}
};
const handleStop = async () => {
if (!botId) return;
try {
await stopMutation.mutateAsync(botId);
} catch (error) {
console.error('Failed to stop bot:', error);
}
};
const handleDelete = async () => {
if (!botId) return;
try {
await deleteMutation.mutateAsync(botId);
navigate('/trading/agents');
} catch (error) {
console.error('Failed to delete bot:', error);
}
};
// Loading state
if (botLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
</div>
);
}
// Error state
if (botError || !bot) {
return (
<div className="space-y-6">
<Link to="/trading/agents" className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="w-4 h-4" />
Back to Agents
</Link>
<div className="bg-red-900/20 border border-red-700/50 rounded-xl p-6 text-center">
<AlertTriangle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-red-400">Bot Not Found</h2>
<p className="text-red-500/80 mt-2">The requested trading bot could not be found.</p>
<Link
to="/trading/agents"
className="inline-block mt-4 px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
>
Return to Agents
</Link>
</div>
</div>
);
}
const isActive = bot.status === 'active';
const isPaused = bot.status === 'paused';
const isStopped = bot.status === 'stopped';
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Link to="/trading/agents" className="text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="flex items-center gap-3">
<StrategyIcon type={bot.strategyType || 'custom'} />
<div>
<h1 className="text-2xl font-bold text-white">{bot.name}</h1>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={bot.status} />
<span className="text-gray-400 text-sm">{bot.strategyType || 'Custom'}</span>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => refetchBot()}
className="p-2 text-gray-400 hover:text-white transition-colors"
title="Refresh"
>
<RefreshCw className="w-5 h-5" />
</button>
{(isStopped || isPaused) && (
<button
onClick={handleStart}
disabled={startMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-700 text-white rounded-lg transition-colors"
>
{startMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
Start
</button>
)}
{isActive && (
<button
onClick={handleStop}
disabled={stopMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-700 text-white rounded-lg transition-colors"
>
{stopMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Square className="w-4 h-4" />}
Stop
</button>
)}
<button className="p-2 text-gray-400 hover:text-blue-400 transition-colors" title="Edit">
<Edit2 className="w-5 h-5" />
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={isActive}
className="p-2 text-gray-400 hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title={isActive ? 'Stop bot before deleting' : 'Delete'}
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Performance */}
<div className="lg:col-span-2 space-y-6">
{/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MetricCard
label="Total P&L"
value={formatCurrency(bot.totalProfitLoss)}
icon={<DollarSign className="w-5 h-5" />}
trend={bot.totalProfitLoss >= 0 ? 'up' : 'down'}
/>
<MetricCard
label="Win Rate"
value={`${bot.winRate.toFixed(1)}%`}
icon={<Target className="w-5 h-5" />}
trend={bot.winRate >= 50 ? 'up' : 'down'}
/>
<MetricCard
label="Total Trades"
value={bot.totalTrades.toString()}
icon={<Activity className="w-5 h-5" />}
/>
<MetricCard
label="Current Capital"
value={formatCurrency(bot.currentCapital)}
icon={<DollarSign className="w-5 h-5" />}
/>
</div>
{/* Performance Metrics */}
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Performance Metrics</h3>
{perfLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
</div>
) : performance ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<PerformanceItem label="Sharpe Ratio" value={performance.sharpeRatio.toFixed(2)} />
<PerformanceItem label="Max Drawdown" value={`${performance.maxDrawdown.toFixed(1)}%`} />
<PerformanceItem label="Profit Factor" value={performance.profitFactor.toFixed(2)} />
<PerformanceItem label="Avg Win" value={formatCurrency(performance.averageWin)} />
<PerformanceItem label="Avg Loss" value={formatCurrency(performance.averageLoss)} />
<PerformanceItem label="Total Profit" value={formatCurrency(performance.profitLoss)} />
<PerformanceItem label="Winning Trades" value={bot.winningTrades.toString()} />
<PerformanceItem label="Losing Trades" value={(bot.totalTrades - bot.winningTrades).toString()} />
</div>
) : (
<p className="text-gray-400 text-center py-4">No performance data available</p>
)}
</div>
{/* Execution History */}
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Execution History</h3>
{execLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
</div>
) : executions && executions.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-gray-400 text-sm border-b border-gray-700">
<th className="pb-3 font-medium">Time</th>
<th className="pb-3 font-medium">Symbol</th>
<th className="pb-3 font-medium">Action</th>
<th className="pb-3 font-medium">Price</th>
<th className="pb-3 font-medium">Qty</th>
<th className="pb-3 font-medium">P&L</th>
<th className="pb-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{executions.map((exec) => (
<ExecutionRow key={exec.id} execution={exec} />
))}
</tbody>
</table>
</div>
) : (
<p className="text-gray-400 text-center py-8">No executions yet</p>
)}
</div>
</div>
{/* Right Column - Configuration */}
<div className="space-y-6">
{/* Bot Configuration */}
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Configuration</h3>
<div className="space-y-4">
<ConfigItem label="Symbols" value={bot.symbols.join(', ')} />
<ConfigItem label="Timeframe" value={bot.timeframe.toUpperCase()} />
<ConfigItem label="Initial Capital" value={formatCurrency(bot.initialCapital)} />
<ConfigItem label="Max Position Size" value={`${bot.maxPositionSizePct}%`} />
<ConfigItem label="Max Daily Loss" value={`${bot.maxDailyLossPct}%`} />
<ConfigItem label="Max Drawdown" value={`${bot.maxDrawdownPct}%`} />
</div>
</div>
{/* Status & Timestamps */}
<div className="bg-gray-800 rounded-xl p-6 border border-gray-700">
<h3 className="text-lg font-semibold text-white mb-4">Status</h3>
<div className="space-y-4">
<ConfigItem label="Created" value={formatDate(bot.createdAt)} />
{bot.startedAt && <ConfigItem label="Started" value={formatDate(bot.startedAt)} />}
{bot.stoppedAt && <ConfigItem label="Stopped" value={formatDate(bot.stoppedAt)} />}
{bot.lastTradeAt && <ConfigItem label="Last Trade" value={formatDate(bot.lastTradeAt)} />}
</div>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 rounded-xl p-6 max-w-md w-full border border-gray-700">
<h3 className="text-xl font-bold text-white mb-2">Delete Bot</h3>
<p className="text-gray-400 mb-6">
Are you sure you want to delete "{bot.name}"? This action cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-700 text-white rounded-lg transition-colors"
>
{deleteMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ============================================================================
// Sub-components
// ============================================================================
function StrategyIcon({ type }: { type: StrategyType }) {
const iconClass = 'w-6 h-6 text-white';
const bgClass = {
atlas: 'bg-blue-600',
orion: 'bg-purple-600',
nova: 'bg-orange-600',
custom: 'bg-gray-600',
}[type];
return (
<div className={`p-3 rounded-lg ${bgClass}`}>
{type === 'atlas' && <Shield className={iconClass} />}
{type === 'orion' && <Target className={iconClass} />}
{type === 'nova' && <Rocket className={iconClass} />}
{type === 'custom' && <Activity className={iconClass} />}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles = {
active: 'bg-green-900/50 text-green-400 border-green-700',
paused: 'bg-yellow-900/50 text-yellow-400 border-yellow-700',
stopped: 'bg-gray-900/50 text-gray-400 border-gray-700',
error: 'bg-red-900/50 text-red-400 border-red-700',
}[status] || 'bg-gray-900/50 text-gray-400 border-gray-700';
return (
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold border ${styles}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
interface MetricCardProps {
label: string;
value: string;
icon: React.ReactNode;
trend?: 'up' | 'down';
}
function MetricCard({ label, value, icon, trend }: MetricCardProps) {
return (
<div className="bg-gray-800 rounded-xl p-4 border border-gray-700">
<div className="flex items-center justify-between mb-2">
<span className="text-gray-400 text-sm">{label}</span>
<span className="text-gray-500">{icon}</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-xl font-bold ${trend === 'up' ? 'text-green-400' : trend === 'down' ? 'text-red-400' : 'text-white'}`}>
{value}
</span>
{trend === 'up' && <TrendingUp className="w-4 h-4 text-green-400" />}
{trend === 'down' && <TrendingDown className="w-4 h-4 text-red-400" />}
</div>
</div>
);
}
function PerformanceItem({ label, value }: { label: string; value: string }) {
return (
<div className="text-center p-3 bg-gray-900 rounded-lg">
<span className="text-gray-500 text-xs block mb-1">{label}</span>
<span className="text-white font-semibold">{value}</span>
</div>
);
}
function ConfigItem({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between items-center">
<span className="text-gray-400 text-sm">{label}</span>
<span className="text-white font-medium text-sm">{value}</span>
</div>
);
}
function ExecutionRow({ execution }: { execution: BotExecution }) {
const isBuy = execution.action === 'buy';
const isSuccess = execution.result === 'success';
const isPnlPositive = (execution.pnl || 0) >= 0;
return (
<tr className="border-b border-gray-700/50 text-sm">
<td className="py-3 text-gray-300">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3 text-gray-500" />
{formatTime(execution.timestamp)}
</div>
</td>
<td className="py-3 text-white font-medium">{execution.symbol}</td>
<td className="py-3">
<span className={`px-2 py-0.5 rounded text-xs font-semibold ${isBuy ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'}`}>
{execution.action.toUpperCase()}
</span>
</td>
<td className="py-3 text-gray-300">{formatCurrency(execution.price)}</td>
<td className="py-3 text-gray-300">{execution.quantity.toFixed(4)}</td>
<td className={`py-3 font-medium ${isPnlPositive ? 'text-green-400' : 'text-red-400'}`}>
{execution.pnl !== undefined ? formatCurrency(execution.pnl) : '-'}
</td>
<td className="py-3">
{isSuccess ? (
<CheckCircle2 className="w-4 h-4 text-green-400" />
) : execution.result === 'failed' ? (
<XCircle className="w-4 h-4 text-red-400" />
) : (
<Loader2 className="w-4 h-4 text-yellow-400 animate-spin" />
)}
</td>
</tr>
);
}
// ============================================================================
// Helpers
// ============================================================================
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatTime(dateStr: string): string {
return new Date(dateStr).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

View File

@ -0,0 +1,299 @@
/**
* Trading Bots Service
* ====================
* Client for trading bots API (Atlas, Orion, Nova)
* Endpoints: /api/v1/trading/bots/*
*/
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3080';
// ============================================================================
// Types
// ============================================================================
export type BotStatus = 'active' | 'paused' | 'stopped' | 'error';
export type BotType = 'paper' | 'live';
export type StrategyType = 'atlas' | 'orion' | 'nova' | 'custom';
export type Timeframe = '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d' | '1w';
export type RiskLevel = 'low' | 'medium' | 'high';
export interface TradingBot {
id: string;
userId: string;
name: string;
botType: BotType;
status: BotStatus;
symbols: string[];
timeframe: Timeframe;
initialCapital: number;
currentCapital: number;
maxPositionSizePct: number;
maxDailyLossPct: number;
maxDrawdownPct: number;
strategyType?: StrategyType;
strategyConfig: TradingBotStrategyConfig;
totalTrades: number;
winningTrades: number;
totalProfitLoss: number;
winRate: number;
startedAt?: string;
stoppedAt?: string;
lastTradeAt?: string;
createdAt: string;
updatedAt: string;
}
export interface TradingBotStrategyConfig {
strategy_name?: string;
entry_rules?: Record<string, unknown>;
exit_rules?: Record<string, unknown>;
risk_management?: {
max_position_size?: number;
stop_loss_percent?: number;
take_profit_percent?: number;
max_daily_loss?: number;
};
indicators?: Array<{ name: string; params: Record<string, unknown> }>;
}
export interface BotTemplate {
type: StrategyType;
name: string;
description: string;
defaultConfig: TradingBotStrategyConfig;
riskLevel: RiskLevel;
recommendedTimeframes: Timeframe[];
recommendedSymbols: string[];
}
export interface BotPerformance {
totalTrades: number;
winRate: number;
profitLoss: number;
sharpeRatio: number;
maxDrawdown: number;
averageWin: number;
averageLoss: number;
profitFactor: number;
}
export interface BotExecution {
id: string;
botId: string;
action: 'buy' | 'sell';
symbol: string;
price: number;
quantity: number;
timestamp: string;
result: 'success' | 'failed' | 'pending';
pnl?: number;
metadata?: Record<string, unknown>;
}
export interface CreateBotInput {
name: string;
botType?: BotType;
symbols: string[];
timeframe?: Timeframe;
initialCapital: number;
strategyType?: StrategyType;
strategyConfig?: TradingBotStrategyConfig;
maxPositionSizePct?: number;
maxDailyLossPct?: number;
maxDrawdownPct?: number;
}
export interface UpdateBotInput {
name?: string;
status?: BotStatus;
symbols?: string[];
timeframe?: Timeframe;
strategyType?: StrategyType;
strategyConfig?: TradingBotStrategyConfig;
maxPositionSizePct?: number;
maxDailyLossPct?: number;
maxDrawdownPct?: number;
}
export interface BotFilters {
status?: BotStatus;
botType?: BotType;
strategyType?: StrategyType;
limit?: number;
offset?: number;
}
// ============================================================================
// Helper Function
// ============================================================================
/**
* Generic fetch wrapper with error handling
*/
async function fetchAPI<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
...options,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
error: { message: 'Failed to fetch data', code: 'FETCH_ERROR' },
}));
throw new Error(error.error?.message || 'Request failed');
}
const data = await response.json();
return data.data || data;
}
// ============================================================================
// Bot CRUD Operations
// ============================================================================
/**
* Get all bots for the authenticated user
*/
export async function getBots(filters?: BotFilters): Promise<TradingBot[]> {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.botType) params.append('botType', filters.botType);
if (filters?.strategyType) params.append('strategyType', filters.strategyType);
if (filters?.limit) params.append('limit', filters.limit.toString());
if (filters?.offset) params.append('offset', filters.offset.toString());
const queryString = params.toString();
const url = `${API_URL}/api/v1/trading/bots${queryString ? `?${queryString}` : ''}`;
return fetchAPI<TradingBot[]>(url);
}
/**
* Get a specific bot by ID
*/
export async function getBotById(botId: string): Promise<TradingBot> {
return fetchAPI<TradingBot>(`${API_URL}/api/v1/trading/bots/${botId}`);
}
/**
* Create a new trading bot
*/
export async function createBot(input: CreateBotInput): Promise<TradingBot> {
return fetchAPI<TradingBot>(`${API_URL}/api/v1/trading/bots`, {
method: 'POST',
body: JSON.stringify(input),
});
}
/**
* Update bot configuration
*/
export async function updateBot(botId: string, input: UpdateBotInput): Promise<TradingBot> {
return fetchAPI<TradingBot>(`${API_URL}/api/v1/trading/bots/${botId}`, {
method: 'PUT',
body: JSON.stringify(input),
});
}
/**
* Delete a bot (must be stopped first)
*/
export async function deleteBot(botId: string): Promise<void> {
await fetchAPI<void>(`${API_URL}/api/v1/trading/bots/${botId}`, {
method: 'DELETE',
});
}
// ============================================================================
// Bot Control Operations
// ============================================================================
/**
* Start a bot
*/
export async function startBot(botId: string): Promise<TradingBot> {
return fetchAPI<TradingBot>(`${API_URL}/api/v1/trading/bots/${botId}/start`, {
method: 'POST',
});
}
/**
* Stop a bot
*/
export async function stopBot(botId: string): Promise<TradingBot> {
return fetchAPI<TradingBot>(`${API_URL}/api/v1/trading/bots/${botId}/stop`, {
method: 'POST',
});
}
// ============================================================================
// Performance & Executions
// ============================================================================
/**
* Get bot performance metrics
*/
export async function getBotPerformance(botId: string): Promise<BotPerformance> {
return fetchAPI<BotPerformance>(`${API_URL}/api/v1/trading/bots/${botId}/performance`);
}
/**
* Get bot execution history
*/
export async function getBotExecutions(
botId: string,
options?: { limit?: number; offset?: number }
): Promise<BotExecution[]> {
const params = new URLSearchParams();
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
const queryString = params.toString();
const url = `${API_URL}/api/v1/trading/bots/${botId}/executions${queryString ? `?${queryString}` : ''}`;
return fetchAPI<BotExecution[]>(url);
}
// ============================================================================
// Bot Templates
// ============================================================================
/**
* Get available bot templates (Atlas, Orion, Nova, Custom)
*/
export async function getBotTemplates(): Promise<BotTemplate[]> {
return fetchAPI<BotTemplate[]>(`${API_URL}/api/v1/trading/bots/templates`);
}
/**
* Get a specific bot template by type
*/
export async function getBotTemplate(type: StrategyType): Promise<BotTemplate> {
return fetchAPI<BotTemplate>(`${API_URL}/api/v1/trading/bots/templates/${type}`);
}
// ============================================================================
// Export all functions
// ============================================================================
export const botsService = {
// CRUD
getBots,
getBotById,
createBot,
updateBot,
deleteBot,
// Control
startBot,
stopBot,
// Performance
getBotPerformance,
getBotExecutions,
// Templates
getBotTemplates,
getBotTemplate,
};
export default botsService;

View File

@ -16,6 +16,13 @@ import type {
PaginatedResponse, PaginatedResponse,
ApiResponse, ApiResponse,
ProductCategory, ProductCategory,
SellerProduct,
SellerProductFilters,
SellerStats,
SellerSalesData,
SellerPayoutInfo,
CreateProductData,
ProductDraft,
} from '../types/marketplace.types'; } from '../types/marketplace.types';
// ============================================================================ // ============================================================================
@ -255,6 +262,169 @@ export async function cancelBooking(id: string): Promise<ConsultationBooking> {
return response.data.data; return response.data.data;
} }
// ============================================================================
// Seller Products
// ============================================================================
export async function getSellerProducts(
filters?: SellerProductFilters
): Promise<PaginatedResponse<SellerProduct>> {
const response = await api.get<ApiResponse<PaginatedResponse<SellerProduct>>>(
'/marketplace/seller/products',
{ params: filters }
);
return response.data.data;
}
export async function getSellerProductById(id: string): Promise<SellerProduct> {
const response = await api.get<ApiResponse<SellerProduct>>(
`/marketplace/seller/products/${id}`
);
return response.data.data;
}
export async function updateSellerProduct(
id: string,
data: Partial<SellerProduct>
): Promise<SellerProduct> {
const response = await api.patch<ApiResponse<SellerProduct>>(
`/marketplace/seller/products/${id}`,
data
);
return response.data.data;
}
export async function toggleProductStatus(
id: string,
status: 'active' | 'inactive'
): Promise<SellerProduct> {
const response = await api.patch<ApiResponse<SellerProduct>>(
`/marketplace/seller/products/${id}/status`,
{ status }
);
return response.data.data;
}
export async function deleteSellerProduct(id: string): Promise<void> {
await api.delete(`/marketplace/seller/products/${id}`);
}
// ============================================================================
// Seller Stats
// ============================================================================
export async function getSellerStats(): Promise<SellerStats> {
const response = await api.get<ApiResponse<SellerStats>>(
'/marketplace/seller/stats'
);
return response.data.data;
}
export async function getSellerSales(
period: '7d' | '30d' | '90d' | '1y'
): Promise<SellerSalesData[]> {
const response = await api.get<ApiResponse<SellerSalesData[]>>(
'/marketplace/seller/sales',
{ params: { period } }
);
return response.data.data;
}
export async function getSellerPayouts(): Promise<SellerPayoutInfo> {
const response = await api.get<ApiResponse<SellerPayoutInfo>>(
'/marketplace/seller/payouts'
);
return response.data.data;
}
export async function getSellerEarnings(
period: '7d' | '30d' | '90d' | '1y'
): Promise<{
total: number;
byProduct: { productId: string; productName: string; earnings: number }[];
trend: number;
}> {
const response = await api.get<ApiResponse<{
total: number;
byProduct: { productId: string; productName: string; earnings: number }[];
trend: number;
}>>('/marketplace/seller/earnings', { params: { period } });
return response.data.data;
}
// ============================================================================
// Product Creation
// ============================================================================
export async function createProduct(data: CreateProductData): Promise<SellerProduct> {
const response = await api.post<ApiResponse<SellerProduct>>(
'/marketplace/seller/products',
data
);
return response.data.data;
}
export async function saveProductDraft(
data: Partial<CreateProductData>
): Promise<ProductDraft> {
const response = await api.post<ApiResponse<ProductDraft>>(
'/marketplace/seller/drafts',
data
);
return response.data.data;
}
export async function updateProductDraft(
id: string,
data: Partial<CreateProductData>
): Promise<ProductDraft> {
const response = await api.patch<ApiResponse<ProductDraft>>(
`/marketplace/seller/drafts/${id}`,
data
);
return response.data.data;
}
export async function publishDraft(draftId: string): Promise<SellerProduct> {
const response = await api.post<ApiResponse<SellerProduct>>(
`/marketplace/seller/drafts/${draftId}/publish`
);
return response.data.data;
}
export async function deleteProductDraft(id: string): Promise<void> {
await api.delete(`/marketplace/seller/drafts/${id}`);
}
export async function uploadProductImage(file: File): Promise<{ url: string }> {
const formData = new FormData();
formData.append('image', file);
const response = await api.post<ApiResponse<{ url: string }>>(
'/marketplace/seller/upload',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
return response.data.data;
}
export async function validateCoupon(
code: string,
productId?: string
): Promise<{
isValid: boolean;
discountPercent?: number;
discountAmount?: number;
message?: string;
}> {
const response = await api.post<ApiResponse<{
isValid: boolean;
discountPercent?: number;
discountAmount?: number;
message?: string;
}>>('/marketplace/coupons/validate', { code, productId });
return response.data.data;
}
// ============================================================================ // ============================================================================
// Export service object // Export service object
// ============================================================================ // ============================================================================
@ -299,4 +469,26 @@ export const marketplaceService = {
getMyBookings, getMyBookings,
getBookingById, getBookingById,
cancelBooking, cancelBooking,
// Seller Products
getSellerProducts,
getSellerProductById,
updateSellerProduct,
toggleProductStatus,
deleteSellerProduct,
// Seller Stats
getSellerStats,
getSellerSales,
getSellerPayouts,
getSellerEarnings,
// Product Creation
createProduct,
saveProductDraft,
updateProductDraft,
publishDraft,
deleteProductDraft,
uploadProductImage,
validateCoupon,
}; };

View File

@ -22,6 +22,9 @@ import type {
SubscriptionResponse, SubscriptionResponse,
WalletResponse, WalletResponse,
PlanInterval, PlanInterval,
Refund,
RefundEligibility,
RefundRequest,
} from '../types/payment.types'; } from '../types/payment.types';
// Uses centralized apiClient from lib/apiClient.ts (auto-refresh, multi-tab sync) // Uses centralized apiClient from lib/apiClient.ts (auto-refresh, multi-tab sync)
@ -282,6 +285,44 @@ export async function withdrawFromWallet(
return response.data.data; return response.data.data;
} }
// ============================================================================
// Refunds
// ============================================================================
export async function getRefunds(
limit = 20,
offset = 0,
status?: string
): Promise<{ refunds: Refund[]; total: number }> {
const response = await api.get<ApiResponse<{ refunds: Refund[]; total: number }>>(
'/payments/refunds',
{ params: { limit, offset, status } }
);
return response.data.data;
}
export async function getRefundById(refundId: string): Promise<Refund> {
const response = await api.get<ApiResponse<Refund>>(`/payments/refunds/${refundId}`);
return response.data.data;
}
export async function getRefundEligibility(subscriptionId: string): Promise<RefundEligibility> {
const response = await api.get<ApiResponse<RefundEligibility>>(
`/payments/refunds/eligibility/${subscriptionId}`
);
return response.data.data;
}
export async function requestRefund(request: RefundRequest): Promise<Refund> {
const response = await api.post<ApiResponse<Refund>>('/payments/refunds', request);
return response.data.data;
}
export async function cancelRefund(refundId: string): Promise<Refund> {
const response = await api.delete<ApiResponse<Refund>>(`/payments/refunds/${refundId}`);
return response.data.data;
}
// ============================================================================ // ============================================================================
// Coupons // Coupons
// ============================================================================ // ============================================================================
@ -368,6 +409,12 @@ export const paymentService = {
getWalletTransactions, getWalletTransactions,
depositToWallet, depositToWallet,
withdrawFromWallet, withdrawFromWallet,
// Refunds
getRefunds,
getRefundById,
getRefundEligibility,
requestRefund,
cancelRefund,
// Coupons // Coupons
validateCoupon, validateCoupon,
// Summary // Summary

View File

@ -336,3 +336,97 @@ export interface ConsultationBooking {
priceUsd: number; priceUsd: number;
createdAt: string; createdAt: string;
} }
// ============================================================================
// Seller Types
// ============================================================================
export interface SellerProduct {
id: string;
name: string;
type: ProductCategory;
status: ProductStatus;
price: number;
subscribers: number;
rating: number;
reviews: number;
revenue: number;
views: number;
thumbnailUrl?: string;
createdAt: string;
updatedAt: string;
}
export interface SellerProductFilters {
status?: ProductStatus;
type?: ProductCategory;
search?: string;
page?: number;
pageSize?: number;
sortBy?: 'newest' | 'revenue' | 'subscribers' | 'rating';
}
export interface SellerStats {
totalRevenue: number;
revenueChange: number;
totalSubscribers: number;
subscribersChange: number;
activeProducts: number;
averageRating: number;
pendingPayouts: number;
nextPayoutDate: string;
}
export interface SellerSalesData {
date: string;
revenue: number;
subscribers: number;
newOrders: number;
}
export interface SellerPayoutInfo {
pendingAmount: number;
nextPayoutDate: string;
lastPayoutDate?: string;
lastPayoutAmount?: number;
payoutMethod: 'bank_transfer' | 'paypal' | 'stripe';
accountInfo: {
type: string;
lastFour?: string;
email?: string;
};
}
export interface CreateProductData {
type: ProductCategory;
name: string;
description: string;
shortDescription: string;
tags: string[];
thumbnailUrl?: string;
pricing: CreateProductPricing[];
hasFreeTrial: boolean;
freeTrialDays?: number;
// Signal-specific
riskLevel?: 'low' | 'medium' | 'high';
symbolsCovered?: string[];
// Course-specific
difficultyLevel?: 'beginner' | 'intermediate' | 'advanced';
// Advisory-specific
specializations?: string[];
sessionDuration?: number;
}
export interface CreateProductPricing {
period: SubscriptionPeriod;
price: number;
originalPrice?: number;
isPopular: boolean;
}
export interface ProductDraft {
id: string;
data: Partial<CreateProductData>;
lastSaved: string;
createdAt: string;
}

View File

@ -341,3 +341,54 @@ export interface WalletResponse {
wallet: Wallet; wallet: Wallet;
recentTransactions: WalletTransaction[]; recentTransactions: WalletTransaction[];
} }
// ============================================================================
// Refund Types
// ============================================================================
export type RefundStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'canceled';
export type RefundReason =
| 'changed_mind'
| 'not_needed'
| 'duplicate_charge'
| 'service_issue'
| 'billing_error'
| 'other';
export interface Refund {
id: string;
subscriptionId: string;
subscriptionName: string;
invoiceId?: string;
amount: number;
currency: string;
reason: RefundReason;
reasonDetails?: string;
status: RefundStatus;
refundMethod: 'original' | 'wallet';
requestedAt: string;
processedAt?: string;
failureReason?: string;
transactionId?: string;
createdAt: string;
updatedAt: string;
}
export interface RefundEligibility {
eligible: boolean;
refundableAmount: number;
originalAmount: number;
daysRemaining: number;
maxRefundDays: number;
partialRefundAllowed: boolean;
reason?: string;
}
export interface RefundRequest {
subscriptionId: string;
amount: number;
reason: RefundReason;
reasonDetails?: string;
refundMethod: 'original' | 'wallet';
}