diff --git a/src/App.tsx b/src/App.tsx
index cf144bd..fbca158 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -26,6 +26,7 @@ const SecuritySettings = lazy(() => import('./modules/auth/pages/SecuritySetting
const Dashboard = lazy(() => import('./modules/dashboard/pages/Dashboard'));
const Trading = lazy(() => import('./modules/trading/pages/Trading'));
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 BacktestingDashboard = lazy(() => import('./modules/backtesting/pages/BacktestingDashboard'));
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 ProductDetail = lazy(() => import('./modules/investment/pages/ProductDetail'));
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 Assistant = lazy(() => import('./modules/assistant/pages/Assistant'));
+const AgentSettingsPage = lazy(() => import('./modules/assistant/pages/AgentSettingsPage'));
// Lazy load modules - Portfolio
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 CheckoutSuccess = lazy(() => import('./modules/payments/pages/CheckoutSuccess'));
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
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 SignalPackDetail = lazy(() => import('./modules/marketplace/pages/SignalPackDetail'));
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() {
return (
@@ -104,6 +114,7 @@ function App() {
{/* Trading */}
} />
} />
+ } />
} />
} />
} />
@@ -115,6 +126,8 @@ function App() {
} />
} />
} />
+ } />
+ } />
{/* Portfolio Manager */}
} />
@@ -135,6 +148,9 @@ function App() {
{/* Payments */}
} />
} />
+ } />
+ } />
+ } />
} />
} />
@@ -149,6 +165,7 @@ function App() {
{/* Assistant */}
} />
+ } />
{/* Admin */}
} />
@@ -161,6 +178,10 @@ function App() {
} />
} />
} />
+ } />
+ } />
+ } />
+ } />
{/* Redirects */}
diff --git a/src/components/payments/PaymentMethodsManager.tsx b/src/components/payments/PaymentMethodsManager.tsx
new file mode 100644
index 0000000..830631b
--- /dev/null
+++ b/src/components/payments/PaymentMethodsManager.tsx
@@ -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 = ({
+ onSuccess,
+ onCancel,
+}) => {
+ const stripe = useStripe();
+ const elements = useElements();
+ const addPaymentMethod = useAddPaymentMethod();
+
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [cardError, setCardError] = useState(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 (
+
+ );
+};
+
+// ============================================================================
+// Main PaymentMethodsManager Component
+// ============================================================================
+
+export interface PaymentMethodsManagerProps {
+ title?: string;
+ showTitle?: boolean;
+ compact?: boolean;
+ onMethodChange?: () => void;
+}
+
+const PaymentMethodsManager: React.FC = ({
+ 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 (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+ {error instanceof Error ? error.message : 'Failed to load payment methods'}
+
+
refetch()}
+ className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm"
+ >
+ Retry
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+ {showTitle && (
+
+
+
+
+
+
+
{title}
+
+ {methods?.length ?? 0} saved payment method{methods?.length !== 1 ? 's' : ''}
+
+
+
+ {!showAddForm && (
+
+
+ Add New
+
+ )}
+
+ )}
+
+ {/* Add Payment Method Form */}
+ {showAddForm && (
+
+
+
+
+ Add New Payment Method
+
+ setShowAddForm(false)}
+ className="p-1 text-gray-400 hover:text-white transition-colors"
+ >
+
+
+
+
+ setShowAddForm(false)}
+ />
+
+
+ )}
+
+ {/* Payment Methods List */}
+
+
+ );
+};
+
+export default PaymentMethodsManager;
diff --git a/src/components/payments/RefundList.tsx b/src/components/payments/RefundList.tsx
index 01cca8e..7df92fe 100644
--- a/src/components/payments/RefundList.tsx
+++ b/src/components/payments/RefundList.tsx
@@ -40,8 +40,8 @@ export interface Refund {
reasonDetails?: string;
status: RefundStatus;
refundMethod: 'original' | 'wallet';
- requestedAt: Date;
- processedAt?: Date;
+ requestedAt: string;
+ processedAt?: string;
failureReason?: string;
transactionId?: string;
}
@@ -103,22 +103,24 @@ const formatCurrency = (amount: number, currency: string = 'USD') => {
}).format(amount);
};
-// Date formatter
-const formatDate = (date: Date) => {
+// Date formatter (accepts Date or string)
+function formatDate(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
- }).format(date);
-};
+ }).format(d);
+}
-// Time formatter
-const formatTime = (date: Date) => {
+// Time formatter (accepts Date or string)
+function formatTime(date: Date | string): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
- }).format(date);
-};
+ }).format(d);
+}
// Status badge component
const StatusBadge: React.FC<{ status: RefundStatus }> = ({ status }) => {
diff --git a/src/components/payments/index.ts b/src/components/payments/index.ts
index 20951e6..061bf17 100644
--- a/src/components/payments/index.ts
+++ b/src/components/payments/index.ts
@@ -31,6 +31,8 @@ export type { InvoiceLineItem, InvoiceDiscount, InvoiceTax, InvoicePreviewData }
// Payment Methods Management
export { default as PaymentMethodsList } from './PaymentMethodsList';
export type { PaymentMethod } from './PaymentMethodsList';
+export { default as PaymentMethodsManager } from './PaymentMethodsManager';
+export type { PaymentMethodsManagerProps } from './PaymentMethodsManager';
// Subscription Management
export { default as SubscriptionUpgradeFlow } from './SubscriptionUpgradeFlow';
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 1cbc94f..8a03650 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -33,3 +33,22 @@ export type {
// Feature Flags
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';
diff --git a/src/hooks/usePayments.ts b/src/hooks/usePayments.ts
new file mode 100644
index 0000000..c4a9df6
--- /dev/null
+++ b/src/hooks/usePayments.ts
@@ -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,
+};
diff --git a/src/modules/assistant/components/AgentModeSelector.tsx b/src/modules/assistant/components/AgentModeSelector.tsx
new file mode 100644
index 0000000..1c67da1
--- /dev/null
+++ b/src/modules/assistant/components/AgentModeSelector.tsx
@@ -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;
+}
+
+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) => void;
+ onAddTrigger?: () => void;
+ onRemoveTrigger?: (triggerId: string) => void;
+ onUpdateSchedule: (scheduleId: string, updates: Partial) => void;
+ onUpdateConfig: (updates: Partial) => void;
+ isLoading?: boolean;
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const TRIGGER_TYPES: Record = {
+ 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 = ({ mode, isSelected, onSelect }) => {
+ const isProactive = mode === 'proactive';
+ const Icon = isProactive ? (isSelected ? BoltSolidIcon : BoltIcon) : (isSelected ? HandRaisedSolidIcon : HandRaisedIcon);
+
+ return (
+
+
+
+
+
+
+ {isProactive ? 'Proactivo' : 'Reactivo'}
+
+
+ {isProactive
+ ? 'El agente toma acciones automaticamente'
+ : 'El agente solo responde a tus preguntas'}
+
+
+
+ );
+};
+
+interface TriggerItemProps {
+ trigger: TriggerCondition;
+ onToggle: (enabled: boolean) => void;
+ onRemove?: () => void;
+}
+
+const TriggerItem: React.FC = ({ trigger, onToggle, onRemove }) => {
+ const typeInfo = TRIGGER_TYPES[trigger.type];
+ const Icon = typeInfo?.icon || BellIcon;
+
+ return (
+
+
+
+
+
+
{trigger.name}
+
{typeInfo?.description}
+
+
+ {onRemove && (
+
+
+
+ )}
+
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'
+ }`}
+ >
+
+
+
+
+ );
+};
+
+interface ScheduleEditorProps {
+ schedules: ActivitySchedule[];
+ onUpdate: (scheduleId: string, updates: Partial) => void;
+}
+
+const ScheduleEditor: React.FC = ({ schedules, onUpdate }) => {
+ return (
+
+
Horarios de Actividad
+
+ {DAYS_OF_WEEK.map((day) => {
+ const schedule = schedules.find((s) => s.dayOfWeek === day.value);
+ return (
+ {
+ 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'
+ }`}
+ >
+ {day.label}
+
+ );
+ })}
+
+ {schedules.filter((s) => s.enabled).length > 0 && (
+
+ )}
+
+ );
+};
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+export const AgentModeSelector: React.FC = ({
+ config,
+ onModeChange,
+ onUpdateTrigger,
+ onAddTrigger,
+ onRemoveTrigger,
+ onUpdateSchedule,
+ onUpdateConfig,
+ isLoading = false,
+}) => {
+ const [showAdvanced, setShowAdvanced] = useState(false);
+
+ const handleModeChange = useCallback((mode: AgentMode) => {
+ onModeChange(mode);
+ }, [onModeChange]);
+
+ return (
+
+ {/* Header */}
+
+
+
+
Modo del Agente
+
+
+ Configura como interactua el agente contigo
+
+
+
+ {/* Mode Selection */}
+
+
+ handleModeChange('reactive')}
+ />
+ handleModeChange('proactive')}
+ />
+
+
+
+ {/* Mode Description */}
+
+
+
+
+
+
+ Modo {config.mode === 'proactive' ? 'Proactivo' : 'Reactivo'} Activado
+
+
+ {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.'}
+
+
+
+
+
+
+ {/* Proactive Mode Configuration */}
+ {config.mode === 'proactive' && (
+ <>
+ {/* Triggers */}
+
+
+
Triggers de Activacion
+ {onAddTrigger && (
+
+
+ Agregar
+
+ )}
+
+
+ {config.triggers.map((trigger) => (
+ onUpdateTrigger(trigger.id, { enabled })}
+ onRemove={onRemoveTrigger ? () => onRemoveTrigger(trigger.id) : undefined}
+ />
+ ))}
+
+
+
+ {/* Schedule */}
+
+
+
+ >
+ )}
+
+ {/* Advanced Settings */}
+
+
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"
+ >
+
+
+ Configuracion Avanzada
+
+ {showAdvanced ? : }
+
+
+ {showAdvanced && (
+
+ {/* Notify on Action */}
+
+
+
Notificar al actuar
+
Recibir notificacion cuando el agente tome una accion
+
+
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'
+ }`}
+ >
+
+
+
+
+ {/* Require Confirmation */}
+
+
+
Requerir confirmacion
+
Pedir confirmacion antes de ejecutar acciones criticas
+
+
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'
+ }`}
+ >
+
+
+
+
+ {/* Max Actions Per Hour */}
+
+
+
Max acciones por hora
+
{config.maxActionsPerHour}
+
+
onUpdateConfig({ maxActionsPerHour: parseInt(e.target.value) })}
+ className="w-full"
+ />
+
+ 1
+ 20
+
+
+
+ )}
+
+
+ );
+};
+
+export default AgentModeSelector;
diff --git a/src/modules/assistant/components/ConversationHistoryAdvanced.tsx b/src/modules/assistant/components/ConversationHistoryAdvanced.tsx
new file mode 100644
index 0000000..406b536
--- /dev/null
+++ b/src/modules/assistant/components/ConversationHistoryAdvanced.tsx
@@ -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 = ({ 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 (
+
+ {filters.map((filter) => (
+ 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}
+
+ ))}
+
+ );
+};
+
+interface SessionListItemProps {
+ session: ChatSession;
+ isActive: boolean;
+ onSelect: () => void;
+ onDelete: () => void;
+ onContinue?: () => void;
+ onArchive?: () => void;
+}
+
+const SessionListItem: React.FC = ({
+ 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 (
+ 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'
+ }`}
+ >
+
+ {/* Icon */}
+
+ {isActive ? : }
+
+
+ {/* Content */}
+
+
+ {getSessionTitle(session)}
+
+
+ {getLastMessagePreview(session)}
+
+
+
+ {formatDistanceToNow(new Date(session.updatedAt), { addSuffix: true, locale: es })}
+
+ |
+ {session.messages.length} msg
+
+
+
+ {/* Actions */}
+
+ {!confirmDelete ? (
+ <>
+ {onContinue && (
+
{ 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"
+ >
+
+
+ )}
+ {onArchive && (
+
{ 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"
+ >
+
+
+ )}
+
+
+
+ >
+ ) : (
+ <>
+
+ Confirmar
+
+
{ e.stopPropagation(); setConfirmDelete(false); }}
+ className="p-1 text-gray-400 hover:text-gray-600"
+ >
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+export const ConversationHistoryAdvanced: React.FC = ({
+ sessions,
+ currentSessionId,
+ loading,
+ onSelectSession,
+ onCreateSession,
+ onDeleteSession,
+ onContinueSession,
+ onArchiveSession,
+ onExportSession,
+}) => {
+ const [searchTerm, setSearchTerm] = useState('');
+ const [dateFilter, setDateFilter] = useState('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 (
+
+ {/* Header with New Chat */}
+
+
+
+ Nueva Conversacion
+
+
+
+ {/* Search */}
+
+
+
+ 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"
+ />
+ 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'
+ }`}
+ >
+
+
+
+
+ {/* Filters */}
+ {showFilters && (
+
+
+
+ Filtrar por fecha
+
+
setShowCustomDatePicker(true)}
+ />
+ {dateFilter === 'custom' && customRange && (
+
+ {format(customRange.start, 'dd/MM/yyyy', { locale: es })}
+ -
+ {format(customRange.end, 'dd/MM/yyyy', { locale: es })}
+ { setCustomRange(null); setDateFilter('all'); }}
+ className="ml-auto text-gray-400 hover:text-red-500"
+ >
+
+
+
+ )}
+ {showCustomDatePicker && (
+
+ )}
+
+ )}
+
+
+ {/* Results Summary */}
+ {(searchTerm || dateFilter !== 'all') && (
+
+
+ {filteredSessions.length} de {sessions.length} conversaciones
+
+
+ )}
+
+ {/* Session List */}
+
+ {loading && sessions.length === 0 ? (
+
+
+
Cargando conversaciones...
+
+ ) : filteredSessions.length === 0 ? (
+
+
+
+
+
+ {searchTerm || dateFilter !== 'all' ? 'No se encontraron resultados' : 'Sin conversaciones'}
+
+
+ {searchTerm || dateFilter !== 'all' ? 'Intenta con otros terminos o filtros' : 'Inicia una nueva conversacion'}
+
+
+ ) : (
+
+ {filteredSessions.map((session) => (
+ onSelectSession(session.id)}
+ onDelete={() => onDeleteSession(session.id)}
+ onContinue={onContinueSession ? () => handleContinue(session.id) : undefined}
+ onArchive={onArchiveSession ? () => onArchiveSession(session.id) : undefined}
+ />
+ ))}
+
+ )}
+
+
+ {/* Footer Stats */}
+
+
+ {sessions.length} conversaciones
+ {totalMessages.toLocaleString()} mensajes totales
+
+
+
+ );
+};
+
+export default ConversationHistoryAdvanced;
diff --git a/src/modules/assistant/components/MemoryManagerPanel.tsx b/src/modules/assistant/components/MemoryManagerPanel.tsx
new file mode 100644
index 0000000..faa0647
--- /dev/null
+++ b/src/modules/assistant/components/MemoryManagerPanel.tsx
@@ -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;
+ createdAt: string;
+ updatedAt: string;
+ isPinned?: boolean;
+}
+
+export interface MemoryStats {
+ totalItems: number;
+ totalTokens: number;
+ maxTokens: number;
+ byCategory: Record;
+}
+
+interface MemoryManagerPanelProps {
+ memories: MemoryItem[];
+ stats: MemoryStats;
+ onAddMemory: (memory: Omit) => Promise;
+ onUpdateMemory: (id: string, updates: Partial) => Promise;
+ onDeleteMemory: (id: string) => Promise;
+ onClearCategory?: (category: MemoryCategory) => Promise;
+ onRefresh?: () => Promise;
+ isLoading?: boolean;
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const CATEGORY_INFO: Record = {
+ 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 = ({
+ memory,
+ onEdit,
+ onDelete,
+ isEditing,
+ editValue,
+ onEditChange,
+ onSave,
+ onCancel,
+}) => {
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const category = CATEGORY_INFO[memory.category];
+ const CategoryIcon = category.icon;
+
+ return (
+
+
+
+
+
+
+
+ {memory.key}
+ {memory.isPinned && (
+
+ Fijado
+
+ )}
+
+ {isEditing ? (
+
+ ) : (
+
{memory.value}
+ )}
+
+ {new Date(memory.updatedAt).toLocaleDateString('es-ES', {
+ day: 'numeric',
+ month: 'short',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+ {!isEditing && (
+
+ {!confirmDelete ? (
+ <>
+
+
+
+
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"
+ >
+
+
+ >
+ ) : (
+ <>
+
{ onDelete(); setConfirmDelete(false); }}
+ className="px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
+ >
+ Confirmar
+
+
setConfirmDelete(false)}
+ className="p-1 text-gray-400 hover:text-gray-600"
+ >
+
+
+ >
+ )}
+
+ )}
+
+
+ );
+};
+
+interface AddMemoryFormProps {
+ onAdd: (memory: Omit) => void;
+ onCancel: () => void;
+}
+
+const AddMemoryForm: React.FC = ({ onAdd, onCancel }) => {
+ const [category, setCategory] = useState('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 (
+
+
+
+ Categoria
+ 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]) => (
+ {info.label}
+ ))}
+
+
+
+ Clave
+ 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"
+ />
+
+
+
+ Valor
+
+
+
+ Cancelar
+
+
+ Agregar Memoria
+
+
+
+ );
+};
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+export const MemoryManagerPanel: React.FC = ({
+ memories,
+ stats,
+ onAddMemory,
+ onUpdateMemory,
+ onDeleteMemory,
+ onClearCategory,
+ onRefresh,
+ isLoading = false,
+}) => {
+ const [activeCategory, setActiveCategory] = useState('all');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [showAddForm, setShowAddForm] = useState(false);
+ const [editingId, setEditingId] = useState(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) => {
+ 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 (
+
+ {/* Header */}
+
+
+
+
+
Memoria del Agente
+
+
+ {onRefresh && (
+
+
+
+ )}
+
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"
+ >
+
+ Agregar
+
+
+
+
+ {/* Usage Bar */}
+
+
+ {stats.totalItems} memorias
+ 80 ? 'text-red-500' : 'text-gray-500'}>
+ {stats.totalTokens.toLocaleString()} / {stats.maxTokens.toLocaleString()} tokens
+
+
+
+
80 ? 'bg-red-500' : usagePercent > 60 ? 'bg-yellow-500' : 'bg-emerald-500'}`}
+ style={{ width: `${Math.min(usagePercent, 100)}%` }}
+ />
+
+ {usagePercent > 80 && (
+
+
+ Memoria casi llena. Considera eliminar memorias antiguas.
+
+ )}
+
+
+ {/* Category Stats */}
+
+ {Object.entries(CATEGORY_INFO).map(([key, info]) => {
+ const Icon = info.icon;
+ const count = stats.byCategory[key as MemoryCategory] || 0;
+ return (
+
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'
+ }`}
+ >
+
+ {count}
+ {info.label}
+
+ );
+ })}
+
+
+
+ {/* Add Form */}
+ {showAddForm && (
+
+
setShowAddForm(false)} />
+
+ )}
+
+ {/* Search */}
+
+
+
+ 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"
+ />
+
+
+
+ {/* Memory List */}
+
+ {filteredMemories.length === 0 ? (
+
+
+
+ {searchQuery ? 'No se encontraron memorias' : 'Sin memorias guardadas'}
+
+
+ Las memorias ayudan al agente a recordar tus preferencias
+
+
+ ) : (
+ filteredMemories.map((memory) => (
+
handleEdit(memory)}
+ onDelete={() => onDeleteMemory(memory.id)}
+ isEditing={editingId === memory.id}
+ editValue={editValue}
+ onEditChange={setEditValue}
+ onSave={() => handleSave(memory.id)}
+ onCancel={() => { setEditingId(null); setEditValue(''); }}
+ />
+ ))
+ )}
+
+
+ {/* Clear Category Footer */}
+ {activeCategory !== 'all' && filteredMemories.length > 0 && onClearCategory && (
+
+ 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"
+ >
+
+ Limpiar {CATEGORY_INFO[activeCategory].label} ({filteredMemories.length})
+
+
+ )}
+
+ );
+};
+
+export default MemoryManagerPanel;
diff --git a/src/modules/assistant/components/ToolsConfigPanel.tsx b/src/modules/assistant/components/ToolsConfigPanel.tsx
new file mode 100644
index 0000000..ea67a42
--- /dev/null
+++ b/src/modules/assistant/components/ToolsConfigPanel.tsx
@@ -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
;
+ requiredPermissions?: string[];
+ lastUsed?: string;
+ usageCount: number;
+ isCore?: boolean;
+}
+
+interface ToolsConfigPanelProps {
+ tools: AgentTool[];
+ onToggleTool: (toolId: string, enabled: boolean) => void;
+ onUpdateToolParams: (toolId: string, params: Record) => void;
+ onResetTool?: (toolId: string) => void;
+ onResetAllTools?: () => void;
+ isLoading?: boolean;
+}
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const CATEGORY_INFO: Record = {
+ 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 = ({ 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 (
+
+
+
{param.label}
+ {param.description &&
{param.description}
}
+
+
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'
+ }`}
+ >
+
+
+
+ );
+
+ case 'select':
+ return (
+
+
+ {param.label}
+
+
onChange(e.target.value)}
+ disabled={disabled}
+ className={baseClasses}
+ >
+ {param.options?.map((opt) => (
+ {opt.label}
+ ))}
+
+ {param.description &&
{param.description}
}
+
+ );
+
+ case 'number':
+ return (
+
+
+ {param.label}
+
+
onChange(parseFloat(e.target.value) || 0)}
+ min={param.min}
+ max={param.max}
+ disabled={disabled}
+ className={baseClasses}
+ />
+ {param.description &&
{param.description}
}
+
+ );
+
+ default:
+ return (
+
+
+ {param.label}
+
+
onChange(e.target.value)}
+ disabled={disabled}
+ className={baseClasses}
+ />
+ {param.description &&
{param.description}
}
+
+ );
+ }
+};
+
+interface ToolCardProps {
+ tool: AgentTool;
+ onToggle: (enabled: boolean) => void;
+ onUpdateParams: (params: Record) => void;
+ onReset?: () => void;
+}
+
+const ToolCard: React.FC = ({ 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 (
+
+
+
+
+
+
+
+
+
+
{tool.name}
+
+ {category.label}
+
+ {tool.isCore && (
+
+ Core
+
+ )}
+
+
{tool.description}
+
+ {tool.usageCount} usos
+ {formatLastUsed(tool.lastUsed)}
+
+
+
+
+ {tool.parameters.length > 0 && (
+
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 ? : }
+
+ )}
+
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')}
+ >
+
+ {tool.enabled ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Required Permissions */}
+ {tool.requiredPermissions && tool.requiredPermissions.length > 0 && (
+
+
+
+ {tool.requiredPermissions.map((perm) => (
+
+ {perm}
+
+ ))}
+
+
+ )}
+
+
+ {/* Expanded Parameters */}
+ {isExpanded && tool.parameters.length > 0 && (
+
+
+
Configuracion
+ {onReset && (
+
+ Restaurar valores
+
+ )}
+
+
+ {tool.parameters.map((param) => (
+
handleParamChange(param.name, value)}
+ disabled={!tool.enabled}
+ />
+ ))}
+
+
+ )}
+
+ );
+};
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+export const ToolsConfigPanel: React.FC = ({
+ tools,
+ onToggleTool,
+ onUpdateToolParams,
+ onResetTool,
+ onResetAllTools,
+ isLoading = false,
+}) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [categoryFilter, setCategoryFilter] = useState('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 (
+
+ {/* Header */}
+
+
+
+
+
Herramientas del Agente
+
+
+ {enabledCount}/{tools.length} activas
+ {onResetAllTools && (
+
+ Restaurar todo
+
+ )}
+
+
+
+ {/* Category Filters */}
+
+ 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
+
+ {categories.map((cat) => {
+ const info = CATEGORY_INFO[cat];
+ const Icon = info.icon;
+ return (
+ 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'
+ }`}
+ >
+
+ {info.label}
+
+ );
+ })}
+
+
+ {/* Search & Filter */}
+
+
+
+ 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"
+ />
+
+
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'
+ }`}
+ >
+
+ Solo activas
+
+
+
+
+ {/* Tools List */}
+
+ {filteredTools.length === 0 ? (
+
+
+
No se encontraron herramientas
+
+ ) : (
+ filteredTools.map((tool) => (
+
onToggleTool(tool.id, enabled)}
+ onUpdateParams={(params) => onUpdateToolParams(tool.id, params)}
+ onReset={onResetTool ? () => onResetTool(tool.id) : undefined}
+ />
+ ))
+ )}
+
+
+ {/* Info Footer */}
+
+
+
+
+ Las herramientas extienden las capacidades del agente. Activa las herramientas que necesites
+ para analisis de mercado, ejecucion de operaciones, calculo de riesgo y mas.
+
+
+
+
+ );
+};
+
+export default ToolsConfigPanel;
diff --git a/src/modules/assistant/components/index.ts b/src/modules/assistant/components/index.ts
index c72ee8e..726d4fc 100644
--- a/src/modules/assistant/components/index.ts
+++ b/src/modules/assistant/components/index.ts
@@ -89,3 +89,18 @@ export type { LLMTool, ToolParameter, ToolExecution } from './LLMToolsPanel';
// Fine-Tuning Interface (OQI-007 - SUBTASK-008)
export { default as FineTuningPanel } 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';
diff --git a/src/modules/assistant/hooks/index.ts b/src/modules/assistant/hooks/index.ts
index fadbcfa..2681040 100644
--- a/src/modules/assistant/hooks/index.ts
+++ b/src/modules/assistant/hooks/index.ts
@@ -1,8 +1,10 @@
/**
* Assistant Hooks - Index Export
* Custom hooks for LLM assistant functionality
+ * OQI-007: LLM Strategy Agent
*/
+// Core Chat Hooks
export { default as useChatAssistant } from './useChatAssistant';
export type {
ChatAssistantOptions,
@@ -20,3 +22,24 @@ export type {
StreamOptions,
UseStreamingChatReturn,
} 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';
diff --git a/src/modules/assistant/hooks/useAgentMemory.ts b/src/modules/assistant/hooks/useAgentMemory.ts
new file mode 100644
index 0000000..0967377
--- /dev/null
+++ b/src/modules/assistant/hooks/useAgentMemory.ts
@@ -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) => Promise;
+ updateMemory: (id: string, updates: Partial) => Promise;
+ deleteMemory: (id: string) => Promise;
+ clearCategory: (category: MemoryCategory) => Promise;
+ refresh: () => Promise;
+ 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): Promise => {
+ const response = await apiClient.post('/llm/memory', memory);
+ return response.data;
+ },
+
+ update: async (id: string, updates: Partial): Promise => {
+ const response = await apiClient.patch(`/llm/memory/${id}`, updates);
+ return response.data;
+ },
+
+ delete: async (id: string): Promise => {
+ await apiClient.delete(`/llm/memory/${id}`);
+ },
+
+ clearCategory: async (category: MemoryCategory): Promise => {
+ 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 }) =>
+ 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) => {
+ await addMutation.mutateAsync(memory);
+ }, [addMutation]);
+
+ const updateMemory = useCallback(async (id: string, updates: Partial) => {
+ 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;
diff --git a/src/modules/assistant/hooks/useAgentMode.ts b/src/modules/assistant/hooks/useAgentMode.ts
new file mode 100644
index 0000000..3ac21cb
--- /dev/null
+++ b/src/modules/assistant/hooks/useAgentMode.ts
@@ -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;
+ // Triggers
+ triggers: TriggerCondition[];
+ enabledTriggers: TriggerCondition[];
+ updateTrigger: (triggerId: string, updates: Partial) => Promise;
+ addTrigger: (trigger: Omit) => Promise;
+ removeTrigger: (triggerId: string) => Promise;
+ // Schedules
+ schedules: ActivitySchedule[];
+ activeSchedules: ActivitySchedule[];
+ updateSchedule: (scheduleId: string, updates: Partial) => Promise;
+ // Config
+ updateConfig: (updates: Partial) => Promise;
+ resetConfig: () => Promise;
+ refresh: () => Promise;
+}
+
+// ============================================================================
+// API Functions
+// ============================================================================
+
+const modeApi = {
+ getConfig: async (): Promise => {
+ const response = await apiClient.get('/llm/agent/mode');
+ return response.data;
+ },
+
+ setMode: async (mode: AgentMode): Promise => {
+ const response = await apiClient.patch('/llm/agent/mode', { mode });
+ return response.data;
+ },
+
+ updateTrigger: async (triggerId: string, updates: Partial): Promise => {
+ const response = await apiClient.patch(`/llm/agent/triggers/${triggerId}`, updates);
+ return response.data;
+ },
+
+ addTrigger: async (trigger: Omit): Promise => {
+ const response = await apiClient.post('/llm/agent/triggers', trigger);
+ return response.data;
+ },
+
+ removeTrigger: async (triggerId: string): Promise => {
+ await apiClient.delete(`/llm/agent/triggers/${triggerId}`);
+ },
+
+ updateSchedule: async (scheduleId: string, updates: Partial): Promise => {
+ const response = await apiClient.patch(`/llm/agent/schedules/${scheduleId}`, updates);
+ return response.data;
+ },
+
+ updateConfig: async (updates: Partial): Promise => {
+ const response = await apiClient.patch('/llm/agent/config', updates);
+ return response.data;
+ },
+
+ resetConfig: async (): Promise => {
+ 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(['agent-mode-config']);
+ queryClient.setQueryData(['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 }) =>
+ modeApi.updateTrigger(triggerId, updates),
+ onMutate: async ({ triggerId, updates }) => {
+ await queryClient.cancelQueries({ queryKey: ['agent-mode-config'] });
+ const previousConfig = queryClient.getQueryData(['agent-mode-config']);
+ queryClient.setQueryData(['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(['agent-mode-config']);
+ queryClient.setQueryData(['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 }) =>
+ modeApi.updateSchedule(scheduleId, updates),
+ onMutate: async ({ scheduleId, updates }) => {
+ await queryClient.cancelQueries({ queryKey: ['agent-mode-config'] });
+ const previousConfig = queryClient.getQueryData(['agent-mode-config']);
+ queryClient.setQueryData(['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(['agent-mode-config']);
+ queryClient.setQueryData(['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) => {
+ await updateTriggerMutation.mutateAsync({ triggerId, updates });
+ }, [updateTriggerMutation]);
+
+ const addTrigger = useCallback(async (trigger: Omit) => {
+ await addTriggerMutation.mutateAsync(trigger);
+ }, [addTriggerMutation]);
+
+ const removeTrigger = useCallback(async (triggerId: string) => {
+ await removeTriggerMutation.mutateAsync(triggerId);
+ }, [removeTriggerMutation]);
+
+ const updateSchedule = useCallback(async (scheduleId: string, updates: Partial) => {
+ await updateScheduleMutation.mutateAsync({ scheduleId, updates });
+ }, [updateScheduleMutation]);
+
+ const updateConfig = useCallback(async (updates: Partial) => {
+ 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;
diff --git a/src/modules/assistant/hooks/useAgentSettings.ts b/src/modules/assistant/hooks/useAgentSettings.ts
new file mode 100644
index 0000000..319e158
--- /dev/null
+++ b/src/modules/assistant/hooks/useAgentSettings.ts
@@ -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) => Promise;
+ // Limits
+ limits: OperationLimits;
+ updateLimits: (updates: Partial) => Promise;
+ // Communication
+ communication: CommunicationPreferences;
+ updateCommunication: (updates: Partial) => Promise;
+ // General
+ updateSettings: (updates: Partial) => Promise;
+ resetSettings: () => Promise;
+ refresh: () => Promise;
+}
+
+// ============================================================================
+// API Functions
+// ============================================================================
+
+const settingsApi = {
+ get: async (): Promise => {
+ const response = await apiClient.get('/llm/agent/settings');
+ return response.data;
+ },
+
+ update: async (updates: Partial): Promise => {
+ const response = await apiClient.patch('/llm/agent/settings', updates);
+ return response.data;
+ },
+
+ updateNotifications: async (updates: Partial): Promise => {
+ const response = await apiClient.patch('/llm/agent/settings/notifications', updates);
+ return response.data;
+ },
+
+ updateLimits: async (updates: Partial): Promise => {
+ const response = await apiClient.patch('/llm/agent/settings/limits', updates);
+ return response.data;
+ },
+
+ updateCommunication: async (updates: Partial): Promise => {
+ const response = await apiClient.patch('/llm/agent/settings/communication', updates);
+ return response.data;
+ },
+
+ reset: async (): Promise => {
+ 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(['agent-settings']);
+ queryClient.setQueryData(['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(['agent-settings']);
+ queryClient.setQueryData(['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(['agent-settings']);
+ queryClient.setQueryData(['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(['agent-settings']);
+ queryClient.setQueryData(['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) => {
+ await updateMutation.mutateAsync(updates);
+ }, [updateMutation]);
+
+ const updateNotifications = useCallback(async (updates: Partial) => {
+ await updateNotificationsMutation.mutateAsync(updates);
+ }, [updateNotificationsMutation]);
+
+ const updateLimits = useCallback(async (updates: Partial) => {
+ await updateLimitsMutation.mutateAsync(updates);
+ }, [updateLimitsMutation]);
+
+ const updateCommunication = useCallback(async (updates: Partial) => {
+ 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;
diff --git a/src/modules/assistant/hooks/useAgentTools.ts b/src/modules/assistant/hooks/useAgentTools.ts
new file mode 100644
index 0000000..60ab38e
--- /dev/null
+++ b/src/modules/assistant/hooks/useAgentTools.ts
@@ -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;
+ isLoading: boolean;
+ isError: boolean;
+ error: Error | null;
+ toggleTool: (toolId: string, enabled: boolean) => Promise;
+ updateToolParams: (toolId: string, params: Record) => Promise;
+ resetTool: (toolId: string) => Promise;
+ resetAllTools: () => Promise;
+ getTool: (toolId: string) => AgentTool | undefined;
+ refresh: () => Promise;
+}
+
+// ============================================================================
+// API Functions
+// ============================================================================
+
+const toolsApi = {
+ getAll: async (): Promise => {
+ const response = await apiClient.get('/llm/tools');
+ return response.data;
+ },
+
+ toggle: async (toolId: string, enabled: boolean): Promise => {
+ const response = await apiClient.patch(`/llm/tools/${toolId}`, { enabled });
+ return response.data;
+ },
+
+ updateParams: async (toolId: string, params: Record): Promise => {
+ const response = await apiClient.patch(`/llm/tools/${toolId}/params`, { params });
+ return response.data;
+ },
+
+ reset: async (toolId: string): Promise => {
+ const response = await apiClient.post(`/llm/tools/${toolId}/reset`);
+ return response.data;
+ },
+
+ resetAll: async (): Promise => {
+ 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(['agent-tools']);
+ queryClient.setQueryData(['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 }) =>
+ toolsApi.updateParams(toolId, params),
+ onMutate: async ({ toolId, params }) => {
+ await queryClient.cancelQueries({ queryKey: ['agent-tools'] });
+ const previousTools = queryClient.getQueryData(['agent-tools']);
+ queryClient.setQueryData(['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 = {
+ 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) => {
+ 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;
diff --git a/src/modules/assistant/hooks/useConversations.ts b/src/modules/assistant/hooks/useConversations.ts
new file mode 100644
index 0000000..68eb737
--- /dev/null
+++ b/src/modules/assistant/hooks/useConversations.ts
@@ -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;
+ deleteSession: (sessionId: string) => Promise;
+ archiveSession: (sessionId: string) => Promise;
+ exportSession: (sessionId: string) => Promise;
+ refresh: () => Promise;
+ // Stats
+ totalMessages: number;
+ sessionCount: number;
+}
+
+// ============================================================================
+// API Functions
+// ============================================================================
+
+const conversationsApi = {
+ getAll: async (): Promise => {
+ 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 => {
+ await apiClient.delete(`/llm/sessions/${sessionId}`);
+ },
+
+ archive: async (sessionId: string): Promise => {
+ await apiClient.patch(`/llm/sessions/${sessionId}/archive`);
+ },
+
+ export: async (sessionId: string): Promise => {
+ 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(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [dateFilter, setDateFilter] = useState('all');
+ const [customDateRange, setCustomDateRange] = useState(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(['conversations']);
+ queryClient.setQueryData(['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 => {
+ 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 => {
+ 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;
diff --git a/src/modules/assistant/pages/AgentSettingsPage.tsx b/src/modules/assistant/pages/AgentSettingsPage.tsx
new file mode 100644
index 0000000..8c272c1
--- /dev/null
+++ b/src/modules/assistant/pages/AgentSettingsPage.tsx
@@ -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('mode');
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const [saveSuccess, setSaveSuccess] = useState(false);
+ const [error, setError] = useState(null);
+
+ // State
+ const [memories, setMemories] = useState(MOCK_MEMORIES);
+ const [memoryStats, setMemoryStats] = useState(MOCK_STATS);
+ const [tools, setTools] = useState(MOCK_TOOLS);
+ const [modeConfig, setModeConfig] = useState(MOCK_MODE_CONFIG);
+ const [notifications, setNotifications] = useState({
+ emailNotifications: true,
+ pushNotifications: true,
+ signalAlerts: true,
+ riskAlerts: true,
+ dailyDigest: false,
+ weeklyReport: true,
+ });
+ const [limits, setLimits] = useState({
+ maxDailyTrades: 10,
+ maxPositionSize: 2,
+ maxDailyLoss: 3,
+ maxDrawdown: 10,
+ pauseOnLossStreak: 3,
+ });
+
+ // Handlers - Memory
+ const handleAddMemory = useCallback(async (memory: Omit) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ setModeConfig((prev) => ({
+ ...prev,
+ triggers: prev.triggers.map((t) => t.id === triggerId ? { ...t, ...updates } : t),
+ }));
+ }, []);
+
+ const handleUpdateSchedule = useCallback((scheduleId: string, updates: Partial) => {
+ setModeConfig((prev) => ({
+ ...prev,
+ schedules: prev.schedules.map((s) => s.id === scheduleId ? { ...s, ...updates } : s),
+ }));
+ }, []);
+
+ const handleUpdateModeConfig = useCallback((updates: Partial) => {
+ 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 (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+
+
+
+
Configuracion del Agente
+
Personaliza tu asistente de trading
+
+
+
+
+ {saveSuccess && (
+
+
+ Guardado
+
+ )}
+ {error && (
+
+
+ {error}
+
+ )}
+
+ {isSaving ? (
+
+ ) : (
+
+ )}
+ Guardar Cambios
+
+
+
+
+
+
+ {/* Main Content */}
+
+
+ {/* Sidebar Tabs */}
+
+
+ {TABS.map((tab) => {
+ const Icon = tab.icon;
+ return (
+ 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'
+ }`}
+ >
+
+ {tab.label}
+
+ );
+ })}
+
+
+
+ {/* Content */}
+
+ {activeTab === 'mode' && (
+
+ )}
+
+ {activeTab === 'memory' && (
+
+ )}
+
+ {activeTab === 'tools' && (
+
+ )}
+
+ {activeTab === 'notifications' && (
+
+
+
+
Notificaciones
+
+
+ {Object.entries(notifications).map(([key, value]) => (
+
+
+
+ {key.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())}
+
+
+
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'
+ }`}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+ {activeTab === 'limits' && (
+
+
+
+
Limites de Operacion
+
+
+ {[
+ { 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) => (
+
+
+
{item.label}
+
+ {limits[item.key as keyof OperationLimits]}{item.unit}
+
+
+
setLimits((prev) => ({
+ ...prev,
+ [item.key]: parseFloat(e.target.value),
+ }))}
+ className="w-full"
+ />
+
+ {item.min}{item.unit}
+ {item.max}{item.unit}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default AgentSettingsPage;
diff --git a/src/modules/auth/README.md b/src/modules/auth/README.md
index 5748cc0..704268f 100644
--- a/src/modules/auth/README.md
+++ b/src/modules/auth/README.md
@@ -1,7 +1,7 @@
# Módulo Auth
**Epic:** OQI-001 - Fundamentos Auth
-**Progreso:** 70%
+**Progreso:** 95%
**Responsable:** Backend + Frontend Teams
## 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/
├── components/
-│ ├── PhoneLoginForm.tsx
-│ ├── SocialLoginButtons.tsx
-│ ├── DeviceCard.tsx
-│ └── SessionsList.tsx
+│ ├── PhoneLoginForm.tsx - Phone number login with SMS verification
+│ ├── SocialLoginButtons.tsx - Google, Facebook, Apple OAuth buttons
+│ ├── DeviceCard.tsx - Session/device display card with revoke
+│ ├── 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/
-│ ├── Login.tsx
-│ ├── Register.tsx
-│ ├── ForgotPassword.tsx
-│ ├── ResetPassword.tsx
-│ ├── VerifyEmail.tsx
-│ ├── AuthCallback.tsx
-│ └── SecuritySettings.tsx
+│ ├── Login.tsx - Main login page with 2FA inline support
+│ ├── Register.tsx - User registration
+│ ├── ForgotPassword.tsx - Password recovery email request
+│ ├── ResetPassword.tsx - Set new password with token
+│ ├── VerifyEmail.tsx - Email verification confirmation
+│ ├── AuthCallback.tsx - OAuth callback handler
+│ └── SecuritySettings.tsx - Security hub (sessions, password, 2FA)
└── README.md
```
@@ -67,6 +71,15 @@ modules/auth/
| `/auth/logout` | POST | Cerrar sesión y revocar token |
| `/auth/social/:provider` | GET | Iniciar flujo OAuth con proveedor social |
| `/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
@@ -139,8 +152,13 @@ npm run test:e2e auth
## 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)
-- [ ] **2FA Implementation** (45h) - Autenticación de dos factores con TOTP
- [ ] **Auto-refresh Tokens** (60h) - Renovación automática de JWT sin logout forzado
- [ ] **CSRF Protection** (16h) - Protección contra Cross-Site Request Forgery
diff --git a/src/modules/auth/pages/AuthCallback.tsx b/src/modules/auth/pages/AuthCallback.tsx
index 9989687..e8bb374 100644
--- a/src/modules/auth/pages/AuthCallback.tsx
+++ b/src/modules/auth/pages/AuthCallback.tsx
@@ -31,8 +31,8 @@ export default function AuthCallback() {
// Redirect after a brief delay
setTimeout(() => {
if (isNewUser) {
- // New user - redirect to onboarding
- navigate('/onboarding');
+ // New user - redirect to dashboard (onboarding not yet implemented)
+ navigate('/dashboard');
} else {
navigate(returnUrl);
}
diff --git a/src/modules/auth/pages/SecuritySettings.tsx b/src/modules/auth/pages/SecuritySettings.tsx
index 5a65f8c..3ea2785 100644
--- a/src/modules/auth/pages/SecuritySettings.tsx
+++ b/src/modules/auth/pages/SecuritySettings.tsx
@@ -1,11 +1,14 @@
/**
* SecuritySettings Page
- * Security settings including active sessions management
+ * Security settings including active sessions management, password change, and 2FA
*/
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { SessionsList } from '../components/SessionsList';
+import { TwoFactorSetup } from '../components/TwoFactorSetup';
+import { use2FAStatus, useDisable2FA, useRegenerateBackupCodes } from '../hooks/use2FA';
+import { apiClient } from '../../../lib/apiClient';
// ============================================================================
// Icons
@@ -59,6 +62,83 @@ export default function SecuritySettings() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('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(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(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 = [
{ id: 'sessions' as const, name: 'Active Sessions', icon: DevicesIcon },
{ id: 'password' as const, name: 'Change Password', icon: KeyIcon },
@@ -144,15 +224,30 @@ export default function SecuritySettings() {
-
@@ -198,73 +311,268 @@ export default function SecuritySettings() {
-
-
-
-
+ {is2FALoading ? (
+
+
+
+
-
-
- Two-Factor Authentication is not enabled
-
-
- Enable 2FA to add an extra layer of security to your account.
- You'll need to enter a code from your authenticator app when signing in.
-
-
-
-
-
-
Available Methods
-
- {/* Authenticator App Option */}
-
-
-
-
+ ) : twoFAStatus?.enabled ? (
+ <>
+ {/* 2FA Enabled Status */}
+
+
+
+ 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" />
-
-
-
Authenticator App
-
Use an app like Google Authenticator or Authy
+
+
+ Two-Factor Authentication is enabled
+
+
+ Your account is protected with {twoFAStatus.method === '2fa_totp' ? 'TOTP authenticator app' : '2FA'}.
+ {twoFAStatus.backupCodesRemaining !== undefined && (
+
+ Backup codes remaining: {twoFAStatus.backupCodesRemaining}
+
+ )}
+
+
-
- Setup
-
-
- {/* SMS Option */}
-
-
-
-
-
-
+ {/* Actions when 2FA is enabled */}
+
+
Manage 2FA
+
+ {/* Regenerate Backup Codes */}
+
+
+
+
+
Backup Codes
+
Generate new backup codes
+
+
+
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
+
-
-
SMS
-
Receive codes via text message
+
+ {/* Disable 2FA */}
+
+
+
+
+
Disable 2FA
+
Remove two-factor authentication
+
+
+
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
+
-
- Setup
-
-
-
+
+ {/* Regenerate Backup Codes Dialog */}
+ {showRegenBackupCodes && (
+
+
Regenerate Backup Codes
+ {newBackupCodes ? (
+
+
+ Save these codes in a secure location. Each code can only be used once.
+
+
+ {newBackupCodes.map((code, idx) => (
+
+ {code}
+
+ ))}
+
+
{ setShowRegenBackupCodes(false); setNewBackupCodes(null); refetch2FAStatus(); }}
+ className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors"
+ >
+ Done
+
+
+ ) : (
+ <>
+
+ Enter your current TOTP code to generate new backup codes. Your old codes will be invalidated.
+
+
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 && (
+
Invalid code. Please try again.
+ )}
+
+ { setShowRegenBackupCodes(false); setRegenCode(''); }}
+ className="flex-1 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition-colors"
+ >
+ Cancel
+
+
+ {regenBackupCodesMutation.isPending ? 'Generating...' : 'Generate'}
+
+
+ >
+ )}
+
+ )}
+
+ {/* Disable 2FA Dialog */}
+ {showDisable2FAConfirm && (
+
+
Confirm Disable 2FA
+
+ This will remove two-factor authentication from your account. Enter your current TOTP code to confirm.
+
+
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 && (
+
Invalid code. Please try again.
+ )}
+
+ { setShowDisable2FAConfirm(false); setDisableCode(''); }}
+ className="flex-1 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition-colors"
+ >
+ Cancel
+
+
+ {disable2FAMutation.isPending ? 'Disabling...' : 'Disable 2FA'}
+
+
+
+ )}
+ >
+ ) : (
+ <>
+ {/* 2FA Not Enabled Status */}
+
+
+
+
+
+
+
+ Two-Factor Authentication is not enabled
+
+
+ Enable 2FA to add an extra layer of security to your account.
+ You'll need to enter a code from your authenticator app when signing in.
+
+
+
+
+
+
+
Available Methods
+
+ {/* Authenticator App Option */}
+
+
+
+
+
Authenticator App
+
Use an app like Google Authenticator or Authy
+
+
+
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
+
+
+
+ {/* SMS Option - Coming Soon */}
+
+
+
+
+
SMS
+
Receive codes via text message
+
+
+
+ Coming Soon
+
+
+
+ >
+ )}
)}
+
+ {/* 2FA Setup Modal */}
+
setShowSetup2FA(false)}
+ onSuccess={() => refetch2FAStatus()}
+ />
diff --git a/src/modules/education/components/ContinueLearningCard.tsx b/src/modules/education/components/ContinueLearningCard.tsx
new file mode 100644
index 0000000..7802552
--- /dev/null
+++ b/src/modules/education/components/ContinueLearningCard.tsx
@@ -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
= ({ className = '' }) => {
+ const { data: continueLearning, isLoading, error } = useContinueLearning();
+
+ // Loading state
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ // No continue learning data
+ if (!continueLearning || error) {
+ return (
+
+
+
+
Comienza tu viaje de aprendizaje
+
+ Inscribete en un curso para empezar
+
+
+
+ Explorar Cursos
+
+
+
+ );
+ }
+
+ 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 (
+
+
+ {/* Thumbnail */}
+
+ {continueLearning.courseThumbnail ? (
+
+ ) : (
+
+
+
+ )}
+ {/* Progress overlay */}
+
+
+
+ {/* Content */}
+
+
+
+
Continua donde lo dejaste
+
+ {continueLearning.courseTitle}
+
+
+ Siguiente: {' '}
+ {continueLearning.lastLessonTitle}
+
+
+ {/* Stats */}
+
+
+
+ {continueLearning.progressPercentage}% completado
+
+
+
+ {formatTime(continueLearning.lastAccessedAt)}
+
+
+
+
+ {/* Action Button */}
+
+
+
Continuar
+
+
+
+
+
+ );
+};
+
+export default ContinueLearningCard;
diff --git a/src/modules/education/components/CourseReviewsSection.tsx b/src/modules/education/components/CourseReviewsSection.tsx
new file mode 100644
index 0000000..82411ca
--- /dev/null
+++ b/src/modules/education/components/CourseReviewsSection.tsx
@@ -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 = ({
+ courseId,
+ isEnrolled = false,
+ hasCompleted = false,
+}) => {
+ const [filterRating, setFilterRating] = useState(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 (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Section Header */}
+
+
+
Resenas del Curso
+
+
+ {/* Review Form (if eligible) */}
+ {canReview && (
+
+ )}
+
+ {/* Show existing user review */}
+ {myReview && (
+
+
Tu resena:
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ ★
+
+ ))}
+
+ {myReview.title &&
{myReview.title}
}
+
{myReview.comment}
+
+ )}
+
+ {/* Prompt for non-enrolled users */}
+ {!isEnrolled && (
+
+
+ Inscribete y completa el curso para dejar tu resena
+
+
+ )}
+
+ {/* Prompt for enrolled but not completed */}
+ {isEnrolled && !hasCompleted && !myReview && (
+
+
+ Completa el curso para dejar tu resena
+
+
+ )}
+
+ {/* Reviews List */}
+
+
+ );
+};
+
+export default CourseReviewsSection;
diff --git a/src/modules/education/components/LessonProgress.tsx b/src/modules/education/components/LessonProgress.tsx
new file mode 100644
index 0000000..38e56eb
--- /dev/null
+++ b/src/modules/education/components/LessonProgress.tsx
@@ -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 = {
+ video: ,
+ text: ,
+ quiz: ,
+ exercise: ,
+};
+
+const LessonProgress: React.FC = ({
+ 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 (
+
+ {/* Progress Header */}
+
+
+
+ Leccion {currentIndex + 1} de {stats.total}
+
+
+ ({stats.completed} completadas)
+
+
+
{stats.percentage}%
+
+
+ {/* Progress Dots */}
+
+ {allLessons.map((lesson, index) => {
+ const isCurrent = lesson.id === currentLessonId;
+ const isPast = index < currentIndex;
+
+ return (
+ {
+ 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 ? (
+
+ ) : lesson.isLocked ? (
+
+ ) : (
+ {index + 1}
+ )}
+
+ );
+ })}
+
+
+ {/* Current Lesson Info */}
+ {currentIndex >= 0 && allLessons[currentIndex] && (
+
+
+
+ {contentTypeIcons[allLessons[currentIndex].contentType]}
+
+
+
+ {allLessons[currentIndex].title}
+
+
+ {allLessons[currentIndex].moduleTitle}
+
+
+ {allLessons[currentIndex].durationMinutes > 0 && (
+
+
+ {formatTime(allLessons[currentIndex].durationMinutes)}
+
+ )}
+
+
+ )}
+
+ );
+ }
+
+ // Full vertical view with module sections
+ return (
+
+ {/* Header */}
+
+
+
Contenido del Curso
+ {stats.percentage}% completado
+
+
+
+ {stats.completed} de {stats.total} lecciones completadas
+
+
+
+ {/* Module List */}
+
+ {modules.map((module, moduleIndex) => (
+
+ ))}
+
+
+ );
+};
+
+// Module Section Component
+interface ModuleSectionProps {
+ module: CourseModule;
+ moduleIndex: number;
+ currentLessonId: string;
+ courseSlug: string;
+ onLessonClick?: (lessonId: string) => void;
+}
+
+const ModuleSection: React.FC = ({
+ 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 (
+
+ {/* Module Header */}
+
setIsExpanded(!isExpanded)}
+ className="w-full flex items-center justify-between p-4 hover:bg-gray-700/30 transition-colors"
+ >
+
+
+ {moduleProgress === 100 ? (
+
+ ) : (
+ moduleIndex + 1
+ )}
+
+
+
{module.title}
+
+ {completedLessons}/{totalLessons} lecciones
+
+
+
+
+ {module.isLocked && }
+
+
+
+
+ {/* Module Lessons */}
+ {isExpanded && (
+
+ {module.lessons.map((lesson, lessonIndex) => {
+ const isCurrent = lesson.id === currentLessonId;
+
+ return (
+
{
+ 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 */}
+
+ {lesson.isCompleted ? (
+
+ ) : module.isLocked ? (
+
+ ) : (
+ contentTypeIcons[lesson.contentType]
+ )}
+
+
+ {/* Lesson Info */}
+
+
+ {/* Duration */}
+ {lesson.durationMinutes > 0 && (
+
+ {lesson.durationMinutes}m
+
+ )}
+
+ {/* Free Badge */}
+ {lesson.isFree && !lesson.isCompleted && !isCurrent && (
+
+ Gratis
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default LessonProgress;
diff --git a/src/modules/education/components/ReviewForm.tsx b/src/modules/education/components/ReviewForm.tsx
new file mode 100644
index 0000000..039917d
--- /dev/null
+++ b/src/modules/education/components/ReviewForm.tsx
@@ -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;
+ disabled?: boolean;
+ maxCommentLength?: number;
+}
+
+const ReviewForm: React.FC = ({
+ 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(null);
+ const [success, setSuccess] = useState(false);
+
+ const ratingLabels: Record = {
+ 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 (
+
+
+
+
+
Gracias por tu resena
+
+ Tu opinion ayuda a otros estudiantes a tomar mejores decisiones
+
+
setSuccess(false)}
+ className="text-sm text-blue-400 hover:text-blue-300"
+ >
+ Escribir otra resena
+
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default ReviewForm;
diff --git a/src/modules/education/components/index.ts b/src/modules/education/components/index.ts
index 284f1f5..60b7b84 100644
--- a/src/modules/education/components/index.ts
+++ b/src/modules/education/components/index.ts
@@ -39,3 +39,9 @@ export { default as QuizHistoryCard } from './QuizHistoryCard';
export type { QuizAttemptHistory } from './QuizHistoryCard';
export { default as EarnedCertificates } 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';
diff --git a/src/modules/education/hooks/index.ts b/src/modules/education/hooks/index.ts
new file mode 100644
index 0000000..85e2f2c
--- /dev/null
+++ b/src/modules/education/hooks/index.ts
@@ -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';
diff --git a/src/modules/education/hooks/useCertificates.ts b/src/modules/education/hooks/useCertificates.ts
new file mode 100644
index 0000000..cc901f8
--- /dev/null
+++ b/src/modules/education/hooks/useCertificates.ts
@@ -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 {
+ const response = await api.get>('/education/my/certificates');
+ return response.data.data;
+}
+
+async function fetchCertificateById(id: string): Promise {
+ const response = await api.get>(`/education/certificates/${id}`);
+ return response.data.data;
+}
+
+async function fetchCertificateByCourse(courseId: string): Promise {
+ try {
+ const response = await api.get>(
+ `/education/courses/${courseId}/certificate`
+ );
+ return response.data.data;
+ } catch {
+ return null;
+ }
+}
+
+async function verifyCertificate(credentialId: string): Promise {
+ const response = await api.get>(
+ `/education/certificates/verify/${credentialId}`
+ );
+ return response.data.data;
+}
+
+async function downloadCertificate(id: string): Promise {
+ 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>(
+ `/education/certificates/${id}/share`,
+ { platform }
+ );
+ return response.data.data;
+}
+
+// ============================================================================
+// Hooks
+// ============================================================================
+
+/**
+ * Hook to fetch all user certificates
+ */
+export function useCertificates() {
+ return useQuery({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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;
diff --git a/src/modules/education/hooks/useCourseProgress.ts b/src/modules/education/hooks/useCourseProgress.ts
new file mode 100644
index 0000000..bfac5c3
--- /dev/null
+++ b/src/modules/education/hooks/useCourseProgress.ts
@@ -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 {
+ const response = await api.get>(
+ `/education/courses/${courseId}/progress`
+ );
+ return response.data.data;
+}
+
+async function fetchContinueLearning(): Promise {
+ try {
+ const response = await api.get>('/education/my/continue');
+ return response.data.data;
+ } catch {
+ return null;
+ }
+}
+
+async function downloadProgressReport(courseId: string): Promise {
+ 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({
+ 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({
+ queryKey: courseProgressKeys.continue(),
+ queryFn: fetchContinueLearning,
+ staleTime: 30 * 1000, // 30 seconds
+ });
+}
+
+/**
+ * Hook to download progress report
+ */
+export function useDownloadProgressReport() {
+ return useMutation({
+ 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;
diff --git a/src/modules/education/hooks/useCourseReviews.ts b/src/modules/education/hooks/useCourseReviews.ts
new file mode 100644
index 0000000..3423673
--- /dev/null
+++ b/src/modules/education/hooks/useCourseReviews.ts
@@ -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 {
+ const params = new URLSearchParams({
+ page: String(page),
+ pageSize: String(pageSize),
+ });
+ if (rating) {
+ params.append('rating', String(rating));
+ }
+
+ const response = await api.get>(
+ `/education/courses/${courseId}/reviews?${params.toString()}`
+ );
+ return response.data.data;
+}
+
+async function fetchRatingSummary(courseId: string): Promise {
+ const response = await api.get>(
+ `/education/courses/${courseId}/reviews/summary`
+ );
+ return response.data.data;
+}
+
+async function fetchMyReview(courseId: string): Promise {
+ try {
+ const response = await api.get>(
+ `/education/courses/${courseId}/reviews/my`
+ );
+ return response.data.data;
+ } catch {
+ return null;
+ }
+}
+
+async function createReview(courseId: string, data: CreateReviewData): Promise {
+ const response = await api.post>(
+ `/education/courses/${courseId}/reviews`,
+ data
+ );
+ return response.data.data;
+}
+
+async function updateReview(
+ courseId: string,
+ reviewId: string,
+ data: Partial
+): Promise {
+ const response = await api.patch>(
+ `/education/courses/${courseId}/reviews/${reviewId}`,
+ data
+ );
+ return response.data.data;
+}
+
+async function deleteReview(courseId: string, reviewId: string): Promise {
+ await api.delete(`/education/courses/${courseId}/reviews/${reviewId}`);
+}
+
+async function markReviewHelpful(courseId: string, reviewId: string): Promise<{ helpful: number }> {
+ const response = await api.post>(
+ `/education/courses/${courseId}/reviews/${reviewId}/helpful`
+ );
+ return response.data.data;
+}
+
+async function reportReview(
+ courseId: string,
+ reviewId: string,
+ reason: string
+): Promise {
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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 }
+ >({
+ 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({
+ 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({
+ mutationFn: ({ courseId, reviewId, reason }) => reportReview(courseId, reviewId, reason),
+ });
+}
+
+export default useCourseReviews;
diff --git a/src/modules/education/hooks/useQuiz.ts b/src/modules/education/hooks/useQuiz.ts
new file mode 100644
index 0000000..6acddf2
--- /dev/null
+++ b/src/modules/education/hooks/useQuiz.ts
@@ -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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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;
diff --git a/src/modules/investment/components/ProductCard.tsx b/src/modules/investment/components/ProductCard.tsx
new file mode 100644
index 0000000..c3dc5f2
--- /dev/null
+++ b/src/modules/investment/components/ProductCard.tsx
@@ -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 (
+
+
+ {c.label}
+
+ );
+};
+
+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 (
+
+
+ {c.label}
+
+ );
+};
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+export const ProductCard: React.FC = ({
+ id,
+ code,
+ name,
+ description,
+ riskProfile,
+ targetReturnMin,
+ targetReturnMax,
+ maxDrawdown,
+ minInvestment,
+ managementFee,
+ performanceFee,
+ status = 'open',
+ historicalReturn,
+ totalInvestors,
+ aum,
+ isNew,
+ isFeatured,
+ onInvest,
+ compact = false,
+}) => {
+ const icons: Record = {
+ atlas: ,
+ orion: ,
+ nova: ,
+ };
+
+ const isAvailable = status === 'open' || status === 'limited';
+
+ if (compact) {
+ return (
+
+
+ {icons[code] || }
+
+
+
+
{name}
+ {isNew && (
+
+ Nuevo
+
+ )}
+
+
+
+
+ {targetReturnMin}-{targetReturnMax}%
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Featured/New Badges */}
+ {(isFeatured || isNew) && (
+
+ {isFeatured && (
+
+
+ Destacado
+
+ )}
+ {isNew && (
+
+ Nuevo
+
+ )}
+
+ )}
+
+ {/* Header */}
+
+
+
+ {icons[code] || }
+
+
+
+
+
{description}
+
+ {/* Stats Grid */}
+
+
+
Target Mensual
+
+ {targetReturnMin}-{targetReturnMax}%
+
+
+
+
Max Drawdown
+
{maxDrawdown}%
+
+
+
+ {/* Additional Info */}
+
+
+ Inversion Minima
+ ${minInvestment.toLocaleString()}
+
+ {historicalReturn !== undefined && (
+
+ Rendimiento Historico
+ = 0 ? 'text-emerald-400' : 'text-red-400'
+ }`}
+ >
+ {historicalReturn >= 0 ? '+' : ''}
+ {historicalReturn.toFixed(1)}%
+
+
+ )}
+ {performanceFee !== undefined && (
+
+ Performance Fee
+ {performanceFee}%
+
+ )}
+ {totalInvestors !== undefined && (
+
+
+
+ Inversores
+
+ {totalInvestors.toLocaleString()}
+
+ )}
+
+
+
+ {/* Actions */}
+
+
+
+ Ver Detalles
+
+ {isAvailable ? (
+ onInvest ? (
+ 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
+
+ ) : (
+
+ Invertir Ahora
+
+ )
+ ) : (
+
+ {status === 'closed' ? 'No Disponible' : 'Proximamente'}
+
+ )}
+
+
+ {status === 'limited' && (
+
+
+ Cupo limitado - actua rapido
+
+ )}
+
+
+ {/* Risk Warning */}
+
+
+
+
+ El trading conlleva riesgos. Los rendimientos pasados no garantizan
+ rendimientos futuros.
+
+
+
+
+ );
+};
+
+export default ProductCard;
diff --git a/src/modules/investment/components/index.ts b/src/modules/investment/components/index.ts
index 0994b14..f91b6ea 100644
--- a/src/modules/investment/components/index.ts
+++ b/src/modules/investment/components/index.ts
@@ -34,3 +34,10 @@ export type { RiskMetrics, RiskScore, RiskRecommendation } from './RiskAnalysisP
export { default as PortfolioOptimizerWidget } 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';
diff --git a/src/modules/investment/hooks/index.ts b/src/modules/investment/hooks/index.ts
new file mode 100644
index 0000000..7782956
--- /dev/null
+++ b/src/modules/investment/hooks/index.ts
@@ -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';
diff --git a/src/modules/investment/hooks/useDeposit.ts b/src/modules/investment/hooks/useDeposit.ts
new file mode 100644
index 0000000..7a2c4e3
--- /dev/null
+++ b/src/modules/investment/hooks/useDeposit.ts
@@ -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({
+ 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;
diff --git a/src/modules/investment/hooks/useInvestmentAccounts.ts b/src/modules/investment/hooks/useInvestmentAccounts.ts
new file mode 100644
index 0000000..f76dfbb
--- /dev/null
+++ b/src/modules/investment/hooks/useInvestmentAccounts.ts
@@ -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({
+ queryKey: investmentAccountKeys.lists(),
+ queryFn: getUserAccounts,
+ staleTime: 30 * 1000, // 30 seconds
+ });
+}
+
+/**
+ * Hook to fetch account summary (total balance, earnings, etc.)
+ */
+export function useAccountSummary() {
+ return useQuery({
+ queryKey: investmentAccountKeys.summary(),
+ queryFn: getAccountSummary,
+ staleTime: 30 * 1000,
+ });
+}
+
+/**
+ * Hook to fetch a single account by ID
+ */
+export function useAccountDetail(accountId: string) {
+ return useQuery({
+ 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({
+ 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;
diff --git a/src/modules/investment/hooks/useInvestmentProducts.ts b/src/modules/investment/hooks/useInvestmentProducts.ts
new file mode 100644
index 0000000..36e8d7c
--- /dev/null
+++ b/src/modules/investment/hooks/useInvestmentProducts.ts
@@ -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({
+ 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({
+ 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({
+ queryKey: investmentProductKeys.performance(productId, period),
+ queryFn: () => getProductPerformance(productId, period),
+ enabled: !!productId,
+ staleTime: 60 * 1000, // 1 minute
+ });
+}
+
+export default useInvestmentProducts;
diff --git a/src/modules/investment/hooks/useKYCStatus.ts b/src/modules/investment/hooks/useKYCStatus.ts
new file mode 100644
index 0000000..2e5b7c2
--- /dev/null
+++ b/src/modules/investment/hooks/useKYCStatus.ts
@@ -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 {
+ 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 {
+ 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({
+ 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({
+ 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;
diff --git a/src/modules/investment/hooks/useWithdraw.ts b/src/modules/investment/hooks/useWithdraw.ts
new file mode 100644
index 0000000..3ecf7c1
--- /dev/null
+++ b/src/modules/investment/hooks/useWithdraw.ts
@@ -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({
+ 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({
+ 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;
diff --git a/src/modules/investment/pages/Deposit.tsx b/src/modules/investment/pages/Deposit.tsx
new file mode 100644
index 0000000..f8da177
--- /dev/null
+++ b/src/modules/investment/pages/Deposit.tsx
@@ -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([]);
+
+ 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 (
+
+ );
+ }
+
+ if (accountsError) {
+ return (
+
+
+
+
+ Error al cargar cuentas
+
+
+ {accountsError.message}
+
+
+ Volver al Portfolio
+
+
+
+ );
+ }
+
+ if (accountOptions.length === 0) {
+ return (
+
+
+
+
+
+
+
Depositar Fondos
+
Agrega fondos a tus cuentas de inversion
+
+
+
+
+
+
+ No tienes cuentas activas
+
+
+ Primero necesitas abrir una cuenta de inversion para poder depositar
+
+
+ Ver Productos de Inversion
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
Depositar Fondos
+
Agrega fondos a tus cuentas de inversion
+
+
+
+
+ {/* Form Section */}
+
+
+
+ Detalles del Deposito
+
+
+
+
+
+ {/* Info Sidebar */}
+
+ {/* Portfolio Summary */}
+ {summary && (
+
+
Tu Portfolio
+
+
+
+
+
+
+
Balance Total
+
+ ${summary.totalBalance.toLocaleString(undefined, { minimumFractionDigits: 2 })}
+
+
+
+
+
+
+
+
+
Rendimiento
+
= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
+ {summary.overallReturnPercent >= 0 ? '+' : ''}{summary.overallReturnPercent.toFixed(2)}%
+
+
+
+
+
+ )}
+
+ {/* Security Info */}
+
+
+
+
Pago Seguro
+
+
+
+ ✓
+ Procesado por Stripe, lider en pagos seguros
+
+
+ ✓
+ Encriptacion SSL de 256 bits
+
+
+ ✓
+ No almacenamos datos de tarjeta
+
+
+ ✓
+ Fondos acreditados al instante
+
+
+
+
+ {/* Help */}
+
+
+ Nota: Los depositos se acreditan inmediatamente despues
+ de la confirmacion del pago. Si tienes problemas, contacta a soporte.
+
+
+
+
+
+ );
+};
+
+export default Deposit;
diff --git a/src/modules/investment/pages/Investment.tsx b/src/modules/investment/pages/Investment.tsx
index 1d1da54..3bf6d73 100644
--- a/src/modules/investment/pages/Investment.tsx
+++ b/src/modules/investment/pages/Investment.tsx
@@ -486,14 +486,14 @@ export default function Investment() {
icon={ }
label="Depositar"
description="Agregar fondos a tus cuentas"
- to="/investment/portfolio"
+ to="/investment/deposit"
color="bg-blue-500/20"
/>
}
label="Retirar"
description="Solicitar retiro de fondos"
- to="/investment/withdrawals"
+ to="/investment/withdraw"
color="bg-purple-500/20"
/>
{
+ 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([]);
+
+ 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 (
+
+ );
+ }
+
+ if (accountsError) {
+ return (
+
+
+
+
+ Error al cargar cuentas
+
+
+ {accountsError.message}
+
+
+ Volver al Portfolio
+
+
+
+ );
+ }
+
+ // KYC not approved - show verification required
+ if (!isKYCApproved) {
+ return (
+
+
+
+
+
+
+
Retirar Fondos
+
Solicita un retiro de tus cuentas de inversion
+
+
+
+
+
+
+ Verificacion de Identidad Requerida
+
+
+ Para poder realizar retiros, necesitas completar la verificacion de identidad (KYC).
+ Este proceso ayuda a proteger tu cuenta y cumplir con regulaciones.
+
+
+ {kycData && (
+
+
+
+ )}
+
+ {isKYCPending ? (
+
+
+
+ Tu verificacion esta en proceso. Te notificaremos cuando este lista.
+
+
+ ) : (
+
+ Iniciar Verificacion KYC
+
+ )}
+
+
+ );
+ }
+
+ // No accounts with balance
+ if (accountOptions.length === 0) {
+ return (
+
+
+
+
+
+
+
Retirar Fondos
+
Solicita un retiro de tus cuentas de inversion
+
+
+
+
+
+
+ No hay fondos disponibles para retiro
+
+
+ No tienes cuentas activas con balance disponible para retirar
+
+
+ Volver al Portfolio
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
Retirar Fondos
+
Solicita un retiro de tus cuentas de inversion
+
+
+
+
+ {/* Form Section */}
+
+
+
+ Solicitud de Retiro
+
+
+
+
+
+ {/* Info Sidebar */}
+
+ {/* Available Balance */}
+ {summary && (
+
+
Disponible para Retiro
+
+
+
+
+
+
Balance Total
+
+ ${summary.totalBalance.toLocaleString(undefined, { minimumFractionDigits: 2 })}
+
+
+
+
+ )}
+
+ {/* Process Info */}
+
+
+
+
Proceso de Retiro
+
+
+
+ 1.
+ Solicita tu retiro completando el formulario
+
+
+ 2.
+ Verifica con tu codigo 2FA
+
+
+ 3.
+ Nuestro equipo revisa la solicitud (24h)
+
+
+ 4.
+ Fondos enviados (1-3 dias habiles)
+
+
+
+
+ {/* Important Notice */}
+
+
+
+
+
Importante
+
+ - Limite diario: $10,000
+ - Minimo por retiro: $50
+ - Se requiere verificacion 2FA
+ - Los retiros estan sujetos a revision
+
+
+
+
+
+ {/* View History Link */}
+
+ Ver Historial de Retiros
+
+
+
+
+ );
+};
+
+export default Withdraw;
diff --git a/src/modules/marketplace/components/MarketplaceFilters.tsx b/src/modules/marketplace/components/MarketplaceFilters.tsx
new file mode 100644
index 0000000..2d97db2
--- /dev/null
+++ b/src/modules/marketplace/components/MarketplaceFilters.tsx
@@ -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 = ({
+ 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 (
+
+ {/* Search and Sort Bar */}
+
+
+
+ {/* Category Pills */}
+ {!compact && (
+
+ {CATEGORY_OPTIONS.map((cat) => (
+ 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'
+ }`}
+ >
+ {cat.icon}
+ {cat.label}
+
+ ))}
+
+ )}
+
+ {/* Advanced Filters Panel */}
+ {showAdvanced && (
+
+
+ {/* Price Range */}
+
+
+ Price Range
+
+
+ {PRICE_RANGES.map((range) => {
+ const isSelected =
+ filters.minPrice === range.min &&
+ filters.maxPrice === range.max;
+ return (
+ 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 && }
+
+ );
+ })}
+
+
+
+ {/* Rating Filter */}
+
+
+ Minimum Rating
+
+
+ {RATING_OPTIONS.map((rating) => (
+ 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'
+ }`}
+ >
+
+ {rating.label} Stars
+ {filters.minRating === rating.value && (
+
+ )}
+
+ ))}
+
+
+
+ {/* Quick Filters */}
+
+
+ Quick Filters
+
+
+
+
+ {filters.verified && }
+
+ Verified Providers Only
+
+
+
+
+ {filters.hasFreeTrial && }
+
+ Free Trial Available
+
+
+
+
+ {/* Active Filters Summary */}
+
+
+ Active Filters
+
+
+ {hasActiveFilters ? (
+
+ {filters.category && (
+
+ {CATEGORY_OPTIONS.find((c) => c.value === filters.category)?.label}
+ handleCategoryChange('')}
+ className="hover:text-white"
+ >
+
+
+
+ )}
+ {filters.minRating && (
+
+ {filters.minRating}+ Stars
+ handleRatingChange(undefined)}
+ className="hover:text-white"
+ >
+
+
+
+ )}
+ {(filters.minPrice !== undefined || filters.maxPrice !== undefined) && (
+
+ ${filters.minPrice || 0}
+ {filters.maxPrice ? ` - $${filters.maxPrice}` : '+'}
+ handlePriceRangeSelect(0, undefined)}
+ className="hover:text-white"
+ >
+
+
+
+ )}
+ {filters.verified && (
+
+ Verified
+
+
+
+
+ )}
+ {filters.hasFreeTrial && (
+
+ Free Trial
+
+
+
+
+ )}
+
+ ) : (
+
No filters applied
+ )}
+
+
+
+
+ )}
+
+ {/* Results Count */}
+
+
+ {isLoading
+ ? 'Searching...'
+ : totalResults !== undefined
+ ? `${totalResults.toLocaleString()} products found`
+ : 'Loading...'}
+
+
+
+
+ );
+};
+
+export default MarketplaceFilters;
diff --git a/src/modules/marketplace/components/index.ts b/src/modules/marketplace/components/index.ts
index dfb2f91..efe43c7 100644
--- a/src/modules/marketplace/components/index.ts
+++ b/src/modules/marketplace/components/index.ts
@@ -7,3 +7,4 @@ export { AdvisoryCard } from './AdvisoryCard';
export { CourseProductCard } from './CourseProductCard';
export { ProductCard } from './ProductCard';
export { ReviewCard } from './ReviewCard';
+export { MarketplaceFilters } from './MarketplaceFilters';
diff --git a/src/modules/marketplace/hooks/index.ts b/src/modules/marketplace/hooks/index.ts
new file mode 100644
index 0000000..0f85469
--- /dev/null
+++ b/src/modules/marketplace/hooks/index.ts
@@ -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';
diff --git a/src/modules/marketplace/hooks/useCheckout.ts b/src/modules/marketplace/hooks/useCheckout.ts
new file mode 100644
index 0000000..2416718
--- /dev/null
+++ b/src/modules/marketplace/hooks/useCheckout.ts
@@ -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({
+ queryKey: checkoutKeys.subscriptions(),
+ queryFn: () => marketplaceService.getMySubscriptions(),
+ staleTime: 1000 * 60 * 5,
+ });
+}
+
+/**
+ * Hook for fetching a single subscription
+ */
+export function useSubscription(id: string) {
+ return useQuery({
+ 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({
+ mutationFn: (id) => marketplaceService.cancelSubscription(id),
+ onSuccess: (data, id) => {
+ // Update cache with cancelled subscription
+ queryClient.setQueryData(
+ 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({
+ queryKey: checkoutKeys.bookings(),
+ queryFn: () => marketplaceService.getMyBookings(),
+ staleTime: 1000 * 60 * 5,
+ });
+}
+
+/**
+ * Hook for fetching a single booking
+ */
+export function useBooking(id: string) {
+ return useQuery({
+ 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({
+ mutationFn: (id) => marketplaceService.cancelBooking(id),
+ onSuccess: (data, id) => {
+ // Update cache with cancelled booking
+ queryClient.setQueryData(
+ 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) {
+ const defaultState: CheckoutState = {
+ productId: null,
+ productType: null,
+ pricingId: null,
+ couponCode: null,
+ discountAmount: 0,
+ ...initialState,
+ };
+
+ return defaultState;
+}
diff --git a/src/modules/marketplace/hooks/useCreateProduct.ts b/src/modules/marketplace/hooks/useCreateProduct.ts
new file mode 100644
index 0000000..590b7a1
--- /dev/null
+++ b/src/modules/marketplace/hooks/useCreateProduct.ts
@@ -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({
+ 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>({
+ 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 }
+ >({
+ 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({
+ 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({
+ 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),
+ });
+}
diff --git a/src/modules/marketplace/hooks/useMarketplaceProducts.ts b/src/modules/marketplace/hooks/useMarketplaceProducts.ts
new file mode 100644
index 0000000..b5b2722
--- /dev/null
+++ b/src/modules/marketplace/hooks/useMarketplaceProducts.ts
@@ -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, 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
+) {
+ 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({
+ 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({
+ 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({
+ queryKey: [...marketplaceKeys.related(productId), limit],
+ queryFn: () => marketplaceService.getRelatedProducts(productId, limit),
+ enabled: !!productId,
+ staleTime: 1000 * 60 * 10,
+ });
+}
diff --git a/src/modules/marketplace/hooks/useProductDetail.ts b/src/modules/marketplace/hooks/useProductDetail.ts
new file mode 100644
index 0000000..9b99f55
--- /dev/null
+++ b/src/modules/marketplace/hooks/useProductDetail.ts
@@ -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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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({
+ 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, 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({
+ mutationFn: ({ reviewId }) => marketplaceService.markReviewHelpful(reviewId),
+ onSuccess: (_, variables) => {
+ // Invalidate reviews cache
+ queryClient.invalidateQueries({
+ queryKey: productDetailKeys.reviews(variables.productId, variables.productType),
+ });
+ },
+ });
+}
diff --git a/src/modules/marketplace/hooks/useSellerProducts.ts b/src/modules/marketplace/hooks/useSellerProducts.ts
new file mode 100644
index 0000000..ba35615
--- /dev/null
+++ b/src/modules/marketplace/hooks/useSellerProducts.ts
@@ -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, 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({
+ 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 }
+ >({
+ mutationFn: ({ id, data }) => marketplaceService.updateSellerProduct(id, data),
+ onSuccess: (data, variables) => {
+ // Update cache with updated product
+ queryClient.setQueryData(
+ 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(
+ sellerProductKeys.detail(variables.id),
+ data
+ );
+ queryClient.invalidateQueries({ queryKey: sellerProductKeys.list() });
+ },
+ });
+}
+
+/**
+ * Hook for deleting a seller product
+ */
+export function useDeleteSellerProduct() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (id) => marketplaceService.deleteSellerProduct(id),
+ onSuccess: (_, id) => {
+ // Remove from cache
+ queryClient.removeQueries({ queryKey: sellerProductKeys.detail(id) });
+ // Invalidate list
+ queryClient.invalidateQueries({ queryKey: sellerProductKeys.list() });
+ },
+ });
+}
diff --git a/src/modules/marketplace/hooks/useSellerStats.ts b/src/modules/marketplace/hooks/useSellerStats.ts
new file mode 100644
index 0000000..eda7874
--- /dev/null
+++ b/src/modules/marketplace/hooks/useSellerStats.ts
@@ -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({
+ 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({
+ 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({
+ 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,
+ });
+}
diff --git a/src/modules/marketplace/pages/CheckoutFlow.tsx b/src/modules/marketplace/pages/CheckoutFlow.tsx
new file mode 100644
index 0000000..65e6d13
--- /dev/null
+++ b/src/modules/marketplace/pages/CheckoutFlow.tsx
@@ -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;
+ isProcessing: boolean;
+ amount: number;
+}
+
+const PaymentForm: React.FC = ({ onSubmit, isProcessing, amount }) => {
+ const stripe = useStripe();
+ const elements = useElements();
+ const [error, setError] = useState(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 (
+
+ );
+};
+
+// ============================================================================
+// 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(null);
+ const [selectedPricing, setSelectedPricing] = useState(null);
+ const [couponCode, setCouponCode] = useState('');
+ const [appliedCoupon, setAppliedCoupon] = useState(null);
+ const [isValidatingCoupon, setIsValidatingCoupon] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [checkoutError, setCheckoutError] = useState(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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (!product && !loadingDetail) {
+ return (
+
+
+
Product Not Found
+
The requested product does not exist.
+
+ Back to Marketplace
+
+
+ );
+ }
+
+ // Success state
+ if (isComplete) {
+ return (
+
+
+
+
+
+
Purchase Complete!
+
+ Thank you for your purchase. You now have access to {product?.name}.
+
+
+
+ Continue Shopping
+
+
+ Go to Dashboard
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Back link */}
+
+
+ Back to Product
+
+
+ {/* Progress Steps */}
+
+ {CHECKOUT_STEPS.map((step, index) => (
+
+
+
= step.id
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-700 text-gray-400'
+ }`}
+ >
+ {currentStep > step.id ? : step.id}
+
+
+
{step.title}
+
{step.description}
+
+
+ {index < CHECKOUT_STEPS.length - 1 && (
+ step.id ? 'bg-blue-600' : 'bg-gray-700'
+ }`}
+ />
+ )}
+
+ ))}
+
+
+ {/* Main Content */}
+
+ {/* Left Column - Step Content */}
+
+ {/* Step 1: Review */}
+ {currentStep === 1 && product && currentSignalPack && (
+
+
Review Your Selection
+
+ {/* Product Info */}
+
+
+ {product.thumbnailUrl ? (
+
+ ) : (
+
+ )}
+
+
+
{product.name}
+
{product.providerName}
+
+ {product.shortDescription}
+
+
+
+
+ {/* Pricing Options */}
+
+
+ Select Subscription Plan
+
+
+ {currentSignalPack.pricing.map((pricing) => (
+
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'
+ }`}
+ >
+
+
+
+ {selectedPricing?.id === pricing.id && (
+
+ )}
+
+
+
+ {pricing.period}
+
+ {pricing.discountPercent && (
+
+ Save {pricing.discountPercent}%
+
+ )}
+
+
+
+
+ ${pricing.priceUsd}
+
+ {pricing.originalPriceUsd && (
+
+ ${pricing.originalPriceUsd}
+
+ )}
+
+
+ {pricing.isPopular && (
+
+ Most Popular
+
+ )}
+
+ ))}
+
+
+
+ {/* Coupon Code */}
+
+
+ Have a Coupon Code?
+
+ {appliedCoupon?.isValid ? (
+
+
+
+
+ {appliedCoupon.code} - {appliedCoupon.discountPercent}% off
+
+
+
+
+
+
+ ) : (
+
+ 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"
+ />
+
+ {isValidatingCoupon ? (
+
+ ) : (
+ 'Apply'
+ )}
+
+
+ )}
+ {appliedCoupon && !appliedCoupon.isValid && (
+
Invalid coupon code
+ )}
+
+
+ {/* Continue Button */}
+
+ Continue to Payment
+
+
+
+ )}
+
+ {/* Step 2: Payment */}
+ {currentStep === 2 && (
+
+
+
Payment Details
+
+
+ Back
+
+
+
+ {checkoutError && (
+
+ )}
+
+
+
+
+
+ {/* Security Notice */}
+
+
+
+ Your payment is secured with 256-bit SSL encryption
+
+
+
+ )}
+
+
+ {/* Right Column - Order Summary */}
+
+
+
Order Summary
+
+ {product && (
+ <>
+ {/* Product */}
+
+
+
+
+ {product.name}
+
+
+ {selectedPricing?.period || 'Monthly'} subscription
+
+
+
+
+ {/* Price Breakdown */}
+
+
+ Subtotal
+
+ ${selectedPricing?.priceUsd.toFixed(2) || '0.00'}
+
+
+
+ {appliedCoupon?.isValid && (
+
+
+ Discount ({appliedCoupon.discountPercent}%)
+
+
+ -$
+ {(
+ (selectedPricing?.priceUsd || 0) *
+ (appliedCoupon.discountPercent / 100)
+ ).toFixed(2)}
+
+
+ )}
+
+ {selectedPricing?.originalPriceUsd && (
+
+ Plan Savings
+
+ -$
+ {(
+ selectedPricing.originalPriceUsd - selectedPricing.priceUsd
+ ).toFixed(2)}
+
+
+ )}
+
+
+ {/* Total */}
+
+
+ Total
+
+ ${finalPrice.toFixed(2)}
+
+
+ {selectedPricing?.period !== 'lifetime' && (
+
+ Billed {selectedPricing?.period}
+
+ )}
+
+ >
+ )}
+
+ {/* Trust Badges */}
+
+
+
+ 30-day money-back guarantee
+
+
+
+ Secure SSL encryption
+
+
+
+ Cancel anytime
+
+
+
+
+
+
+ );
+}
diff --git a/src/modules/marketplace/pages/CreateProductWizard.tsx b/src/modules/marketplace/pages/CreateProductWizard.tsx
new file mode 100644
index 0000000..c807882
--- /dev/null
+++ b/src/modules/marketplace/pages/CreateProductWizard.tsx
@@ -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:
},
+ { id: 2, title: 'Details', icon:
},
+ { id: 3, title: 'Pricing', icon:
},
+ { id: 4, title: 'Preview', icon:
},
+];
+
+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
= ({ selectedType, onSelect }) => (
+
+
+
What would you like to create?
+
Choose the type of product you want to sell
+
+
+
+ {PRODUCT_TYPES.map((product) => (
+
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'
+ }`}
+ >
+ {product.icon}
+ {product.title}
+ {product.description}
+ {selectedType === product.type && (
+
+
+ Selected
+
+ )}
+
+ ))}
+
+
+);
+
+// Step 2: Product Details
+interface DetailsStepProps {
+ data: ProductFormData;
+ onChange: (field: keyof ProductFormData, value: unknown) => void;
+}
+
+const DetailsStep: React.FC = ({ 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 (
+
+
+
Product Details
+
Provide information about your product
+
+
+
+ {/* Product Name */}
+
+
+ Product Name *
+
+ 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"
+ />
+
+
+ {/* Short Description */}
+
+
+ Short Description *
+
+
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"
+ />
+
+ {data.shortDescription.length}/150 characters
+
+
+
+ {/* Full Description */}
+
+
+ Full Description *
+
+
+
+ {/* Thumbnail */}
+
+
+ Thumbnail Image
+
+
+ {data.thumbnailUrl ? (
+
+
+
onChange('thumbnailUrl', '')}
+ className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center"
+ >
+
+
+
+ ) : (
+ <>
+
+
+ Drag and drop or click to upload
+
+
+ PNG, JPG up to 2MB (recommended: 800x600)
+
+ >
+ )}
+
+
+
+ {/* Tags */}
+
+
Tags
+
+ {data.tags.map((tag) => (
+
+ {tag}
+ handleRemoveTag(tag)}
+ className="hover:text-white"
+ >
+
+
+
+ ))}
+
+
+
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"
+ />
+
+
+
+
+
+
+ {/* Type-specific fields */}
+ {data.type === 'signals' && (
+
+
+ Risk Level
+
+
+ {(['low', 'medium', 'high'] as const).map((level) => (
+ 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
+
+ ))}
+
+
+ )}
+
+ {data.type === 'courses' && (
+
+
+ Difficulty Level
+
+
+ {(['beginner', 'intermediate', 'advanced'] as const).map((level) => (
+ 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}
+
+ ))}
+
+
+ )}
+
+
+ );
+};
+
+// Step 3: Pricing Configuration
+interface PricingStepProps {
+ data: ProductFormData;
+ onChange: (field: keyof ProductFormData, value: unknown) => void;
+}
+
+const PricingStep: React.FC = ({ 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 (
+
+
+
Set Your Pricing
+
Configure subscription plans for your product
+
+
+
+ {/* Pricing Tiers */}
+
+ {data.pricing.map((pricing) => (
+
+
+ {pricing.period}
+ 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'}
+
+
+
+
+
+
Price (USD)
+
+
+ $
+
+
+ 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"
+ />
+
+
+
+
+ Original Price (optional)
+
+
+
+ $
+
+
+ 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"
+ />
+
+
+
+
+ ))}
+
+
+ {/* Free Trial */}
+
+
+
+
Free Trial
+
+ Let customers try before they buy
+
+
+
onChange('hasFreeTrial', !data.hasFreeTrial)}
+ className={`w-12 h-6 rounded-full transition-colors ${
+ data.hasFreeTrial ? 'bg-blue-600' : 'bg-gray-700'
+ }`}
+ >
+
+
+
+
+ {data.hasFreeTrial && (
+
+
Trial Period
+
+
+ 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"
+ />
+ days
+
+
+ )}
+
+
+
+ );
+};
+
+// Step 4: Preview
+interface PreviewStepProps {
+ data: ProductFormData;
+}
+
+const PreviewStep: React.FC = ({ data }) => {
+ const typeInfo = PRODUCT_TYPES.find((t) => t.type === data.type);
+
+ return (
+
+
+
Preview Your Product
+
Review how your product will appear in the marketplace
+
+
+
+ {/* Product Card Preview */}
+
+ {/* Thumbnail */}
+
+ {data.thumbnailUrl ? (
+
+ ) : (
+
{typeInfo?.icon}
+ )}
+
+
+ {/* Content */}
+
+
+ {data.name || 'Product Name'}
+
+
+ {data.shortDescription || 'Short description will appear here...'}
+
+
+ {/* Tags */}
+ {data.tags.length > 0 && (
+
+ {data.tags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ {/* Price */}
+
+
+
+ ${data.pricing.find((p) => p.period === 'monthly')?.price || 0}
+
+ /mo
+
+ {data.hasFreeTrial && (
+
+ {data.freeTrialDays} days free
+
+ )}
+
+
+
+
+ {/* Summary */}
+
+
Product Summary
+
+
+
Type
+ {data.type}
+
+
+
Pricing Tiers
+ {data.pricing.length}
+
+
+
Free Trial
+
+ {data.hasFreeTrial ? `${data.freeTrialDays} days` : 'No'}
+
+
+
+
Tags
+ {data.tags.length}
+
+
+
+
+
+ );
+};
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+export default function CreateProductWizard() {
+ const navigate = useNavigate();
+ const [currentStep, setCurrentStep] = useState(1);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [formData, setFormData] = useState({
+ 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 (
+
+ {/* Back Link */}
+
+
+ Back to Dashboard
+
+
+ {/* Progress Steps */}
+
+ {WIZARD_STEPS.map((step, index) => (
+
+ step.id < currentStep && setCurrentStep(step.id)}
+ >
+
= step.id
+ ? 'bg-blue-600 text-white'
+ : 'bg-gray-700 text-gray-400'
+ }`}
+ >
+ {currentStep > step.id ? : step.icon}
+
+
{step.title}
+
+ {index < WIZARD_STEPS.length - 1 && (
+ step.id ? 'bg-blue-600' : 'bg-gray-700'
+ }`}
+ />
+ )}
+
+ ))}
+
+
+ {/* Step Content */}
+
+ {error && (
+
+ )}
+
+ {currentStep === 1 && (
+
handleChange('type', type)}
+ />
+ )}
+
+ {currentStep === 2 && }
+
+ {currentStep === 3 && }
+
+ {currentStep === 4 && }
+
+ {/* Navigation Buttons */}
+
+
+
+ Back
+
+
+ {currentStep < 4 ? (
+
+ Continue
+
+
+ ) : (
+
+ {isSubmitting ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Product
+ >
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/modules/marketplace/pages/SellerDashboard.tsx b/src/modules/marketplace/pages/SellerDashboard.tsx
new file mode 100644
index 0000000..5a6b37e
--- /dev/null
+++ b/src/modules/marketplace/pages/SellerDashboard.tsx
@@ -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
= ({
+ title,
+ value,
+ change,
+ icon,
+ iconBg,
+ prefix,
+ suffix,
+}) => (
+
+
+
+ {icon}
+
+ {change !== undefined && (
+
= 0 ? 'text-green-400' : 'text-red-400'
+ }`}
+ >
+ {change >= 0 ? (
+
+ ) : (
+
+ )}
+ {Math.abs(change)}%
+
+ )}
+
+
+ {prefix}
+ {typeof value === 'number' ? value.toLocaleString() : value}
+ {suffix}
+
+
{title}
+
+);
+
+// ============================================================================
+// Product Row Component
+// ============================================================================
+
+interface ProductRowProps {
+ product: SellerProduct;
+ onEdit: (id: string) => void;
+ onDelete: (id: string) => void;
+ onView: (id: string) => void;
+}
+
+const ProductRow: React.FC = ({ 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 (
+
+
+
+
+ {typeIcons[product.type]}
+
+
+
{product.name}
+
{product.type}
+
+
+
+
+
+ {product.status}
+
+
+ ${product.price.toFixed(2)}
+ {product.subscribers}
+
+ {product.rating > 0 ? (
+
+
+ {product.rating.toFixed(1)}
+ ({product.reviews})
+
+ ) : (
+ No reviews
+ )}
+
+
+ ${product.revenue.toLocaleString()}
+
+
+
+
setShowMenu(!showMenu)}
+ className="p-2 text-gray-400 hover:text-white rounded-lg hover:bg-gray-700 transition-colors"
+ >
+
+
+
+ {showMenu && (
+ <>
+
setShowMenu(false)}
+ />
+
+ {
+ 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"
+ >
+
+ View
+
+ {
+ 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
+
+ {
+ 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"
+ >
+
+ Delete
+
+
+ >
+ )}
+
+
+
+ );
+};
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+export default function SellerDashboard() {
+ const [stats, setStats] = useState(null);
+ const [products, setProducts] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
+
+
Failed to Load
+
{error}
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
Seller Dashboard
+
Manage your products and track sales
+
+
+
+ Create Product
+
+
+
+ {/* Stats Grid */}
+ {stats && (
+
+ }
+ iconBg="bg-green-500/20"
+ prefix="$"
+ />
+ }
+ iconBg="bg-blue-500/20"
+ />
+ }
+ iconBg="bg-purple-500/20"
+ />
+ }
+ iconBg="bg-yellow-500/20"
+ />
+
+ )}
+
+ {/* Payout Notice */}
+ {stats && stats.pendingPayouts > 0 && (
+
+
+
+
+
+
+
+ Pending Payout: ${stats.pendingPayouts.toFixed(2)}
+
+
+ Next payout scheduled for{' '}
+ {new Date(stats.nextPayoutDate).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ })}
+
+
+
+
+ View Details
+
+
+
+ )}
+
+ {/* Products Table */}
+
+ {/* Table Header */}
+
+
Your Products
+
+ {/* Status Filter */}
+ 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"
+ >
+ All Status
+ Active
+ Draft
+ Inactive
+
+
+ {/* Type Filter */}
+ 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"
+ >
+ All Types
+ Signals
+ Courses
+ Advisory
+
+
+
+
+ {/* Table */}
+ {filteredProducts.length > 0 ? (
+
+
+
+
+ Product
+ Status
+ Price
+ Subscribers
+ Rating
+ Revenue
+
+
+
+
+ {filteredProducts.map((product) => (
+
+ ))}
+
+
+
+ ) : (
+
+
+
No products found
+
+ {products.length === 0
+ ? "You haven't created any products yet."
+ : 'No products match the selected filters.'}
+
+ {products.length === 0 && (
+
+
+ Create Your First Product
+
+ )}
+
+ )}
+
+
+ {/* Quick Actions */}
+
+
+
+
+
+
Create New Product
+
Add a new listing to marketplace
+
+
+
+
+
+
+
+
+
+
+
Payout Settings
+
Manage payment methods
+
+
+
+
+
+
+
+
+
+
+
Analytics
+
View detailed performance
+
+
+
+
+
+ );
+}
diff --git a/src/modules/marketplace/pages/index.ts b/src/modules/marketplace/pages/index.ts
index 56a6b6d..7b9df41 100644
--- a/src/modules/marketplace/pages/index.ts
+++ b/src/modules/marketplace/pages/index.ts
@@ -5,3 +5,6 @@
export { default as MarketplaceCatalog } from './MarketplaceCatalog';
export { default as SignalPackDetail } from './SignalPackDetail';
export { default as AdvisoryDetail } from './AdvisoryDetail';
+export { default as CheckoutFlow } from './CheckoutFlow';
+export { default as SellerDashboard } from './SellerDashboard';
+export { default as CreateProductWizard } from './CreateProductWizard';
diff --git a/src/modules/payments/index.ts b/src/modules/payments/index.ts
index e834dc8..f0e6ff9 100644
--- a/src/modules/payments/index.ts
+++ b/src/modules/payments/index.ts
@@ -1,7 +1,14 @@
/**
* Payments Module
* Export all payment-related pages and components
+ * Epic: OQI-005 Pagos y Stripe
*/
+// Pages
export { default as Pricing } from './pages/Pricing';
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';
diff --git a/src/modules/payments/pages/InvoicesPage.tsx b/src/modules/payments/pages/InvoicesPage.tsx
new file mode 100644
index 0000000..ba818a0
--- /dev/null
+++ b/src/modules/payments/pages/InvoicesPage.tsx
@@ -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(
+ 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 (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+
+ Invoices
+
+
+ View and download your billing history
+
+
+
+
+ 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'
+ }`}
+ >
+
+ Filters
+ {hasActiveFilters && (
+
+ )}
+
+ 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"
+ >
+
+ Refresh
+
+
+
+
+ {/* Filters Panel */}
+ {showFilters && (
+
+
+
Filter Invoices
+ {hasActiveFilters && (
+
+
+ Clear filters
+
+ )}
+
+
+ {/* Status Filter */}
+
+ Status
+ 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"
+ >
+ All Statuses
+ Paid
+ Pending
+ Failed
+
+
+
+ {/* Date Range */}
+
+
From Date
+
+
+ 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"
+ />
+
+
+
+
+
To Date
+
+
+ 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"
+ />
+
+
+
+
+ )}
+
+ {/* Summary Stats */}
+ {data && !isLoading && (
+
+
+
Total Invoices
+
{data.total}
+
+
+
Paid
+
+ {data.invoices.filter((i) => i.status === 'paid').length}
+
+
+
+
Pending
+
+ {data.invoices.filter((i) => i.status === 'open' || i.status === 'draft').length}
+
+
+
+
Total Amount
+
+ ${data.invoices.reduce((sum, i) => sum + i.amount, 0).toLocaleString('en-US', { minimumFractionDigits: 2 })}
+
+
+
+ )}
+
+ {/* Invoice List */}
+
+ {error ? (
+
+
+
+ {error instanceof Error ? error.message : 'Failed to load invoices'}
+
+
refetch()}
+ className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm"
+ >
+ Retry
+
+
+ ) : (
+
+ )}
+
+
+ {/* Invoice Detail Modal */}
+ {selectedInvoiceId && (
+
+ )}
+
+ {/* Download Loading Indicator */}
+ {downloadInvoice.isPending && (
+
+
+ Downloading invoice...
+
+ )}
+
+ );
+}
diff --git a/src/modules/payments/pages/PaymentMethodsPage.tsx b/src/modules/payments/pages/PaymentMethodsPage.tsx
new file mode 100644
index 0000000..1498405
--- /dev/null
+++ b/src/modules/payments/pages/PaymentMethodsPage.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+
+ Payment Methods
+
+
+ Manage your saved payment methods
+
+
+
+
+
+ Stripe Portal
+
+
+
+ {/* Security Notice */}
+
+
+
+
Secure Payment Processing
+
+ 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.
+
+
+
+
+ {/* Subscription Info (if active) */}
+ {currentSubscription && (
+
+
+
+
Active Subscription
+
+ 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',
+ })}
+
+
+
+ )}
+
+ {/* Payment Methods Manager */}
+
+
+ {
+ // Could trigger a notification here
+ }}
+ />
+
+
+
+ {/* Help Section */}
+
+
Frequently Asked Questions
+
+
+
+ What payment methods do you accept?
+
+
+ We accept all major credit and debit cards (Visa, Mastercard, American Express,
+ Discover), as well as regional payment methods in select countries.
+
+
+
+
How do I update my default payment method?
+
+ 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.
+
+
+
+
Is my payment information secure?
+
+ 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.
+
+
+
+
+ What happens if my payment fails?
+
+
+ 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.
+
+
+
+
+
+ {/* Need Help */}
+
+
+ Need help with your billing?{' '}
+
+ Contact Support
+
+
+
+
+ );
+}
diff --git a/src/modules/payments/pages/RefundsPage.tsx b/src/modules/payments/pages/RefundsPage.tsx
new file mode 100644
index 0000000..302ae6b
--- /dev/null
+++ b/src/modules/payments/pages/RefundsPage.tsx
@@ -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('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 (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+
+ Refunds
+
+
+ View and manage your refund requests
+
+
+
+
+ {eligibility?.eligible && currentSubscription && (
+ 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"
+ >
+
+ Request Refund
+
+ )}
+ 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'
+ }`}
+ >
+
+ Filters
+ {hasActiveFilters && (
+
+ )}
+
+ 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"
+ >
+
+ Refresh
+
+
+
+
+ {/* Filters Panel */}
+ {showFilters && (
+
+
+
Filter Refunds
+ {hasActiveFilters && (
+
+
+ Clear filters
+
+ )}
+
+
+ {STATUS_OPTIONS.map((option) => (
+ {
+ 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}
+
+ ))}
+
+
+ )}
+
+ {/* Summary Stats */}
+
+
+
+
+ Total Requests
+
+
{stats.total}
+
+
+
+
+ Pending
+
+
{stats.pending}
+
+
+
+
+ Completed
+
+
{stats.completed}
+
+
+
Total Refunded
+
+ ${stats.totalAmount.toLocaleString('en-US', { minimumFractionDigits: 2 })}
+
+
+
+
+ {/* Eligibility Notice */}
+ {currentSubscription && eligibility && (
+
+
+ {eligibility.eligible ? (
+
+ ) : (
+
+ )}
+
+
+ {eligibility.eligible
+ ? `You are eligible for a refund of up to $${eligibility.refundableAmount.toFixed(2)}`
+ : 'You are not currently eligible for a refund'}
+
+ {eligibility.eligible && (
+
+ {eligibility.daysRemaining} days remaining in the {eligibility.maxRefundDays}-day refund window
+
+ )}
+ {!eligibility.eligible && eligibility.reason && (
+
{eligibility.reason}
+ )}
+
+
+
+ )}
+
+ {/* Refund List */}
+ {error ? (
+
+
+
+ {error instanceof Error ? error.message : 'Failed to load refunds'}
+
+
refetch()}
+ className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm"
+ >
+ Retry
+
+
+ ) : (
+
+ )}
+
+ {/* Refund Request Modal */}
+ {showRefundModal && currentSubscription && eligibility && (
+
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) && (
+
+
+
+ {cancelRefund.isPending ? 'Canceling refund...' : 'Processing refund request...'}
+
+
+ )}
+
+ );
+}
diff --git a/src/modules/portfolio/components/AllocationOptimizer.tsx b/src/modules/portfolio/components/AllocationOptimizer.tsx
new file mode 100644
index 0000000..ee3f09f
--- /dev/null
+++ b/src/modules/portfolio/components/AllocationOptimizer.tsx
@@ -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 = {
+ 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 = ({
+ asset,
+ current,
+ optimal,
+ color,
+}) => {
+ const diff = optimal - current;
+ const maxPercent = Math.max(current, optimal, 30);
+
+ return (
+
+
+
{asset}
+
+
+ {(current * 100).toFixed(1)}% / {(optimal * 100).toFixed(1)}%
+
+ 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)}%
+
+
+
+
+ {/* Current bar */}
+
+ {/* Optimal bar (outlined) */}
+
+
+
+ );
+};
+
+const ASSET_COLORS: Record = {
+ 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 = ({
+ portfolioId,
+ allocations,
+ totalValue,
+ compact = false,
+}) => {
+ const [constraints, setConstraints] = useState({
+ riskTolerance: 50,
+ minWeight: 0.02,
+ maxWeight: 0.40,
+ includeStablecoins: true,
+ });
+ const [isOptimizing, setIsOptimizing] = useState(false);
+ const [result, setResult] = useState(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 (
+
+
+
+
+ {isOptimizing ? (
+
+ ) : (
+
+ )}
+ Optimizar
+
+
+
+ {result ? (
+
+
+
+
Sharpe Actual
+
+ {result.currentSharpe.toFixed(2)}
+
+
+
+
Sharpe Optimo
+
+ {result.sharpeRatio.toFixed(2)}
+
+
+
+ {improvements && improvements.sharpeImprovement > 0 && (
+
+
+ +{(improvements.sharpeImprovement * 100).toFixed(0)}% mejora potencial
+
+ )}
+
+ ) : (
+
+
+ Ejecuta el optimizador para ver sugerencias
+
+
+ )}
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Optimizador de Allocacion
+
+
+ Teoria Moderna de Portafolios (MPT)
+
+
+
+
+
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'
+ }`}
+ >
+
+
+
+ {isOptimizing ? (
+ <>
+
+ Optimizando...
+ >
+ ) : (
+ <>
+
+ Optimizar
+ >
+ )}
+
+
+
+
+ {/* Settings Panel */}
+ {showSettings && (
+
+
+ Restricciones de Optimizacion
+
+
+ {/* Risk Tolerance */}
+
+
+ Tolerancia al Riesgo: {constraints.riskTolerance}%
+
+
+ setConstraints({ ...constraints, riskTolerance: Number(e.target.value) })
+ }
+ className="w-full"
+ />
+
+ Conservador
+ Agresivo
+
+
+
+ {/* Min Weight */}
+
+
+ Peso Minimo: {(constraints.minWeight * 100).toFixed(0)}%
+
+
+ setConstraints({ ...constraints, minWeight: Number(e.target.value) / 100 })
+ }
+ className="w-full"
+ />
+
+
+ {/* Max Weight */}
+
+
+ Peso Maximo: {(constraints.maxWeight * 100).toFixed(0)}%
+
+
+ setConstraints({ ...constraints, maxWeight: Number(e.target.value) / 100 })
+ }
+ className="w-full"
+ />
+
+
+ {/* Include Stablecoins */}
+
+
+ Incluir Stablecoins
+
+
+ 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'
+ }`}
+ >
+
+
+
+
+
+ )}
+
+ {/* Content */}
+
+ {result ? (
+ <>
+ {/* Improvement Summary */}
+ {improvements && (
+
+
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'
+ }`}>
+
+
0 ? 'text-green-500' : 'text-gray-400'
+ }`} />
+
+ Retorno Esperado
+
+
+
+ {(result.expectedReturn * 100).toFixed(1)}%
+
+ {improvements.returnImprovement > 0 && (
+
+ +{(improvements.returnImprovement * 100).toFixed(1)}% vs actual
+
+ )}
+
+
+
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'
+ }`}>
+
+ 0 ? 'text-blue-500' : 'text-gray-400'
+ }`} />
+
+ Volatilidad
+
+
+
+ {(result.expectedVolatility * 100).toFixed(1)}%
+
+ {improvements.riskReduction > 0 && (
+
+ -{(improvements.riskReduction * 100).toFixed(1)}% vs actual
+
+ )}
+
+
+
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'
+ }`}>
+
+ 0 ? 'text-amber-500' : 'text-gray-400'
+ }`} />
+
+ Sharpe Ratio
+
+
+
+ {result.sharpeRatio.toFixed(2)}
+
+ {improvements.sharpeImprovement > 0 && (
+
+ +{improvements.sharpeImprovement.toFixed(2)} vs actual ({result.currentSharpe.toFixed(2)})
+
+ )}
+
+
+ )}
+
+ {/* Efficient Frontier Chart */}
+
+
+ Frontera Eficiente
+
+
+
+
+
+
+
+ [
+ `${value.toFixed(1)}%`,
+ name === 'risk' ? 'Riesgo' : 'Retorno',
+ ]}
+ />
+ {/* Frontier Line */}
+ !p.isCurrent && !p.isOptimal)}
+ fill="#6366F1"
+ line={{ stroke: '#6366F1', strokeWidth: 2 }}
+ shape="circle"
+ />
+ {/* Current Portfolio */}
+ p.isCurrent)}
+ fill="#EF4444"
+ shape="star"
+ >
+ {efficientFrontier
+ .filter((p) => p.isCurrent)
+ .map((_, index) => (
+ |
+ ))}
+
+ {/* Optimal Portfolio */}
+ p.isOptimal)}
+ fill="#10B981"
+ shape="diamond"
+ >
+ {efficientFrontier
+ .filter((p) => p.isOptimal)
+ .map((_, index) => (
+ |
+ ))}
+
+
+
+
+ {/* Legend */}
+
+
+
+ {/* Allocation Comparison */}
+
+
+ Comparacion de Allocacion (Actual vs Optima)
+
+
+ {result.allocations.map((alloc) => (
+
+ ))}
+
+
+
+ {/* Recommendations Table */}
+
+
+ Ajustes Recomendados
+
+
+
+
+
+
+ Activo
+
+
+ Actual
+
+
+ Optimo
+
+
+ Cambio
+
+
+ Monto USD
+
+
+
+
+ {result.allocations
+ .filter((a) => Math.abs(a.difference) >= 0.01)
+ .map((alloc) => {
+ const changeUSD = alloc.difference * totalValue;
+ return (
+
+
+ {alloc.asset}
+
+
+ {(alloc.currentWeight * 100).toFixed(1)}%
+
+
+ {(alloc.optimalWeight * 100).toFixed(1)}%
+
+ 0 ? 'text-green-500' : 'text-red-500'
+ }`}>
+ {alloc.difference > 0 ? '+' : ''}{(alloc.difference * 100).toFixed(1)}%
+
+ 0 ? 'text-green-500' : 'text-red-500'
+ }`}>
+ {changeUSD > 0 ? '+' : ''}${Math.abs(changeUSD).toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+
+ );
+ })}
+
+
+
+
+
+ {/* Info Footer */}
+
+
+
+
+
+ Sobre la Optimizacion
+
+
+ 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.
+
+
+
+
+ >
+ ) : (
+
+
+
+ Optimiza tu Portfolio
+
+
+ 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.
+
+
+ Ejecutar Optimizacion
+
+
+ )}
+
+
+ );
+};
+
+export default AllocationOptimizer;
diff --git a/src/modules/portfolio/components/index.ts b/src/modules/portfolio/components/index.ts
index cbbf0e5..4b3c446 100644
--- a/src/modules/portfolio/components/index.ts
+++ b/src/modules/portfolio/components/index.ts
@@ -4,6 +4,7 @@
*/
export { AllocationChart } from './AllocationChart';
+export { AllocationOptimizer } from './AllocationOptimizer';
export { AllocationTable } from './AllocationTable';
export { AllocationsCard } from './AllocationsCard';
export { CorrelationMatrix } from './CorrelationMatrix';
diff --git a/src/modules/portfolio/hooks/index.ts b/src/modules/portfolio/hooks/index.ts
new file mode 100644
index 0000000..24c77fd
--- /dev/null
+++ b/src/modules/portfolio/hooks/index.ts
@@ -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';
diff --git a/src/modules/portfolio/hooks/useMonteCarloSimulation.ts b/src/modules/portfolio/hooks/useMonteCarloSimulation.ts
new file mode 100644
index 0000000..3be2614
--- /dev/null
+++ b/src/modules/portfolio/hooks/useMonteCarloSimulation.ts
@@ -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 {
+ 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 {
+ 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(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;
diff --git a/src/modules/portfolio/hooks/useOptimization.ts b/src/modules/portfolio/hooks/useOptimization.ts
new file mode 100644
index 0000000..0697701
--- /dev/null
+++ b/src/modules/portfolio/hooks/useOptimization.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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(null);
+ const [constraints, setConstraints] = useState({
+ 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) => {
+ 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;
diff --git a/src/modules/portfolio/hooks/usePortfolioGoals.ts b/src/modules/portfolio/hooks/usePortfolioGoals.ts
new file mode 100644
index 0000000..7e2e083
--- /dev/null
+++ b/src/modules/portfolio/hooks/usePortfolioGoals.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
diff --git a/src/modules/portfolio/hooks/useRebalancing.ts b/src/modules/portfolio/hooks/useRebalancing.ts
new file mode 100644
index 0000000..c8418d3
--- /dev/null
+++ b/src/modules/portfolio/hooks/useRebalancing.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
+): Promise {
+ 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) =>
+ 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;
diff --git a/src/modules/portfolio/hooks/useRiskMetrics.ts b/src/modules/portfolio/hooks/useRiskMetrics.ts
new file mode 100644
index 0000000..2a64e1b
--- /dev/null
+++ b/src/modules/portfolio/hooks/useRiskMetrics.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
diff --git a/src/modules/portfolio/index.ts b/src/modules/portfolio/index.ts
index 45412f2..2d32857 100644
--- a/src/modules/portfolio/index.ts
+++ b/src/modules/portfolio/index.ts
@@ -8,3 +8,6 @@ export * from './components';
// Pages
export * from './pages';
+
+// Hooks
+export * from './hooks';
diff --git a/src/modules/portfolio/pages/PortfolioDetailPage.tsx b/src/modules/portfolio/pages/PortfolioDetailPage.tsx
index 66982a3..790d85d 100644
--- a/src/modules/portfolio/pages/PortfolioDetailPage.tsx
+++ b/src/modules/portfolio/pages/PortfolioDetailPage.tsx
@@ -18,6 +18,10 @@ import {
ArrowTrendingDownIcon,
EllipsisVerticalIcon,
TrashIcon,
+ ShieldExclamationIcon,
+ FlagIcon,
+ SparklesIcon,
+ TableCellsIcon,
} from '@heroicons/react/24/solid';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
import {
@@ -32,6 +36,18 @@ import { AllocationsCard } from '../components/AllocationsCard';
import { AllocationTable } from '../components/AllocationTable';
import { PerformanceMetricsCard } from '../components/PerformanceMetricsCard';
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
@@ -214,6 +230,38 @@ const PerformanceChartSection: React.FC = ({
// 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() {
const { portfolioId } = useParams<{ portfolioId: string }>();
const navigate = useNavigate();
@@ -227,6 +275,8 @@ export default function PortfolioDetailPage() {
const [error, setError] = useState(null);
const [showRebalanceModal, setShowRebalanceModal] = useState(false);
const [showActions, setShowActions] = useState(false);
+ const [activeTab, setActiveTab] = useState('overview');
+ const [goals] = useState(generateMockGoals());
// Store
const { fetchPortfolios } = usePortfolioStore();
@@ -398,7 +448,7 @@ export default function PortfolioDetailPage() {
Rebalancear
@@ -471,81 +521,199 @@ export default function PortfolioDetailPage() {
/>
- {/* Main Content Grid */}
-
- {/* Left Column: Allocations & Positions */}
-
- {/* Allocations Chart */}
-
setShowRebalanceModal(true)}
- maxDriftThreshold={5}
- />
+ {/* Tab Navigation */}
+
+
+
setActiveTab('overview')}
+ className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
+ activeTab === 'overview'
+ ? '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'
+ }`}
+ >
+
+ Resumen
+
+
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'
+ }`}
+ >
+
+ Riesgo
+
+
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'
+ }`}
+ >
+
+ Metas
+
+
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'
+ }`}
+ >
+
+ Optimizar
+
+
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'
+ }`}
+ >
+
+ Simular
+
+
+
- {/* Positions Table */}
-
-
-
- Detalle de Posiciones
-
-
- Editar Allocaciones
-
-
-
+ {/* Left Column: Allocations & Positions */}
+
+ {/* Allocations Chart */}
+
setShowRebalanceModal(true)}
+ maxDriftThreshold={5}
+ />
+
+ {/* Positions Table */}
+
+
+
+ Detalle de Posiciones
+
+
+ Editar Allocaciones
+
+
+
+
+
+
+ {/* Right Column: Metrics */}
+
+
+
+ {/* Portfolio Info */}
+
+
+ Informacion del Portfolio
+
+
+
+ Perfil de Riesgo
+
+ {riskProfileLabels[portfolio.riskProfile]}
+
+
+
+ Ultimo Rebalanceo
+
+ {portfolio.lastRebalanced
+ ? new Date(portfolio.lastRebalanced).toLocaleDateString()
+ : 'Nunca'}
+
+
+
+ Creado
+
+ {new Date(portfolio.createdAt).toLocaleDateString()}
+
+
+
+ Actualizado
+
+ {new Date(portfolio.updatedAt).toLocaleString()}
+
+
+
+
+
+
+ )}
+
+ {/* Risk Tab */}
+ {activeTab === 'risk' && (
+
+
+
+
+ setShowRebalanceModal(true)}
+ lastRebalanced={portfolio.lastRebalanced}
/>
+ )}
- {/* Right Column: Metrics */}
-
-
+ 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 */}
-
-
- Informacion del Portfolio
-
-
-
- Perfil de Riesgo
-
- {riskProfileLabels[portfolio.riskProfile]}
-
-
-
- Ultimo Rebalanceo
-
- {portfolio.lastRebalanced
- ? new Date(portfolio.lastRebalanced).toLocaleDateString()
- : 'Nunca'}
-
-
-
- Creado
-
- {new Date(portfolio.createdAt).toLocaleDateString()}
-
-
-
- Actualizado
-
- {new Date(portfolio.updatedAt).toLocaleString()}
-
-
-
-
-
+ )}
+
+ {/* Optimize Tab */}
+ {activeTab === 'optimize' && (
+
+ )}
+
+ {/* Simulate Tab */}
+ {activeTab === 'simulate' && (
+
+
+
+ )}
{/* Rebalance Modal */}
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 = ({ isOpen, onClose, onSuccess }) => {
+ const [step, setStep] = useState(1);
+ const [formData, setFormData] = useState(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 (
+
+
+ {/* Header */}
+
+
+
Create Trading Bot
+
Step {step} of 4
+
+
+
+
+
+
+ {/* Progress Bar */}
+
+
+ {[1, 2, 3, 4].map((s) => (
+
+ ))}
+
+
+
+ {/* Content */}
+
+ {/* Step 1: Select Template */}
+ {step === 1 && (
+
+
Select Bot Template
+
+ {templatesLoading ? (
+
+
+
+ ) : (
+
+ {templates?.map((template) => (
+ handleSelectTemplate(template.type, template)}
+ />
+ ))}
+ handleSelectTemplate('custom')}
+ />
+
+ )}
+
+ )}
+
+ {/* Step 2: Configure Symbol */}
+ {step === 2 && (
+
+
Configure Trading Pair
+
+ {/* Bot Name */}
+
+ Bot Name
+ 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"
+ />
+
+
+ {/* Symbols */}
+
+
Trading Symbols
+
+ 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)"
+ />
+
+ Add
+
+
+
+ {/* Selected Symbols */}
+
+ {formData.symbols.map((symbol) => (
+
+ {symbol}
+ handleRemoveSymbol(symbol)} className="hover:text-blue-300">
+
+
+
+ ))}
+
+
+ {/* Quick Add */}
+
+
Quick add:
+
+ {POPULAR_SYMBOLS.filter((s) => !formData.symbols.includes(s)).map((symbol) => (
+
+ 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}
+
+ ))}
+
+
+
+
+ {/* Timeframe */}
+
+
Timeframe
+
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) => (
+
+ {opt.label}
+
+ ))}
+
+ {selectedTemplate && (
+
+ Recommended: {selectedTemplate.recommendedTimeframes.join(', ')}
+
+ )}
+
+
+ )}
+
+ {/* Step 3: Risk Management */}
+ {step === 3 && (
+
+
Risk Management
+
+ {/* Initial Capital */}
+
+
Initial Capital (USD)
+
+ 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"
+ />
+
Minimum: $100
+
+
+ {/* Max Position Size */}
+
+
+ Max Position Size (% of capital)
+
+
+ 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"
+ />
+
+
+ {/* Max Daily Loss */}
+
+
+ Max Daily Loss (% of capital)
+
+
+ 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"
+ />
+
+
+ {/* Max Drawdown */}
+
+
+ Max Drawdown (% of capital)
+
+
+ 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"
+ />
+
+
+ {/* Risk Warning */}
+
+
+
+
Risk Warning
+
+ Trading involves substantial risk. Never trade with money you cannot afford to lose.
+ Past performance does not guarantee future results.
+
+
+
+
+ )}
+
+ {/* Step 4: Review */}
+ {step === 4 && (
+
+
Review Configuration
+
+
+
+
+
+ t.value === formData.timeframe)?.label || formData.timeframe}
+ />
+
+
+
+
+
+
+ {createBotMutation.isError && (
+
+ {createBotMutation.error instanceof Error
+ ? createBotMutation.error.message
+ : 'Failed to create bot'}
+
+ )}
+
+ )}
+
+
+ {/* Footer */}
+
+ (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"
+ >
+
+ {step === 1 ? 'Cancel' : 'Back'}
+
+
+ {step < 4 ? (
+ 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
+
+
+ ) : (
+
+ {createBotMutation.isPending ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Bot
+ >
+ )}
+
+ )}
+
+
+
+ );
+};
+
+// ============================================================================
+// Sub-components
+// ============================================================================
+
+interface TemplateCardProps {
+ template: BotTemplate;
+ selected: boolean;
+ onClick: () => void;
+}
+
+const TemplateCard: React.FC = ({ template, selected, onClick }) => {
+ const getIcon = () => {
+ switch (template.type) {
+ case 'atlas':
+ return ;
+ case 'orion':
+ return ;
+ case 'nova':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ 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 (
+
+
+
{getIcon()}
+
+
+
{template.name}
+
+ {template.riskLevel.charAt(0).toUpperCase() + template.riskLevel.slice(1)} Risk
+
+
+
{template.description}
+
+ {selected &&
}
+
+
+ );
+};
+
+interface ReviewItemProps {
+ label: string;
+ value: string;
+}
+
+const ReviewItem: React.FC = ({ label, value }) => (
+
+ {label}
+ {value}
+
+);
+
+export default CreateBotModal;
diff --git a/src/modules/trading/components/agents/index.ts b/src/modules/trading/components/agents/index.ts
index 4f526d7..d4f4d8f 100644
--- a/src/modules/trading/components/agents/index.ts
+++ b/src/modules/trading/components/agents/index.ts
@@ -5,3 +5,4 @@
export { AgentsList } from './AgentsList';
export { AgentCard } from './AgentCard';
export { BotCard } from './BotCard';
+export { CreateBotModal } from './CreateBotModal';
diff --git a/src/modules/trading/hooks/index.ts b/src/modules/trading/hooks/index.ts
index b2ab51c..0ab5245 100644
--- a/src/modules/trading/hooks/index.ts
+++ b/src/modules/trading/hooks/index.ts
@@ -1,11 +1,29 @@
/**
* Trading Hooks - Index Export
+ * OQI-003: Trading Charts & Agents
* OQI-009: Trading Execution (MT4 Gateway)
*/
+// MT4 WebSocket
export { useMT4WebSocket } from './useMT4WebSocket';
export type {
MT4AccountInfo,
MT4Position,
MT4Order,
} from './useMT4WebSocket';
+
+// Trading Bots (Atlas, Orion, Nova)
+export {
+ useBots,
+ useBot,
+ useBotPerformance,
+ useBotExecutions,
+ useBotTemplates,
+ useBotTemplate,
+ useCreateBot,
+ useUpdateBot,
+ useDeleteBot,
+ useStartBot,
+ useStopBot,
+ botQueryKeys,
+} from './useBots';
diff --git a/src/modules/trading/hooks/useBots.ts b/src/modules/trading/hooks/useBots.ts
new file mode 100644
index 0000000..83bc3b9
--- /dev/null
+++ b/src/modules/trading/hooks/useBots.ts
@@ -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() });
+ },
+ });
+}
diff --git a/src/modules/trading/pages/AgentsPage.tsx b/src/modules/trading/pages/AgentsPage.tsx
index de19660..19c79e9 100644
--- a/src/modules/trading/pages/AgentsPage.tsx
+++ b/src/modules/trading/pages/AgentsPage.tsx
@@ -1,23 +1,281 @@
/**
* 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() {
+ const navigate = useNavigate();
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [statusFilter, setStatusFilter] = useState('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 (
{/* Header */}
-
-
Trading Agents
-
- Gestiona tus bots de trading automatizado: Atlas, Orion y Nova
-
+
+
+
Trading Agents
+
+ Manage your automated trading bots: Atlas, Orion, and Nova
+
+
+
+ {/* Status Filter */}
+
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"
+ >
+ All Status
+ Active
+ Paused
+ Stopped
+ Error
+
+
+ {/* Refresh Button */}
+
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"
+ >
+
+
+
+ {/* Create Bot Button */}
+
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"
+ >
+
+ Create Bot
+
+
- {/* Main Content */}
-
+ {/* Strategy Templates Info */}
+
+ }
+ name="Atlas"
+ description="Trend Following"
+ risk="Medium"
+ color="blue"
+ />
+ }
+ name="Orion"
+ description="Mean Reversion"
+ risk="Low"
+ color="purple"
+ />
+ }
+ name="Nova"
+ description="Breakout"
+ risk="High"
+ color="orange"
+ />
+
+
+ {/* Loading State */}
+ {isLoading && (
+
+
+
+ Loading bots...
+
+
+ )}
+
+ {/* Error State */}
+ {error && (
+
+
+
+
+
Error Loading Bots
+
+ {error instanceof Error ? error.message : 'Failed to load bots'}
+
+
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
+
+
+
+
+ )}
+
+ {/* Bots Grid */}
+ {!isLoading && !error && bots && bots.length > 0 && (
+
+ {bots.map((bot) => (
+ handleSelectBot(bot)}
+ onStart={() => handleStartBot(bot.id)}
+ onStop={() => handleStopBot(bot.id)}
+ loading={startMutation.isPending || stopMutation.isPending}
+ />
+ ))}
+
+ )}
+
+ {/* Empty State */}
+ {!isLoading && !error && (!bots || bots.length === 0) && (
+
+
+
+
+
+
No Trading Bots
+
+ {statusFilter !== 'all'
+ ? `No bots with status "${statusFilter}" found.`
+ : 'Create your first trading bot to start automated trading with Atlas, Orion, or Nova strategies.'}
+
+
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"
+ >
+
+ Create Your First Bot
+
+
+
+ )}
+
+ {/* Create Bot Modal */}
+
setShowCreateModal(false)}
+ onSuccess={() => {
+ refetch();
+ setShowCreateModal(false);
+ }}
+ />
+
+ );
+}
+
+// ============================================================================
+// 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
= {
+ Low: 'text-green-400',
+ Medium: 'text-yellow-400',
+ High: 'text-red-400',
+ };
+
+ return (
+
+
+
+ {icon}
+
+
+
{name}
+
{description}
+
+
+ {risk} Risk
+
+
);
}
diff --git a/src/modules/trading/pages/BotDetailPage.tsx b/src/modules/trading/pages/BotDetailPage.tsx
new file mode 100644
index 0000000..0382667
--- /dev/null
+++ b/src/modules/trading/pages/BotDetailPage.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ // Error state
+ if (botError || !bot) {
+ return (
+
+
+
+ Back to Agents
+
+
+
+
Bot Not Found
+
The requested trading bot could not be found.
+
+ Return to Agents
+
+
+
+ );
+ }
+
+ const isActive = bot.status === 'active';
+ const isPaused = bot.status === 'paused';
+ const isStopped = bot.status === 'stopped';
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
{bot.name}
+
+
+ {bot.strategyType || 'Custom'}
+
+
+
+
+
+
+
refetchBot()}
+ className="p-2 text-gray-400 hover:text-white transition-colors"
+ title="Refresh"
+ >
+
+
+
+ {(isStopped || isPaused) && (
+
+ {startMutation.isPending ? : }
+ Start
+
+ )}
+
+ {isActive && (
+
+ {stopMutation.isPending ? : }
+ Stop
+
+ )}
+
+
+
+
+
+
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'}
+ >
+
+
+
+
+
+ {/* Main Grid */}
+
+ {/* Left Column - Performance */}
+
+ {/* Key Metrics */}
+
+ }
+ trend={bot.totalProfitLoss >= 0 ? 'up' : 'down'}
+ />
+ }
+ trend={bot.winRate >= 50 ? 'up' : 'down'}
+ />
+ }
+ />
+ }
+ />
+
+
+ {/* Performance Metrics */}
+
+
Performance Metrics
+ {perfLoading ? (
+
+
+
+ ) : performance ? (
+
+ ) : (
+
No performance data available
+ )}
+
+
+ {/* Execution History */}
+
+
Execution History
+ {execLoading ? (
+
+
+
+ ) : executions && executions.length > 0 ? (
+
+
+
+
+ Time
+ Symbol
+ Action
+ Price
+ Qty
+ P&L
+ Status
+
+
+
+ {executions.map((exec) => (
+
+ ))}
+
+
+
+ ) : (
+
No executions yet
+ )}
+
+
+
+ {/* Right Column - Configuration */}
+
+ {/* Bot Configuration */}
+
+
Configuration
+
+
+
+
+
+
+
+
+
+
+ {/* Status & Timestamps */}
+
+
Status
+
+
+ {bot.startedAt && }
+ {bot.stoppedAt && }
+ {bot.lastTradeAt && }
+
+
+
+
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteConfirm && (
+
+
+
Delete Bot
+
+ Are you sure you want to delete "{bot.name}"? This action cannot be undone.
+
+
+ setShowDeleteConfirm(false)}
+ className="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
+ >
+ Cancel
+
+
+ {deleteMutation.isPending ? : }
+ Delete
+
+
+
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// 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 (
+
+ {type === 'atlas' &&
}
+ {type === 'orion' &&
}
+ {type === 'nova' &&
}
+ {type === 'custom' &&
}
+
+ );
+}
+
+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 (
+
+ {status.charAt(0).toUpperCase() + status.slice(1)}
+
+ );
+}
+
+interface MetricCardProps {
+ label: string;
+ value: string;
+ icon: React.ReactNode;
+ trend?: 'up' | 'down';
+}
+
+function MetricCard({ label, value, icon, trend }: MetricCardProps) {
+ return (
+
+
+ {label}
+ {icon}
+
+
+
+ {value}
+
+ {trend === 'up' && }
+ {trend === 'down' && }
+
+
+ );
+}
+
+function PerformanceItem({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function ConfigItem({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function ExecutionRow({ execution }: { execution: BotExecution }) {
+ const isBuy = execution.action === 'buy';
+ const isSuccess = execution.result === 'success';
+ const isPnlPositive = (execution.pnl || 0) >= 0;
+
+ return (
+
+
+
+
+ {formatTime(execution.timestamp)}
+
+
+ {execution.symbol}
+
+
+ {execution.action.toUpperCase()}
+
+
+ {formatCurrency(execution.price)}
+ {execution.quantity.toFixed(4)}
+
+ {execution.pnl !== undefined ? formatCurrency(execution.pnl) : '-'}
+
+
+ {isSuccess ? (
+
+ ) : execution.result === 'failed' ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+// ============================================================================
+// 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',
+ });
+}
diff --git a/src/services/bots.service.ts b/src/services/bots.service.ts
new file mode 100644
index 0000000..e30308c
--- /dev/null
+++ b/src/services/bots.service.ts
@@ -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;
+ exit_rules?: Record;
+ risk_management?: {
+ max_position_size?: number;
+ stop_loss_percent?: number;
+ take_profit_percent?: number;
+ max_daily_loss?: number;
+ };
+ indicators?: Array<{ name: string; params: Record }>;
+}
+
+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;
+}
+
+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(url: string, options?: RequestInit): Promise {
+ 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 {
+ 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(url);
+}
+
+/**
+ * Get a specific bot by ID
+ */
+export async function getBotById(botId: string): Promise {
+ return fetchAPI(`${API_URL}/api/v1/trading/bots/${botId}`);
+}
+
+/**
+ * Create a new trading bot
+ */
+export async function createBot(input: CreateBotInput): Promise {
+ return fetchAPI(`${API_URL}/api/v1/trading/bots`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ });
+}
+
+/**
+ * Update bot configuration
+ */
+export async function updateBot(botId: string, input: UpdateBotInput): Promise {
+ return fetchAPI(`${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 {
+ await fetchAPI(`${API_URL}/api/v1/trading/bots/${botId}`, {
+ method: 'DELETE',
+ });
+}
+
+// ============================================================================
+// Bot Control Operations
+// ============================================================================
+
+/**
+ * Start a bot
+ */
+export async function startBot(botId: string): Promise {
+ return fetchAPI(`${API_URL}/api/v1/trading/bots/${botId}/start`, {
+ method: 'POST',
+ });
+}
+
+/**
+ * Stop a bot
+ */
+export async function stopBot(botId: string): Promise {
+ return fetchAPI(`${API_URL}/api/v1/trading/bots/${botId}/stop`, {
+ method: 'POST',
+ });
+}
+
+// ============================================================================
+// Performance & Executions
+// ============================================================================
+
+/**
+ * Get bot performance metrics
+ */
+export async function getBotPerformance(botId: string): Promise {
+ return fetchAPI(`${API_URL}/api/v1/trading/bots/${botId}/performance`);
+}
+
+/**
+ * Get bot execution history
+ */
+export async function getBotExecutions(
+ botId: string,
+ options?: { limit?: number; offset?: number }
+): Promise {
+ 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(url);
+}
+
+// ============================================================================
+// Bot Templates
+// ============================================================================
+
+/**
+ * Get available bot templates (Atlas, Orion, Nova, Custom)
+ */
+export async function getBotTemplates(): Promise {
+ return fetchAPI(`${API_URL}/api/v1/trading/bots/templates`);
+}
+
+/**
+ * Get a specific bot template by type
+ */
+export async function getBotTemplate(type: StrategyType): Promise {
+ return fetchAPI(`${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;
diff --git a/src/services/marketplace.service.ts b/src/services/marketplace.service.ts
index 0a0451e..8361bc9 100644
--- a/src/services/marketplace.service.ts
+++ b/src/services/marketplace.service.ts
@@ -16,6 +16,13 @@ import type {
PaginatedResponse,
ApiResponse,
ProductCategory,
+ SellerProduct,
+ SellerProductFilters,
+ SellerStats,
+ SellerSalesData,
+ SellerPayoutInfo,
+ CreateProductData,
+ ProductDraft,
} from '../types/marketplace.types';
// ============================================================================
@@ -255,6 +262,169 @@ export async function cancelBooking(id: string): Promise {
return response.data.data;
}
+// ============================================================================
+// Seller Products
+// ============================================================================
+
+export async function getSellerProducts(
+ filters?: SellerProductFilters
+): Promise> {
+ const response = await api.get>>(
+ '/marketplace/seller/products',
+ { params: filters }
+ );
+ return response.data.data;
+}
+
+export async function getSellerProductById(id: string): Promise {
+ const response = await api.get>(
+ `/marketplace/seller/products/${id}`
+ );
+ return response.data.data;
+}
+
+export async function updateSellerProduct(
+ id: string,
+ data: Partial
+): Promise {
+ const response = await api.patch>(
+ `/marketplace/seller/products/${id}`,
+ data
+ );
+ return response.data.data;
+}
+
+export async function toggleProductStatus(
+ id: string,
+ status: 'active' | 'inactive'
+): Promise {
+ const response = await api.patch>(
+ `/marketplace/seller/products/${id}/status`,
+ { status }
+ );
+ return response.data.data;
+}
+
+export async function deleteSellerProduct(id: string): Promise {
+ await api.delete(`/marketplace/seller/products/${id}`);
+}
+
+// ============================================================================
+// Seller Stats
+// ============================================================================
+
+export async function getSellerStats(): Promise {
+ const response = await api.get>(
+ '/marketplace/seller/stats'
+ );
+ return response.data.data;
+}
+
+export async function getSellerSales(
+ period: '7d' | '30d' | '90d' | '1y'
+): Promise {
+ const response = await api.get>(
+ '/marketplace/seller/sales',
+ { params: { period } }
+ );
+ return response.data.data;
+}
+
+export async function getSellerPayouts(): Promise {
+ const response = await api.get>(
+ '/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>('/marketplace/seller/earnings', { params: { period } });
+ return response.data.data;
+}
+
+// ============================================================================
+// Product Creation
+// ============================================================================
+
+export async function createProduct(data: CreateProductData): Promise {
+ const response = await api.post>(
+ '/marketplace/seller/products',
+ data
+ );
+ return response.data.data;
+}
+
+export async function saveProductDraft(
+ data: Partial
+): Promise {
+ const response = await api.post>(
+ '/marketplace/seller/drafts',
+ data
+ );
+ return response.data.data;
+}
+
+export async function updateProductDraft(
+ id: string,
+ data: Partial
+): Promise {
+ const response = await api.patch>(
+ `/marketplace/seller/drafts/${id}`,
+ data
+ );
+ return response.data.data;
+}
+
+export async function publishDraft(draftId: string): Promise {
+ const response = await api.post>(
+ `/marketplace/seller/drafts/${draftId}/publish`
+ );
+ return response.data.data;
+}
+
+export async function deleteProductDraft(id: string): Promise {
+ 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>(
+ '/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>('/marketplace/coupons/validate', { code, productId });
+ return response.data.data;
+}
+
// ============================================================================
// Export service object
// ============================================================================
@@ -299,4 +469,26 @@ export const marketplaceService = {
getMyBookings,
getBookingById,
cancelBooking,
+
+ // Seller Products
+ getSellerProducts,
+ getSellerProductById,
+ updateSellerProduct,
+ toggleProductStatus,
+ deleteSellerProduct,
+
+ // Seller Stats
+ getSellerStats,
+ getSellerSales,
+ getSellerPayouts,
+ getSellerEarnings,
+
+ // Product Creation
+ createProduct,
+ saveProductDraft,
+ updateProductDraft,
+ publishDraft,
+ deleteProductDraft,
+ uploadProductImage,
+ validateCoupon,
};
diff --git a/src/services/payment.service.ts b/src/services/payment.service.ts
index c6c766c..7435e86 100644
--- a/src/services/payment.service.ts
+++ b/src/services/payment.service.ts
@@ -22,6 +22,9 @@ import type {
SubscriptionResponse,
WalletResponse,
PlanInterval,
+ Refund,
+ RefundEligibility,
+ RefundRequest,
} from '../types/payment.types';
// Uses centralized apiClient from lib/apiClient.ts (auto-refresh, multi-tab sync)
@@ -282,6 +285,44 @@ export async function withdrawFromWallet(
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>(
+ '/payments/refunds',
+ { params: { limit, offset, status } }
+ );
+ return response.data.data;
+}
+
+export async function getRefundById(refundId: string): Promise {
+ const response = await api.get>(`/payments/refunds/${refundId}`);
+ return response.data.data;
+}
+
+export async function getRefundEligibility(subscriptionId: string): Promise {
+ const response = await api.get>(
+ `/payments/refunds/eligibility/${subscriptionId}`
+ );
+ return response.data.data;
+}
+
+export async function requestRefund(request: RefundRequest): Promise {
+ const response = await api.post>('/payments/refunds', request);
+ return response.data.data;
+}
+
+export async function cancelRefund(refundId: string): Promise {
+ const response = await api.delete>(`/payments/refunds/${refundId}`);
+ return response.data.data;
+}
+
// ============================================================================
// Coupons
// ============================================================================
@@ -368,6 +409,12 @@ export const paymentService = {
getWalletTransactions,
depositToWallet,
withdrawFromWallet,
+ // Refunds
+ getRefunds,
+ getRefundById,
+ getRefundEligibility,
+ requestRefund,
+ cancelRefund,
// Coupons
validateCoupon,
// Summary
diff --git a/src/types/marketplace.types.ts b/src/types/marketplace.types.ts
index 2197a40..3044eb0 100644
--- a/src/types/marketplace.types.ts
+++ b/src/types/marketplace.types.ts
@@ -336,3 +336,97 @@ export interface ConsultationBooking {
priceUsd: number;
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;
+ lastSaved: string;
+ createdAt: string;
+}
diff --git a/src/types/payment.types.ts b/src/types/payment.types.ts
index 0d85fd2..4bfc345 100644
--- a/src/types/payment.types.ts
+++ b/src/types/payment.types.ts
@@ -341,3 +341,54 @@ export interface WalletResponse {
wallet: Wallet;
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';
+}