feat: full web dashboard redesign + 6 new API proxy endpoints

Replaces the 12-tab dashboard with a 3-column layout matching the
investing app mockup (sidebar nav, main chart area, right panel).

Web changes:
- New context/AppContext.tsx — shared botState/auth across all views
- New layout: Sidebar, Header (with market index sparklines), RightPanel
- New views: Home, Portfolio, Research, Markets, Screener, Watchlist, Alerts, Settings
- AppShell wires React Router routes to all views
- App.tsx refactored to use AppContext.Provider + BrowserRouter

Backend changes:
- 6 new proxy endpoints: /api/news, /api/market/indices,
  /api/research/profile, /api/research/metrics,
  /api/research/earnings, /api/screener
- config/index.ts: FMP_API_KEY env var added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Saravana Achu Mac 2026-05-03 23:50:01 -07:00
parent d955d00c00
commit f62c3b15ee
17 changed files with 1593 additions and 345 deletions

View File

@ -59,6 +59,10 @@ export const config = {
.map(s => s.trim())
.filter(Boolean),
// Research data — Financial Modeling Prep (free tier: 250 req/day)
// Register free at https://financialmodelingprep.com/developer/docs
FMP_API_KEY: process.env.FMP_API_KEY || 'demo',
// Supabase
SUPABASE_URL: process.env.SUPABASE_URL || '',
SUPABASE_KEY: process.env.SUPABASE_KEY || process.env.SUPABASE_ANON_KEY || '',

View File

@ -2617,6 +2617,134 @@ RULES:
res.status(500).json({ error: `Chat failed: ${error.message}` });
}
});
// ══════════════════════════════════════════════════════════════════════
// MARKET DATA PROXY ENDPOINTS (Phase 3-6 of web dashboard redesign)
// ══════════════════════════════════════════════════════════════════════
// ── News: proxy to Alpaca /v1beta1/news ───────────────────────────────
this.app.get('/api/news', this.requireAuth, async (req, res) => {
try {
const symbols = String(req.query.symbols || '').trim().toUpperCase();
const limit = Math.max(1, Math.min(50, Number(req.query.limit) || 10));
const alpacaKey = config.ALPACA_API_KEY;
const alpacaSecret = config.ALPACA_API_SECRET;
if (!alpacaKey || !alpacaSecret) {
return res.status(503).json({ error: 'Alpaca credentials not configured' });
}
const qs = new URLSearchParams({
...(symbols ? { symbols } : {}),
limit: String(limit),
sort: 'desc',
});
const url = `https://data.alpaca.markets/v1beta1/news?${qs.toString()}`;
const r = await fetch(url, {
headers: {
'APCA-API-KEY-ID': alpacaKey,
'APCA-API-SECRET-KEY': alpacaSecret,
},
});
if (!r.ok) return res.status(r.status).json({ error: 'Alpaca news fetch failed' });
const data = await r.json() as any;
res.json({ news: data.news ?? data });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ── Market indices: SPY / DIA / QQQ snapshots from Alpaca ─────────────
this.app.get('/api/market/indices', this.requireAuth, async (req, res) => {
try {
const alpacaKey = config.ALPACA_API_KEY;
const alpacaSecret = config.ALPACA_API_SECRET;
if (!alpacaKey || !alpacaSecret) {
return res.status(503).json({ error: 'Alpaca credentials not configured' });
}
const url = 'https://data.alpaca.markets/v2/stocks/snapshots?symbols=SPY,DIA,QQQ&feed=iex';
const r = await fetch(url, {
headers: {
'APCA-API-KEY-ID': alpacaKey,
'APCA-API-SECRET-KEY': alpacaSecret,
},
});
if (!r.ok) return res.status(r.status).json({ error: 'Alpaca snapshots fetch failed' });
const data = await r.json() as any;
res.json(data);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ── Research: company profile from FMP ───────────────────────────────
this.app.get('/api/research/profile', this.requireAuth, async (req, res) => {
try {
const symbol = String(req.query.symbol || '').trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = process.env.FMP_API_KEY || 'demo';
const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`;
const r = await fetch(url);
if (!r.ok) return res.status(r.status).json({ error: 'FMP profile fetch failed' });
const data = await r.json() as any;
res.json(Array.isArray(data) ? data[0] ?? {} : data);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ── Research: key metrics (P/E, ROE, etc.) from FMP ──────────────────
this.app.get('/api/research/metrics', this.requireAuth, async (req, res) => {
try {
const symbol = String(req.query.symbol || '').trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = process.env.FMP_API_KEY || 'demo';
const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`;
const r = await fetch(url);
if (!r.ok) return res.status(r.status).json({ error: 'FMP metrics fetch failed' });
const data = await r.json() as any;
res.json(Array.isArray(data) ? data[0] ?? {} : data);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ── Research: earnings calendar from FMP ──────────────────────────────
this.app.get('/api/research/earnings', this.requireAuth, async (req, res) => {
try {
const symbol = String(req.query.symbol || '').trim().toUpperCase();
if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = process.env.FMP_API_KEY || 'demo';
const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`;
const r = await fetch(url);
if (!r.ok) return res.status(r.status).json({ error: 'FMP earnings fetch failed' });
const data = await r.json() as any;
res.json({ earnings: Array.isArray(data) ? data : [] });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ── Screener: stock screener from FMP ────────────────────────────────
this.app.get('/api/screener', this.requireAuth, async (req, res) => {
try {
const apiKey = process.env.FMP_API_KEY || 'demo';
const qs = new URLSearchParams();
if (req.query.sector) qs.set('sector', String(req.query.sector));
if (req.query.marketCapMoreThan) qs.set('marketCapMoreThan', String(req.query.marketCapMoreThan));
if (req.query.marketCapLessThan) qs.set('marketCapLessThan', String(req.query.marketCapLessThan));
if (req.query.betaMoreThan) qs.set('betaMoreThan', String(req.query.betaMoreThan));
if (req.query.betaLessThan) qs.set('betaLessThan', String(req.query.betaLessThan));
qs.set('limit', String(Math.min(100, Number(req.query.limit) || 50)));
qs.set('apikey', apiKey);
qs.set('isEtf', 'false');
const url = `https://financialmodelingprep.com/api/v3/stock-screener?${qs.toString()}`;
const r = await fetch(url);
if (!r.ok) return res.status(r.status).json({ error: 'FMP screener fetch failed' });
const data = await r.json() as any;
res.json({ results: Array.isArray(data) ? data : [] });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
}
private setupSocketHandlers() {

View File

@ -21,9 +21,14 @@
"@bytelyst/kill-switch-client": "file:../vendor/bytelyst/kill-switch-client",
"@bytelyst/react-auth": "file:../vendor/bytelyst/react-auth",
"@bytelyst/telemetry-client": "file:../vendor/bytelyst/telemetry-client",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.14.2",
"recharts": "^3.6.0",
"socket.io-client": "^4.8.3"
},

View File

@ -1,43 +1,30 @@
import { useState, useEffect, useCallback } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { useWebSocket } from './hooks/useWebSocket';
import { LivePulseTicker } from './components/LivePulseTicker';
import { OverviewTab } from './tabs/OverviewTab';
import { SignalsTab } from './tabs/SignalsTab';
import { PositionsTab } from './tabs/PositionsTab';
import { HistoryTab } from './tabs/HistoryTab';
import { SettingsTab } from './tabs/SettingsTab';
import { EntriesTab } from './tabs/EntriesTab';
import { AdminTab } from './tabs/AdminTab';
import { TradeProfileManager } from './components/TradeProfileManager';
import { StrategyWizard } from './components/StrategyWizard';
import { MyStrategiesTab } from './tabs/MyStrategiesTab';
import { MarketplaceTab } from './tabs/MarketplaceTab';
import { MembershipTab } from './tabs/MembershipTab';
import { BacktestTab } from './tabs/BacktestTab';
import { ChatControl } from './components/ChatControl';
import type { StrategyPreset } from './lib/PresetRegistry';
import { RISK_STYLE_TEMPLATES } from './lib/RiskStyleTemplates';
import './App.css';
import { useAuth } from './components/AuthContext';
import { Login } from './components/Login';
import { ResetPassword } from './components/ResetPassword';
import { ChatControl } from './components/ChatControl';
import { AppContext } from './context/AppContext';
import { AppShell } from './components/layout/AppShell';
import { useBacktestFeatureGate } from './backtest/useBacktestFeatureGate';
import { useTabFeatureFlags } from './hooks/useTabFeatureFlags';
import { tradingRuntime, tradingTelemetry } from './lib/runtime';
import { createTradeProfile, fetchTradeProfiles, updateTradeProfile } from './lib/profileApi';
// ─── Helpers (preserved from original App.tsx) ───────────────────────────────
export const resolveProfileNameForAction = (
action: string,
requestedName: string | undefined,
chatProfiles: Array<{ name?: string }>,
suffixProvider: () => string = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }).replace(/:/g, '')
suffixProvider: () => string = () =>
new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }).replace(/:/g, '')
) => {
let profileName = requestedName || 'AI Profile';
if (action === 'create_profile') {
const existing = chatProfiles.find(p => p.name === profileName);
if (existing) {
profileName = `${profileName} (${suffixProvider()})`;
}
if (existing) profileName = `${profileName} (${suffixProvider()})`;
}
return profileName;
};
@ -56,168 +43,79 @@ export const buildChatApplyPayload = (
strategy_config: profileData.strategy_config,
});
// ─── App ─────────────────────────────────────────────────────────────────────
function App() {
const { user, profile, loading, signOut } = useAuth();
const { socket, botState, connected } = useWebSocket(tradingRuntime.tradingApiUrl);
const [activeTab, setActiveTab] = useState('overview');
const [wizardSeed, setWizardSeed] = useState<any>(null);
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
const [activeSymbol, setActiveSymbol] = useState('');
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
const [previewAsCustomer, setPreviewAsCustomer] = useState(false);
const { enabled: backtestEnabledForView, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer });
const { flags: tabFlags } = useTabFeatureFlags();
const systemHealthData = botState.health || {};
const hasCapitalViolation = ((systemHealthData as any)?.capitalInvariantViolations || 0) > 0;
const anyLoopUnhealthy = (systemHealthData as any)?.tradingLoopHealthy === false || (systemHealthData as any)?.reconciliationLoopHealthy === false;
const systemHealthState = hasCapitalViolation ? 'Unhealthy' : anyLoopUnhealthy ? 'Degraded' : 'Healthy';
const systemHealthColor = hasCapitalViolation ? '#ff6657' : anyLoopUnhealthy ? '#facc15' : '#34c759';
const systemHealthTooltip = hasCapitalViolation
? 'Capital invariant violation detected—resolve ledger divergence before trading resumes.'
: anyLoopUnhealthy
? 'Degraded indicates a trading or reconciliation loop is lagging or experiencing lock contention.'
: 'All monitored loops are healthy.';
const recentCriticalEvents = (botState.operationalEvents || []).filter(e =>
const { enabled: backtestEnabledForView, loading: backtestGateLoading } =
useBacktestFeatureGate({ previewAsCustomer });
const { flags: tabFlags } = useTabFeatureFlags();
// Feature gates
const isAdminAccount = profile?.role === 'admin';
const isAdmin = isAdminAccount && !previewAsCustomer;
const showBacktestTab = isAdmin || (!backtestGateLoading && backtestEnabledForView);
const showMarketplaceTab = isAdmin || tabFlags.marketplace;
// Critical system events (for the alert banner)
const recentCriticalEvents = (botState.operationalEvents ?? []).filter(e =>
(e.severity === 'ERROR' || e.severity === 'WARN') &&
(Date.now() - e.timestamp < 600000)
Date.now() - e.timestamp < 600_000
);
const hasCriticalEvents = recentCriticalEvents.length > 0;
// Chat profile management
const fetchChatProfiles = useCallback(async () => {
const data = await fetchTradeProfiles();
setChatProfiles(data || []);
setChatProfiles(data ?? []);
}, []);
useEffect(() => {
if (user) {
fetchChatProfiles();
const interval = setInterval(fetchChatProfiles, 30000);
return () => clearInterval(interval);
const id = setInterval(fetchChatProfiles, 30_000);
return () => clearInterval(id);
}
}, [user, fetchChatProfiles]);
const handleChatApply = async (action: string, profileData: any): Promise<{ success: boolean; error?: string }> => {
const profileName = resolveProfileNameForAction(action, profileData.name, chatProfiles);
// IMPORTANT: Use the authenticated user's ID (auth.uid()) for RLS compliance
const handleChatApply = async (
action: string,
profileData: any
): Promise<{ success: boolean; error?: string }> => {
const currentUserId = user?.id;
if (!currentUserId) {
console.error('[ChatApply] No authenticated user found');
return { success: false, error: 'Not authenticated' };
}
if (!currentUserId) return { success: false, error: 'Not authenticated' };
const profileName = resolveProfileNameForAction(action, profileData.name, chatProfiles);
const payload = buildChatApplyPayload(profileData, currentUserId, profileName);
console.log('[ChatApply] Action:', action, 'user_id:', currentUserId, 'Payload:', payload);
if (action === 'create_profile') {
try {
await createTradeProfile(payload);
} catch (error: any) {
console.error('[ChatApply] Insert error:', error);
return { success: false, error: error.message };
fetchChatProfiles();
window.dispatchEvent(new Event('profiles-updated'));
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
console.log('[ChatApply] Profile created successfully:', profileName);
fetchChatProfiles();
window.dispatchEvent(new Event('profiles-updated'));
return { success: true };
} else if (action === 'update_profile' && profileData.id) {
}
if (action === 'update_profile' && profileData.id) {
try {
await updateTradeProfile(profileData.id, payload);
} catch (error: any) {
console.error('[ChatApply] Update error:', error);
return { success: false, error: error.message };
fetchChatProfiles();
window.dispatchEvent(new Event('profiles-updated'));
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
console.log('[ChatApply] Profile updated successfully:', profileName);
fetchChatProfiles();
window.dispatchEvent(new Event('profiles-updated'));
return { success: true };
}
return { success: false, error: 'Unknown action' };
};
const handleClonePreset = (preset: StrategyPreset) => {
const style = RISK_STYLE_TEMPLATES.find(t => t.id === preset.riskStyleId);
const dummyProfile = {
name: `${preset.name} (Clone)`,
symbols: preset.recommendedAssets.join(','),
allocated_capital: 1000,
is_active: false,
strategy_config: {
execution: { minRulePassRatio: style?.minRulePassRatio || 1.0 },
riskLimits: { dailyProfitTargetUsd: 100 },
rules: [
{ ruleId: 'RiskManagementRule', enabled: true, ruleType: 'mandatory' },
{ ruleId: 'SessionRule', enabled: true, ruleType: 'mandatory', params: { sessions: 'LDN,NY' } },
...(style?.mandatoryRules || []).map(r => ({ ruleId: r, enabled: true, ruleType: 'mandatory' })),
...(style?.votingRules || []).map(r => ({ ruleId: r, enabled: true, ruleType: 'voting' }))
]
}
};
setWizardSeed(dummyProfile);
setActiveTab('wizard');
};
// Handle Password Reset Route
if (window.location.pathname === '/reset-callback') {
return <ResetPassword />;
}
if (loading) {
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#0a0b0d', color: '#fff' }}>Loading...</div>;
}
if (!user) {
return <Login />;
}
const isAdminAccount = profile?.role === 'admin';
const isAdmin = isAdminAccount && !previewAsCustomer;
const showBacktestTab = isAdmin || (!backtestGateLoading && backtestEnabledForView);
const showMarketplaceTab = isAdmin || tabFlags.marketplace;
const showMembershipTab = isAdmin || tabFlags.membership;
const renderTab = () => {
switch (activeTab) {
case 'overview': return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
case 'signals':
if (!isAdmin) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
return <SignalsTab botState={botState} connected={connected} />;
case 'entries':
if (!isAdmin) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
return <EntriesTab botState={botState} />;
case 'positions': return <PositionsTab botState={botState} />;
case 'history': return <HistoryTab botState={botState} />;
case 'strategy_clusters':
return <TradeProfileManager botState={botState} />;
case 'profiles':
return <MyStrategiesTab botState={botState} alerts={botState.alerts} previewAsCustomer={previewAsCustomer} />;
case 'marketplace':
if (!showMarketplaceTab) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
return <MarketplaceTab onClone={handleClonePreset} botState={botState} />;
case 'membership':
if (!showMembershipTab) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
return <MembershipTab />;
case 'wizard':
return <StrategyWizard
editingProfile={wizardSeed}
profile={profile}
previewAsCustomer={previewAsCustomer}
onComplete={() => {
setWizardSeed(null);
setActiveTab('profiles');
}}
/>;
case 'backtest':
if (!showBacktestTab) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
return <BacktestTab previewAsCustomer={previewAsCustomer} />;
case 'settings': return <SettingsTab botState={botState} />;
case 'admin':
if (!isAdmin) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
return <AdminTab botState={botState} socket={socket} />;
default: return <OverviewTab botState={botState} connected={connected} />;
}
};
const handleSignOut = async () => {
tradingTelemetry.client.trackEvent('info', 'auth', 'trading_web_sign_out', {
userId: user?.id ?? 'anonymous',
@ -227,212 +125,72 @@ function App() {
await signOut();
};
// ── Auth gates ──────────────────────────────────────────────────────────────
if (window.location.pathname === '/reset-callback') return <ResetPassword />;
if (loading) {
return (
<div style={{
display: 'flex', justifyContent: 'center', alignItems: 'center',
height: '100vh', background: '#F3F4F6', color: '#374151',
fontSize: 15, fontFamily: 'Inter, system-ui, sans-serif',
}}>
Loading
</div>
);
}
if (!user) return <Login />;
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="app-container">
{hasCriticalEvents && (
<div
className="system-critical-notice"
style={{
<BrowserRouter>
<AppContext.Provider value={{
botState,
socket,
connected,
activeSymbol,
setActiveSymbol,
isAdmin,
user,
profile,
showBacktestTab,
showMarketplaceTab,
handleSignOut,
}}>
{/* Critical system alert banner */}
{hasCriticalEvents && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 9999,
background: 'linear-gradient(90deg, #991b1b 0%, #dc2626 50%, #991b1b 100%)',
color: 'white',
padding: '8px 20px',
color: '#fff',
padding: '6px 20px',
textAlign: 'center',
fontSize: '11px',
fontWeight: '900',
fontSize: 11,
fontWeight: 900,
textTransform: 'uppercase',
letterSpacing: '0.1em',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
zIndex: 1000
}}
onClick={() => setActiveTab('admin')}
>
<span style={{ fontSize: '14px' }}></span>
<span>SYSTEM ALERT: {recentCriticalEvents.length} CRITICAL ISSUES DETECTED. CLICK TO REVIEW IN ADMIN PANEL.</span>
<span style={{ fontSize: '14px' }}></span>
</div>
)}
<header className="app-header">
<div className="header-main">
<h1>Trading Bot Dashboard <small style={{ fontSize: '0.6rem', opacity: 0.5 }}>v2.3</small></h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className={`connection-status ${connected ? 'online' : 'offline'}`}>
{connected ? 'Connected' : 'Reconnecting...'}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4rem 0.8rem',
background: botState.settings.isAlgoEnabled ? 'rgba(52,199,89,0.15)' : 'rgba(255,149,0,0.15)',
border: `1px solid ${botState.settings.isAlgoEnabled ? 'rgba(52,199,89,0.4)' : 'rgba(255,149,0,0.4)'}`,
borderRadius: '8px',
boxShadow: botState.settings.isAlgoEnabled ? '0 0 20px rgba(52,199,89,0.1)' : 'none'
}}>
<span className={botState.settings.isAlgoEnabled ? 'pulse-green' : ''} style={{ fontSize: '0.85rem' }}>
{botState.settings.isAlgoEnabled ? '🟢' : '⏸️'}
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{
fontSize: '0.75rem',
fontWeight: '900',
color: botState.settings.isAlgoEnabled ? '#34c759' : '#ff9500',
letterSpacing: '0.05em',
textTransform: 'uppercase'
}}>
{botState.settings.isAlgoEnabled ? 'Bot Active' : 'Bot Monitoring'}
</span>
<span style={{
fontSize: '10px',
color: '#666',
fontWeight: 'bold'
}}>
Mode: <span style={{ color: '#aaa' }}>{botState.settings.executionMode}</span>
</span>
</div>
</div>
{/* Trading Control Status Badge */}
{botState.health?.tradingControl && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4rem 0.8rem',
background: botState.health.tradingControl.mode === 'PAUSED' ? 'rgba(255,149,0,0.15)' : 'rgba(52,199,89,0.08)',
border: `1px solid ${botState.health.tradingControl.mode === 'PAUSED' ? 'rgba(255,149,0,0.4)' : 'rgba(52,199,89,0.3)'}`,
borderRadius: '8px'
}}
title={botState.health.tradingControl.mode === 'PAUSED'
? `Trading paused by ${botState.health.tradingControl.lastChangedBy}. No new entries will be placed.`
: 'Auto-trading is active'}
>
<span style={{ fontSize: '0.85rem' }}>
{botState.health.tradingControl.mode === 'PAUSED' ? '⏸️' : '▶️'}
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{
fontSize: '0.75rem',
fontWeight: '900',
color: botState.health.tradingControl.mode === 'PAUSED' ? '#ff9500' : '#34c759',
letterSpacing: '0.05em',
textTransform: 'uppercase'
}}>
{botState.health.tradingControl.mode === 'PAUSED' ? 'Trading Paused' : 'Trading Active'}
</span>
<span style={{
fontSize: '10px',
color: '#666',
fontWeight: 'bold'
}}>
{botState.health.tradingControl.mode === 'PAUSED' ? 'No new entries' : 'Entries allowed'}
</span>
</div>
</div>
)}
{/* Preview as Customer Toggle — Admin Only */}
{isAdminAccount && (
<button
onClick={() => setPreviewAsCustomer(p => !p)}
title={previewAsCustomer ? 'Exit customer preview mode' : 'Preview the app as a regular customer'}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '6px 14px',
background: previewAsCustomer ? 'rgba(251,191,36,0.15)' : 'rgba(255,255,255,0.04)',
border: previewAsCustomer ? '1px solid rgba(251,191,36,0.5)' : '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
color: previewAsCustomer ? '#fbbf24' : '#666',
cursor: 'pointer',
fontSize: '0.78rem',
fontWeight: 700,
transition: 'all 0.2s',
letterSpacing: '0.02em'
}}
>
<span style={{ fontSize: '0.9rem' }}>{previewAsCustomer ? '🎭' : '👁️'}</span>
{previewAsCustomer ? 'Exit Preview' : 'Preview as Customer'}
</button>
)}
<div className="system-health-badge" style={{ borderColor: systemHealthColor, color: systemHealthColor }} title={systemHealthTooltip}>
<span className="system-health-dot" style={{ background: systemHealthColor }} />
<div className="system-health-texts">
<span className="system-health-label">System Health</span>
<span className="system-health-value">{systemHealthState}</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem', background: 'rgba(255,255,255,0.05)', borderRadius: '8px' }}>
<span style={{ fontSize: '0.9rem', color: '#aaa' }}>👤</span>
<span style={{ fontSize: '0.85rem', color: '#fff' }}>{user?.email}</span>
<button
onClick={handleSignOut}
style={{
marginLeft: '0.5rem',
padding: '0.25rem 0.75rem',
background: 'rgba(255,59,48,0.2)',
border: '1px solid rgba(255,59,48,0.4)',
borderRadius: '4px',
color: '#ff3b30',
cursor: 'pointer',
fontSize: '0.8rem',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,59,48,0.3)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,59,48,0.2)'}
>
Logout
</button>
</div>
gap: 10,
}}>
<span></span>
<span>
SYSTEM ALERT: {recentCriticalEvents.length} CRITICAL ISSUES DETECTED GO TO SETTINGS ADMIN PANEL
</span>
<span></span>
</div>
)}
<div style={{ paddingTop: hasCriticalEvents ? 32 : 0 }}>
<AppShell />
</div>
<nav className="tab-navigation">
<button className={activeTab === 'overview' ? 'active' : ''} onClick={() => setActiveTab('overview')}>Overview</button>
{isAdmin && (
<button className={activeTab === 'signals' ? 'active' : ''} onClick={() => setActiveTab('signals')}>📡 Signals</button>
)}
{isAdmin && (
<button className={activeTab === 'entries' ? 'active' : ''} onClick={() => setActiveTab('entries')}>📋 Entries</button>
)}
<button className={activeTab === 'positions' ? 'active' : ''} onClick={() => setActiveTab('positions')}>Positions & Orders</button>
<button className={activeTab === 'history' ? 'active' : ''} onClick={() => setActiveTab('history')}>Trade History</button>
<button className={activeTab === 'profiles' ? 'active' : ''} onClick={() => setActiveTab('profiles')}>My Strategies</button>
{showMarketplaceTab && (
<button className={activeTab === 'marketplace' ? 'active' : ''} onClick={() => setActiveTab('marketplace')}> Marketplace</button>
)}
{showMembershipTab && (
<button className={activeTab === 'membership' ? 'active' : ''} onClick={() => setActiveTab('membership')}>💎 Plans</button>
)}
<button className={activeTab === 'wizard' ? 'active' : ''} onClick={() => setActiveTab('wizard')}>🛠 Build Strategy</button>
{showBacktestTab && (
<button className={activeTab === 'backtest' ? 'active' : ''} onClick={() => setActiveTab('backtest')}>📈 Backtesting</button>
)}
{isAdmin && (
<button className={activeTab === 'strategy_clusters' ? 'active' : ''} onClick={() => setActiveTab('strategy_clusters')}>🛡 Strategy Clusters</button>
)}
<button className={activeTab === 'settings' ? 'active' : ''} onClick={() => setActiveTab('settings')}>Settings</button>
{isAdmin && (
<button className={activeTab === 'admin' ? 'active' : ''} onClick={() => setActiveTab('admin')}> Admin Panel</button>
)}
</nav>
</header>
<main className="app-content">
<LivePulseTicker botState={botState} />
<div className="tab-content-full">
{renderTab()}
</div>
</main>
{/* Global AI Strategy Assistant - floating robot icon on all pages */}
<ChatControl profiles={chatProfiles} onApplyProfile={handleChatApply} />
</div>
{/* Floating AI strategy assistant */}
<ChatControl profiles={chatProfiles} onApplyProfile={handleChatApply} />
</AppContext.Provider>
</BrowserRouter>
);
}

