fix: web + mobile pre-beta audit — real APIs, socket routing, empty states
Web:
- runtime.ts: use import.meta.env (process.env is undefined in Vite browser bundle)
- tradingApiUrl local fallback: drop /api suffix (API libs already append /api/*)
- useWebSocket: deriveSocketParams() — correctly splits origin + socket path for
Caddy handle_path /invttrdg/* proxy (io(origin, {path}), not io(url-with-path))
- App.tsx: pass socket prop to AdminTab; pass connected prop to SignalsTab
- AdminTab: remove duplicate useWebSocket; accept socket as prop
- SignalsTab: connection-aware empty state message
- backtest/flags: default to disabled when VITE_BACKTEST_ENABLED unset
- EntryForm: NaN guard before live trade execution
- MarketplaceTab: null-safety on symbols.rules access
- Tests: pass socket prop to AdminTab; update empty state assertion
Mobile:
- TradingDataProvider: same deriveSocketParams fix — EXPO_PUBLIC_SOCKET_PATH
overrides auto-derived path from tradingApiUrl
- strategies: replace mock data with real GET /api/profiles + PATCH active toggle
- chat: wire to real POST /api/chat; remove hardcoded mock reply
- marketplace: fetch GET /api/marketplace-presets; USE STRATEGY calls POST /api/profiles
- settings: sign-out confirmation dialog; execution mode read-only hint;
version from expo-constants instead of hardcoded v2.3
- positions/history: empty state UI when no data
- CustomTabBar: always show tab labels (not only when focused)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f0a6ce09ff
commit
e2008f70b9
@ -67,7 +67,12 @@ export default function HistoryScreen() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{timeFilteredTrades.map((trade, index) => (
|
{timeFilteredTrades.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyText}>No trades found</Text>
|
||||||
|
<Text style={styles.emptyHint}>Closed trades will appear here once the bot exits a position.</Text>
|
||||||
|
</View>
|
||||||
|
) : timeFilteredTrades.map((trade, index) => (
|
||||||
<TradeRow key={`${trade.symbol}-${trade.timestamp}-${index}`} trade={trade} index={index} />
|
<TradeRow key={`${trade.symbol}-${trade.timestamp}-${index}`} trade={trade} index={index} />
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@ -299,4 +304,23 @@ const styles = StyleSheet.create({
|
|||||||
color: Colors.text.secondary,
|
color: Colors.text.secondary,
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
},
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center' as const,
|
||||||
|
justifyContent: 'center' as const,
|
||||||
|
paddingVertical: 60,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontFamily: Fonts.inter.bold,
|
||||||
|
fontSize: FontSize.subheading,
|
||||||
|
color: Colors.text.secondary,
|
||||||
|
},
|
||||||
|
emptyHint: {
|
||||||
|
fontFamily: Fonts.inter.medium,
|
||||||
|
fontSize: FontSize.body,
|
||||||
|
color: Colors.text.muted,
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
maxWidth: 260,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -156,10 +156,15 @@ export default function PositionsScreen() {
|
|||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{activeTab === 0
|
{activeTab === 0 ? (
|
||||||
? positions.map((pos, i) => <PositionCard key={pos.id} pos={pos} index={i} />)
|
positions.length > 0
|
||||||
: orders.map((ord, i) => <OrderCard key={ord.id} order={ord} index={i} />)
|
? positions.map((pos, i) => <PositionCard key={pos.id} pos={pos} index={i} />)
|
||||||
}
|
: <View style={styles.emptyState}><Text style={styles.emptyText}>No open positions</Text><Text style={styles.emptyHint}>Active positions will appear here once the bot enters a trade.</Text></View>
|
||||||
|
) : (
|
||||||
|
orders.length > 0
|
||||||
|
? orders.map((ord, i) => <OrderCard key={ord.id} order={ord} index={i} />)
|
||||||
|
: <View style={styles.emptyState}><Text style={styles.emptyText}>No pending orders</Text><Text style={styles.emptyHint}>Orders placed by the bot or manually will appear here.</Text></View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@ -295,4 +300,23 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: FontSize.micro,
|
fontSize: FontSize.micro,
|
||||||
color: Colors.text.secondary,
|
color: Colors.text.secondary,
|
||||||
},
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center' as const,
|
||||||
|
justifyContent: 'center' as const,
|
||||||
|
paddingVertical: 60,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontFamily: Fonts.inter.bold,
|
||||||
|
fontSize: FontSize.subheading,
|
||||||
|
color: Colors.text.secondary,
|
||||||
|
},
|
||||||
|
emptyHint: {
|
||||||
|
fontFamily: Fonts.inter.medium,
|
||||||
|
fontSize: FontSize.body,
|
||||||
|
color: Colors.text.muted,
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
maxWidth: 260,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Alert, View, Text, ScrollView, Switch, StyleSheet } from 'react-native';
|
import { Alert, View, Text, ScrollView, Switch, StyleSheet } from 'react-native';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { ChevronRight, Lock, Check } from 'lucide-react-native';
|
import { ChevronRight, Lock, Check } from 'lucide-react-native';
|
||||||
@ -84,6 +85,7 @@ export default function SettingsScreen() {
|
|||||||
activeColor={modeColors[executionModeIndex]}
|
activeColor={modeColors[executionModeIndex]}
|
||||||
activeTextColor={executionModeIndex === 2 ? '#fff' : '#000'}
|
activeTextColor={executionModeIndex === 2 ? '#fff' : '#000'}
|
||||||
/>
|
/>
|
||||||
|
<Text style={styles.readOnlyHint}>Execution mode is managed from the web dashboard.</Text>
|
||||||
{executionModeIndex === 2 && (
|
{executionModeIndex === 2 && (
|
||||||
<View style={styles.warningBanner}>
|
<View style={styles.warningBanner}>
|
||||||
<Text style={styles.warningText}>
|
<Text style={styles.warningText}>
|
||||||
@ -248,9 +250,14 @@ export default function SettingsScreen() {
|
|||||||
<Text style={styles.sectionHeader}>ABOUT</Text>
|
<Text style={styles.sectionHeader}>ABOUT</Text>
|
||||||
<View style={styles.aboutRow}>
|
<View style={styles.aboutRow}>
|
||||||
<Text style={styles.aboutLabel}>Version</Text>
|
<Text style={styles.aboutLabel}>Version</Text>
|
||||||
<Text style={styles.aboutValue}>v2.3</Text>
|
<Text style={styles.aboutValue}>v{Constants.expoConfig?.version ?? '1.0.0'}</Text>
|
||||||
</View>
|
</View>
|
||||||
<PressableScale style={styles.linkRow} onPress={() => void signOut()}>
|
<PressableScale style={styles.linkRow} onPress={() => {
|
||||||
|
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Sign Out', style: 'destructive', onPress: () => void signOut() },
|
||||||
|
]);
|
||||||
|
}}>
|
||||||
<Text style={styles.linkText}>Sign Out</Text>
|
<Text style={styles.linkText}>Sign Out</Text>
|
||||||
<ChevronRight size={16} color={Colors.text.muted} />
|
<ChevronRight size={16} color={Colors.text.muted} />
|
||||||
</PressableScale>
|
</PressableScale>
|
||||||
@ -381,6 +388,12 @@ const styles = StyleSheet.create({
|
|||||||
color: Colors.accent.green,
|
color: Colors.accent.green,
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
},
|
},
|
||||||
|
readOnlyHint: {
|
||||||
|
fontFamily: Fonts.mono.regular,
|
||||||
|
fontSize: FontSize.micro,
|
||||||
|
color: Colors.text.muted,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
warningBanner: {
|
warningBanner: {
|
||||||
backgroundColor: 'rgba(230,126,34,0.1)',
|
backgroundColor: 'rgba(230,126,34,0.1)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|||||||
@ -1,14 +1,30 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { View, Text, ScrollView, Switch, StyleSheet } from 'react-native';
|
import { View, Text, ScrollView, Switch, StyleSheet, ActivityIndicator } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
|
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
|
||||||
import { strategies } from '@/constants/mockData';
|
|
||||||
import { formatCurrency } from '@/utils/format';
|
import { formatCurrency } from '@/utils/format';
|
||||||
import AnimatedCard from '@/components/AnimatedCard';
|
import AnimatedCard from '@/components/AnimatedCard';
|
||||||
import PillBadge from '@/components/PillBadge';
|
import PillBadge from '@/components/PillBadge';
|
||||||
import PressableScale from '@/components/PressableScale';
|
import PressableScale from '@/components/PressableScale';
|
||||||
|
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
||||||
|
import { mobileRuntime } from '@/lib/runtime';
|
||||||
|
import { createRequestId } from '../../../shared/request-id.js';
|
||||||
|
|
||||||
|
interface TradeProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
allocated_capital: number;
|
||||||
|
risk_per_trade_percent: number;
|
||||||
|
symbols: string;
|
||||||
|
strategy_config?: {
|
||||||
|
riskStyle?: string;
|
||||||
|
execution?: { minRulePassRatio?: number };
|
||||||
|
riskLimits?: { dailyProfitTargetUsd?: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const RISK_COLORS: Record<string, { color: string; label: string; icon: string }> = {
|
const RISK_COLORS: Record<string, { color: string; label: string; icon: string }> = {
|
||||||
aggressive: { color: Colors.accent.orange, label: 'Aggressive', icon: '\u{1F525}' },
|
aggressive: { color: Colors.accent.orange, label: 'Aggressive', icon: '\u{1F525}' },
|
||||||
@ -16,10 +32,29 @@ const RISK_COLORS: Record<string, { color: string; label: string; icon: string }
|
|||||||
safe: { color: Colors.accent.blue, label: 'Conservative', icon: '\u{1F6E1}\u{FE0F}' },
|
safe: { color: Colors.accent.blue, label: 'Conservative', icon: '\u{1F6E1}\u{FE0F}' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function StrategyCard({ strategy, index }: { strategy: typeof strategies[0]; index: number }) {
|
function StrategyCard({ profile, index, onToggle }: {
|
||||||
const [isActive, setIsActive] = useState(strategy.isActive);
|
profile: TradeProfile;
|
||||||
const risk = RISK_COLORS[strategy.riskStyle];
|
index: number;
|
||||||
const isPositive = strategy.netPnl >= 0;
|
onToggle: (id: string, isActive: boolean) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [isActive, setIsActive] = useState(profile.is_active);
|
||||||
|
const [toggling, setToggling] = useState(false);
|
||||||
|
const riskStyle = profile.strategy_config?.riskStyle || 'balanced';
|
||||||
|
const risk = RISK_COLORS[riskStyle] || RISK_COLORS.balanced;
|
||||||
|
const dailyTarget = profile.strategy_config?.riskLimits?.dailyProfitTargetUsd ?? 0;
|
||||||
|
const symbolList = profile.symbols ? profile.symbols.split(',').map(s => s.trim()) : [];
|
||||||
|
|
||||||
|
const handleToggle = async (newValue: boolean) => {
|
||||||
|
setIsActive(newValue);
|
||||||
|
setToggling(true);
|
||||||
|
try {
|
||||||
|
await onToggle(profile.id, newValue);
|
||||||
|
} catch {
|
||||||
|
setIsActive(!newValue);
|
||||||
|
} finally {
|
||||||
|
setToggling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard index={index} style={[Shadows.card, { borderRadius: BorderRadius.large }]}>
|
<AnimatedCard index={index} style={[Shadows.card, { borderRadius: BorderRadius.large }]}>
|
||||||
@ -32,10 +67,11 @@ function StrategyCard({ strategy, index }: { strategy: typeof strategies[0]; ind
|
|||||||
<View style={[styles.accentLine, { backgroundColor: risk.color }]} />
|
<View style={[styles.accentLine, { backgroundColor: risk.color }]} />
|
||||||
|
|
||||||
<View style={styles.cardHeader}>
|
<View style={styles.cardHeader}>
|
||||||
<Text style={styles.stratName}>{strategy.name}</Text>
|
<Text style={styles.stratName}>{profile.name}</Text>
|
||||||
<Switch
|
<Switch
|
||||||
value={isActive}
|
value={isActive}
|
||||||
onValueChange={setIsActive}
|
onValueChange={handleToggle}
|
||||||
|
disabled={toggling}
|
||||||
trackColor={{ false: Colors.background.elevated, true: 'rgba(0,255,136,0.3)' }}
|
trackColor={{ false: Colors.background.elevated, true: 'rgba(0,255,136,0.3)' }}
|
||||||
thumbColor={isActive ? Colors.accent.green : '#666'}
|
thumbColor={isActive ? Colors.accent.green : '#666'}
|
||||||
/>
|
/>
|
||||||
@ -47,15 +83,8 @@ function StrategyCard({ strategy, index }: { strategy: typeof strategies[0]; ind
|
|||||||
bgColor={`${risk.color}20`}
|
bgColor={`${risk.color}20`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.statsGrid}>
|
|
||||||
<StatCell label="TRADES" value={strategy.tradeCount.toString()} />
|
|
||||||
<StatCell label="WINS" value={strategy.wins.toString()} />
|
|
||||||
<StatCell label="WIN RATE" value={`${strategy.winRate}%`} color={Colors.accent.green} />
|
|
||||||
<StatCell label="NET P&L" value={formatCurrency(strategy.netPnl)} color={isPositive ? Colors.accent.green : Colors.accent.red} mono />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.assetPills}>
|
<View style={styles.assetPills}>
|
||||||
{strategy.symbols.map(s => (
|
{symbolList.map(s => (
|
||||||
<View key={s} style={styles.assetPill}>
|
<View key={s} style={styles.assetPill}>
|
||||||
<Text style={styles.assetText}>{s}</Text>
|
<Text style={styles.assetText}>{s}</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -64,49 +93,62 @@ function StrategyCard({ strategy, index }: { strategy: typeof strategies[0]; ind
|
|||||||
|
|
||||||
<View style={styles.capitalRow}>
|
<View style={styles.capitalRow}>
|
||||||
<Text style={styles.capitalLabel}>
|
<Text style={styles.capitalLabel}>
|
||||||
${strategy.allocatedCapital.toLocaleString()} allocated
|
{formatCurrency(profile.allocated_capital)} allocated · {profile.risk_per_trade_percent}% risk/trade
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
{dailyTarget > 0 && (
|
||||||
|
<Text style={styles.capitalLabel}>Daily target: {formatCurrency(dailyTarget)}</Text>
|
||||||
<View style={styles.progressSection}>
|
)}
|
||||||
<View style={styles.progressHeader}>
|
|
||||||
<Text style={styles.progressLabel}>Daily Target</Text>
|
|
||||||
<Text style={styles.progressValue}>
|
|
||||||
${strategy.dailyProgress} / ${strategy.dailyTarget}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.progressTrack}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={['#00ff88', '#00cc6a']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={[styles.progressFill, { width: `${(strategy.dailyProgress / strategy.dailyTarget) * 100}%` as any }]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCell({ label, value, color, mono }: { label: string; value: string; color?: string; mono?: boolean }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.statCell}>
|
|
||||||
<Text style={styles.statLabel}>{label}</Text>
|
|
||||||
<Text style={[
|
|
||||||
styles.statValue,
|
|
||||||
color ? { color } : null,
|
|
||||||
mono ? { fontFamily: Fonts.mono.extraBold } : null,
|
|
||||||
]}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StrategiesScreen() {
|
export default function StrategiesScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { accessToken } = useMobileAuth();
|
||||||
|
const [profiles, setProfiles] = useState<TradeProfile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchProfiles = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${mobileRuntime.tradingApiUrl}/profiles`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'x-request-id': createRequestId('mobile-strategies'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to load profiles (${res.status})`);
|
||||||
|
const body = await res.json();
|
||||||
|
setProfiles(Array.isArray(body.profiles) ? body.profiles : []);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load strategies');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchProfiles();
|
||||||
|
}, [fetchProfiles]);
|
||||||
|
|
||||||
|
const handleToggle = async (id: string, isActive: boolean) => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
const res = await fetch(`${mobileRuntime.tradingApiUrl}/profiles/${id}/active`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'x-request-id': createRequestId('mobile-toggle'),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ is_active: isActive }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Toggle failed (${res.status})`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||||
@ -120,9 +162,23 @@ export default function StrategiesScreen() {
|
|||||||
contentContainerStyle={styles.content}
|
contentContainerStyle={styles.content}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{strategies.map((s, i) => (
|
{loading ? (
|
||||||
<StrategyCard key={s.id} strategy={s} index={i} />
|
<ActivityIndicator color={Colors.accent.green} style={{ marginTop: 40 }} />
|
||||||
))}
|
) : error ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyText}>Failed to load strategies</Text>
|
||||||
|
<Text style={styles.emptyHint}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
) : profiles.length === 0 ? (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyText}>No strategies yet</Text>
|
||||||
|
<Text style={styles.emptyHint}>Create a strategy from the marketplace or web dashboard.</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
profiles.map((p, i) => (
|
||||||
|
<StrategyCard key={p.id} profile={p} index={i} onToggle={handleToggle} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
<PressableScale
|
<PressableScale
|
||||||
haptic="medium"
|
haptic="medium"
|
||||||
@ -199,27 +255,8 @@ const styles = StyleSheet.create({
|
|||||||
fontFamily: Fonts.inter.black,
|
fontFamily: Fonts.inter.black,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: Colors.text.primary,
|
color: Colors.text.primary,
|
||||||
},
|
flex: 1,
|
||||||
statsGrid: {
|
marginRight: 12,
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 10,
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
statCell: {
|
|
||||||
width: '46%',
|
|
||||||
},
|
|
||||||
statLabel: {
|
|
||||||
fontFamily: Fonts.inter.black,
|
|
||||||
fontSize: FontSize.micro,
|
|
||||||
color: Colors.text.secondary,
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
marginBottom: 3,
|
|
||||||
},
|
|
||||||
statValue: {
|
|
||||||
fontFamily: Fonts.inter.extraBold,
|
|
||||||
fontSize: FontSize.bodyLarge,
|
|
||||||
color: Colors.text.primary,
|
|
||||||
},
|
},
|
||||||
assetPills: {
|
assetPills: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@ -238,41 +275,13 @@ const styles = StyleSheet.create({
|
|||||||
color: Colors.text.secondary,
|
color: Colors.text.secondary,
|
||||||
},
|
},
|
||||||
capitalRow: {
|
capitalRow: {
|
||||||
marginTop: 2,
|
gap: 2,
|
||||||
},
|
},
|
||||||
capitalLabel: {
|
capitalLabel: {
|
||||||
fontFamily: Fonts.mono.medium,
|
fontFamily: Fonts.mono.medium,
|
||||||
fontSize: FontSize.bodySmall,
|
fontSize: FontSize.bodySmall,
|
||||||
color: Colors.text.secondary,
|
color: Colors.text.secondary,
|
||||||
},
|
},
|
||||||
progressSection: {
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
progressHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
progressLabel: {
|
|
||||||
fontFamily: Fonts.inter.semiBold,
|
|
||||||
fontSize: FontSize.bodySmall,
|
|
||||||
color: Colors.text.secondary,
|
|
||||||
},
|
|
||||||
progressValue: {
|
|
||||||
fontFamily: Fonts.mono.bold,
|
|
||||||
fontSize: FontSize.bodySmall,
|
|
||||||
color: Colors.text.primary,
|
|
||||||
},
|
|
||||||
progressTrack: {
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: Colors.background.elevated,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
progressFill: {
|
|
||||||
height: 4,
|
|
||||||
borderRadius: 2,
|
|
||||||
},
|
|
||||||
ctaWrapper: {
|
ctaWrapper: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
@ -294,4 +303,23 @@ const styles = StyleSheet.create({
|
|||||||
color: '#000',
|
color: '#000',
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
},
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontFamily: Fonts.inter.bold,
|
||||||
|
fontSize: FontSize.subheading,
|
||||||
|
color: Colors.text.secondary,
|
||||||
|
},
|
||||||
|
emptyHint: {
|
||||||
|
fontFamily: Fonts.inter.medium,
|
||||||
|
fontSize: FontSize.body,
|
||||||
|
color: Colors.text.muted,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 260,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,8 +15,17 @@ import { LinearGradient } from 'expo-linear-gradient';
|
|||||||
import { X, ArrowUp, Cpu } from 'lucide-react-native';
|
import { X, ArrowUp, Cpu } from 'lucide-react-native';
|
||||||
import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
|
import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
|
||||||
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
|
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
|
||||||
import { chatMessages, chatSuggestions, ChatMessage } from '@/constants/mockData';
|
import { chatSuggestions } from '@/constants/mockData';
|
||||||
import PressableScale from '@/components/PressableScale';
|
import PressableScale from '@/components/PressableScale';
|
||||||
|
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
||||||
|
import { mobileRuntime } from '@/lib/runtime';
|
||||||
|
import { createRequestId } from '../../../shared/request-id.js';
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'bot';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||||
const isBot = message.role === 'bot';
|
const isBot = message.role === 'bot';
|
||||||
@ -54,28 +63,46 @@ function MessageBubble({ message }: { message: ChatMessage }) {
|
|||||||
export default function ChatScreen() {
|
export default function ChatScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>(chatMessages);
|
const { accessToken } = useMobileAuth();
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [inputText, setInputText] = useState('');
|
const [inputText, setInputText] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
const scrollRef = useRef<ScrollView>(null);
|
const scrollRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
const sendMessage = (text: string) => {
|
const sendMessage = async (text: string) => {
|
||||||
if (!text.trim()) return;
|
if (!text.trim() || sending) return;
|
||||||
const newMsg: ChatMessage = {
|
const userMsg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', text: text.trim() };
|
||||||
id: `user-${Date.now()}`,
|
setMessages(prev => [...prev, userMsg]);
|
||||||
role: 'user',
|
|
||||||
text: text.trim(),
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, newMsg]);
|
|
||||||
setInputText('');
|
setInputText('');
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
setTimeout(() => {
|
try {
|
||||||
const botReply: ChatMessage = {
|
const res = await fetch(`${mobileRuntime.tradingApiUrl}/chat`, {
|
||||||
id: `bot-${Date.now()}`,
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'x-request-id': createRequestId('mobile-chat'),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ message: text.trim(), context: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Chat request failed (${res.status})`);
|
||||||
|
const body = await res.json();
|
||||||
|
const reply = body.summary || body.message || body.response || 'No response from assistant.';
|
||||||
|
|
||||||
|
const botMsg: ChatMessage = { id: `bot-${Date.now()}`, role: 'bot', text: reply };
|
||||||
|
setMessages(prev => [...prev, botMsg]);
|
||||||
|
} catch (e) {
|
||||||
|
const errMsg: ChatMessage = {
|
||||||
|
id: `bot-err-${Date.now()}`,
|
||||||
role: 'bot',
|
role: 'bot',
|
||||||
text: "I'm analyzing your request. Based on current market conditions and your portfolio allocation, I'd recommend reviewing your SOL/USDT position which is showing strong momentum.",
|
text: `Error: ${e instanceof Error ? e.message : 'Failed to reach assistant'}`,
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, botReply]);
|
setMessages(prev => [...prev, errMsg]);
|
||||||
}, 1000);
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -113,17 +140,25 @@ export default function ChatScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
|
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
|
||||||
>
|
>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<View style={styles.welcomeMsg}>
|
||||||
|
<Cpu size={28} color={Colors.accent.green} />
|
||||||
|
<Text style={styles.welcomeTitle}>Bytelyst AI</Text>
|
||||||
|
<Text style={styles.welcomeText}>Ask about your strategies, market conditions, or get trade recommendations.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<MessageBubble key={msg.id} message={msg} />
|
<MessageBubble key={msg.id} message={msg} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{messages.length <= chatMessages.length && (
|
{messages.length === 0 && (
|
||||||
<View style={styles.suggestions}>
|
<View style={styles.suggestions}>
|
||||||
{chatSuggestions.map((s) => (
|
{chatSuggestions.map((s) => (
|
||||||
<PressableScale
|
<PressableScale
|
||||||
key={s}
|
key={s}
|
||||||
style={styles.suggestionChip}
|
style={styles.suggestionChip}
|
||||||
onPress={() => sendMessage(s)}
|
onPress={() => void sendMessage(s)}
|
||||||
>
|
>
|
||||||
<Text style={styles.suggestionText}>{s}</Text>
|
<Text style={styles.suggestionText}>{s}</Text>
|
||||||
</PressableScale>
|
</PressableScale>
|
||||||
@ -140,21 +175,22 @@ export default function ChatScreen() {
|
|||||||
placeholderTextColor={Colors.text.ultraDim}
|
placeholderTextColor={Colors.text.ultraDim}
|
||||||
value={inputText}
|
value={inputText}
|
||||||
onChangeText={setInputText}
|
onChangeText={setInputText}
|
||||||
onSubmitEditing={() => sendMessage(inputText)}
|
onSubmitEditing={() => void sendMessage(inputText)}
|
||||||
selectionColor={Colors.accent.green}
|
selectionColor={Colors.accent.green}
|
||||||
|
editable={!sending}
|
||||||
/>
|
/>
|
||||||
<PressableScale
|
<PressableScale
|
||||||
haptic="medium"
|
haptic="medium"
|
||||||
style={styles.sendBtnWrapper}
|
style={[styles.sendBtnWrapper, sending && styles.sendBtnDisabled]}
|
||||||
onPress={() => sendMessage(inputText)}
|
onPress={() => void sendMessage(inputText)}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#00ff88', '#00cc6a']}
|
colors={sending ? ['#333', '#222'] : ['#00ff88', '#00cc6a']}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
style={styles.sendBtn}
|
style={styles.sendBtn}
|
||||||
>
|
>
|
||||||
<ArrowUp size={18} color="#000" strokeWidth={3} />
|
<ArrowUp size={18} color={sending ? '#666' : '#000'} strokeWidth={3} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</PressableScale>
|
</PressableScale>
|
||||||
</View>
|
</View>
|
||||||
@ -220,6 +256,24 @@ const styles = StyleSheet.create({
|
|||||||
gap: 14,
|
gap: 14,
|
||||||
paddingBottom: 20,
|
paddingBottom: 20,
|
||||||
},
|
},
|
||||||
|
welcomeMsg: {
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
paddingVertical: 32,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
welcomeTitle: {
|
||||||
|
fontFamily: Fonts.inter.black,
|
||||||
|
fontSize: FontSize.subheading,
|
||||||
|
color: Colors.text.primary,
|
||||||
|
},
|
||||||
|
welcomeText: {
|
||||||
|
fontFamily: Fonts.inter.medium,
|
||||||
|
fontSize: FontSize.body,
|
||||||
|
color: Colors.text.secondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
msgRow: {
|
msgRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
@ -313,6 +367,9 @@ const styles = StyleSheet.create({
|
|||||||
sendBtnWrapper: {
|
sendBtnWrapper: {
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
},
|
},
|
||||||
|
sendBtnDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
sendBtn: {
|
sendBtn: {
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
|
|||||||
@ -1,24 +1,102 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native';
|
import { View, Text, ScrollView, Pressable, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { ArrowLeft } from 'lucide-react-native';
|
import { ArrowLeft } from 'lucide-react-native';
|
||||||
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
|
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
|
||||||
import { marketplacePresets } from '@/constants/mockData';
|
|
||||||
import AnimatedCard from '@/components/AnimatedCard';
|
import AnimatedCard from '@/components/AnimatedCard';
|
||||||
import PillBadge from '@/components/PillBadge';
|
import PillBadge from '@/components/PillBadge';
|
||||||
import PressableScale from '@/components/PressableScale';
|
import PressableScale from '@/components/PressableScale';
|
||||||
|
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
||||||
|
import { mobileRuntime } from '@/lib/runtime';
|
||||||
|
import { createRequestId } from '../../../shared/request-id.js';
|
||||||
|
|
||||||
|
interface MarketplacePreset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
risk_style_id: string;
|
||||||
|
recommended_assets: string[];
|
||||||
|
typical_trades_per_day: string;
|
||||||
|
performance_tag: string;
|
||||||
|
is_popular: boolean;
|
||||||
|
strategy_config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
const RISK_COLORS: Record<string, { color: string; label: string }> = {
|
const RISK_COLORS: Record<string, { color: string; label: string }> = {
|
||||||
aggressive: { color: Colors.accent.orange, label: 'Aggressive' },
|
aggressive: { color: Colors.accent.orange, label: 'Aggressive' },
|
||||||
balanced: { color: Colors.accent.green, label: 'Balanced' },
|
balanced: { color: Colors.accent.green, label: 'Balanced' },
|
||||||
safe: { color: Colors.accent.blue, label: 'Conservative' },
|
safe: { color: Colors.accent.blue, label: 'Conservative' },
|
||||||
|
scalping: { color: Colors.accent.purple, label: 'Scalping' },
|
||||||
|
swing: { color: Colors.accent.amber, label: 'Swing' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MarketplaceScreen() {
|
export default function MarketplaceScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { accessToken, user } = useMobileAuth();
|
||||||
|
const [presets, setPresets] = useState<MarketplacePreset[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [applyingId, setApplyingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchPresets = useCallback(async () => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${mobileRuntime.tradingApiUrl}/marketplace-presets`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'x-request-id': createRequestId('mobile-marketplace'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to load presets (${res.status})`);
|
||||||
|
const body = await res.json();
|
||||||
|
setPresets(Array.isArray(body.presets) ? body.presets : []);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Error', e instanceof Error ? e.message : 'Failed to load marketplace');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchPresets();
|
||||||
|
}, [fetchPresets]);
|
||||||
|
|
||||||
|
const handleUseStrategy = async (preset: MarketplacePreset) => {
|
||||||
|
if (!accessToken || !user?.id) return;
|
||||||
|
setApplyingId(preset.id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${mobileRuntime.tradingApiUrl}/profiles`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'x-request-id': createRequestId('mobile-use-strategy'),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: `${preset.name} (Mobile)`,
|
||||||
|
user_id: user.id,
|
||||||
|
allocated_capital: 1000,
|
||||||
|
risk_per_trade_percent: 1,
|
||||||
|
symbols: (preset.recommended_assets || []).join(','),
|
||||||
|
is_active: false,
|
||||||
|
strategy_config: preset.strategy_config || {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((body as { error?: string }).error || `Failed to create profile (${res.status})`);
|
||||||
|
}
|
||||||
|
Alert.alert('Strategy Added', `"${preset.name}" has been added to your strategies. You can activate it from the Strategies tab.`, [
|
||||||
|
{ text: 'OK', onPress: () => router.back() },
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
Alert.alert('Error', e instanceof Error ? e.message : 'Failed to apply strategy');
|
||||||
|
} finally {
|
||||||
|
setApplyingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||||
@ -34,62 +112,75 @@ export default function MarketplaceScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
{loading ? (
|
||||||
style={styles.scroll}
|
<ActivityIndicator color={Colors.accent.green} style={{ marginTop: 40 }} />
|
||||||
contentContainerStyle={styles.content}
|
) : (
|
||||||
showsVerticalScrollIndicator={false}
|
<ScrollView
|
||||||
>
|
style={styles.scroll}
|
||||||
{marketplacePresets.map((preset, index) => {
|
contentContainerStyle={styles.content}
|
||||||
const risk = RISK_COLORS[preset.risk];
|
showsVerticalScrollIndicator={false}
|
||||||
return (
|
>
|
||||||
<AnimatedCard key={preset.id} index={index} style={[Shadows.card, { borderRadius: 28 }]}>
|
{presets.length === 0 ? (
|
||||||
<PressableScale style={styles.cardOuter}>
|
<View style={styles.emptyState}>
|
||||||
<View style={styles.card}>
|
<Text style={styles.emptyText}>No strategies available</Text>
|
||||||
{preset.isPopular && (
|
<Text style={styles.emptyHint}>Check back later or create a custom strategy from the web dashboard.</Text>
|
||||||
<View style={styles.popularBadge}>
|
</View>
|
||||||
<Text style={styles.popularText}>Popular</Text>
|
) : presets.map((preset, index) => {
|
||||||
</View>
|
const risk = RISK_COLORS[preset.risk_style_id] || RISK_COLORS.balanced;
|
||||||
)}
|
const isApplying = applyingId === preset.id;
|
||||||
|
return (
|
||||||
<Text style={styles.presetName}>{preset.name}</Text>
|
<AnimatedCard key={preset.id} index={index} style={[Shadows.card, { borderRadius: 28 }]}>
|
||||||
<Text style={styles.presetDesc}>{preset.description}</Text>
|
<PressableScale style={styles.cardOuter}>
|
||||||
|
<View style={styles.card}>
|
||||||
<View style={styles.metaRow}>
|
{preset.is_popular && (
|
||||||
<PillBadge label={risk.label} color={risk.color} bgColor={`${risk.color}20`} />
|
<View style={styles.popularBadge}>
|
||||||
<Text style={styles.tradesPerDay}>{preset.trades}</Text>
|
<Text style={styles.popularText}>Popular</Text>
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.assetPills}>
|
|
||||||
{preset.assets.map(a => (
|
|
||||||
<View key={a} style={styles.assetPill}>
|
|
||||||
<Text style={styles.assetText}>{a}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
)}
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.tagRow}>
|
<Text style={styles.presetName}>{preset.name}</Text>
|
||||||
<Text style={styles.tagText}>{preset.tag}</Text>
|
<Text style={styles.presetDesc}>{preset.description}</Text>
|
||||||
</View>
|
|
||||||
|
|
||||||
<PressableScale
|
<View style={styles.metaRow}>
|
||||||
haptic="medium"
|
<PillBadge label={risk.label} color={risk.color} bgColor={`${risk.color}20`} />
|
||||||
style={styles.ctaWrapper}
|
<Text style={styles.tradesPerDay}>{preset.typical_trades_per_day}</Text>
|
||||||
>
|
</View>
|
||||||
<LinearGradient
|
|
||||||
colors={['#00ff88', '#00cc6a']}
|
<View style={styles.assetPills}>
|
||||||
start={{ x: 0, y: 0 }}
|
{(preset.recommended_assets || []).map(a => (
|
||||||
end={{ x: 1, y: 1 }}
|
<View key={a} style={styles.assetPill}>
|
||||||
style={styles.ctaButton}
|
<Text style={styles.assetText}>{a}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.tagRow}>
|
||||||
|
<Text style={styles.tagText}>{preset.performance_tag}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<PressableScale
|
||||||
|
haptic="medium"
|
||||||
|
style={[styles.ctaWrapper, isApplying && styles.ctaDisabled]}
|
||||||
|
onPress={() => void handleUseStrategy(preset)}
|
||||||
>
|
>
|
||||||
<Text style={styles.ctaText}>USE STRATEGY</Text>
|
<LinearGradient
|
||||||
</LinearGradient>
|
colors={isApplying ? ['#333', '#222'] : ['#00ff88', '#00cc6a']}
|
||||||
</PressableScale>
|
start={{ x: 0, y: 0 }}
|
||||||
</View>
|
end={{ x: 1, y: 1 }}
|
||||||
</PressableScale>
|
style={styles.ctaButton}
|
||||||
</AnimatedCard>
|
>
|
||||||
);
|
<Text style={[styles.ctaText, isApplying && { color: '#666' }]}>
|
||||||
})}
|
{isApplying ? 'ADDING...' : 'USE STRATEGY'}
|
||||||
</ScrollView>
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</PressableScale>
|
||||||
|
</View>
|
||||||
|
</PressableScale>
|
||||||
|
</AnimatedCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -214,6 +305,9 @@ const styles = StyleSheet.create({
|
|||||||
shadowRadius: 36,
|
shadowRadius: 36,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
},
|
},
|
||||||
|
ctaDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
ctaButton: {
|
ctaButton: {
|
||||||
height: 56,
|
height: 56,
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
@ -226,4 +320,23 @@ const styles = StyleSheet.create({
|
|||||||
color: '#000',
|
color: '#000',
|
||||||
letterSpacing: 2,
|
letterSpacing: 2,
|
||||||
},
|
},
|
||||||
|
emptyState: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontFamily: Fonts.inter.bold,
|
||||||
|
fontSize: FontSize.subheading,
|
||||||
|
color: Colors.text.secondary,
|
||||||
|
},
|
||||||
|
emptyHint: {
|
||||||
|
fontFamily: Fonts.inter.medium,
|
||||||
|
fontSize: FontSize.body,
|
||||||
|
color: Colors.text.muted,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 260,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -51,9 +51,7 @@ function TabItem({
|
|||||||
/>
|
/>
|
||||||
{isFocused && <View style={styles.glowDot} />}
|
{isFocused && <View style={styles.glowDot} />}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{isFocused && (
|
<Text style={[styles.activeLabel, !isFocused && styles.inactiveLabel]}>{label}</Text>
|
||||||
<Text style={styles.activeLabel}>{label}</Text>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -128,4 +126,8 @@ const styles = StyleSheet.create({
|
|||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
},
|
},
|
||||||
|
inactiveLabel: {
|
||||||
|
color: Colors.text.secondary,
|
||||||
|
fontFamily: Fonts.inter.medium,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -127,7 +127,21 @@ interface TradingDataContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TradingDataContext = createContext<TradingDataContextValue | null>(null);
|
const TradingDataContext = createContext<TradingDataContextValue | null>(null);
|
||||||
const tradingSocketUrl = mobileRuntime.tradingApiUrl.replace(/\/api$/, '');
|
|
||||||
|
function deriveSocketParams(tradingApiUrl: string): { socketOrigin: string; socketPath: string } {
|
||||||
|
const envPath = process.env.EXPO_PUBLIC_SOCKET_PATH?.trim();
|
||||||
|
try {
|
||||||
|
const parsed = new URL(tradingApiUrl);
|
||||||
|
const prefix = parsed.pathname.replace(/\/api\/?$/, '');
|
||||||
|
const socketPath = envPath || (prefix && prefix !== '/' ? `${prefix}/socket.io` : '/socket.io');
|
||||||
|
return { socketOrigin: parsed.origin, socketPath };
|
||||||
|
} catch {
|
||||||
|
return { socketOrigin: tradingApiUrl.replace(/\/api$/, ''), socketPath: envPath || '/socket.io' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { socketOrigin: tradingSocketOrigin, socketPath: tradingSocketPath } =
|
||||||
|
deriveSocketParams(mobileRuntime.tradingApiUrl);
|
||||||
|
|
||||||
const EMPTY_STATE: TradingPortfolioSummary = {
|
const EMPTY_STATE: TradingPortfolioSummary = {
|
||||||
netPnl: 0,
|
netPnl: 0,
|
||||||
@ -226,7 +240,7 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
socket = io(tradingSocketUrl, buildTradingSocketOptions(accessToken));
|
socket = io(tradingSocketOrigin, buildTradingSocketOptions(accessToken, tradingSocketPath));
|
||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export const buildChatApplyPayload = (
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { user, profile, loading, signOut } = useAuth();
|
const { user, profile, loading, signOut } = useAuth();
|
||||||
const { botState, connected } = useWebSocket(tradingRuntime.tradingApiUrl);
|
const { socket, botState, connected } = useWebSocket(tradingRuntime.tradingApiUrl);
|
||||||
const [activeTab, setActiveTab] = useState('overview');
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
const [wizardSeed, setWizardSeed] = useState<any>(null);
|
const [wizardSeed, setWizardSeed] = useState<any>(null);
|
||||||
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
|
const [chatProfiles, setChatProfiles] = useState<any[]>([]);
|
||||||
@ -177,7 +177,7 @@ function App() {
|
|||||||
case 'overview': return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
case 'overview': return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
||||||
case 'signals':
|
case 'signals':
|
||||||
if (!isAdmin) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
if (!isAdmin) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
||||||
return <SignalsTab botState={botState} />;
|
return <SignalsTab botState={botState} connected={connected} />;
|
||||||
case 'entries':
|
case 'entries':
|
||||||
if (!isAdmin) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
if (!isAdmin) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
||||||
return <EntriesTab botState={botState} />;
|
return <EntriesTab botState={botState} />;
|
||||||
@ -207,7 +207,7 @@ function App() {
|
|||||||
case 'settings': return <SettingsTab botState={botState} />;
|
case 'settings': return <SettingsTab botState={botState} />;
|
||||||
case 'admin':
|
case 'admin':
|
||||||
if (!isAdmin) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
if (!isAdmin) return <OverviewTab botState={botState} previewAsCustomer={previewAsCustomer} connected={connected} />;
|
||||||
return <AdminTab botState={botState} />;
|
return <AdminTab botState={botState} socket={socket} />;
|
||||||
default: return <OverviewTab botState={botState} connected={connected} />;
|
default: return <OverviewTab botState={botState} connected={connected} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -29,10 +29,10 @@ const toBoolean = (value: unknown, fallback: boolean = false): boolean => {
|
|||||||
export const isBacktestBuildEnabled = (): boolean => {
|
export const isBacktestBuildEnabled = (): boolean => {
|
||||||
const raw = import.meta.env.VITE_BACKTEST_ENABLED;
|
const raw = import.meta.env.VITE_BACKTEST_ENABLED;
|
||||||
if (raw === undefined || String(raw).trim() === '') {
|
if (raw === undefined || String(raw).trim() === '') {
|
||||||
// Default to enabled unless explicitly disabled at build-time.
|
// Default to disabled — backtest is not yet production-ready.
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
return toBoolean(raw, true);
|
return toBoolean(raw, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearBacktestRuntimeFlagCache = (): void => {
|
export const clearBacktestRuntimeFlagCache = (): void => {
|
||||||
|
|||||||
@ -108,6 +108,10 @@ export function EntryForm({ onSuccess, initialData }: EntryFormProps) {
|
|||||||
|
|
||||||
// --- 🚀 REAL TRADE EXECUTION ---
|
// --- 🚀 REAL TRADE EXECUTION ---
|
||||||
if (formData.execute_order && !initialData) {
|
if (formData.execute_order && !initialData) {
|
||||||
|
if (!Number.isFinite(payload.quantity) || !payload.symbol.trim()) {
|
||||||
|
alert('Symbol and a valid quantity are required to execute a trade.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Determine side (Buy if buying, Sell if selling - simplistic for now assuming Entry = Buy)
|
// Determine side (Buy if buying, Sell if selling - simplistic for now assuming Entry = Buy)
|
||||||
// If closing, we handle differently (via close button usually), but here handle Entry.
|
// If closing, we handle differently (via close button usually), but here handle Entry.
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,10 @@ export function ProductAccessibilityGate({ children }: { children: ReactNode })
|
|||||||
|
|
||||||
setState({ status: 'available' });
|
setState({ status: 'available' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[ProductAccessibilityGate] Failed to evaluate kill switch.', error);
|
// Fail open — kill switch service being down should not block users.
|
||||||
|
// The kill switch is a safety net, not an auth gate; degraded availability
|
||||||
|
// is preferable to a hard block when the check service is unreachable.
|
||||||
|
console.warn('[ProductAccessibilityGate] Kill switch check failed, defaulting to available.', error);
|
||||||
if (active) {
|
if (active) {
|
||||||
setState({ status: 'available' });
|
setState({ status: 'available' });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -264,6 +264,26 @@ export const replaceSettingsUpdate = (prev: BotState, settings: BotState['settin
|
|||||||
settings
|
settings
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the Socket.IO server origin and path from a trading API URL.
|
||||||
|
* - Production: https://api.bytelyst.com/invttrdg/api
|
||||||
|
* → origin: https://api.bytelyst.com, path: /invttrdg/socket.io
|
||||||
|
* - Local dev: http://localhost:4018/api
|
||||||
|
* → origin: http://localhost:4018, path: /socket.io (default)
|
||||||
|
* VITE_SOCKET_PATH overrides the derived path when explicitly set.
|
||||||
|
*/
|
||||||
|
function deriveSocketParams(tradingApiUrl: string): { socketOrigin: string; socketPath: string } {
|
||||||
|
const envPath = (import.meta.env.VITE_SOCKET_PATH as string | undefined)?.trim();
|
||||||
|
try {
|
||||||
|
const parsed = new URL(tradingApiUrl);
|
||||||
|
const prefix = parsed.pathname.replace(/\/api\/?$/, ''); // e.g. '/invttrdg'
|
||||||
|
const socketPath = envPath || (prefix && prefix !== '/' ? `${prefix}/socket.io` : '/socket.io');
|
||||||
|
return { socketOrigin: parsed.origin, socketPath };
|
||||||
|
} catch {
|
||||||
|
return { socketOrigin: tradingApiUrl.replace(/\/api$/, ''), socketPath: envPath || '/socket.io' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useWebSocket = (url: string) => {
|
export const useWebSocket = (url: string) => {
|
||||||
const [socket, setSocket] = useState<Socket | null>(null);
|
const [socket, setSocket] = useState<Socket | null>(null);
|
||||||
const [botState, setBotState] = useState<BotState>(DEFAULT_BOT_STATE);
|
const [botState, setBotState] = useState<BotState>(DEFAULT_BOT_STATE);
|
||||||
@ -275,7 +295,8 @@ export const useWebSocket = (url: string) => {
|
|||||||
let newSocket: Socket | null = null;
|
let newSocket: Socket | null = null;
|
||||||
|
|
||||||
const connectSocket = async () => {
|
const connectSocket = async () => {
|
||||||
console.log('🔌 Attempting to connect to:', url);
|
const { socketOrigin, socketPath } = deriveSocketParams(url);
|
||||||
|
console.log('🔌 Attempting to connect to:', socketOrigin, 'path:', socketPath);
|
||||||
const token = await getPlatformAccessToken().catch(() => null);
|
const token = await getPlatformAccessToken().catch(() => null);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@ -284,9 +305,9 @@ export const useWebSocket = (url: string) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const socketOptions = buildTradingSocketOptions(token, import.meta.env.VITE_SOCKET_PATH);
|
const socketOptions = buildTradingSocketOptions(token, socketPath);
|
||||||
|
|
||||||
newSocket = io(url, socketOptions);
|
newSocket = io(socketOrigin, socketOptions);
|
||||||
|
|
||||||
newSocket.on('connect', () => {
|
newSocket.on('connect', () => {
|
||||||
console.log('✅ Connected to bot');
|
console.log('✅ Connected to bot');
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { getRuntimeEnvironment } from '../../../shared/runtime.js';
|
|
||||||
import { createTradingKillSwitchClient, createTradingWebTelemetry } from '../../../shared/platform-web.js';
|
import { createTradingKillSwitchClient, createTradingWebTelemetry } from '../../../shared/platform-web.js';
|
||||||
|
|
||||||
export const tradingRuntime = getRuntimeEnvironment('web');
|
// Read Vite env vars via import.meta.env (not process.env — Vite does not inject those in the browser bundle)
|
||||||
|
export const tradingRuntime = {
|
||||||
|
productId: (import.meta.env.VITE_PRODUCT_ID as string) || 'invttrdg',
|
||||||
|
platformApiUrl: (import.meta.env.VITE_PLATFORM_URL as string) || 'http://localhost:4003/api',
|
||||||
|
tradingApiUrl: (import.meta.env.VITE_TRADING_API_URL as string) || 'http://localhost:4018',
|
||||||
|
};
|
||||||
|
|
||||||
export const tradingKillSwitchClient = createTradingKillSwitchClient('web');
|
export const tradingKillSwitchClient = createTradingKillSwitchClient('web');
|
||||||
export const tradingTelemetry = createTradingWebTelemetry();
|
export const tradingTelemetry = createTradingWebTelemetry();
|
||||||
|
|||||||
@ -45,7 +45,7 @@ describe('AdminTab coverage', () => {
|
|||||||
|
|
||||||
it('denies access to non-admins', () => {
|
it('denies access to non-admins', () => {
|
||||||
authState.profile.role = 'user';
|
authState.profile.role = 'user';
|
||||||
render(<AdminTab botState={DUMMY_BOT_STATE} />);
|
render(<AdminTab botState={DUMMY_BOT_STATE} socket={socketMock as any} />);
|
||||||
expect(screen.getByText(/Access Denied/i)).toBeInTheDocument();
|
expect(screen.getByText(/Access Denied/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ describe('AdminTab coverage', () => {
|
|||||||
if (event === 'debug_log') logHandler = rb;
|
if (event === 'debug_log') logHandler = rb;
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<AdminTab botState={DUMMY_BOT_STATE} />);
|
render(<AdminTab botState={DUMMY_BOT_STATE} socket={socketMock as any} />);
|
||||||
|
|
||||||
// Switch to Debug tab to see logs
|
// Switch to Debug tab to see logs
|
||||||
const debugTabBtn = screen.getByRole('button', { name: /Debug/i });
|
const debugTabBtn = screen.getByRole('button', { name: /Debug/i });
|
||||||
|
|||||||
@ -11,15 +11,16 @@ import {
|
|||||||
Database, RefreshCcw, Heart, Info, XCircle
|
Database, RefreshCcw, Heart, Info, XCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { BotState } from '../hooks/useWebSocket';
|
import type { BotState } from '../hooks/useWebSocket';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import type { Socket } from 'socket.io-client';
|
||||||
import { useAuth } from '../components/AuthContext';
|
import { useAuth } from '../components/AuthContext';
|
||||||
import { tradingRuntime } from '../lib/runtime';
|
|
||||||
import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi';
|
import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi';
|
||||||
import { getPlatformAccessToken } from '../lib/authSession';
|
import { getPlatformAccessToken } from '../lib/authSession';
|
||||||
import { createRequestId } from '../../../shared/request-id.js';
|
import { createRequestId } from '../../../shared/request-id.js';
|
||||||
|
import { tradingRuntime } from '../lib/runtime';
|
||||||
|
|
||||||
interface AdminTabProps {
|
interface AdminTabProps {
|
||||||
botState: BotState;
|
botState: BotState;
|
||||||
|
socket: Socket | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ruleDescriptions: { [key: string]: { desc: string; category: string; icon: typeof Hexagon } } = {
|
const ruleDescriptions: { [key: string]: { desc: string; category: string; icon: typeof Hexagon } } = {
|
||||||
@ -41,9 +42,8 @@ const categoryColors: { [key: string]: string } = {
|
|||||||
'AI': '#10b981',
|
'AI': '#10b981',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminTab = ({ botState }: AdminTabProps) => {
|
export const AdminTab = ({ botState, socket }: AdminTabProps) => {
|
||||||
const { profile } = useAuth();
|
const { profile } = useAuth();
|
||||||
const { socket } = useWebSocket(tradingRuntime.tradingApiUrl);
|
|
||||||
const [subTab, setSubTab] = React.useState<'rules' | 'config' | 'debug' | 'health' | 'reconciliation'>('rules');
|
const [subTab, setSubTab] = React.useState<'rules' | 'config' | 'debug' | 'health' | 'reconciliation'>('rules');
|
||||||
const [debugLogs, setDebugLogs] = React.useState<any[]>([]);
|
const [debugLogs, setDebugLogs] = React.useState<any[]>([]);
|
||||||
const logEndRef = React.useRef<HTMLDivElement>(null);
|
const logEndRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@ -13,8 +13,8 @@ export const MarketplaceTab: React.FC<MarketplaceTabProps> = ({ onClone, botStat
|
|||||||
const symbols = Object.keys(botState.symbols);
|
const symbols = Object.keys(botState.symbols);
|
||||||
|
|
||||||
const aiSetups = symbols
|
const aiSetups = symbols
|
||||||
.filter(s => botState.symbols[s].rules['AIAnalysisRule']?.metadata?.confidence !== undefined)
|
.filter(s => botState.symbols[s]?.rules?.['AIAnalysisRule']?.metadata?.confidence !== undefined)
|
||||||
.sort((a, b) => (botState.symbols[b].rules['AIAnalysisRule']?.metadata?.confidence || 0) - (botState.symbols[a].rules['AIAnalysisRule']?.metadata?.confidence || 0))
|
.sort((a, b) => (botState.symbols[b]?.rules?.['AIAnalysisRule']?.metadata?.confidence || 0) - (botState.symbols[a]?.rules?.['AIAnalysisRule']?.metadata?.confidence || 0))
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
|
|
||||||
const topVolatile = [...symbols]
|
const topVolatile = [...symbols]
|
||||||
|
|||||||
@ -3,11 +3,16 @@ import type { BotState } from '../hooks/useWebSocket';
|
|||||||
|
|
||||||
interface SignalsTabProps {
|
interface SignalsTabProps {
|
||||||
botState: BotState;
|
botState: BotState;
|
||||||
|
connected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SignalsTab = ({ botState }: SignalsTabProps) => {
|
export const SignalsTab = ({ botState, connected }: SignalsTabProps) => {
|
||||||
const symbols = Object.keys(botState.symbols);
|
const symbols = Object.keys(botState.symbols);
|
||||||
|
|
||||||
|
const emptyMessage = connected === false
|
||||||
|
? 'Connecting to bot… signals will appear once the real-time feed is established.'
|
||||||
|
: 'No symbols available. Add symbols to your trading profiles to see live signals here.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="signals-tab">
|
<div className="signals-tab">
|
||||||
<div className="tab-header">
|
<div className="tab-header">
|
||||||
@ -21,7 +26,7 @@ export const SignalsTab = ({ botState }: SignalsTabProps) => {
|
|||||||
<SymbolCard key={symbol} symbol={symbol} data={botState.symbols[symbol]} />
|
<SymbolCard key={symbol} symbol={symbol} data={botState.symbols[symbol]} />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-state">No symbols configured. Check your .env file.</div>
|
<div className="empty-state">{emptyMessage}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -96,7 +96,7 @@ describe('dashboard tabs smoke coverage', () => {
|
|||||||
const emptyHtml = renderToStaticMarkup(
|
const emptyHtml = renderToStaticMarkup(
|
||||||
React.createElement(SignalsTab, { botState: emptyState })
|
React.createElement(SignalsTab, { botState: emptyState })
|
||||||
);
|
);
|
||||||
expect(emptyHtml).toContain('No symbols configured');
|
expect(emptyHtml).toContain('No symbols available');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders SettingsTab user config and bot status blocks', () => {
|
it('renders SettingsTab user config and bot status blocks', () => {
|
||||||
@ -129,7 +129,8 @@ describe('dashboard tabs smoke coverage', () => {
|
|||||||
|
|
||||||
const adminHtml = renderToStaticMarkup(
|
const adminHtml = renderToStaticMarkup(
|
||||||
React.createElement(AdminTab, {
|
React.createElement(AdminTab, {
|
||||||
botState: adminState
|
botState: adminState,
|
||||||
|
socket: null
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -148,4 +149,4 @@ describe('dashboard tabs smoke coverage', () => {
|
|||||||
);
|
);
|
||||||
expect(html).toContain('animate-spin');
|
expect(html).toContain('animate-spin');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user