From 6a8cb3b9cfc91c6e73005d8fdb0f0ce99ac2e3ac Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Wed, 4 Feb 2026 00:29:03 -0600 Subject: [PATCH] [SPRINT-4-5] feat: Education, LLM, Portfolio, Marketplace modules SPRINT 4 (128 SP): SUBTASK-007 Education (21 SP): - Add QuizHistoryCard with stats and trend indicators - Add EarnedCertificates with search, sort, sharing - Enhance CourseDetail with "Resume where you left off" SUBTASK-008 LLM Agent (44 SP): - Add MemoryManager with conversation history management - Add LLMToolsPanel with tool enable/disable and execution history - Add FineTuningPanel with model selection and training config SUBTASK-009 Portfolio (63 SP): - Add MonteCarloSimulator with VaR/CVaR calculations - Add CorrelationMatrix with heatmap visualization - Add RebalancingPanel with drift thresholds and history - Add GoalsManager with savings rate calculator - Add RiskAnalytics with comprehensive metrics SPRINT 5 (55 SP): SUBTASK-010 Marketplace (42 SP): - Create full marketplace module structure - Add MarketplaceCatalog with grid/list view, filters - Add SignalPackCard, SignalPackDetail pages - Add AdvisoryCard, AdvisoryDetail with booking - Add CourseProductCard, ReviewCard components - Add marketplace types, service, and store - Add routes: /marketplace, /marketplace/signals/:slug, etc. Co-Authored-By: Claude Opus 4.5 --- src/App.tsx | 11 + .../assistant/components/FineTuningPanel.tsx | 653 ++++++++++++++++ .../assistant/components/LLMToolsPanel.tsx | 505 ++++++++++++ .../assistant/components/MemoryManager.tsx | 522 +++++++++++++ src/modules/assistant/components/index.ts | 12 + .../components/EarnedCertificates.tsx | 426 ++++++++++ .../education/components/QuizHistoryCard.tsx | 305 ++++++++ src/modules/education/components/index.ts | 6 + src/modules/education/pages/CourseDetail.tsx | 28 +- .../marketplace/components/AdvisoryCard.tsx | 186 +++++ .../components/CourseProductCard.tsx | 179 +++++ .../marketplace/components/ProductCard.tsx | 30 + .../marketplace/components/ReviewCard.tsx | 123 +++ .../marketplace/components/SignalPackCard.tsx | 174 +++++ src/modules/marketplace/components/index.ts | 9 + .../marketplace/pages/AdvisoryDetail.tsx | 453 +++++++++++ .../marketplace/pages/MarketplaceCatalog.tsx | 396 ++++++++++ .../marketplace/pages/SignalPackDetail.tsx | 399 ++++++++++ src/modules/marketplace/pages/index.ts | 7 + .../components/CorrelationMatrix.tsx | 379 +++++++++ .../portfolio/components/GoalsManager.tsx | 739 ++++++++++++++++++ .../components/MonteCarloSimulator.tsx | 591 ++++++++++++++ .../portfolio/components/RebalancingPanel.tsx | 613 +++++++++++++++ .../portfolio/components/RiskAnalytics.tsx | 690 ++++++++++++++++ src/modules/portfolio/components/index.ts | 5 + src/services/marketplace.service.ts | 302 +++++++ src/stores/marketplaceStore.ts | 525 +++++++++++++ src/types/marketplace.types.ts | 338 ++++++++ 28 files changed, 8604 insertions(+), 2 deletions(-) create mode 100644 src/modules/assistant/components/FineTuningPanel.tsx create mode 100644 src/modules/assistant/components/LLMToolsPanel.tsx create mode 100644 src/modules/assistant/components/MemoryManager.tsx create mode 100644 src/modules/education/components/EarnedCertificates.tsx create mode 100644 src/modules/education/components/QuizHistoryCard.tsx create mode 100644 src/modules/marketplace/components/AdvisoryCard.tsx create mode 100644 src/modules/marketplace/components/CourseProductCard.tsx create mode 100644 src/modules/marketplace/components/ProductCard.tsx create mode 100644 src/modules/marketplace/components/ReviewCard.tsx create mode 100644 src/modules/marketplace/components/SignalPackCard.tsx create mode 100644 src/modules/marketplace/components/index.ts create mode 100644 src/modules/marketplace/pages/AdvisoryDetail.tsx create mode 100644 src/modules/marketplace/pages/MarketplaceCatalog.tsx create mode 100644 src/modules/marketplace/pages/SignalPackDetail.tsx create mode 100644 src/modules/marketplace/pages/index.ts create mode 100644 src/modules/portfolio/components/CorrelationMatrix.tsx create mode 100644 src/modules/portfolio/components/GoalsManager.tsx create mode 100644 src/modules/portfolio/components/MonteCarloSimulator.tsx create mode 100644 src/modules/portfolio/components/RebalancingPanel.tsx create mode 100644 src/modules/portfolio/components/RiskAnalytics.tsx create mode 100644 src/services/marketplace.service.ts create mode 100644 src/stores/marketplaceStore.ts create mode 100644 src/types/marketplace.types.ts diff --git a/src/App.tsx b/src/App.tsx index e527d1c..cf144bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -70,6 +70,11 @@ const MLModelsPage = lazy(() => import('./modules/admin/pages/MLModelsPage')); const AgentsPage = lazy(() => import('./modules/admin/pages/AgentsPage')); const PredictionsPage = lazy(() => import('./modules/admin/pages/PredictionsPage')); +// Lazy load modules - Marketplace +const MarketplaceCatalog = lazy(() => import('./modules/marketplace/pages/MarketplaceCatalog')); +const SignalPackDetail = lazy(() => import('./modules/marketplace/pages/SignalPackDetail')); +const AdvisoryDetail = lazy(() => import('./modules/marketplace/pages/AdvisoryDetail')); + function App() { return ( @@ -150,6 +155,12 @@ function App() { } /> } /> } /> + + {/* Marketplace */} + } /> + } /> + } /> + } /> {/* Redirects */} diff --git a/src/modules/assistant/components/FineTuningPanel.tsx b/src/modules/assistant/components/FineTuningPanel.tsx new file mode 100644 index 0000000..3953082 --- /dev/null +++ b/src/modules/assistant/components/FineTuningPanel.tsx @@ -0,0 +1,653 @@ +/** + * FineTuningPanel Component + * Fine-tuning interface for LLM models (UI only, backend mock) + * OQI-007: LLM Strategy Agent - SUBTASK-008 + */ + +import React, { useState, useRef, useMemo } from 'react'; +import { + AcademicCapIcon, + CloudArrowUpIcon, + PlayIcon, + StopIcon, + CheckCircleIcon, + XCircleIcon, + ClockIcon, + ChartBarIcon, + ArrowPathIcon, + DocumentTextIcon, + TrashIcon, + InformationCircleIcon, + ExclamationTriangleIcon, + ArrowDownTrayIcon, + EyeIcon, + ScaleIcon, +} from '@heroicons/react/24/outline'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface BaseModel { + id: string; + name: string; + provider: string; + description: string; + contextWindow: number; + costPerMToken: number; + supportsFinetuning: boolean; +} + +export interface TrainingDataset { + id: string; + name: string; + fileName: string; + sampleCount: number; + sizeKB: number; + format: 'jsonl' | 'csv' | 'json'; + uploadedAt: string; + validationStatus: 'pending' | 'valid' | 'invalid'; + validationErrors?: string[]; +} + +export interface FineTunedModel { + id: string; + name: string; + baseModelId: string; + baseModelName: string; + status: 'training' | 'completed' | 'failed' | 'cancelled'; + progress: number; + createdAt: string; + completedAt?: string; + trainingLoss?: number; + validationLoss?: number; + epochs: number; + trainingSamples: number; + estimatedCost: number; + metrics?: { + accuracy?: number; + f1Score?: number; + perplexity?: number; + }; +} + +export interface TrainingConfig { + epochs: number; + batchSize: number; + learningRate: number; + warmupSteps: number; + validationSplit: number; +} + +interface FineTuningPanelProps { + baseModels: BaseModel[]; + datasets: TrainingDataset[]; + fineTunedModels: FineTunedModel[]; + onUploadDataset?: (file: File) => void; + onDeleteDataset?: (datasetId: string) => void; + onStartTraining?: (config: { + baseModelId: string; + datasetId: string; + name: string; + config: TrainingConfig; + }) => void; + onCancelTraining?: (modelId: string) => void; + onDownloadModel?: (modelId: string) => void; + onDeleteModel?: (modelId: string) => void; + isLoading?: boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_TRAINING_CONFIG: TrainingConfig = { + epochs: 3, + batchSize: 4, + learningRate: 0.0001, + warmupSteps: 100, + validationSplit: 0.1, +}; + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface DatasetCardProps { + dataset: TrainingDataset; + onDelete?: () => void; + isSelected?: boolean; + onSelect?: () => void; +} + +const DatasetCard: React.FC = ({ dataset, onDelete, isSelected, onSelect }) => { + const statusColors = { + pending: 'text-yellow-500 bg-yellow-500/10', + valid: 'text-emerald-500 bg-emerald-500/10', + invalid: 'text-red-500 bg-red-500/10', + }; + + const StatusIcon = { + pending: ClockIcon, + valid: CheckCircleIcon, + invalid: XCircleIcon, + }[dataset.validationStatus]; + + return ( +
+
+
+ +
+
+
+