View File

@ -0,0 +1,71 @@
import { Routes, Route } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
import { RightPanel } from './RightPanel';
import { HomeView } from '../../views/HomeView';
import { PortfolioView } from '../../views/PortfolioView';
import { ResearchView } from '../../views/ResearchView';
import { MarketsView } from '../../views/MarketsView';
import { ScreenerView } from '../../views/ScreenerView';
import { WatchlistView } from '../../views/WatchlistView';
import { AlertsView } from '../../views/AlertsView';
import { SettingsView } from '../../views/SettingsView';
export function AppShell() {
return (
<div style={{
display: 'flex',
minHeight: '100vh',
background: '#F3F4F6',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
}}>
{/* Fixed left sidebar */}
<Sidebar />
{/* Main area (right of sidebar) */}
<div style={{
marginLeft: 72,
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
overflow: 'hidden',
}}>
{/* Sticky header */}
<Header />
{/* Content row: main + right panel */}
<div style={{
flex: 1,
display: 'flex',
overflow: 'hidden',
minHeight: 0,
}}>
{/* Scrollable main content */}
<main style={{
flex: 1,
overflowY: 'auto',
padding: '24px 24px 32px',
minWidth: 0,
}}>
<Routes>
<Route path="/" element={<HomeView />} />
<Route path="/portfolio" element={<PortfolioView />} />
<Route path="/research" element={<ResearchView />} />
<Route path="/markets" element={<MarketsView />} />
<Route path="/screener" element={<ScreenerView />} />
<Route path="/watchlist" element={<WatchlistView />} />
<Route path="/alerts" element={<AlertsView />} />
<Route path="/settings" element={<SettingsView />} />
{/* Fallback */}
<Route path="*" element={<HomeView />} />
</Routes>
</main>
{/* Fixed right panel */}
<RightPanel />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,164 @@
import { useState, useRef } from 'react';
import { Search } from 'lucide-react';
import { useAppContext } from '../../context/AppContext';
import { useNavigate } from 'react-router-dom';
// Minimal SVG sparkline
function Sparkline({ values, color }: { values: number[]; color: string }) {
if (values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const W = 64, H = 28;
const pts = values
.map((v, i) => {
const x = (i / (values.length - 1)) * W;
const y = H - ((v - min) / range) * H;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
return (
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ display: 'block' }}>
<polyline
points={pts}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
// Static placeholder indices — Phase 3 will replace with live Alpaca data
const PLACEHOLDER_INDICES = [
{
label: 'S&P 500',
change: '+0.38%',
positive: true,
spark: [395, 396, 394, 397, 399, 398, 401, 403, 402, 404],
},
{
label: 'Dow Jones',
change: '-0.11%',
positive: false,
spark: [338, 339, 337, 336, 337, 336, 335, 336, 335, 338],
},
{
label: 'Nasdaq',
change: '+0.65%',
positive: true,
spark: [174, 175, 176, 175, 177, 178, 179, 178, 180, 181],
},
];
export function Header() {
const { activeSymbol, setActiveSymbol, connected } = useAppContext();
const [query, setQuery] = useState('');
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const handleSearch = (raw: string) => {
const symbol = raw.trim().toUpperCase();
if (!symbol) return;
setActiveSymbol(symbol);
setQuery('');
navigate('/');
inputRef.current?.blur();
};
return (
<header style={{
height: 56,
background: '#ffffff',
borderBottom: '1px solid #E5E7EB',
display: 'flex',
alignItems: 'center',
paddingLeft: 20,
paddingRight: 24,
gap: 16,
flexShrink: 0,
}}>
{/* Search bar */}
<div style={{ position: 'relative', width: 300 }}>
<Search
size={15}
style={{
position: 'absolute',
left: 11,
top: '50%',
transform: 'translateY(-50%)',
color: '#9CA3AF',
pointerEvents: 'none',
}}
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleSearch(query);
}}
placeholder="Ask about a company..."
style={{
width: '100%',
paddingLeft: 34,
paddingRight: 12,
paddingTop: 8,
paddingBottom: 8,
border: '1px solid #E5E7EB',
borderRadius: 8,
fontSize: 13,
outline: 'none',
color: '#374151',
background: '#F9FAFB',
boxSizing: 'border-box',
fontFamily: 'inherit',
}}
/>
</div>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Market indices */}
<div style={{ display: 'flex', gap: 28, alignItems: 'center' }}>
{PLACEHOLDER_INDICES.map(idx => (
<div key={idx.label} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div>
<div style={{ fontSize: 11, color: '#6B7280', fontWeight: 500, lineHeight: 1.3 }}>
{idx.label}
</div>
<div style={{
fontSize: 13,
fontWeight: 700,
color: idx.positive ? '#16A34A' : '#DC2626',
lineHeight: 1.3,
}}>
{idx.change}
</div>
</div>
<Sparkline values={idx.spark} color={idx.positive ? '#16A34A' : '#DC2626'} />
</div>
))}
</div>
{/* Live indicator */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginLeft: 8 }}>
<span style={{
width: 7,
height: 7,
borderRadius: '50%',
background: connected ? '#22C55E' : '#EF4444',
display: 'inline-block',
flexShrink: 0,
}} />
<span style={{ fontSize: 11, color: '#9CA3AF', whiteSpace: 'nowrap' }}>
{connected ? 'Live' : 'Reconnecting'}
</span>
</div>
</header>
);
}

View File

@ -0,0 +1,219 @@
import { useState, useEffect, useRef } from 'react';
import { ArrowRight } from 'lucide-react';
import { useAppContext } from '../../context/AppContext';
// ─── Portfolio Summary ────────────────────────────────────────────────────────
function PortfolioSummary() {
const { botState } = useAppContext();
const positions = botState.positions ?? [];
const account = botState.accountSnapshot;
const totalValue = account
? account.cash + positions.reduce((s, p) => s + (p.marketValue ?? 0), 0)
: positions.reduce((s, p) => s + (p.marketValue ?? 0), 0);
const totalPnl = positions.reduce((s, p) => s + (p.unrealizedPnl ?? 0), 0);
const pnlPos = totalPnl >= 0;
const fmt$ = (n: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
return (
<div style={{ padding: '16px 16px 12px' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#111827' }}>Portfolio</span>
<span style={{ fontSize: 12, color: '#2563EB', cursor: 'pointer', fontWeight: 500 }}>
View All <ArrowRight size={12} style={{ display: 'inline', verticalAlign: 'middle' }} />
</span>
</div>
{/* Total value */}
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 20, fontWeight: 800, color: '#111827', letterSpacing: '-0.5px' }}>
{fmt$(totalValue)}
</div>
<div style={{
fontSize: 12, fontWeight: 600, marginTop: 2,
color: pnlPos ? '#16A34A' : '#DC2626',
}}>
{pnlPos ? '+' : ''}{fmt$(totalPnl)} unrealized
</div>
</div>
{/* Column headers */}
<div style={{
display: 'grid',
gridTemplateColumns: '2fr 1.2fr 1fr 1.2fr',
gap: 4, paddingBottom: 6,
borderBottom: '1px solid #F3F4F6', marginBottom: 4,
}}>
{['Symbol','Price','Change','Value'].map(h => (
<span key={h} style={{ fontSize: 10, color: '#9CA3AF', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{h}
</span>
))}
</div>
{/* Rows */}
{positions.length === 0 ? (
<div style={{ fontSize: 12, color: '#9CA3AF', padding: '12px 0', textAlign: 'center' }}>
No open positions
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{positions.slice(0, 6).map(pos => {
const pct = pos.unrealizedPnlPercent ?? 0;
const pos_ = pct >= 0;
return (
<div key={pos.id} style={{
display: 'grid',
gridTemplateColumns: '2fr 1.2fr 1fr 1.2fr',
gap: 4, padding: '5px 0',
borderBottom: '1px solid #F9FAFB',
alignItems: 'center',
}}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827' }}>{pos.symbol}</span>
<span style={{ fontSize: 12, color: '#374151' }}>{pos.currentPrice?.toFixed(2) ?? '—'}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: pos_ ? '#16A34A' : '#DC2626' }}>
{pos_ ? '+' : ''}{pct.toFixed(2)}%
</span>
<span style={{ fontSize: 12, color: '#374151' }}>{fmt$(pos.marketValue ?? 0)}</span>
</div>
);
})}
</div>
)}
</div>
);
}
// ─── News Feed ────────────────────────────────────────────────────────────────
interface NewsArticle {
id?: string;
url: string;
headline: string;
source: string;
created_at: string;
images?: Array<{ url: string; size?: string }>;
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function NewsCard({ article }: { article: NewsArticle }) {
const img = article.images?.[0]?.url;
return (
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex', gap: 10,
padding: '10px 16px',
textDecoration: 'none',
borderBottom: '1px solid #F3F4F6',
transition: 'background 0.1s',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#F9FAFB')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{img && (
<img
src={img} alt=""
style={{ width: 52, height: 44, objectFit: 'cover', borderRadius: 6, flexShrink: 0 }}
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 12, fontWeight: 600, color: '#111827',
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
marginBottom: 4,
}}>
{article.headline}
</div>
<div style={{ fontSize: 10, color: '#9CA3AF' }}>
{article.source} · {timeAgo(article.created_at)}
</div>
</div>
</a>
);
}
function NewsFeed() {
const { activeSymbol } = useAppContext();
const [news, setNews] = useState<NewsArticle[]>([]);
const [loading, setLoading] = useState(false);
const apiBase = (import.meta.env.VITE_TRADING_API_URL as string) || 'http://localhost:4018';
useEffect(() => {
if (!activeSymbol) { setNews([]); return; }
let cancelled = false;
setLoading(true);
fetch(`${apiBase}/api/news?symbols=${activeSymbol}&limit=8`)
.then(r => r.ok ? r.json() : Promise.reject())
.then(d => { if (!cancelled) { setNews(d.news ?? d ?? []); setLoading(false); } })
.catch(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [activeSymbol, apiBase]);
return (
<div>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '12px 16px 8px',
}}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#111827' }}>Latest News</span>
<span style={{ fontSize: 12, color: '#2563EB', fontWeight: 500, cursor: 'pointer' }}>
View All <ArrowRight size={12} style={{ display: 'inline', verticalAlign: 'middle' }} />
</span>
</div>
{loading && (
<div style={{ fontSize: 12, color: '#9CA3AF', padding: '12px 16px' }}>Loading news</div>
)}
{!loading && news.length === 0 && (
<div style={{ fontSize: 12, color: '#9CA3AF', padding: '16px', textAlign: 'center' }}>
{activeSymbol ? `No news found for ${activeSymbol}` : 'Search a ticker to see news'}
</div>
)}
{news.map((a, i) => <NewsCard key={a.id ?? a.url ?? i} article={a} />)}
</div>
);
}
// ─── Right Panel container ────────────────────────────────────────────────────
export function RightPanel() {
return (
<aside style={{
width: 308, minWidth: 308, maxWidth: 308,
background: '#ffffff',
borderLeft: '1px solid #E5E7EB',
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
flexShrink: 0,
}}>
<div style={{ borderBottom: '1px solid #E5E7EB' }}>
<PortfolioSummary />
</div>
<NewsFeed />
</aside>
);
}

View File

@ -0,0 +1,141 @@
import { NavLink, useNavigate } from 'react-router-dom';
import {
Home, Briefcase, FlaskConical, TrendingUp,
SlidersHorizontal, Star, Bell, Settings,
} from 'lucide-react';
import { useAppContext } from '../../context/AppContext';
const NAV = [
{ to: '/', icon: Home, label: 'Home', end: true },
{ to: '/portfolio', icon: Briefcase, label: 'Portfolio', end: false },
{ to: '/research', icon: FlaskConical, label: 'Research', end: false },
{ to: '/markets', icon: TrendingUp, label: 'Markets', end: false },
{ to: '/screener', icon: SlidersHorizontal, label: 'Screener', end: false },
{ to: '/watchlist', icon: Star, label: 'Watchlist', end: false },
{ to: '/alerts', icon: Bell, label: 'Alerts', end: false },
{ to: '/settings', icon: Settings, label: 'Settings', end: false },
];
export function Sidebar() {
const { user, handleSignOut } = useAppContext();
const initials = user?.email
? user.email.slice(0, 2).toUpperCase()
: 'US';
return (
<aside style={{
width: 72,
minHeight: '100vh',
background: '#ffffff',
borderRight: '1px solid #E5E7EB',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
paddingTop: 16,
paddingBottom: 20,
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
zIndex: 50,
}}>
{/* Logo */}
<div style={{
width: 40,
height: 40,
borderRadius: 10,
background: 'linear-gradient(135deg, #2563EB, #1D4ED8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 24,
flexShrink: 0,
boxShadow: '0 2px 8px rgba(37,99,235,0.35)',
cursor: 'pointer',
fontSize: 20,
}}>
📈
</div>
{/* Nav items */}
<nav style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
width: '100%',
alignItems: 'center',
gap: 2,
}}>
{NAV.map(({ to, icon: Icon, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
style={({ isActive }) => ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
padding: '10px 0 8px',
gap: 3,
borderLeft: isActive ? '3px solid #2563EB' : '3px solid transparent',
color: isActive ? '#2563EB' : '#6B7280',
textDecoration: 'none',
background: isActive ? '#EFF6FF' : 'transparent',
transition: 'all 0.15s',
})}
>
{({ isActive }) => (
<>
<Icon size={20} strokeWidth={isActive ? 2.2 : 1.8} />
<span style={{
fontSize: 10,
fontWeight: isActive ? 700 : 500,
letterSpacing: '0.01em',
lineHeight: 1,
}}>
{label}
</span>
</>
)}
</NavLink>
))}
</nav>
{/* User avatar */}
<div
title={`${user?.email ?? ''} — click to sign out`}
onClick={handleSignOut}
style={{
width: 36,
height: 36,
borderRadius: '50%',
background: '#2563EB',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 12,
fontWeight: 700,
cursor: 'pointer',
position: 'relative',
userSelect: 'none',
flexShrink: 0,
}}
>
{initials}
<span style={{
position: 'absolute',
bottom: 1,
right: 1,
width: 9,
height: 9,
borderRadius: '50%',
background: '#22C55E',
border: '2px solid #fff',
}} />
</div>
</aside>
);
}

