272 lines
9.9 KiB
TypeScript
272 lines
9.9 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { BrowserRouter } from 'react-router-dom';
|
||
import { useWebSocket } from './hooks/useWebSocket';
|
||
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, '')
|
||
) => {
|
||
let profileName = requestedName || 'AI Profile';
|
||
if (action === 'create_profile') {
|
||
const existing = chatProfiles.find(p => p.name === profileName);
|
||
if (existing) profileName = `${profileName} (${suffixProvider()})`;
|
||
}
|
||
return profileName;
|
||
};
|
||
|
||
export const buildChatApplyPayload = (
|
||
profileData: any,
|
||
currentUserId: string,
|
||
profileName: string
|
||
) => ({
|
||
name: profileName,
|
||
user_id: currentUserId,
|
||
allocated_capital: Number(profileData.allocated_capital || 1000),
|
||
risk_per_trade_percent: Number(profileData.risk_per_trade_percent || 1),
|
||
symbols: profileData.symbols || 'BTC/USDT',
|
||
is_active: profileData.is_active ?? true,
|
||
strategy_config: profileData.strategy_config,
|
||
});
|
||
|
||
// ─── App ─────────────────────────────────────────────────────────────────────
|
||
|
||
function App() {
|
||
const { user, profile, loading, signOut } = useAuth();
|
||
const { socket, botState, connected } = useWebSocket(tradingRuntime.tradingApiUrl);
|
||
const [activeSymbol, setActiveSymbol] = useState('');
|
||
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
|
||
const [previewAsCustomer] = useState(false);
|
||
const [simpleToasts, setSimpleToasts] = useState<Array<{
|
||
id: string;
|
||
message: string;
|
||
severity: 'INFO' | 'WARN' | 'ERROR';
|
||
}>>([]);
|
||
const seenSimpleEventIdsRef = useRef<Set<string>>(new Set());
|
||
|
||
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 is NonNullable<typeof e> =>
|
||
Boolean(e)
|
||
&& (e.severity === 'ERROR' || e.severity === 'WARN')
|
||
&& Date.now() - e.timestamp < 600_000
|
||
);
|
||
const hasCriticalEvents = recentCriticalEvents.length > 0;
|
||
|
||
useEffect(() => {
|
||
const events = botState.operationalEvents ?? [];
|
||
const now = Date.now();
|
||
const nextToasts: Array<{ id: string; message: string; severity: 'INFO' | 'WARN' | 'ERROR' }> = [];
|
||
|
||
for (const event of events) {
|
||
if (!event || event.type !== 'SIMPLE_SETUP_UPDATE') continue;
|
||
if (seenSimpleEventIdsRef.current.has(event.id)) continue;
|
||
seenSimpleEventIdsRef.current.add(event.id);
|
||
if (now - event.timestamp > 120_000) continue;
|
||
nextToasts.push({
|
||
id: event.id,
|
||
message: event.message,
|
||
severity: (event.severity as 'INFO' | 'WARN' | 'ERROR') || 'INFO',
|
||
});
|
||
}
|
||
|
||
if (nextToasts.length === 0) return;
|
||
|
||
setSimpleToasts((prev) => [...prev, ...nextToasts].slice(-4));
|
||
for (const toast of nextToasts) {
|
||
window.setTimeout(() => {
|
||
setSimpleToasts((prev) => prev.filter((entry) => entry.id !== toast.id));
|
||
}, 4500);
|
||
}
|
||
}, [botState.operationalEvents]);
|
||
|
||
// Chat profile management
|
||
const fetchChatProfiles = useCallback(async () => {
|
||
const data = await fetchTradeProfiles();
|
||
setChatProfiles(data ?? []);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (user) {
|
||
fetchChatProfiles();
|
||
const id = setInterval(fetchChatProfiles, 30_000);
|
||
return () => clearInterval(id);
|
||
}
|
||
}, [user, fetchChatProfiles]);
|
||
|
||
const handleChatApply = async (
|
||
action: string,
|
||
profileData: any
|
||
): Promise<{ success: boolean; error?: string }> => {
|
||
const currentUserId = user?.id;
|
||
if (!currentUserId) return { success: false, error: 'Not authenticated' };
|
||
|
||
const profileName = resolveProfileNameForAction(action, profileData.name, chatProfiles);
|
||
const payload = buildChatApplyPayload(profileData, currentUserId, profileName);
|
||
|
||
if (action === 'create_profile') {
|
||
try {
|
||
await createTradeProfile(payload);
|
||
fetchChatProfiles();
|
||
return { success: true };
|
||
} catch (err: any) {
|
||
return { success: false, error: err.message };
|
||
}
|
||
}
|
||
if (action === 'update_profile' && profileData.id) {
|
||
try {
|
||
await updateTradeProfile(profileData.id, payload);
|
||
fetchChatProfiles();
|
||
return { success: true };
|
||
} catch (err: any) {
|
||
return { success: false, error: err.message };
|
||
}
|
||
}
|
||
return { success: false, error: 'Unknown action' };
|
||
};
|
||
|
||
const handleSignOut = async () => {
|
||
tradingTelemetry.client.trackEvent('info', 'auth', 'trading_web_sign_out', {
|
||
userId: user?.id ?? 'anonymous',
|
||
feature: 'sign_out',
|
||
tags: { surface: 'web' },
|
||
});
|
||
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: 'var(--background)', color: 'var(--foreground)',
|
||
fontSize: 15, fontFamily: 'Inter, system-ui, sans-serif',
|
||
}}>
|
||
Loading…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!user) return <Login />;
|
||
|
||
// ── Render ──────────────────────────────────────────────────────────────────
|
||
return (
|
||
<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, #7f1d1d 0%, #dc2626 50%, #7f1d1d 100%)',
|
||
color: '#fff',
|
||
padding: '6px 20px',
|
||
textAlign: 'center',
|
||
fontSize: 11,
|
||
fontWeight: 900,
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.1em',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
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>
|
||
|
||
{simpleToasts.length > 0 && (
|
||
<div style={{
|
||
position: 'fixed',
|
||
right: 16,
|
||
bottom: 16,
|
||
zIndex: 9998,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 10,
|
||
maxWidth: 420,
|
||
}}>
|
||
{simpleToasts.map((toast) => {
|
||
const palette = toast.severity === 'ERROR'
|
||
? { border: 'rgba(239,68,68,0.35)', bg: 'rgba(127,29,29,0.95)', fg: '#fff' }
|
||
: toast.severity === 'WARN'
|
||
? { border: 'rgba(245,158,11,0.35)', bg: 'rgba(120,53,15,0.95)', fg: '#fff' }
|
||
: { border: 'rgba(16,185,129,0.35)', bg: 'rgba(6,78,59,0.95)', fg: '#fff' };
|
||
return (
|
||
<div
|
||
key={toast.id}
|
||
style={{
|
||
border: `1px solid ${palette.border}`,
|
||
background: palette.bg,
|
||
color: palette.fg,
|
||
borderRadius: 16,
|
||
padding: '12px 14px',
|
||
boxShadow: '0 12px 32px rgba(0,0,0,0.28)',
|
||
fontSize: 13,
|
||
lineHeight: 1.45,
|
||
backdropFilter: 'blur(8px)',
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 10, fontWeight: 900, letterSpacing: '0.16em', textTransform: 'uppercase', opacity: 0.8, marginBottom: 4 }}>
|
||
Simple update
|
||
</div>
|
||
<div>{toast.message}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Floating AI strategy assistant */}
|
||
<ChatControl profiles={chatProfiles} onApplyProfile={handleChatApply} />
|
||
</AppContext.Provider>
|
||
</BrowserRouter>
|
||
);
|
||
}
|
||
|
||
export default App;
|