{dataset.name}

+ + + {dataset.validationStatus} + +
+

{dataset.fileName}

+
+ {dataset.sampleCount.toLocaleString()} samples + {(dataset.sizeKB / 1024).toFixed(2)} MB + {dataset.format.toUpperCase()} +
+ {dataset.validationErrors && dataset.validationErrors.length > 0 && ( +
+ {dataset.validationErrors[0]} + {dataset.validationErrors.length > 1 && ` (+${dataset.validationErrors.length - 1} more)`} +
+ )} +
+ {onDelete && ( + + )} +
+
+ ); +}; + +interface ModelComparisonCardProps { + model: FineTunedModel; + onCancel?: () => void; + onDownload?: () => void; + onDelete?: () => void; +} + +const ModelComparisonCard: React.FC = ({ model, onCancel, onDownload, onDelete }) => { + const statusColors = { + training: 'text-blue-500 bg-blue-500/10 border-blue-500/30', + completed: 'text-emerald-500 bg-emerald-500/10 border-emerald-500/30', + failed: 'text-red-500 bg-red-500/10 border-red-500/30', + cancelled: 'text-gray-500 bg-gray-500/10 border-gray-500/30', + }; + + const StatusIcon = { + training: ArrowPathIcon, + completed: CheckCircleIcon, + failed: XCircleIcon, + cancelled: StopIcon, + }[model.status]; + + return ( +
+ {/* Header */} +
+
+
+

{model.name}

+

Base: {model.baseModelName}

+
+
+ + {model.status} +
+
+ + {/* Progress Bar (if training) */} + {model.status === 'training' && ( +
+
+ Training Progress + {model.progress}% +
+
+
+
+
+ )} +
+ + {/* Metrics */} +
+
+
Training Loss
+
+ {model.trainingLoss?.toFixed(4) || '-'} +
+
+
+
Validation Loss
+
+ {model.validationLoss?.toFixed(4) || '-'} +
+
+ {model.metrics?.accuracy !== undefined && ( +
+
Accuracy
+
+ {(model.metrics.accuracy * 100).toFixed(1)}% +
+
+ )} + {model.metrics?.f1Score !== undefined && ( +
+
F1 Score
+
+ {model.metrics.f1Score.toFixed(3)} +
+
+ )} +
+
Epochs
+
{model.epochs}
+
+
+
Samples
+
+ {model.trainingSamples.toLocaleString()} +
+
+
+ + {/* Footer */} +
+
+ Cost: ${model.estimatedCost.toFixed(2)} +
+
+ {model.status === 'training' && onCancel && ( + + )} + {model.status === 'completed' && onDownload && ( + + )} + {(model.status === 'completed' || model.status === 'failed' || model.status === 'cancelled') && onDelete && ( + + )} +
+
+
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export const FineTuningPanel: React.FC = ({ + baseModels, + datasets, + fineTunedModels, + onUploadDataset, + onDeleteDataset, + onStartTraining, + onCancelTraining, + onDownloadModel, + onDeleteModel, + isLoading = false, +}) => { + const [activeTab, setActiveTab] = useState<'create' | 'models'>('create'); + const [selectedBaseModel, setSelectedBaseModel] = useState(''); + const [selectedDataset, setSelectedDataset] = useState(''); + const [modelName, setModelName] = useState(''); + const [trainingConfig, setTrainingConfig] = useState(DEFAULT_TRAINING_CONFIG); + const [showAdvanced, setShowAdvanced] = useState(false); + const fileInputRef = useRef(null); + + const supportedModels = baseModels.filter((m) => m.supportsFinetuning); + const validDatasets = datasets.filter((d) => d.validationStatus === 'valid'); + const trainingModels = fineTunedModels.filter((m) => m.status === 'training'); + + const canStartTraining = selectedBaseModel && selectedDataset && modelName.trim() && !isLoading; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file && onUploadDataset) { + onUploadDataset(file); + } + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleStartTraining = () => { + if (!canStartTraining || !onStartTraining) return; + onStartTraining({ + baseModelId: selectedBaseModel, + datasetId: selectedDataset, + name: modelName.trim(), + config: trainingConfig, + }); + setModelName(''); + setSelectedDataset(''); + }; + + const estimatedCost = useMemo(() => { + if (!selectedBaseModel || !selectedDataset) return 0; + const model = baseModels.find((m) => m.id === selectedBaseModel); + const dataset = datasets.find((d) => d.id === selectedDataset); + if (!model || !dataset) return 0; + const tokensPerSample = 500; + const totalTokens = dataset.sampleCount * tokensPerSample * trainingConfig.epochs; + return (totalTokens / 1000000) * model.costPerMToken * 10; + }, [selectedBaseModel, selectedDataset, trainingConfig.epochs, baseModels, datasets]); + + return ( +
+ {/* Header */} +
+
+
+ +

Fine-Tuning

+
+ {trainingModels.length > 0 && ( + + + {trainingModels.length} training + + )} +
+ + {/* Stats */} +
+
+
{datasets.length}
+
Datasets
+
+
+
{fineTunedModels.length}
+
Models
+
+
+
+ {fineTunedModels.filter((m) => m.status === 'completed').length} +
+
Ready
+
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Create Training Tab */} + {activeTab === 'create' && ( +
+ {/* Model Selection */} +
+ + + {selectedBaseModel && ( +

+ {baseModels.find((m) => m.id === selectedBaseModel)?.description} +

+ )} +
+ + {/* Dataset Upload & Selection */} +
+ + + + {datasets.length > 0 && ( +
+ {datasets.map((dataset) => ( + setSelectedDataset(dataset.id)} + onDelete={onDeleteDataset ? () => onDeleteDataset(dataset.id) : undefined} + /> + ))} +
+ )} +
+ + {/* Model Name */} +
+ + setModelName(e.target.value)} + placeholder="e.g., trading-signals-v1" + className="w-full px-3 py-2.5 bg-gray-100 dark:bg-gray-800 border-0 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500" + /> +
+ + {/* Advanced Config */} +
+ + {showAdvanced && ( +
+
+
+ + setTrainingConfig({ ...trainingConfig, epochs: parseInt(e.target.value) || 1 })} + className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm" + /> +
+
+ + setTrainingConfig({ ...trainingConfig, batchSize: parseInt(e.target.value) || 1 })} + className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm" + /> +
+
+ + setTrainingConfig({ ...trainingConfig, learningRate: parseFloat(e.target.value) || 0.0001 })} + className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm" + /> +
+
+ + setTrainingConfig({ ...trainingConfig, validationSplit: parseFloat(e.target.value) || 0.1 })} + className="w-full px-3 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg text-sm" + /> +
+
+
+ )} +
+ + {/* Estimated Cost */} + {estimatedCost > 0 && ( +
+
+ + Estimated training cost +
+ + ~${estimatedCost.toFixed(2)} + +
+ )} + + {/* Start Training Button */} + + + {/* Info */} +
+ +

+ Fine-tuning customizes a base model on your trading data. This improves accuracy for + your specific use case but may take hours to complete. Training costs are estimates. +