View File

@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { BotState } from '../hooks/useWebSocket';
import type { Socket } from 'socket.io-client';
export interface AppContextValue {
botState: BotState;
socket: Socket | null;
connected: boolean;
activeSymbol: string;
setActiveSymbol: (symbol: string) => void;
isAdmin: boolean;
user: any;
profile: any;
showBacktestTab: boolean;
showMarketplaceTab: boolean;
handleSignOut: () => void;
}
export const AppContext = createContext<AppContextValue>({} as AppContextValue);
export const useAppContext = () => useContext(AppContext);

View File

@ -0,0 +1,12 @@
import { useAppContext } from '../context/AppContext';
import { AlertFeed } from '../components/AlertFeed';
export function AlertsView() {
const { botState } = useAppContext();
return (
<div>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#111827', margin: '0 0 20px' }}>Alerts</h2>
<AlertFeed alerts={botState.alerts} />
</div>
);
}

383
web/src/views/HomeView.tsx Normal file
View File

@ -0,0 +1,383 @@
import { useState, useEffect, useRef } from 'react';
import { Star, Bell, BarChart2 } from 'lucide-react';
import {
AreaChart, Area, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid,
} from 'recharts';
import { useAppContext } from '../context/AppContext';
// ─── Time period config ───────────────────────────────────────────────────────
const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const;
type Period = typeof PERIODS[number];
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmt$ = (n: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
function formatPriceLabel(ts: number, period: Period) {
const d = new Date(ts);
if (period === '1D') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (period === '5D') return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
// ─── Ticker header ────────────────────────────────────────────────────────────
function TickerHeader({ symbol }: { symbol: string }) {
const { botState } = useAppContext();
const data = botState.symbols?.[symbol];
const price = data?.price ?? 0;
const change = data?.changeToday ?? 0;
const changePct = price > 0 ? (change / (price - change)) * 100 : 0;
const positive = change >= 0;
return (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
<h1 style={{ fontSize: 28, fontWeight: 800, color: '#111827', margin: 0 }}>
{symbol}
</h1>
<span style={{ fontSize: 13, color: '#6B7280', fontWeight: 500, marginTop: 4 }}>
{/* Company name placeholder — Phase 4 will fill from FMP */}
{symbol}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{/* Saved badge */}
<button style={{
display: 'flex', alignItems: 'center', gap: 6,
padding: '5px 12px', borderRadius: 20,
background: '#F0FDF4', border: '1px solid #86EFAC',
color: '#16A34A', fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}>
<Star size={13} fill="#16A34A" /> Watchlist
</button>
<button style={{
width: 32, height: 32, borderRadius: '50%',
border: '1px solid #E5E7EB', background: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: '#6B7280',
}}>
<Bell size={15} />
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<span style={{ fontSize: 32, fontWeight: 800, color: '#111827', letterSpacing: '-1px' }}>
{price > 0 ? price.toFixed(2) : '—'}
</span>
{price > 0 && (
<span style={{ fontSize: 15, fontWeight: 600, color: positive ? '#16A34A' : '#DC2626' }}>
{positive ? '+' : ''}{change.toFixed(2)} ({positive ? '+' : ''}{changePct.toFixed(2)}%)
</span>
)}
</div>
<div style={{ fontSize: 11, color: '#9CA3AF', marginTop: 3 }}>
{new Date().toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})} ET · NASDAQ
</div>
</div>
);
}
// ─── Stock chart ──────────────────────────────────────────────────────────────
function StockChart({ symbol }: { symbol: string }) {
const { botState } = useAppContext();
const [period, setPeriod] = useState<Period>('1Y');
// Use botState price history if available, else empty
const raw = botState.symbols?.[symbol]?.priceHistory ?? [];
const chartData = raw.map(p => ({
ts: p.timestamp,
price: p.price,
label: formatPriceLabel(p.timestamp, period),
}));
const firstPrice = chartData[0]?.price ?? 0;
const lastPrice = chartData[chartData.length - 1]?.price ?? 0;
const positive = lastPrice >= firstPrice;
const lineColor = positive ? '#2563EB' : '#DC2626';
const fillColor = positive ? '#EFF6FF' : '#FEF2F2';
const minY = chartData.length ? Math.min(...chartData.map(d => d.price)) : 0;
const maxY = chartData.length ? Math.max(...chartData.map(d => d.price)) : 100;
const pad = (maxY - minY) * 0.1 || 10;
return (
<div style={{
background: '#fff',
borderRadius: 12,
border: '1px solid #E5E7EB',
padding: '16px 20px 12px',
marginBottom: 20,
}}>
{/* Period selector + chart type */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 2 }}>
{PERIODS.map(p => (
<button
key={p}
onClick={() => setPeriod(p)}
style={{
padding: '4px 9px',
border: 'none',
borderRadius: 6,
fontSize: 12,
fontWeight: period === p ? 700 : 500,
background: period === p ? '#EFF6FF' : 'transparent',
color: period === p ? '#2563EB' : '#6B7280',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{p}
</button>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: '#6B7280', fontSize: 12 }}>
<BarChart2 size={14} /> Line Chart
</div>
</div>
{/* Chart */}
{chartData.length < 2 ? (
<div style={{
height: 220,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
color: '#9CA3AF',
fontSize: 13,
gap: 8,
}}>
<BarChart2 size={32} color="#D1D5DB" />
<span>Price chart will appear once {symbol} is tracked by the bot</span>
<span style={{ fontSize: 11 }}>Add {symbol} to your strategy symbols to start collecting data</span>
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={lineColor} stopOpacity={0.15} />
<stop offset="95%" stopColor={lineColor} stopOpacity={0.01} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: '#9CA3AF' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
domain={[minY - pad, maxY + pad]}
tick={{ fontSize: 10, fill: '#9CA3AF' }}
tickLine={false}
axisLine={false}
width={55}
tickFormatter={v => `$${v.toFixed(0)}`}
/>
<Tooltip
contentStyle={{
background: '#fff',
border: '1px solid #E5E7EB',
borderRadius: 8,
fontSize: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
}}
formatter={(val: number) => [`$${val.toFixed(2)}`, 'Price']}
labelStyle={{ color: '#6B7280', fontSize: 11 }}
/>
<Area
type="monotone"
dataKey="price"
stroke={lineColor}
strokeWidth={2}
fill="url(#chartGrad)"
dot={false}
activeDot={{ r: 4, fill: lineColor, strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
);
}
// ─── Quick stats cards ────────────────────────────────────────────────────────
function QuickStats({ symbol }: { symbol: string }) {
const { botState } = useAppContext();
const d = botState.symbols?.[symbol];
const stats = [
{ label: 'RSI (1H)', value: d?.indicators?.rsi_1h?.toFixed(1) ?? '—' },
{ label: 'EMA 50', value: d?.indicators?.ema50_4h?.toFixed(2) ?? '—' },
{ label: 'EMA 200', value: d?.indicators?.ema200_4h?.toFixed(2) ?? '—' },
{ label: 'Signal', value: d?.signal ?? '—' },
];
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12, marginBottom: 20 }}>
{stats.map(s => (
<div key={s.label} style={{
background: '#fff',
borderRadius: 10,
border: '1px solid #E5E7EB',
padding: '12px 14px',
}}>
<div style={{ fontSize: 11, color: '#9CA3AF', fontWeight: 500, marginBottom: 4 }}>{s.label}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>{s.value}</div>
</div>
))}
</div>
);
}
// ─── Placeholder research / financials cards ──────────────────────────────────
function ResearchPlaceholder({ symbol }: { symbol: string }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
{/* Company Research */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 16 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 12 }}>
📋 Company Research
</div>
{['Business Overview','Industry Analysis','Competitive Position','Management Quality'].map((item, i) => (
<div key={item} style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '5px 0', borderBottom: i < 3 ? '1px solid #F9FAFB' : 'none',
}}>
<span style={{ fontSize: 14 }}>{i < 2 ? '✅' : '⬜'}</span>
<span style={{ fontSize: 12, color: '#374151' }}>{item}</span>
</div>
))}
<button style={{
marginTop: 12, width: '100%', padding: '7px 0',
border: '1px solid #E5E7EB', borderRadius: 8,
background: '#F9FAFB', color: '#2563EB',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}>
View Research Checklist
</button>
</div>
{/* Financials — Phase 4 fills with real FMP data */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 16 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 12 }}>
📊 Financials
</div>
{[
['Market Cap', '—'],
['Revenue (TTM)', '—'],
['Net Income (TTM)','—'],
['P/E Ratio (TTM)', '—'],
['ROE (TTM)', '—'],
].map(([label, val]) => (
<div key={label} style={{
display: 'flex', justifyContent: 'space-between',
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}>
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>{val}</span>
</div>
))}
<button style={{
marginTop: 12, width: '100%', padding: '7px 0',
border: '1px solid #E5E7EB', borderRadius: 8,
background: '#F9FAFB', color: '#2563EB',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}>
View Financial Statements
</button>
</div>
{/* Events */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 16 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 12 }}>
📅 Events
</div>
{[
['Earnings Report Q2', '—'],
['Shareholder Meeting', '—'],
['Ex-Dividend Date', '—'],
['Product Event', '—'],
].map(([label, val]) => (
<div key={label} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
}}>
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
<span style={{ fontSize: 11, fontWeight: 600, color: '#9CA3AF' }}>{val}</span>
</div>
))}
<button style={{
marginTop: 12, width: '100%', padding: '7px 0',
border: '1px solid #E5E7EB', borderRadius: 8,
background: '#F9FAFB', color: '#2563EB',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
}}>
View All Events
</button>
</div>
</div>
);
}
// ─── Empty state ──────────────────────────────────────────────────────────────
function EmptyState() {
return (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', height: '60vh', gap: 16,
color: '#9CA3AF',
}}>
<div style={{ fontSize: 56 }}>📈</div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#374151' }}>
Search a company to get started
</div>
<div style={{ fontSize: 14, textAlign: 'center', maxWidth: 360 }}>
Type a ticker symbol or company name in the search bar above to view charts, financials, and news.
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
{['AAPL','MSFT','GOOGL','AMZN','NVDA'].map(t => (
<span key={t} style={{
padding: '4px 12px',
background: '#EFF6FF',
color: '#2563EB',
borderRadius: 20,
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
}}>
{t}
</span>
))}
</div>
</div>
);
}
// ─── HomeView ─────────────────────────────────────────────────────────────────
export function HomeView() {
const { activeSymbol, setActiveSymbol } = useAppContext();
// Allow clicking the example tickers in empty state
const handleTickerClick = (t: string) => setActiveSymbol(t);
if (!activeSymbol) return <EmptyState />;
return (
<div>
<TickerHeader symbol={activeSymbol} />
<StockChart symbol={activeSymbol} />
<QuickStats symbol={activeSymbol} />
<ResearchPlaceholder symbol={activeSymbol} />
</div>
);
}

