343 lines
11 KiB
TypeScript
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,
|
|
},
|
|
});
|