+
+
+ )} + + {/* Models Comparison Tab */} + {activeTab === 'models' && ( +
+ {fineTunedModels.length === 0 ? ( +
+ +

No fine-tuned models yet

+

Start training to create your first model

+
+ ) : ( +
+ {fineTunedModels.map((model) => ( + onCancelTraining(model.id) : undefined} + onDownload={onDownloadModel ? () => onDownloadModel(model.id) : undefined} + onDelete={onDeleteModel ? () => onDeleteModel(model.id) : undefined} + /> + ))} +
+ )} +
+ )} +
+ ); +}; + +// Missing import fix +const ChevronUpIcon = ({ className }: { className?: string }) => ( + + + +); + +const ChevronDownIcon = ({ className }: { className?: string }) => ( + + + +); + +export default FineTuningPanel; diff --git a/src/modules/assistant/components/LLMToolsPanel.tsx b/src/modules/assistant/components/LLMToolsPanel.tsx new file mode 100644 index 0000000..ceb53ca --- /dev/null +++ b/src/modules/assistant/components/LLMToolsPanel.tsx @@ -0,0 +1,505 @@ +/** + * LLMToolsPanel Component + * Tools integration panel with enable/disable toggles and execution history + * OQI-007: LLM Strategy Agent - SUBTASK-008 + */ + +import React, { useState, useMemo } from 'react'; +import { + WrenchScrewdriverIcon, + ChartBarIcon, + CurrencyDollarIcon, + MagnifyingGlassIcon, + CalculatorIcon, + NewspaperIcon, + ClockIcon, + CheckIcon, + XMarkIcon, + ExclamationTriangleIcon, + ChevronDownIcon, + ChevronUpIcon, + PlayIcon, + ArrowPathIcon, + InformationCircleIcon, + Cog6ToothIcon, +} from '@heroicons/react/24/outline'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface LLMTool { + id: string; + name: string; + description: string; + category: 'market_data' | 'analysis' | 'trading' | 'research' | 'utility'; + enabled: boolean; + parameters?: ToolParameter[]; + usageCount: number; + avgDuration: number; + lastUsed?: string; + requiredPermissions?: string[]; +} + +export interface ToolParameter { + name: string; + type: 'string' | 'number' | 'boolean' | 'select'; + required: boolean; + default?: string | number | boolean; + options?: string[]; + description?: string; +} + +export interface ToolExecution { + id: string; + toolId: string; + toolName: string; + status: 'pending' | 'running' | 'success' | 'error'; + arguments: Record; + result?: unknown; + error?: string; + startTime: string; + duration?: number; +} + +interface LLMToolsPanelProps { + tools: LLMTool[]; + executionHistory: ToolExecution[]; + onToggleTool: (toolId: string, enabled: boolean) => void; + onConfigureTool?: (toolId: string) => void; + onRetryExecution?: (executionId: string) => void; + onClearHistory?: () => void; + isLoading?: boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const CATEGORY_INFO: Record = { + market_data: { label: 'Market Data', color: 'text-blue-500 bg-blue-500/10', icon: CurrencyDollarIcon }, + analysis: { label: 'Analysis', color: 'text-purple-500 bg-purple-500/10', icon: ChartBarIcon }, + trading: { label: 'Trading', color: 'text-emerald-500 bg-emerald-500/10', icon: CalculatorIcon }, + research: { label: 'Research', color: 'text-orange-500 bg-orange-500/10', icon: NewspaperIcon }, + utility: { label: 'Utility', color: 'text-gray-500 bg-gray-500/10', icon: WrenchScrewdriverIcon }, +}; + +const STATUS_STYLES: Record = { + pending: { color: 'text-gray-400 bg-gray-500/10', icon: ClockIcon }, + running: { color: 'text-blue-400 bg-blue-500/10', icon: ArrowPathIcon }, + success: { color: 'text-emerald-400 bg-emerald-500/10', icon: CheckIcon }, + error: { color: 'text-red-400 bg-red-500/10', icon: XMarkIcon }, +}; + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface ToolCardProps { + tool: LLMTool; + onToggle: (enabled: boolean) => void; + onConfigure?: () => void; +} + +const ToolCard: React.FC = ({ tool, onToggle, onConfigure }) => { + const [isExpanded, setIsExpanded] = useState(false); + const category = CATEGORY_INFO[tool.category]; + const CategoryIcon = category.icon; + + const formatLastUsed = (dateStr?: string) => { + if (!dateStr) return 'Never'; + const date = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + + if (diff < 60000) return 'Just now'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return date.toLocaleDateString(); + }; + + return ( +
+
+
+ {/* Icon */} +
+ +
+ + {/* Content */} +
+
+

{tool.name}

+ + {category.label} + +
+

{tool.description}