View File

@ -0,0 +1,26 @@
import { useAppContext } from '../context/AppContext';
import { MarketplaceTab } from '../tabs/MarketplaceTab';
import { TopVolatile, AISetups } from '../components/MarketOpportunities';
import { RISK_STYLE_TEMPLATES } from '../lib/RiskStyleTemplates';
import type { StrategyPreset } from '../lib/PresetRegistry';
import { useState } from 'react';
export function MarketsView() {
const { botState, showMarketplaceTab } = useAppContext();
const [clonedPreset, setClonedPreset] = useState<StrategyPreset | null>(null);
const handleClone = (preset: StrategyPreset) => setClonedPreset(preset);
return (
<div>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#111827', margin: '0 0 20px' }}>Markets</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 24 }}>
<TopVolatile botState={botState} />
<AISetups botState={botState} />
</div>
{showMarketplaceTab && (
<MarketplaceTab onClone={handleClone} botState={botState} />
)}
</div>
);
}

View File

@ -0,0 +1,48 @@
import { useState } from 'react';
import { useAppContext } from '../context/AppContext';
import { PositionsTab } from '../tabs/PositionsTab';
import { HistoryTab } from '../tabs/HistoryTab';
const TABS = ['Positions & Orders', 'Trade History'] as const;
type Tab = typeof TABS[number];
export function PortfolioView() {
const { botState } = useAppContext();
const [tab, setTab] = useState<Tab>('Positions & Orders');
return (
<div>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#111827', margin: '0 0 20px' }}>Portfolio</h2>
{/* Sub-tabs */}
<div style={{
display: 'flex', gap: 4, marginBottom: 20,
borderBottom: '1px solid #E5E7EB', paddingBottom: 0,
}}>
{TABS.map(t => (
<button
key={t}
onClick={() => setTab(t)}
style={{
padding: '8px 16px',
border: 'none',
borderBottom: tab === t ? '2px solid #2563EB' : '2px solid transparent',
background: 'transparent',
color: tab === t ? '#2563EB' : '#6B7280',
fontSize: 13,
fontWeight: tab === t ? 700 : 500,
cursor: 'pointer',
marginBottom: -1,
transition: 'all 0.15s',
}}
>
{t}
</button>
))}
</div>
{tab === 'Positions & Orders' && <PositionsTab botState={botState} />}
{tab === 'Trade History' && <HistoryTab botState={botState} />}
</div>
);
}

