learning_ai_invt_trdg/mobile/app/marketplace.tsx

343 lines
11 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, ScrollView, Pressable, StyleSheet, ActivityIndicator, Alert } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient';
import { ArrowLeft } from 'lucide-react-native';
import { Colors, Fonts, FontSize, BorderRadius, Shadows, Spacing } from '@/constants/theme';
import AnimatedCard from '@/components/AnimatedCard';
import PillBadge from '@/components/PillBadge';
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 }> = {
aggressive: { color: Colors.accent.orange, label: 'Aggressive' },
balanced: { color: Colors.accent.green, label: 'Balanced' },
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() {
const insets = useSafeAreaInsets();
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 (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.headerSection}>
<Pressable onPress={() => router.back()} style={styles.backBtn}>
<ArrowLeft size={22} color={Colors.text.primary} />
</Pressable>
<View>
<Text style={styles.sectionLabel}>MARKETPLACE</Text>
<Text style={styles.pageTitle}>
Strategy <Text style={{ color: Colors.accent.green }}>Marketplace</Text>
</Text>
</View>
</View>
{loading ? (
<ActivityIndicator color={Colors.accent.green} style={{ marginTop: 40 }} />
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{presets.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyText}>No strategies available</Text>
<Text style={styles.emptyHint}>Check back later or create a custom strategy from the web dashboard.</Text>
</View>
) : presets.map((preset, index) => {
const risk = RISK_COLORS[preset.risk_style_id] || RISK_COLORS.balanced;
const isApplying = applyingId === preset.id;
return (
<AnimatedCard key={preset.id} index={index} style={[Shadows.card, { borderRadius: 28 }]}>
<PressableScale style={styles.cardOuter}>
<View style={styles.card}>
{preset.is_popular && (
<View style={styles.popularBadge}>
<Text style={styles.popularText}>Popular</Text>
</View>
)}
<Text style={styles.presetName}>{preset.name}</Text>
<Text style={styles.presetDesc}>{preset.description}</Text>
<View style={styles.metaRow}>
<PillBadge label={risk.label} color={risk.color} bgColor={`${risk.color}20`} />
<Text style={styles.tradesPerDay}>{preset.typical_trades_per_day}</Text>
</View>
<View style={styles.assetPills}>
{(preset.recommended_assets || []).map(a => (
<View key={a} style={styles.assetPill}>
<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)}
>
<LinearGradient
colors={isApplying ? ['#333', '#222'] : ['#00ff88', '#00cc6a']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.ctaButton}
>
<Text style={[styles.ctaText, isApplying && { color: '#666' }]}>
{isApplying ? 'ADDING...' : 'USE STRATEGY'}
</Text>
</LinearGradient>
</PressableScale>
</View>
</PressableScale>
</AnimatedCard>
);
})}
</ScrollView>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.background.primary,
},
headerSection: {
padding: Spacing.screenPadding,
flexDirection: 'row',
alignItems: 'center',
gap: 14,
},
backBtn: {
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: Colors.background.card,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: Colors.border.default,
},
sectionLabel: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.micro,
color: Colors.accent.green,
letterSpacing: 4,
marginBottom: 4,
},
pageTitle: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.heading,
color: Colors.text.primary,
},
scroll: {
flex: 1,
},
content: {
padding: Spacing.screenPadding,
gap: 20,
paddingBottom: 60,
},
cardOuter: {
borderRadius: 28,
},
card: {
backgroundColor: Colors.background.card,
borderRadius: 28,
padding: 32,
borderWidth: 1,
borderColor: Colors.border.default,
gap: 14,
},
popularBadge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(0,255,136,0.05)',
borderWidth: 1,
borderColor: 'rgba(0,255,136,0.1)',
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 8,
},
popularText: {
fontFamily: Fonts.inter.bold,
fontSize: FontSize.badge,
color: Colors.accent.green,
},
presetName: {
fontFamily: Fonts.inter.black,
fontSize: 18,
color: Colors.text.primary,
},
presetDesc: {
fontFamily: Fonts.inter.medium,
fontSize: FontSize.body,
color: Colors.text.secondary,
lineHeight: 20,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
tradesPerDay: {
fontFamily: Fonts.mono.medium,
fontSize: FontSize.bodySmall,
color: Colors.text.secondary,
},
assetPills: {
flexDirection: 'row',
gap: 6,
flexWrap: 'wrap',
},
assetPill: {
backgroundColor: 'rgba(255,255,255,0.05)',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: BorderRadius.xs,
},
assetText: {
fontFamily: Fonts.mono.medium,
fontSize: FontSize.micro,
color: Colors.text.secondary,
},
tagRow: {
marginTop: 2,
},
tagText: {
fontFamily: Fonts.mono.bold,
fontSize: FontSize.body,
color: Colors.accent.green,
},
ctaWrapper: {
marginTop: 6,
borderRadius: 18,
shadowColor: 'rgba(0,255,136,0.4)',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 1,
shadowRadius: 36,
elevation: 8,
},
ctaDisabled: {
opacity: 0.6,
},
ctaButton: {
height: 56,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
ctaText: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.bodySmall,
color: '#000',
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,
},
});