+ + {/* Stats */} +
+ {tool.usageCount} uses + {tool.avgDuration}ms avg + {formatLastUsed(tool.lastUsed)} +
+
+ + {/* Actions */} +
+ {onConfigure && tool.parameters && tool.parameters.length > 0 && ( + + )} + + {/* Toggle */} + +
+
+ + {/* Expanded Details */} + {isExpanded && ( +
+ {tool.parameters && tool.parameters.length > 0 && ( +
+
Parameters
+ {tool.parameters.map((param) => ( +
+
+ {param.name} + {param.required && *} +
+ {param.type} +
+ ))} +
+ )} + {tool.requiredPermissions && tool.requiredPermissions.length > 0 && ( +
+
Required Permissions
+
+ {tool.requiredPermissions.map((perm) => ( + + {perm} + + ))} +
+
+ )} +
+ )} +
+
+ ); +}; + +interface ExecutionHistoryItemProps { + execution: ToolExecution; + onRetry?: () => void; +} + +const ExecutionHistoryItem: React.FC = ({ execution, onRetry }) => { + const [isExpanded, setIsExpanded] = useState(false); + const status = STATUS_STYLES[execution.status]; + const StatusIcon = status.icon; + + return ( +
+
+
+ +
+ +
+
+ {execution.toolName} + + {execution.status} + +
+ +
+ {new Date(execution.startTime).toLocaleTimeString()} + {execution.duration && {execution.duration}ms} +
+ + {execution.error && ( +
+ + {execution.error} +
+ )} +
+ +
+ {execution.status === 'error' && onRetry && ( + + )} + +
+
+ + {isExpanded && ( +
+
+
Arguments
+
+              {JSON.stringify(execution.arguments, null, 2)}
+            
+
+ {execution.result !== undefined && execution.result !== null && ( +
+
Result
+
+                {JSON.stringify(execution.result, null, 2)}
+              
+
+ )} +
+ )} +
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export const LLMToolsPanel: React.FC = ({ + tools, + executionHistory, + onToggleTool, + onConfigureTool, + onRetryExecution, + onClearHistory, + isLoading = false, +}) => { + const [activeTab, setActiveTab] = useState<'tools' | 'history'>('tools'); + const [searchQuery, setSearchQuery] = useState(''); + const [categoryFilter, setCategoryFilter] = useState('all'); + + const filteredTools = useMemo(() => { + let filtered = tools; + + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter((t) => + t.name.toLowerCase().includes(query) || + t.description.toLowerCase().includes(query) + ); + } + + if (categoryFilter !== 'all') { + filtered = filtered.filter((t) => t.category === categoryFilter); + } + + return filtered; + }, [tools, searchQuery, categoryFilter]); + + const enabledCount = tools.filter((t) => t.enabled).length; + const categories = [...new Set(tools.map((t) => t.category))]; + + const recentExecutions = executionHistory.slice(0, 20); + const successRate = executionHistory.length > 0 + ? (executionHistory.filter((e) => e.status === 'success').length / executionHistory.length * 100).toFixed(0) + : 0; + + return ( +
+ {/* Header */} +
+
+
+ +

LLM Tools

+
+
+ {enabledCount}/{tools.length} enabled +
+
+ + {/* Stats */} +
+
+
{tools.length}
+
Total Tools
+
+
+
{enabledCount}
+
Enabled
+
+
+
{successRate}%
+
Success Rate
+
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Tools Tab */} + {activeTab === 'tools' && ( + <> + {/* Filters */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search tools..." + 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" + /> +
+ +
+ + {/* Tools List */} +
+ {filteredTools.length === 0 ? ( +
+ +

No tools found

+
+ ) : ( + filteredTools.map((tool) => ( + onToggleTool(tool.id, enabled)} + onConfigure={onConfigureTool ? () => onConfigureTool(tool.id) : undefined} + /> + )) + )} +
+ + )} + + {/* History Tab */} + {activeTab === 'history' && ( + <> + {/* Clear History */} + {executionHistory.length > 0 && onClearHistory && ( +
+ +
+ )} + + {/* Execution List */} +
+ {recentExecutions.length === 0 ? ( +
+ +

No execution history

+

Tool executions will appear here

+
+ ) : ( +
+ {recentExecutions.map((execution) => ( + onRetryExecution(execution.id) : undefined} + /> + ))} +
+ )} +
+ + )} + + {/* Info Footer */} +
+
+ +

+ Tools extend AI capabilities. Enable tools to allow the assistant to access market data, + analyze charts, execute trades, and more. Some tools may require additional permissions. +

+
+
+
+ ); +}; + +export default LLMToolsPanel; diff --git a/src/modules/assistant/components/MemoryManager.tsx b/src/modules/assistant/components/MemoryManager.tsx new file mode 100644 index 0000000..f8c3e99 --- /dev/null +++ b/src/modules/assistant/components/MemoryManager.tsx @@ -0,0 +1,522 @@ +/** + * MemoryManager Component + * Enhanced memory management with conversation history, archiving, and visualization + * OQI-007: LLM Strategy Agent - SUBTASK-008 + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { + CircleStackIcon, + ArchiveBoxIcon, + TrashIcon, + ArrowDownTrayIcon, + ClockIcon, + FolderIcon, + DocumentTextIcon, + ChevronRightIcon, + MagnifyingGlassIcon, + ExclamationTriangleIcon, + CheckCircleIcon, + XMarkIcon, + ArrowPathIcon, +} from '@heroicons/react/24/outline'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ConversationRecord { + id: string; + title: string; + preview: string; + messageCount: number; + tokenCount: number; + createdAt: string; + updatedAt: string; + archived: boolean; + tags?: string[]; +} + +export interface MemoryUsage { + totalTokens: number; + maxTokens: number; + activeConversations: number; + archivedConversations: number; + totalStorageKB: number; +} + +export interface ContextWindow { + used: number; + max: number; + reserved: number; + systemPrompt: number; + conversationHistory: number; + tools: number; +} + +interface MemoryManagerProps { + conversations: ConversationRecord[]; + memoryUsage: MemoryUsage; + contextWindow: ContextWindow; + onClearConversation?: (id: string) => void; + onArchiveConversation?: (id: string) => void; + onRestoreConversation?: (id: string) => void; + onExportConversation?: (id: string) => void; + onDeleteConversation?: (id: string) => void; + onClearAllArchived?: () => void; + onRefreshUsage?: () => void; + isLoading?: boolean; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +const formatBytes = (kb: number): string => { + if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`; + return `${kb.toFixed(1)} KB`; +}; + +const formatTokens = (tokens: number): string => { + if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`; + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`; + return tokens.toString(); +}; + +const formatDate = (dateStr: string): string => { + const date = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + + if (diff < 86400000) { + return date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }); + } + if (diff < 604800000) { + return date.toLocaleDateString('es-ES', { weekday: 'short' }); + } + return date.toLocaleDateString('es-ES', { month: 'short', day: 'numeric' }); +}; + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface ContextWindowVisualizationProps { + contextWindow: ContextWindow; +} + +const ContextWindowVisualization: React.FC = ({ contextWindow }) => { + const segments = [ + { label: 'System Prompt', value: contextWindow.systemPrompt, color: 'bg-purple-500' }, + { label: 'Conversation', value: contextWindow.conversationHistory, color: 'bg-blue-500' }, + { label: 'Tools', value: contextWindow.tools, color: 'bg-emerald-500' }, + { label: 'Reserved', value: contextWindow.reserved, color: 'bg-gray-400' }, + ]; + + const available = contextWindow.max - contextWindow.used; + const usagePercent = (contextWindow.used / contextWindow.max) * 100; + + return ( +
+

Context Window

+ + {/* Progress Bar */} +
+ {segments.map((segment, i) => ( +
+ ))} +
+ + {/* Legend */} +
+ {segments.map((segment) => ( +
+
+ {segment.label} + {formatTokens(segment.value)} +
+ ))} +
+ + {/* Summary */} +
+
+ Used: + 80 ? 'text-red-500' : 'text-gray-900 dark:text-white'}`}> + {formatTokens(contextWindow.used)} ({usagePercent.toFixed(0)}%) + +
+
+ Available: + {formatTokens(available)} +
+
+ + {usagePercent > 80 && ( +
+ + Context window is filling up. Consider archiving old conversations. +
+ )} +
+ ); +}; + +interface ConversationListItemProps { + conversation: ConversationRecord; + onArchive?: () => void; + onRestore?: () => void; + onExport?: () => void; + onDelete?: () => void; +} + +const ConversationListItem: React.FC = ({ + conversation, + onArchive, + onRestore, + onExport, + onDelete, +}) => { + const [showActions, setShowActions] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + return ( +
setShowActions(true)} + onMouseLeave={() => { setShowActions(false); setConfirmDelete(false); }} + > +
+
+ {conversation.archived ? ( + + ) : ( + + )} +
+ +
+

+ {conversation.title} +

+

+ {conversation.preview} +

+
+ {conversation.messageCount} messages + {formatTokens(conversation.tokenCount)} tokens + {formatDate(conversation.updatedAt)} +
+ {conversation.tags && conversation.tags.length > 0 && ( +
+ {conversation.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ + {/* Actions */} +
+ {!confirmDelete ? ( + <> + {conversation.archived ? ( + onRestore && ( + + ) + ) : ( + onArchive && ( + + ) + )} + {onExport && ( + + )} + {onDelete && ( + + )} + + ) : ( + <> + + + + )} +
+
+
+ ); +}; + +// ============================================================================ +// Main Component +// ============================================================================ + +export const MemoryManager: React.FC = ({ + conversations, + memoryUsage, + contextWindow, + onClearConversation, + onArchiveConversation, + onRestoreConversation, + onExportConversation, + onDeleteConversation, + onClearAllArchived, + onRefreshUsage, + isLoading = false, +}) => { + const [activeTab, setActiveTab] = useState<'active' | 'archived'>('active'); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState<'recent' | 'tokens' | 'messages'>('recent'); + + const filteredConversations = useMemo(() => { + let filtered = conversations.filter((c) => + activeTab === 'archived' ? c.archived : !c.archived + ); + + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter((c) => + c.title.toLowerCase().includes(query) || + c.preview.toLowerCase().includes(query) || + c.tags?.some((t) => t.toLowerCase().includes(query)) + ); + } + + filtered.sort((a, b) => { + switch (sortBy) { + case 'tokens': + return b.tokenCount - a.tokenCount; + case 'messages': + return b.messageCount - a.messageCount; + default: + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + } + }); + + return filtered; + }, [conversations, activeTab, searchQuery, sortBy]); + + const activeConversations = conversations.filter((c) => !c.archived); + const archivedConversations = conversations.filter((c) => c.archived); + + const usagePercent = (memoryUsage.totalTokens / memoryUsage.maxTokens) * 100; + + return ( +
+ {/* Header */} +
+
+
+ +

Memory Manager

+
+ {onRefreshUsage && ( + + )} +
+ + {/* Usage Overview */} +
+
+
+ {formatTokens(memoryUsage.totalTokens)} +
+
Total Tokens
+
+
+
+ {memoryUsage.activeConversations} +
+
Active
+
+
+
+ {memoryUsage.archivedConversations} +
+
Archived
+
+
+
+ {formatBytes(memoryUsage.totalStorageKB)} +
+
Storage
+
+
+ + {/* Usage Bar */} +
+
+ Memory Usage + 80 ? 'text-red-500' : 'text-gray-900 dark:text-white'}`}> + {usagePercent.toFixed(0)}% + +
+
+
80 ? 'bg-red-500' : usagePercent > 60 ? 'bg-yellow-500' : 'bg-emerald-500' + }`} + style={{ width: `${usagePercent}%` }} + /> +
+
+
+ + {/* Context Window Visualization */} +
+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Search & Sort */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search conversations..." + 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" + /> +
+ +
+ + {/* Conversation List */} +
+ {filteredConversations.length === 0 ? ( +
+
+ {activeTab === 'archived' ? ( + + ) : ( + + )} +
+

+ {searchQuery + ? 'No conversations match your search' + : activeTab === 'archived' + ? 'No archived conversations' + : 'No active conversations'} +

+
+ ) : ( +
+ {filteredConversations.map((conversation) => ( + onArchiveConversation(conversation.id) : undefined} + onRestore={onRestoreConversation ? () => onRestoreConversation(conversation.id) : undefined} + onExport={onExportConversation ? () => onExportConversation(conversation.id) : undefined} + onDelete={onDeleteConversation ? () => onDeleteConversation(conversation.id) : undefined} + /> + ))} +
+ )} +
+ + {/* Footer Actions */} + {activeTab === 'archived' && archivedConversations.length > 0 && onClearAllArchived && ( +
+ +
+ )} +
+ ); +}; + +export default MemoryManager; diff --git a/src/modules/assistant/components/index.ts b/src/modules/assistant/components/index.ts index 72dfc34..c72ee8e 100644 --- a/src/modules/assistant/components/index.ts +++ b/src/modules/assistant/components/index.ts @@ -77,3 +77,15 @@ export type { TokenUsage, TokenCosts, SessionTokenStats, TokenUsageDisplayProps // Prompt Library (OQI-007) export { default as PromptLibrary } from './PromptLibrary'; export type { Prompt, PromptCategory, PromptLibraryProps } from './PromptLibrary'; + +// Memory Management (OQI-007 - SUBTASK-008) +export { default as MemoryManager } from './MemoryManager'; +export type { ConversationRecord, MemoryUsage, ContextWindow } from './MemoryManager'; + +// LLM Tools Integration (OQI-007 - SUBTASK-008) +export { default as LLMToolsPanel } from './LLMToolsPanel'; +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'; diff --git a/src/modules/education/components/EarnedCertificates.tsx b/src/modules/education/components/EarnedCertificates.tsx new file mode 100644 index 0000000..967aa13 --- /dev/null +++ b/src/modules/education/components/EarnedCertificates.tsx @@ -0,0 +1,426 @@ +/** + * EarnedCertificates Component + * Displays all earned certificates for user profile + * OQI-002: Modulo Educativo - SUBTASK-007 + */ + +import React, { useState, useMemo } from 'react'; +import { + Award, + Download, + Share2, + ExternalLink, + Calendar, + Clock, + BadgeCheck, + Search, + Grid, + List, + Trophy, + Loader2, +} from 'lucide-react'; + +export interface EarnedCertificate { + id: string; + credentialId: string; + courseId: string; + courseTitle: string; + courseThumbnail?: string; + instructorName: string; + completedAt: string; + issuedAt: string; + grade?: number; + xpEarned: number; + courseDurationHours: number; + skills?: string[]; + verificationUrl?: string; + downloadUrl?: string; +} + +interface EarnedCertificatesProps { + certificates: EarnedCertificate[]; + loading?: boolean; + onDownload?: (certificateId: string) => void; + onShare?: (certificateId: string, platform: 'linkedin' | 'twitter' | 'email' | 'copy') => void; + onViewCertificate?: (certificateId: string) => void; + compact?: boolean; +} + +type ViewMode = 'grid' | 'list'; +type SortBy = 'newest' | 'oldest' | 'grade' | 'course'; + +const EarnedCertificates: React.FC = ({ + certificates, + loading = false, + onDownload, + onShare, + onViewCertificate, + compact = false, +}) => { + const [viewMode, setViewMode] = useState('grid'); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('newest'); + const [showShareMenu, setShowShareMenu] = useState(null); + + // Filter and sort certificates + const filteredCertificates = useMemo(() => { + let result = [...certificates]; + + // Filter by search query + if (searchQuery) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (cert) => + cert.courseTitle.toLowerCase().includes(query) || + cert.instructorName.toLowerCase().includes(query) || + cert.skills?.some((skill) => skill.toLowerCase().includes(query)) + ); + } + + // Sort + switch (sortBy) { + case 'oldest': + result.sort((a, b) => new Date(a.issuedAt).getTime() - new Date(b.issuedAt).getTime()); + break; + case 'grade': + result.sort((a, b) => (b.grade || 0) - (a.grade || 0)); + break; + case 'course': + result.sort((a, b) => a.courseTitle.localeCompare(b.courseTitle)); + break; + case 'newest': + default: + result.sort((a, b) => new Date(b.issuedAt).getTime() - new Date(a.issuedAt).getTime()); + } + + return result; + }, [certificates, searchQuery, sortBy]); + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + }; + + const handleShare = (certId: string, platform: 'linkedin' | 'twitter' | 'email' | 'copy') => { + onShare?.(certId, platform); + setShowShareMenu(null); + }; + + // Loading state + if (loading) { + return ( +
+
+ +
+
+ ); + } + + // Empty state + if (certificates.length === 0) { + return ( +
+
+
+ +
+

Mis Certificados

+
+
+ +

Aun no has obtenido ningun certificado

+

+ Completa cursos para obtener certificados +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Mis Certificados

+

{certificates.length} certificados obtenidos

+
+
+ + {/* View Toggle */} +
+ + +
+
+ + {/* Search and Sort */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-4 py-2 bg-gray-900/50 border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:border-yellow-500/50 focus:outline-none" + /> +
+ +
+ + {/* Certificates Display */} + {filteredCertificates.length === 0 ? ( +
+

No se encontraron certificados

+
+ ) : viewMode === 'grid' ? ( +
+ {filteredCertificates.map((cert) => ( +
+ {/* Certificate Preview */} +
onViewCertificate?.(cert.id)} + > +
+ +

Certificado de Finalizacion

+

{cert.courseTitle}

+
+
+ +
+
+ + {/* Certificate Info */} +
+
+ + + {formatDate(cert.issuedAt)} + + {cert.grade && ( + {cert.grade}% + )} +
+ +

{cert.instructorName}

+ + {/* Skills Tags */} + {cert.skills && cert.skills.length > 0 && ( +
+ {cert.skills.slice(0, 3).map((skill) => ( + + {skill} + + ))} + {cert.skills.length > 3 && ( + +{cert.skills.length - 3} + )} +
+ )} + + {/* Actions */} +
+ {onDownload && ( + + )} + {onShare && ( +
+ + {showShareMenu === cert.id && ( +
+
+ + + +
+
+ )} +
+ )} + {cert.verificationUrl && ( + + + + )} +
+
+
+ ))} +
+ ) : ( +
+ {filteredCertificates.map((cert) => ( +
+ {/* Icon */} +
+ +
+ + {/* Info */} +
+

onViewCertificate?.(cert.id)} + > + {cert.courseTitle} +

+
+ {cert.instructorName} + + + {formatDate(cert.issuedAt)} + + + + {cert.courseDurationHours}h + +
+ {cert.skills && cert.skills.length > 0 && ( +
+ {cert.skills.slice(0, 4).map((skill) => ( + + {skill} + + ))} +
+ )} +
+ + {/* Grade */} + {cert.grade && ( +
+
{cert.grade}%
+
Calificacion
+
+ )} + + {/* Actions */} +
+ {onDownload && ( + + )} + {onShare && ( + + )} + {cert.verificationUrl && ( + + + + )} +
+
+ ))} +
+ )} + + {/* Credential ID Info */} +
+
+ + + Todos los certificados incluyen un ID unico de verificacion para validar su autenticidad + +
+
+
+ ); +}; + +export default EarnedCertificates; diff --git a/src/modules/education/components/QuizHistoryCard.tsx b/src/modules/education/components/QuizHistoryCard.tsx new file mode 100644 index 0000000..0da4fcf --- /dev/null +++ b/src/modules/education/components/QuizHistoryCard.tsx @@ -0,0 +1,305 @@ +/** + * QuizHistoryCard Component + * Displays user's quiz attempt history with scores and trends + * OQI-002: Modulo Educativo - SUBTASK-007 + */ + +import React, { useState, useMemo } from 'react'; +import { + Trophy, + Clock, + Target, + TrendingUp, + TrendingDown, + CheckCircle, + XCircle, + ChevronDown, + ChevronUp, + Calendar, + BarChart3, + RefreshCw, + Award, + Minus, +} from 'lucide-react'; + +export interface QuizAttemptHistory { + id: string; + quizId: string; + quizTitle: string; + score: number; + maxScore: number; + percentage: number; + passed: boolean; + passingScore: number; + timeSpentMinutes: number; + timeLimitMinutes?: number; + correctAnswers: number; + totalQuestions: number; + xpEarned: number; + attemptNumber: number; + submittedAt: string; +} + +interface QuizHistoryCardProps { + attempts: QuizAttemptHistory[]; + onRetakeQuiz?: (quizId: string) => void; + onViewDetails?: (attemptId: string) => void; + compact?: boolean; + limit?: number; + showQuizTitle?: boolean; +} + +const QuizHistoryCard: React.FC = ({ + attempts, + onRetakeQuiz, + onViewDetails, + compact = false, + limit, + showQuizTitle = true, +}) => { + const [expanded, setExpanded] = useState(false); + + // Sort attempts by date (newest first) + const sortedAttempts = useMemo(() => { + return [...attempts].sort( + (a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime() + ); + }, [attempts]); + + // Calculate statistics + const stats = useMemo(() => { + if (attempts.length === 0) { + return { + totalAttempts: 0, + passedCount: 0, + passRate: 0, + averageScore: 0, + bestScore: 0, + worstScore: 0, + trend: 0, + totalXpEarned: 0, + }; + } + + const passedCount = attempts.filter((a) => a.passed).length; + const scores = attempts.map((a) => a.percentage); + const averageScore = scores.reduce((sum, s) => sum + s, 0) / scores.length; + const bestScore = Math.max(...scores); + const worstScore = Math.min(...scores); + const totalXpEarned = attempts.reduce((sum, a) => sum + a.xpEarned, 0); + + // Calculate trend (last 3 attempts) + let trend = 0; + if (sortedAttempts.length >= 2) { + const recentAttempts = sortedAttempts.slice(0, Math.min(3, sortedAttempts.length)); + const latestScore = recentAttempts[0].percentage; + const olderScore = recentAttempts[recentAttempts.length - 1].percentage; + trend = latestScore - olderScore; + } + + return { + totalAttempts: attempts.length, + passedCount, + passRate: (passedCount / attempts.length) * 100, + averageScore, + bestScore, + worstScore, + trend, + totalXpEarned, + }; + }, [attempts, sortedAttempts]); + + const displayedAttempts = limit && !expanded ? sortedAttempts.slice(0, limit) : sortedAttempts; + const hasMore = limit ? sortedAttempts.length > limit : false; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('es-ES', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + }; + + const formatTime = (minutes: number) => { + if (minutes < 1) return `${Math.round(minutes * 60)}s`; + return `${Math.floor(minutes)}m ${Math.round((minutes % 1) * 60)}s`; + }; + + const getScoreColor = (percentage: number, passingScore: number) => { + if (percentage >= passingScore) return 'text-green-400'; + if (percentage >= passingScore * 0.8) return 'text-yellow-400'; + return 'text-red-400'; + }; + + const getTrendIcon = (trend: number) => { + if (trend > 5) return ; + if (trend < -5) return ; + return ; + }; + + if (attempts.length === 0) { + return ( +
+
+
+ +
+

Historial de Quizzes

+
+

+ Aun no has completado ningun quiz +

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Historial de Quizzes

+

{stats.totalAttempts} intentos totales

+
+
+
+ {getTrendIcon(stats.trend)} + 0 ? 'text-green-400' : stats.trend < 0 ? 'text-red-400' : 'text-gray-400' + }`} + > + {stats.trend > 0 ? '+' : ''} + {stats.trend.toFixed(0)}% + +
+
+ + {/* Statistics Summary */} +
+
+
{stats.averageScore.toFixed(0)}%
+
Promedio
+
+
+
{stats.bestScore.toFixed(0)}%
+
Mejor
+
+
+
{stats.passRate.toFixed(0)}%
+
Aprobados
+
+
+
{stats.totalXpEarned}
+
XP Total
+
+
+ + {/* Attempts List */} +
+ {displayedAttempts.map((attempt) => ( +
onViewDetails?.(attempt.id)} + > +
+
+
+ {attempt.passed ? ( + + ) : ( + + )} +
+
+ {showQuizTitle && ( +

+ {attempt.quizTitle} +

+ )} +
+ + + {formatDate(attempt.submittedAt)} + + + + {formatTime(attempt.timeSpentMinutes)} + + Intento #{attempt.attemptNumber} +
+
+
+ +
+
+ {attempt.percentage.toFixed(0)}% +
+
+ {attempt.correctAnswers}/{attempt.totalQuestions} +
+ {attempt.xpEarned > 0 && ( +
+ +{attempt.xpEarned} XP +
+ )} +
+
+
+ ))} +
+ + {/* Show More / Less */} + {hasMore && ( + + )} + + {/* Retake Button */} + {onRetakeQuiz && sortedAttempts.length > 0 && !sortedAttempts[0].passed && ( + + )} +
+ ); +}; + +export default QuizHistoryCard; diff --git a/src/modules/education/components/index.ts b/src/modules/education/components/index.ts index 1996dfc..284f1f5 100644 --- a/src/modules/education/components/index.ts +++ b/src/modules/education/components/index.ts @@ -33,3 +33,9 @@ export { default as CertificateGenerator } from './CertificateGenerator'; export type { CertificateData, CertificateTemplate } from './CertificateGenerator'; export { default as LiveStreamPlayer } from './LiveStreamPlayer'; export type { StreamInfo, ChatMessage, StreamReaction } from './LiveStreamPlayer'; + +// Quiz History & Earned Certificates (OQI-002 - SUBTASK-007) +export { default as QuizHistoryCard } from './QuizHistoryCard'; +export type { QuizAttemptHistory } from './QuizHistoryCard'; +export { default as EarnedCertificates } from './EarnedCertificates'; +export type { EarnedCertificate } from './EarnedCertificates'; diff --git a/src/modules/education/pages/CourseDetail.tsx b/src/modules/education/pages/CourseDetail.tsx index e53d067..30f8739 100644 --- a/src/modules/education/pages/CourseDetail.tsx +++ b/src/modules/education/pages/CourseDetail.tsx @@ -127,6 +127,30 @@ export default function CourseDetail() { const isEnrolled = !!currentCourse.userEnrollment; const enrollment = currentCourse.userEnrollment; + // Find the next lesson to continue from (first incomplete lesson or last accessed) + const getResumeLessonId = (): string | null => { + if (!currentCourse?.modules) return null; + + // Flatten all lessons with their module order + const allLessons: LessonListItem[] = []; + currentCourse.modules + .sort((a, b) => a.sortOrder - b.sortOrder) + .forEach((module) => { + module.lessons + .sort((a, b) => a.sortOrder - b.sortOrder) + .forEach((lesson) => allLessons.push(lesson)); + }); + + // Find first incomplete lesson + const incompleteLesson = allLessons.find((lesson) => !lesson.isCompleted); + if (incompleteLesson) return incompleteLesson.id; + + // If all completed, return first lesson for review + return allLessons[0]?.id || null; + }; + + const resumeLessonId = getResumeLessonId(); + return (
{/* Back Link */} @@ -278,11 +302,11 @@ export default function CourseDetail() {
) : ( diff --git a/src/modules/marketplace/components/AdvisoryCard.tsx b/src/modules/marketplace/components/AdvisoryCard.tsx new file mode 100644 index 0000000..6a8403e --- /dev/null +++ b/src/modules/marketplace/components/AdvisoryCard.tsx @@ -0,0 +1,186 @@ +/** + * Advisory Card Component + * Displays advisory service with advisor profile, specializations, and booking options + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { + Star, + CheckCircle, + Calendar, + Clock, + MessageCircle, + Award, +} from 'lucide-react'; +import type { MarketplaceProductListItem } from '../../../types/marketplace.types'; + +interface AdvisoryCardProps { + advisory: MarketplaceProductListItem; + onBook?: (advisoryId: string) => void; +} + +export const AdvisoryCard: React.FC = ({ advisory, onBook }) => { + const availabilityColors: Record = { + available: { bg: 'bg-green-500/10', text: 'text-green-400', dot: 'bg-green-400' }, + busy: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', dot: 'bg-yellow-400' }, + away: { bg: 'bg-orange-500/10', text: 'text-orange-400', dot: 'bg-orange-400' }, + offline: { bg: 'bg-gray-500/10', text: 'text-gray-400', dot: 'bg-gray-400' }, + }; + + // Determine availability from tags (mock implementation) + const getAvailability = () => { + if (advisory.tags.includes('available')) return 'available'; + if (advisory.tags.includes('busy')) return 'busy'; + return 'available'; + }; + + const availability = getAvailability(); + const colors = availabilityColors[availability]; + + // Extract specializations from tags + const specializations = advisory.tags.filter( + (t) => !['available', 'busy', 'away', 'offline'].includes(t) + ).slice(0, 4); + + return ( +
+ {/* Advisor Header */} +
+
+ {/* Avatar */} +
+ {advisory.providerAvatar ? ( + {advisory.providerName} + ) : ( +
+ {advisory.providerName.charAt(0)} +
+ )} + {/* Availability indicator */} +
+
+ + {/* Advisor Info */} +
+
+