View File

@ -0,0 +1,70 @@
import { useState } from 'react';
import { useAppContext } from '../context/AppContext';
import { SignalsTab } from '../tabs/SignalsTab';
import { BacktestTab } from '../tabs/BacktestTab';
import { MyStrategiesTab } from '../tabs/MyStrategiesTab';
import { StrategyWizard } from '../components/StrategyWizard';
type ResearchTab = 'Signals' | 'Strategies' | 'Backtesting';
export function ResearchView() {
const { botState, connected, showBacktestTab, isAdmin } = useAppContext();
const [tab, setTab] = useState<ResearchTab>('Strategies');
const [wizardSeed, setWizardSeed] = useState<any>(null);
const tabs: ResearchTab[] = [
'Strategies',
...(isAdmin ? ['Signals' as ResearchTab] : []),
...(showBacktestTab ? ['Backtesting' as ResearchTab] : []),
];
return (
<div>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#111827', margin: '0 0 20px' }}>Research</h2>
<div style={{
display: 'flex', gap: 4, marginBottom: 20,
borderBottom: '1px solid #E5E7EB',
}}>
{tabs.map(t => (
<button
key={t}
onClick={() => setTab(t)}
style={{
padding: '8px 16px',
border: 'none',
borderBottom: tab === t ? '2px solid #2563EB' : '2px solid transparent',
background: 'transparent',
color: tab === t ? '#2563EB' : '#6B7280',
fontSize: 13,
fontWeight: tab === t ? 700 : 500,
cursor: 'pointer',
marginBottom: -1,
transition: 'all 0.15s',
}}
>
{t}
</button>
))}
</div>
{tab === 'Signals' && <SignalsTab botState={botState} connected={connected} />}
{tab === 'Strategies' && !wizardSeed && (
<MyStrategiesTab
botState={botState}
alerts={botState.alerts}
previewAsCustomer={false}
/>
)}
{tab === 'Strategies' && wizardSeed && (
<StrategyWizard
editingProfile={wizardSeed}
profile={null}
previewAsCustomer={false}
onComplete={() => setWizardSeed(null)}
/>
)}
{tab === 'Backtesting' && <BacktestTab previewAsCustomer={false} />}
</div>
);
}

