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:
parent
d955d00c00
commit
f62c3b15ee
@ -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 || '',
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
448
web/src/App.tsx
448
web/src/App.tsx
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
71
web/src/components/layout/AppShell.tsx
Normal file
71
web/src/components/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
web/src/components/layout/Header.tsx
Normal file
164
web/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
web/src/components/layout/RightPanel.tsx
Normal file
219
web/src/components/layout/RightPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
web/src/components/layout/Sidebar.tsx
Normal file
141
web/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
web/src/context/AppContext.tsx
Normal file
20
web/src/context/AppContext.tsx
Normal 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);
|
||||
12
web/src/views/AlertsView.tsx
Normal file
12
web/src/views/AlertsView.tsx
Normal 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
383
web/src/views/HomeView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
web/src/views/MarketsView.tsx
Normal file
26
web/src/views/MarketsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
web/src/views/PortfolioView.tsx
Normal file
48
web/src/views/PortfolioView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
web/src/views/ResearchView.tsx
Normal file
70
web/src/views/ResearchView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
web/src/views/ScreenerView.tsx
Normal file
134
web/src/views/ScreenerView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
web/src/views/SettingsView.tsx
Normal file
53
web/src/views/SettingsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
web/src/views/WatchlistView.tsx
Normal file
12
web/src/views/WatchlistView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user