+ {advisory.providerName} +

+ {advisory.providerVerified && ( + + )} +
+

{advisory.name}

+
+ + {availability.charAt(0).toUpperCase() + availability.slice(1)} +
+
+
+
+ + {/* Content */} +
+ {/* Description */} +

+ {advisory.shortDescription || 'Expert trading advisor with years of market experience.'} +

+ + {/* Specializations */} +
+

Specializations

+
+ {specializations.length > 0 ? ( + specializations.map((spec, idx) => ( + + {spec} + + )) + ) : ( + <> + + Technical Analysis + + + Risk Management + + + )} +
+
+ + {/* Stats */} +
+
+
+ + {advisory.rating.toFixed(1)} +
+

Rating

+
+
+
+ + {advisory.totalReviews} +
+

Reviews

+
+
+
+ + + {advisory.totalSubscribers || 0} + +
+

Sessions

+
+
+ + {/* Response Time */} +
+ + + Typically responds in 2 hours + +
+ + {/* Price and CTA */} +
+
+ ${advisory.priceUsd} + /session +
+
+ + Profile + + +
+
+
+
+ ); +}; + +export default AdvisoryCard; diff --git a/src/modules/marketplace/components/CourseProductCard.tsx b/src/modules/marketplace/components/CourseProductCard.tsx new file mode 100644 index 0000000..a58b68b --- /dev/null +++ b/src/modules/marketplace/components/CourseProductCard.tsx @@ -0,0 +1,179 @@ +/** + * Course Product Card Component + * Displays course product for marketplace with enrollment options + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { + Star, + Users, + Clock, + BookOpen, + CheckCircle, + Play, +} from 'lucide-react'; +import type { MarketplaceProductListItem } from '../../../types/marketplace.types'; + +interface CourseProductCardProps { + course: MarketplaceProductListItem; + onEnroll?: (courseId: string) => void; +} + +export const CourseProductCard: React.FC = ({ course, onEnroll }) => { + const difficultyColors: Record = { + beginner: 'bg-green-500/20 text-green-400', + intermediate: 'bg-yellow-500/20 text-yellow-400', + advanced: 'bg-red-500/20 text-red-400', + }; + + // Extract difficulty from tags + const getDifficulty = () => { + const level = course.tags.find((t) => + ['beginner', 'intermediate', 'advanced'].includes(t.toLowerCase()) + ); + return level?.toLowerCase() || 'beginner'; + }; + + const difficulty = getDifficulty(); + + return ( +
+ {/* Thumbnail */} +
+ {course.thumbnailUrl ? ( + {course.name} + ) : ( +
+ +
+ )} + + {/* Play icon overlay */} +
+
+ +
+
+ + {/* Free badge */} + {course.priceUsd === 0 && ( +
+ FREE +
+ )} + + {/* Difficulty badge */} +
+ {difficulty.charAt(0).toUpperCase() + difficulty.slice(1)} +
+
+ + {/* Content */} +
+ {/* Instructor info */} +
+ {course.providerAvatar ? ( + {course.providerName} + ) : ( +
+ {course.providerName.charAt(0)} +
+ )} + + {course.providerName} + {course.providerVerified && ( + + )} + +
+ + {/* Title */} +