View File

@ -0,0 +1,134 @@
import { useState } from 'react';
import { SlidersHorizontal, Search } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
// Phase 6 will wire this to /api/screener (FMP)
const PLACEHOLDER_RESULTS = [
{ symbol: 'AAPL', name: 'Apple Inc.', price: 201.36, change: 1.02, marketCap: '3.0T', pe: 32.1, sector: 'Technology' },
{ symbol: 'MSFT', name: 'Microsoft Corp.', price: 426.52, change: 0.89, marketCap: '3.2T', pe: 36.4, sector: 'Technology' },
{ symbol: 'GOOGL', name: 'Alphabet Inc.', price: 172.49, change: 1.36, marketCap: '2.1T', pe: 20.3, sector: 'Technology' },
{ symbol: 'AMZN', name: 'Amazon.com Inc.', price: 186.10, change: -0.23, marketCap: '2.0T', pe: 41.8, sector: 'Consumer' },
{ symbol: 'NVDA', name: 'NVIDIA Corp.', price: 134.81, change: 2.31, marketCap: '3.3T', pe: 48.2, sector: 'Technology' },
{ symbol: 'META', name: 'Meta Platforms Inc.', price: 572.40, change: -0.55, marketCap: '1.4T', pe: 26.7, sector: 'Technology' },
{ symbol: 'TSLA', name: 'Tesla Inc.', price: 284.65, change: 3.12, marketCap: '908B', pe: 88.3, sector: 'Automotive' },
{ symbol: 'JPM', name: 'JPMorgan Chase & Co.', price: 249.90, change: 0.34, marketCap: '710B', pe: 13.2, sector: 'Financials' },
];
const SECTORS = ['All', 'Technology', 'Financials', 'Consumer', 'Healthcare', 'Automotive', 'Energy'];
export function ScreenerView() {
const { setActiveSymbol } = useAppContext();
const [sector, setSector] = useState('All');
const [query, setQuery] = useState('');
const filtered = PLACEHOLDER_RESULTS.filter(r => {
const matchSector = sector === 'All' || r.sector === sector;
const matchQuery = !query
|| r.symbol.includes(query.toUpperCase())
|| r.name.toLowerCase().includes(query.toLowerCase());
return matchSector && matchQuery;
});
return (
<div>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#111827', margin: '0 0 20px' }}>Screener</h2>
{/* Filters */}
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
{/* Search */}
<div style={{ position: 'relative', flex: 1, minWidth: 200, maxWidth: 300 }}>
<Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: '#9CA3AF' }} />
<input
type="text"
placeholder="Filter by name or ticker…"
value={query}
onChange={e => setQuery(e.target.value)}
style={{
width: '100%', paddingLeft: 32, paddingRight: 12,
paddingTop: 8, paddingBottom: 8,
border: '1px solid #E5E7EB', borderRadius: 8,
fontSize: 13, outline: 'none', background: '#fff',
color: '#374151', boxSizing: 'border-box', fontFamily: 'inherit',
}}
/>
</div>
{/* Sector pills */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<SlidersHorizontal size={14} color="#6B7280" />
{SECTORS.map(s => (
<button
key={s}
onClick={() => setSector(s)}
style={{
padding: '5px 12px', borderRadius: 20,
border: '1px solid', fontSize: 12, fontWeight: 600,
cursor: 'pointer', transition: 'all 0.15s',
borderColor: sector === s ? '#2563EB' : '#E5E7EB',
background: sector === s ? '#EFF6FF' : '#fff',
color: sector === s ? '#2563EB' : '#6B7280',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Results table */}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', overflow: 'hidden' }}>
{/* Header */}
<div style={{
display: 'grid',
gridTemplateColumns: '1.5fr 2.5fr 1fr 1fr 1fr 1fr',
padding: '10px 16px',
borderBottom: '1px solid #F3F4F6',
background: '#F9FAFB',
}}>
{['Symbol','Name','Price','Change','Mkt Cap','P/E'].map(h => (
<span key={h} style={{ fontSize: 11, color: '#9CA3AF', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{h}
</span>
))}
</div>
{filtered.map((row, i) => (
<div
key={row.symbol}
onClick={() => setActiveSymbol(row.symbol)}
style={{
display: 'grid',
gridTemplateColumns: '1.5fr 2.5fr 1fr 1fr 1fr 1fr',
padding: '12px 16px',
borderBottom: i < filtered.length - 1 ? '1px solid #F9FAFB' : 'none',
cursor: 'pointer',
transition: 'background 0.1s',
alignItems: 'center',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#F9FAFB')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<span style={{ fontSize: 13, fontWeight: 700, color: '#2563EB' }}>{row.symbol}</span>
<span style={{ fontSize: 12, color: '#374151' }}>{row.name}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: '#111827' }}>${row.price.toFixed(2)}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: row.change >= 0 ? '#16A34A' : '#DC2626' }}>
{row.change >= 0 ? '+' : ''}{row.change.toFixed(2)}%
</span>
<span style={{ fontSize: 12, color: '#374151' }}>{row.marketCap}</span>
<span style={{ fontSize: 12, color: '#374151' }}>{row.pe}</span>
</div>
))}
{filtered.length === 0 && (
<div style={{ padding: 32, textAlign: 'center', color: '#9CA3AF', fontSize: 13 }}>
No results match your filters
</div>
)}
</div>
<div style={{ marginTop: 10, fontSize: 11, color: '#9CA3AF' }}>
Phase 6 will connect this to live FMP screener data with full filtering
</div>
</div>
);
}

View File

@ -0,0 +1,53 @@
import { useState } from 'react';
import { useAppContext } from '../context/AppContext';
import { SettingsTab } from '../tabs/SettingsTab';
import { AdminTab } from '../tabs/AdminTab';
import { ConfigTab } from '../tabs/ConfigTab';
type SettingsSection = 'Account' | 'Bot Config' | 'Admin Panel';
export function SettingsView() {
const { botState, isAdmin, socket } = useAppContext();
const sections: SettingsSection[] = [
'Account',
'Bot Config',
...(isAdmin ? ['Admin Panel' as SettingsSection] : []),
];
const [section, setSection] = useState<SettingsSection>('Account');
return (
<div>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#111827', margin: '0 0 20px' }}>Settings</h2>
<div style={{
display: 'flex', gap: 4, marginBottom: 24,
borderBottom: '1px solid #E5E7EB',
}}>
{sections.map(s => (
<button
key={s}
onClick={() => setSection(s)}
style={{
padding: '8px 16px',
border: 'none',
borderBottom: section === s ? '2px solid #2563EB' : '2px solid transparent',
background: 'transparent',
color: section === s ? '#2563EB' : '#6B7280',
fontSize: 13,
fontWeight: section === s ? 700 : 500,
cursor: 'pointer',
marginBottom: -1,
transition: 'all 0.15s',
}}
>
{s}
</button>
))}
</div>
{section === 'Account' && <SettingsTab botState={botState} />}
{section === 'Bot Config' && <ConfigTab />}
{section === 'Admin Panel' && isAdmin && <AdminTab botState={botState} socket={socket} />}
</div>
);
}

View File

@ -0,0 +1,12 @@
import { useAppContext } from '../context/AppContext';
import { EntriesTab } from '../tabs/EntriesTab';
export function WatchlistView() {
const { botState } = useAppContext();
return (
<div>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#111827', margin: '0 0 20px' }}>Watchlist</h2>
<EntriesTab botState={botState} />
</div>
);
}