"react": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
- "react-native-screens": "~4.16.0"
+ "react-native-screens": "~4.16.0",
+ "socket.io-client": "^4.8.3"
},
"devDependencies": {
"@types/react": "~19.1.0",
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"node": ">= 0.8"
}
},
+ "node_modules/engine.io-client": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
+ "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.18.3",
+ "xmlhttprequest-ssl": "~2.1.1"
+ }
+ },
+ "node_modules/engine.io-client/node_modules/ws": {
+ "version": "8.18.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/env-editor": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
"node": ">=8.0.0"
}
},
+ "node_modules/socket.io-client": {
+ "version": "4.8.3",
+ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
+ "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1",
+ "engine.io-client": "~6.6.1",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
+ "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.4.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"node": ">=8.0"
}
},
+ "node_modules/xmlhttprequest-ssl": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
+ "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
- "react-native-screens": "~4.16.0"
+ "react-native-screens": "~4.16.0",
+ "socket.io-client": "^4.8.3"
},
"devDependencies": {
"@types/react": "~19.1.0",
-import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
+import { View, Text, TouchableOpacity } from "react-native";
+import { ui } from "./ui/uiStyles";
type Props = {
onGoSettings: () => void;
};
+/**
+ * ActionsCard
+ * -----------
+ * Regroupe les actions rapides de l'utilisateur.
+ * Pour l'instant :
+ * - Voir stratégie (placeholder)
+ * - Historique (placeholder)
+ * - Paramètres (navigation réelle via callback)
+ */
export default function ActionsCard({ onGoSettings }: Props) {
return (
- <View style={styles.card}>
- <Text style={styles.sectionTitle}>Actions</Text>
+ <View style={ui.card}>
+ <Text style={ui.title}>Actions</Text>
- <View style={styles.row}>
- <TouchableOpacity style={styles.button}>
- <Text style={styles.buttonText}>Voir stratégie</Text>
+ <View style={[ui.rowBetween, { flexWrap: "wrap", gap: 8 }]}>
+ <TouchableOpacity style={ui.button} onPress={() => {}}>
+ <Text style={ui.buttonText}>Voir stratégie</Text>
</TouchableOpacity>
- <TouchableOpacity style={styles.button}>
- <Text style={styles.buttonText}>Historique</Text>
+ <TouchableOpacity style={ui.button} onPress={() => {}}>
+ <Text style={ui.buttonText}>Historique</Text>
</TouchableOpacity>
- <TouchableOpacity style={styles.button} onPress={onGoSettings}>
- <Text style={styles.buttonText}>Paramètres</Text>
+ <TouchableOpacity style={ui.button} onPress={onGoSettings}>
+ <Text style={ui.buttonText}>Paramètres</Text>
</TouchableOpacity>
</View>
</View>
);
-}
-
-const styles = StyleSheet.create({
- card: {
- borderWidth: 1,
- borderColor: "#aaa",
- padding: 12,
- marginBottom: 12,
- borderRadius: 6,
- },
- sectionTitle: {
- fontWeight: "bold",
- marginBottom: 6,
- fontSize: 16,
- },
- row: {
- flexDirection: "row",
- flexWrap: "wrap",
- gap: 8,
- },
- button: {
- backgroundColor: "#555",
- paddingHorizontal: 10,
- paddingVertical: 10,
- borderRadius: 4,
- flexGrow: 1,
- flexBasis: "48%",
- alignItems: "center",
- },
- buttonText: {
- color: "#fff",
- fontWeight: "bold",
- },
-});
\ No newline at end of file
+}
\ No newline at end of file
-import { View, Text, StyleSheet } from "react-native";
+import { View, Text } from "react-native";
import type { UserSettings } from "../models/UserSettings";
+import { ui } from "./ui/uiStyles";
type Props = {
settings: UserSettings;
};
+/**
+ * WalletCard
+ * ----------
+ * Step 1 : placeholder portefeuille.
+ * Pour l'instant on affiche une valeur totale fixe (mock),
+ * mais on utilise déjà la devise des settings.
+ *
+ * Plus tard (Step 3) :
+ * - portefeuille multi-cryptos
+ * - valeur globale calculée
+ */
export default function WalletCard({ settings }: Props) {
+ const totalValue = 10_000; // mock (à remplacer plus tard)
+
return (
- <View style={styles.card}>
- <Text style={styles.sectionTitle}>Portefeuille</Text>
+ <View style={ui.card}>
+ <Text style={ui.title}>Portefeuille</Text>
- <View style={styles.priceRow}>
- <Text style={styles.value}>Valeur totale</Text>
- <Text style={styles.valueBold}>10 000 {settings.currency}</Text>
+ <View style={ui.rowBetween}>
+ <Text style={ui.value}>Valeur totale</Text>
+ <Text style={ui.valueBold}>
+ {totalValue.toLocaleString("fr-BE")} {settings.currency}
+ </Text>
</View>
- <Text style={styles.updated}>Step 1 : mono-utilisateur / mono-crypto</Text>
+ <Text style={[ui.muted, { marginTop: 10, textAlign: "center" }]}>
+ Step 1 : mono-utilisateur / mono-crypto
+ </Text>
</View>
);
-}
-
-const styles = StyleSheet.create({
- card: {
- borderWidth: 1,
- borderColor: "#aaa",
- padding: 12,
- marginBottom: 12,
- borderRadius: 6,
- },
- sectionTitle: {
- fontWeight: "bold",
- marginBottom: 6,
- fontSize: 16,
- },
- priceRow: {
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "center",
- marginVertical: 4,
- },
- value: {
- fontSize: 16,
- },
- valueBold: {
- fontSize: 16,
- fontWeight: "bold",
- },
- updated: {
- fontSize: 12,
- opacity: 0.6,
- marginTop: 6,
- textAlign: "center",
- },
-});
\ No newline at end of file
+}
\ No newline at end of file
--- /dev/null
+export const SERVER_URL = "http://192.168.129.121:3000";
\ No newline at end of file
| "auto";
export interface UserSettings {
+ userId?: string; // ✅ pour Socket.IO (optionnel, Step 4 sera l'auth)
currency: Currency;
favoriteSymbol: string;
alertPreference: AlertPreference;
-import {
- View,
- Text,
- StyleSheet,
- ScrollView,
- TouchableOpacity,
-} from "react-native";
+import { View, Text, StyleSheet, ScrollView } from "react-native";
import { useState, useCallback, useEffect } from "react";
import { SafeAreaView } from "react-native-safe-area-context";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
-import type {
- DashboardSummary,
- TradeDecision,
- AlertLevel,
-} from "../types/DashboardSummary";
+import type { DashboardSummary } from "../types/DashboardSummary";
import { fetchDashboardSummary } from "../services/dashboardService";
import { loadSettings } from "../utils/settingsStorage";
import type { UserSettings } from "../models/UserSettings";
+import MarketCard from "../components/MarketCard";
+import StrategyCard from "../components/StrategyCard";
+import WalletCard from "../components/WalletCard";
+import ActionsCard from "../components/ActionsCard";
+import { ui } from "../components/ui/uiStyles";
+
+// ✅ Socket.IO
+import { socketService } from "../services/socketService";
+import { SERVER_URL } from "../config/env";
+import type { Alert } from "../types/Alert";
+
/**
* DashboardScreen
- * --------------
- * Écran principal mobile (Step 1).
- * Rôle : afficher des données (consommateur API) :
- * - Marché (prix)
- * - Signal (BUY/SELL/HOLD/STOP_LOSS) + niveau d'alerte (CRITICAL/WARNING/INFO)
- * - Portefeuille (placeholder Step 1)
- * - Actions rapides (dont accès aux Paramètres)
+ * ----------------
+ * Écran principal mobile.
+ *
+ * Responsabilités :
+ * - Charger les données (mock/API REST)
+ * - Charger les paramètres utilisateur
+ * - Gérer le refresh automatique (settings.refreshMode)
+ * - Gérer la navigation
+ * - Recevoir les alertes temps réel via Socket.IO (non bloquant)
*
- * Spécificité :
- * - On recharge les données quand l'écran reprend le focus (retour depuis Settings).
- * - Si refreshMode === "auto", on rafraîchit le dashboard périodiquement.
+ * L'affichage "business" est délégué aux composants (Cards).
*/
export default function DashboardScreen() {
- // Données du dashboard (mock pour l'instant)
const [summary, setSummary] = useState<DashboardSummary | null>(null);
-
- // Paramètres utilisateur (AsyncStorage)
const [settings, setSettings] = useState<UserSettings | null>(null);
- // États UI (loading / erreur)
const [loading, setLoading] = useState<boolean>(true);
+
+ // erreurs REST/refresh
const [error, setError] = useState<string | null>(null);
- // Navigation (Stack)
+ // état Socket.IO (non bloquant)
+ const [socketConnected, setSocketConnected] = useState<boolean>(false);
+ const [socketError, setSocketError] = useState<string | null>(null);
+
+ // alertes reçues (optionnel mais utile)
+ const [liveAlerts, setLiveAlerts] = useState<Alert[]>([]);
+
const navigation = useNavigation();
/**
- * Charge dashboard + settings à chaque focus sur l'écran.
+ * Chargement initial + rechargement à chaque focus
* (utile quand on revient des Paramètres)
*/
useFocusEffect(
setSummary(dashboardData);
setSettings(userSettings);
}
- } catch (e) {
+ } catch {
if (isActive) {
setError("Impossible de charger le dashboard.");
}
);
/**
- * Rafraîchissement automatique si l'utilisateur l'a activé.
- * On ne relance pas le loading global (pour éviter que l'écran "clignote"),
- * on met juste à jour le summary.
+ * Refresh automatique si activé
*/
useEffect(() => {
- // Pas de settings => impossible de savoir le mode
if (!settings) return;
-
- // Mode manuel => pas d'interval
if (settings.refreshMode !== "auto") return;
let cancelled = false;
const data = await fetchDashboardSummary();
if (!cancelled) setSummary(data);
} catch {
- // On évite de spammer l'utilisateur : on peut juste logguer
- // ou afficher une erreur discrète.
if (!cancelled) setError("Erreur lors du rafraîchissement automatique.");
}
- }, 5000); // toutes les 5 secondes
+ }, 5000);
return () => {
cancelled = true;
};
}, [settings]);
- // UI : Chargement
+ /**
+ * Socket.IO : connexion / écoute des alertes
+ * - Non bloquant : si socket KO, l'app continue via REST
+ * - Se déclenche quand on a settings (souvent nécessaire pour userId)
+ *
+ * IMPORTANT :
+ * 👉 Version propre : on n'utilise PAS @ts-expect-error.
+ * 👉 On lit un userId optionnel (si présent), sinon on désactive le socket
+ * et on affiche un message clair.
+ */
+ useEffect(() => {
+ if (!settings) return;
+
+ setSocketError(null);
+
+ // ✅ Proposition propre : un champ optionnel dans UserSettings : userId?: string
+ // Si pas dispo => on ne connecte pas (pas de fake "user-123" en prod)
+ const userId = settings.userId;
+
+ if (!userId) {
+ setSocketConnected(false);
+ setSocketError("Socket désactivé : userId absent (pas connecté).");
+ return;
+ }
+
+ try {
+ socketService.connect(SERVER_URL, userId);
+ setSocketConnected(true);
+ } catch {
+ setSocketConnected(false);
+ setSocketError("Socket: impossible de se connecter (URL/userId).");
+ return;
+ }
+
+ const unsubscribeAlert = socketService.onAlert((alert) => {
+ setLiveAlerts((prev) => [alert, ...prev].slice(0, 50));
+ });
+
+ // ping optionnel pour debug
+ socketService.ping();
+
+ return () => {
+ unsubscribeAlert();
+ socketService.disconnect();
+ setSocketConnected(false);
+ };
+ }, [settings]);
+
+ // Chargement
if (loading) {
return (
- <View style={styles.container}>
+ <View style={styles.centered}>
<Text>Chargement du dashboard…</Text>
</View>
);
}
- // UI : Erreur (erreur "bloquante")
+ // Erreur bloquante
if (error && (!summary || !settings)) {
return (
- <View style={styles.container}>
+ <View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
- // Fallback sécurité
+ // Sécurité
if (!summary || !settings) {
return (
- <View style={styles.container}>
+ <View style={styles.centered}>
<Text>Initialisation…</Text>
</View>
);
}
- /**
- * Helpers d'affichage (couleurs) pour respecter le contrat mobile
- */
- const getDecisionColor = (decision: TradeDecision) => {
- switch (decision) {
- case "BUY":
- return "#16a34a";
- case "SELL":
- return "#dc2626";
- case "STOP_LOSS":
- return "#991b1b";
- case "HOLD":
- default:
- return "#ca8a04";
- }
- };
-
- const getAlertColor = (level: AlertLevel) => {
- switch (level) {
- case "CRITICAL":
- return "#b91c1c";
- case "WARNING":
- return "#ca8a04";
- case "INFO":
- default:
- return "#2563eb";
- }
- };
-
return (
<SafeAreaView style={styles.safeArea}>
<ScrollView contentContainerStyle={styles.container}>
- {/* Petit warning non bloquant si l'auto-refresh a eu un souci */}
+ {/* Warning REST/refresh (non bloquant) */}
{error && (
- <View style={styles.warningBanner}>
+ <View style={styles.warningCard}>
<Text style={styles.warningText}>{error}</Text>
</View>
)}
- {/* Carte Marché */}
- <View style={styles.card}>
- <Text style={styles.sectionTitle}>Marché</Text>
-
- <Text style={styles.price}>{summary.pair}</Text>
-
- <View style={styles.priceRow}>
- <Text style={styles.value}>Prix actuel</Text>
- <Text style={styles.valueBold}>
- {summary.price.toFixed(2)} {settings.currency}
- </Text>
- </View>
-
- <Text style={styles.updated}>
- Dernière mise à jour : {new Date(summary.timestamp).toLocaleString()}
- </Text>
- </View>
-
- {/* Carte Stratégie + Signal */}
- <View style={styles.card}>
- <Text style={styles.sectionTitle}>Stratégie</Text>
-
- <Text style={styles.valueBold}>{summary.strategy}</Text>
-
- <Text
- style={[
- styles.decision,
- { color: getDecisionColor(summary.decision) },
- ]}
- >
- {summary.decision}
+ {/* Socket status (non bloquant) */}
+ <View style={styles.socketCard}>
+ <Text style={styles.socketTitle}>
+ Socket.IO : {socketConnected ? "connecté ✅" : "déconnecté ⚠️"}
</Text>
- <Text
- style={[
- styles.alertLevel,
- { color: getAlertColor(summary.alertLevel) },
- ]}
- >
- {summary.alertLevel}
- </Text>
+ {!!socketError && <Text style={styles.socketMuted}>{socketError}</Text>}
- <View style={styles.priceRow}>
- <Text style={styles.value}>Confiance</Text>
- <Text style={styles.valueBold}>
- {(summary.confidence * 100).toFixed(1)} %
- </Text>
- </View>
-
- <Text style={styles.reason}>{summary.reason}</Text>
+ <Text style={styles.socketMuted}>Alertes reçues : {liveAlerts.length}</Text>
</View>
- {/* Carte Portefeuille (placeholder Step 1) */}
- <View style={styles.card}>
- <Text style={styles.sectionTitle}>Portefeuille</Text>
+ <MarketCard summary={summary} settings={settings} />
+ <StrategyCard summary={summary} />
+ <WalletCard settings={settings} />
- <View style={styles.priceRow}>
- <Text style={styles.value}>Valeur totale</Text>
- <Text style={styles.valueBold}>10 000 {settings.currency}</Text>
- </View>
-
- <Text style={styles.updated}>Step 1 : mono-utilisateur / mono-crypto</Text>
- </View>
-
- {/* Carte Actions */}
- <View style={styles.card}>
- <Text style={styles.sectionTitle}>Actions</Text>
-
- <View style={styles.row}>
- <TouchableOpacity style={styles.button}>
- <Text style={styles.buttonText}>Voir stratégie</Text>
- </TouchableOpacity>
-
- <TouchableOpacity style={styles.button}>
- <Text style={styles.buttonText}>Historique</Text>
- </TouchableOpacity>
-
- <TouchableOpacity
- style={styles.button}
- onPress={() => navigation.navigate("Settings" as never)}
- >
- <Text style={styles.buttonText}>Paramètres</Text>
- </TouchableOpacity>
- </View>
- </View>
+ <ActionsCard onGoSettings={() => navigation.navigate("Settings" as never)} />
</ScrollView>
</SafeAreaView>
);
const styles = StyleSheet.create({
safeArea: {
flex: 1,
- backgroundColor: "#fff",
+ backgroundColor: ui.screen.backgroundColor,
},
container: {
padding: 16,
},
- card: {
- borderWidth: 1,
- borderColor: "#aaa",
- padding: 12,
- marginBottom: 12,
- borderRadius: 6,
- },
-
- sectionTitle: {
- fontWeight: "bold",
- marginBottom: 6,
- fontSize: 16,
- },
-
- price: {
- fontSize: 26,
- fontWeight: "bold",
- textAlign: "center",
- marginVertical: 6,
- },
-
- decision: {
- fontSize: 32,
- fontWeight: "bold",
- textAlign: "center",
- marginVertical: 10,
- },
-
- alertLevel: {
- textAlign: "center",
- fontWeight: "bold",
- marginBottom: 6,
- },
-
- row: {
- flexDirection: "row",
- flexWrap: "wrap",
- gap: 8,
- },
-
- priceRow: {
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "center",
- marginVertical: 4,
- },
-
- value: {
- fontSize: 16,
- },
-
- valueBold: {
- fontSize: 16,
- fontWeight: "bold",
- },
-
- reason: {
- marginTop: 6,
- fontStyle: "italic",
- },
-
- button: {
- backgroundColor: "#555",
- paddingHorizontal: 10,
- paddingVertical: 10,
- borderRadius: 4,
- flexGrow: 1,
- flexBasis: "48%",
+ centered: {
+ flex: 1,
+ justifyContent: "center",
alignItems: "center",
- },
-
- buttonText: {
- color: "#fff",
- fontWeight: "bold",
- },
-
- updated: {
- fontSize: 12,
- opacity: 0.6,
- marginTop: 6,
- textAlign: "center",
+ backgroundColor: ui.screen.backgroundColor,
},
errorText: {
color: "#dc2626",
- fontWeight: "bold",
+ fontWeight: "800",
},
- warningBanner: {
+ // Bannières en style "card" comme les mockups
+ warningCard: {
+ ...ui.card,
borderWidth: 1,
borderColor: "#ca8a04",
- padding: 10,
- borderRadius: 6,
- marginBottom: 12,
},
warningText: {
color: "#ca8a04",
- fontWeight: "bold",
+ fontWeight: "800",
+ },
+
+ socketCard: {
+ ...ui.card,
+ borderWidth: 1,
+ borderColor: "#cbd5e1",
+ },
+
+ socketTitle: {
+ fontWeight: "800",
+ color: "#0f172a",
+ marginBottom: 6,
+ },
+
+ socketMuted: {
+ ...ui.muted,
+ marginTop: 2,
},
});
\ No newline at end of file
--- /dev/null
+import { io, Socket } from "socket.io-client";
+import type { Alert } from "../types/Alert";
+
+class SocketService {
+ private socket: Socket | null = null;
+ private listeners = new Set<(alert: Alert) => void>();
+
+ connect(serverUrl: string, userId: string) {
+ if (!serverUrl) throw new Error("serverUrl is required");
+ if (!userId) throw new Error("userId is required");
+
+ // évite d'ouvrir plusieurs connexions si connect() est appelé plusieurs fois
+ if (this.socket?.connected) return;
+
+ this.socket = io(serverUrl, {
+ transports: ["websocket", "polling"], // plus fiable sur mobile
+ reconnection: true,
+ reconnectionAttempts: 5,
+ timeout: 10000,
+ });
+
+ this.socket.on("connect", () => {
+ console.log("✅ Socket connecté:", this.socket?.id);
+ // tuto de ton camarade : auth via userId
+ this.socket?.emit("auth", userId);
+ });
+
+ this.socket.on("auth_success", (data: any) => {
+ console.log("✅ Auth success:", data?.message ?? data);
+ });
+
+ this.socket.on("alert", (alert: Alert) => {
+ for (const cb of this.listeners) cb(alert);
+ });
+
+ this.socket.on("connect_error", (err) => {
+ console.log("❌ Socket connect_error:", err?.message ?? err);
+ });
+
+ this.socket.on("disconnect", (reason) => {
+ console.log("⚠️ Socket disconnect:", reason);
+ });
+ }
+
+ onAlert(callback: (alert: Alert) => void) {
+ this.listeners.add(callback);
+ return () => this.listeners.delete(callback);
+ }
+
+ ping() {
+ this.socket?.emit("ping_alerts");
+ }
+
+ onPong(callback: (data: any) => void) {
+ this.socket?.on("pong_alerts", callback);
+ return () => this.socket?.off("pong_alerts", callback);
+ }
+
+ disconnect() {
+ if (!this.socket) return;
+ this.socket.removeAllListeners();
+ this.socket.disconnect();
+ this.socket = null;
+ this.listeners.clear();
+ }
+}
+
+export const socketService = new SocketService();
\ No newline at end of file
--- /dev/null
+export interface Alert {
+ action: "BUY" | "SELL" | "HOLD";
+ pair: string; // ex: "BTC/EUR"
+ confidence: number; // 0..1
+ reason: string;
+ alertLevel: "INFO" | "WARNING" | "CRITICAL";
+ timestamp: number; // Unix ms
+ price?: number;
+}
\ No newline at end of file