+ {course.name} +

+ + {/* Meta info */} +
+ + + {Math.floor(Math.random() * 10) + 2}h + + + + {Math.floor(Math.random() * 30) + 5} lessons + +
+ + {/* Rating and Enrollments */} +
+
+ + {course.rating.toFixed(1)} + ({course.totalReviews}) +
+
+ + {(course.totalSubscribers || 0).toLocaleString()} enrolled +
+
+ + {/* Topics/Tags */} +
+ {course.tags + .filter((t) => !['beginner', 'intermediate', 'advanced'].includes(t.toLowerCase())) + .slice(0, 3) + .map((tag, idx) => ( + + {tag} + + ))} +
+ + {/* Price and CTA */} +
+
+ {course.priceUsd === 0 ? ( + Free + ) : ( + <> + ${course.priceUsd} + + )} +
+
+ + Preview + + +
+
+
+
+ ); +}; + +export default CourseProductCard; diff --git a/src/modules/marketplace/components/ProductCard.tsx b/src/modules/marketplace/components/ProductCard.tsx new file mode 100644 index 0000000..96dce26 --- /dev/null +++ b/src/modules/marketplace/components/ProductCard.tsx @@ -0,0 +1,30 @@ +/** + * Product Card Component + * Generic product card that adapts to different product types + */ + +import React from 'react'; +import type { MarketplaceProductListItem } from '../../../types/marketplace.types'; +import { SignalPackCard } from './SignalPackCard'; +import { AdvisoryCard } from './AdvisoryCard'; +import { CourseProductCard } from './CourseProductCard'; + +interface ProductCardProps { + product: MarketplaceProductListItem; + onAction?: (productId: string) => void; +} + +export const ProductCard: React.FC = ({ product, onAction }) => { + switch (product.productType) { + case 'signals': + return ; + case 'advisory': + return ; + case 'courses': + return ; + default: + return ; + } +}; + +export default ProductCard; diff --git a/src/modules/marketplace/components/ReviewCard.tsx b/src/modules/marketplace/components/ReviewCard.tsx new file mode 100644 index 0000000..3e34ee5 --- /dev/null +++ b/src/modules/marketplace/components/ReviewCard.tsx @@ -0,0 +1,123 @@ +/** + * Review Card Component + * Displays product review with rating, comment, and helpful count + */ + +import React, { useState } from 'react'; +import { Star, ThumbsUp, CheckCircle, MessageCircle } from 'lucide-react'; +import type { ProductReview } from '../../../types/marketplace.types'; + +interface ReviewCardProps { + review: ProductReview; + onMarkHelpful?: (reviewId: string) => void; +} + +export const ReviewCard: React.FC = ({ review, onMarkHelpful }) => { + const [isHelpful, setIsHelpful] = useState(false); + + const handleMarkHelpful = () => { + if (!isHelpful) { + setIsHelpful(true); + onMarkHelpful?.(review.id); + } + }; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + return ( +
+ {/* Header */} +
+ {/* Avatar */} + {review.userAvatar ? ( + {review.userName} + ) : ( +
+ {review.userName.charAt(0).toUpperCase()} +
+ )} + + {/* User info and rating */} +
+
+

{review.userName}

+ {review.verified && ( + + + Verified Purchase + + )} +
+ + {/* Rating and date */} +
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ {formatDate(review.createdAt)} +
+
+
+ + {/* Review title */} + {review.title && ( +
{review.title}
+ )} + + {/* Review content */} +

{review.comment}

+ + {/* Provider response */} + {review.response && ( +
+
+ + Provider Response + + {formatDate(review.response.respondedAt)} + +
+

{review.response.comment}

+
+ )} + + {/* Actions */} +
+ +
+
+ ); +}; + +export default ReviewCard; diff --git a/src/modules/marketplace/components/SignalPackCard.tsx b/src/modules/marketplace/components/SignalPackCard.tsx new file mode 100644 index 0000000..9a96ad3 --- /dev/null +++ b/src/modules/marketplace/components/SignalPackCard.tsx @@ -0,0 +1,174 @@ +/** + * Signal Pack Card Component + * Displays signal pack summary with provider info, stats, and subscription options + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { + TrendingUp, + TrendingDown, + Star, + Users, + BarChart2, + CheckCircle, + Zap, +} from 'lucide-react'; +import type { MarketplaceProductListItem } from '../../../types/marketplace.types'; + +interface SignalPackCardProps { + pack: MarketplaceProductListItem; + onSubscribe?: (packId: string) => void; +} + +export const SignalPackCard: React.FC = ({ pack, onSubscribe }) => { + const riskLevelColors: Record = { + low: 'bg-green-500/20 text-green-400 border-green-500/30', + medium: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + high: 'bg-red-500/20 text-red-400 border-red-500/30', + }; + + const getRiskTag = () => { + const tag = pack.tags.find((t) => ['low', 'medium', 'high'].includes(t.toLowerCase())); + return tag || 'medium'; + }; + + return ( +
+ {/* Header with thumbnail */} +
+ {pack.thumbnailUrl ? ( + {pack.name} + ) : ( +
+ +
+ )} + + {/* Free trial badge */} + {pack.hasFreeTrial && ( +
+ + Free Trial +
+ )} + + {/* Risk level badge */} +
+ {getRiskTag().charAt(0).toUpperCase() + getRiskTag().slice(1)} Risk +
+
+ + {/* Content */} +
+ {/* Provider info */} +
+ {pack.providerAvatar ? ( + {pack.providerName} + ) : ( +
+ {pack.providerName.charAt(0)} +
+ )} +
+

+ {pack.providerName} + {pack.providerVerified && ( + + )} +

+
+
+ + {/* Title */} +

+ {pack.name} +

+ + {/* Description */} +

+ {pack.shortDescription || 'Professional trading signals for consistent profits.'} +

+ + {/* Stats grid */} +
+
+
+ + Win Rate +
+

+ {(Math.random() * 20 + 70).toFixed(1)}% +

+
+
+
+ + Subscribers +
+

+ {pack.totalSubscribers?.toLocaleString() || '0'} +

+
+
+ + {/* Rating */} +
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ + ({pack.totalReviews} reviews) + +
+ + {/* Price and CTA */} +
+
+ + ${pack.priceUsd} + + /mo +
+
+ + Details + + +
+
+
+
+ ); +}; + +export default SignalPackCard; diff --git a/src/modules/marketplace/components/index.ts b/src/modules/marketplace/components/index.ts new file mode 100644 index 0000000..dfb2f91 --- /dev/null +++ b/src/modules/marketplace/components/index.ts @@ -0,0 +1,9 @@ +/** + * Marketplace Components Index + */ + +export { SignalPackCard } from './SignalPackCard'; +export { AdvisoryCard } from './AdvisoryCard'; +export { CourseProductCard } from './CourseProductCard'; +export { ProductCard } from './ProductCard'; +export { ReviewCard } from './ReviewCard'; diff --git a/src/modules/marketplace/pages/AdvisoryDetail.tsx b/src/modules/marketplace/pages/AdvisoryDetail.tsx new file mode 100644 index 0000000..f7a84c9 --- /dev/null +++ b/src/modules/marketplace/pages/AdvisoryDetail.tsx @@ -0,0 +1,453 @@ +/** + * Advisory Service Detail Page + * Full details of an advisory service with advisor profile, availability, and booking + */ + +import React, { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { + ArrowLeft, + Star, + CheckCircle, + Calendar, + Clock, + MessageCircle, + Award, + Globe, + Shield, + Video, + AlertCircle, + Loader2, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import { useMarketplaceStore } from '../../../stores/marketplaceStore'; +import { ReviewCard } from '../components'; +import type { ServiceType } from '../../../types/marketplace.types'; + +export default function AdvisoryDetail() { + const { slug } = useParams<{ slug: string }>(); + const { + currentAdvisory: advisory, + reviews, + totalReviews, + availableSlots, + availabilityTimezone, + loadingDetail, + loadingReviews, + loadingAvailability, + error, + fetchAdvisoryBySlug, + fetchReviews, + fetchAdvisorAvailability, + bookConsultation, + markReviewHelpful, + resetCurrentProduct, + } = useMarketplaceStore(); + + const [selectedService, setSelectedService] = useState(null); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [selectedSlot, setSelectedSlot] = useState(null); + const [notes, setNotes] = useState(''); + const [booking, setBooking] = useState(false); + + useEffect(() => { + if (slug) { + fetchAdvisoryBySlug(slug); + } + return () => { + resetCurrentProduct(); + }; + }, [slug, fetchAdvisoryBySlug, resetCurrentProduct]); + + useEffect(() => { + if (advisory) { + fetchReviews(advisory.id, 'advisory', { page: 1, pageSize: 5, sortBy: 'helpful' }); + // Set default service + if (advisory.serviceTypes.length > 0 && !selectedService) { + setSelectedService(advisory.serviceTypes[0]); + } + } + }, [advisory, fetchReviews, selectedService]); + + useEffect(() => { + if (advisory && selectedDate) { + const dateStr = selectedDate.toISOString().split('T')[0]; + fetchAdvisorAvailability(advisory.id, dateStr); + } + }, [advisory, selectedDate, fetchAdvisorAvailability]); + + const handleDateChange = (direction: 'prev' | 'next') => { + const newDate = new Date(selectedDate); + if (direction === 'next') { + newDate.setDate(newDate.getDate() + 1); + } else { + newDate.setDate(newDate.getDate() - 1); + } + setSelectedDate(newDate); + setSelectedSlot(null); + }; + + const handleBook = async () => { + if (!advisory || !selectedService || !selectedSlot) return; + setBooking(true); + try { + await bookConsultation(advisory.id, { + serviceTypeId: selectedService.id, + scheduledAt: `${selectedDate.toISOString().split('T')[0]}T${selectedSlot}:00.000Z`, + notes: notes || undefined, + }); + // Show success or redirect + } catch (err) { + console.error('Failed to book:', err); + } finally { + setBooking(false); + } + }; + + if (loadingDetail) { + return ( +
+ +
+ ); + } + + if (error || !advisory) { + return ( +
+ +

Advisory Not Found

+

{error || 'The requested advisory does not exist.'}

+ + Back to Marketplace + +
+ ); + } + + const formatDate = (date: Date) => { + return date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + }); + }; + + const isToday = (date: Date) => { + const today = new Date(); + return date.toDateString() === today.toDateString(); + }; + + return ( +
+ {/* Back link */} + + + Back to Marketplace + + + {/* Main Content */} +
+ {/* Left Column - Advisor Info */} +
+ {/* Advisor Header */} +
+
+ {/* Avatar */} +
+ {advisory.advisor.avatarUrl ? ( + {advisory.advisor.name} + ) : ( +
+ {advisory.advisor.name.charAt(0)} +
+ )} + {/* Online indicator */} + {advisory.availability === 'available' && ( +
+ )} +
+ +
+
+

{advisory.advisor.name}

+ {advisory.advisor.verified && ( + + )} +
+ + {advisory.advisor.title && ( +

{advisory.advisor.title}

+ )} + + {/* Quick Stats */} +
+
+ + {advisory.rating.toFixed(1)} + ({advisory.totalReviews} reviews) +
+ | +
+ + {advisory.totalConsultations} consultations +
+
+ + {/* Experience */} +

{advisory.advisor.experience}

+
+
+
+ + {/* About */} +
+

About

+

{advisory.description}

+ + {/* Credentials */} + {advisory.advisor.credentials.length > 0 && ( +
+

Credentials

+
+ {advisory.advisor.credentials.map((cred, idx) => ( + + + {cred} + + ))} +
+
+ )} + + {/* Specializations */} +
+

Specializations

+
+ {advisory.specializations.map((spec, idx) => ( + + {spec} + + ))} +
+
+ + {/* Languages */} +
+

Languages

+
+ {advisory.languages.map((lang, idx) => ( + + + {lang} + + ))} +
+
+
+ + {/* Service Types */} +
+

Services Offered

+
+ {advisory.serviceTypes.map((service) => ( +
setSelectedService(service)} + > +
+

{service.name}

+

${service.priceUsd}

+
+

{service.description}

+

+ + {service.durationMinutes} minutes +

+
+ ))} +
+
+ + {/* Reviews Section */} +
+
+

+ Reviews ({totalReviews}) +

+
+ + {loadingReviews ? ( +
+ +
+ ) : reviews.length > 0 ? ( +
+ {reviews.map((review) => ( + + ))} +
+ ) : ( +

No reviews yet.

+ )} +
+
+ + {/* Right Column - Booking */} +
+ {/* Booking Card */} +
+

Book a Consultation

+ + {/* Selected Service */} + {selectedService && ( +
+

Selected Service

+

{selectedService.name}

+

+ {selectedService.durationMinutes} min - ${selectedService.priceUsd} +

+
+ )} + + {/* Date Picker */} +
+
+ +
+

{formatDate(selectedDate)}

+

{availabilityTimezone}

+
+ +
+
+ + {/* Time Slots */} +
+

Available Times

+ {loadingAvailability ? ( +
+ +
+ ) : availableSlots.length > 0 ? ( +
+ {availableSlots.map((slot) => ( + + ))} +
+ ) : ( +

No slots available

+ )} +
+ + {/* Notes */} +
+ +