]> git.digitality.be Git - pdw25-26/commitdiff
Mobile : update thibaud-mobile
authorThibaud Moustier <thibaudmoustier0@gmail.com>
Sat, 28 Feb 2026 17:37:55 +0000 (18:37 +0100)
committerThibaud Moustier <thibaudmoustier0@gmail.com>
Sat, 28 Feb 2026 17:37:55 +0000 (18:37 +0100)
Wallette/mobile/src/screens/DashboardScreen.tsx
Wallette/mobile/src/services/api/alertsApi.ts
Wallette/mobile/src/services/api/authApi.ts
Wallette/mobile/src/services/api/http.ts
Wallette/mobile/src/services/api/priceApi.ts
Wallette/mobile/src/services/api/walletApi.ts
Wallette/mobile/src/services/socketService.ts
Wallette/mobile/src/types/Alert.ts

index 2aeeca9df0a7444a42f80c48f7090b6444010685..6c8c0b06a3fdf2f5e12843ce859ca6256f2f888f 100644 (file)
+import React, { useCallback, useEffect, useMemo, useState } from "react";
 import {
   View,
   Text,
   StyleSheet,
-  ScrollView,
   TouchableOpacity,
-  useWindowDimensions,
+  ScrollView,
+  TextInput,
+  Modal,
+  KeyboardAvoidingView,
+  Platform,
+  ActivityIndicator,
+  Alert as RNAlert,
 } from "react-native";
-import { useState, useCallback, useEffect, useMemo } from "react";
+import AsyncStorage from "@react-native-async-storage/async-storage";
 import { SafeAreaView } from "react-native-safe-area-context";
 import { useFocusEffect, useNavigation } from "@react-navigation/native";
-import { Ionicons } from "@expo/vector-icons";
 
-import type { DashboardSummary } from "../types/DashboardSummary";
-
-/**
- * ✅ Façade Dashboard (API-ready)
- * - aujourd'hui : ce fichier peut encore être mock si ton dashboardApi l'est
- * - demain : REST via gateway (/api/...)
- */
-import { getDashboardSummary } from "../services/api/dashboardApi";
-
-import { loadSettings } from "../utils/settingsStorage";
+import type { PortfolioState, PortfolioAsset } from "../models/Portfolio";
 import type { UserSettings } from "../models/UserSettings";
+import type { Alert as AlertType } from "../types/Alert";
 
-import { ui } from "../components/ui/uiStyles";
+import { loadSession } from "../utils/sessionStorage";
+import { loadSettings } from "../utils/settingsStorage";
+import { loadPortfolio, savePortfolio } from "../utils/portfolioStorage";
 
-import { socketService } from "../services/socketService";
 import { SERVER_URL } from "../config/env";
-import type { Alert } from "../types/Alert";
+import { socketService } from "../services/socketService";
 import { alertStore } from "../services/alertStore";
 import { showAlertNotification } from "../services/notificationService";
 
-import { loadPortfolio } from "../utils/portfolioStorage";
-import type { PortfolioState, PortfolioAsset } from "../models/Portfolio";
-
-import { loadSession } from "../utils/sessionStorage";
-
-/**
- * ✅ API-only (price)
- */
 import { getCurrentPrice } from "../services/api/priceApi";
+import { getAlertHistory } from "../services/api/alertsApi";
+import { getPortfolio as getPortfolioFromApi } from "../services/api/walletApi";
 
 /**
- * DashboardScreen — Step 4
- * ------------------------
- * - Multi-users : userId vient de la session (AsyncStorage)
- * - Settings/Portfolio sont déjà "scopés" par userId (via storages)
- * - Socket.IO auth utilise userId de session
+ * DashboardScreen (aligné Web)
+ * ---------------------------
+ * Web consomme :
+ * - GET /api/prices/current?pair=BTC/EUR  -> { price }
+ * - GET /api/wallet/:userId              -> { balance }
+ * - GET /api/alerts/history?userId=...   -> Alert[]
  *
- * IMPORTANT :
- * - Les prix (portfolio) sont chargés via API-only (pas de mock).
- * - Pour performance : on charge les prix du TOP 3 (affichage "valeur partielle").
+ * Mobile :
+ * - UI identique dans l’esprit (crypto + adresse + buy/sell)
+ * - "Acheter/Vendre" = simulation (local) -> pas de trading réel
  */
+
+type CryptoSymbol = "BTC" | "ETH" | "LTC";
+type TradeSide = "BUY" | "SELL";
+const CRYPTOS: CryptoSymbol[] = ["BTC", "ETH", "LTC"];
+
+function walletAddressKey(userId: string) {
+  return `walletAddress:${userId}`;
+}
+
+function normalizeSymbol(s: string) {
+  return s.trim().toUpperCase();
+}
+
+function mergePortfolios(base: PortfolioState, patch: PortfolioState): PortfolioState {
+  // patch écrase base sur les symboles présents dans patch
+  const map = new Map<string, number>();
+
+  for (const a of base.assets) map.set(normalizeSymbol(a.symbol), a.quantity);
+  for (const a of patch.assets) map.set(normalizeSymbol(a.symbol), a.quantity);
+
+  const assets: PortfolioAsset[] = Array.from(map.entries())
+    .filter(([, qty]) => Number.isFinite(qty) && qty > 0)
+    .map(([symbol, quantity]) => ({ symbol, quantity }))
+    .sort((a, b) => a.symbol.localeCompare(b.symbol));
+
+  return { assets, updatedAtMs: Date.now() };
+}
+
 export default function DashboardScreen() {
-  const { height } = useWindowDimensions();
-  const compact = height < 760;
+  const navigation = useNavigation();
 
-  const [summary, setSummary] = useState<DashboardSummary | null>(null);
+  // session / settings
+  const [userId, setUserId] = useState<string | null>(null);
   const [settings, setSettings] = useState<UserSettings | null>(null);
-  const [portfolio, setPortfolio] = useState<PortfolioState | null>(null);
 
-  const [loading, setLoading] = useState<boolean>(true);
-  const [error, setError] = useState<string | null>(null);
+  // dashboard data
+  const [selectedCrypto, setSelectedCrypto] = useState<CryptoSymbol>("BTC");
+  const [currentPrice, setCurrentPrice] = useState<number | null>(null);
+  const [lastPriceUpdateMs, setLastPriceUpdateMs] = useState<number | null>(null);
 
+  const [portfolio, setPortfolio] = useState<PortfolioState>({ assets: [], updatedAtMs: Date.now() });
+
+  // wallet address
+  const [walletAddress, setWalletAddress] = useState<string>("");
+  const [walletAddressInfo, setWalletAddressInfo] = useState<string | null>(null);
+
+  // alerts
+  const [liveAlerts, setLiveAlerts] = useState<AlertType[]>([]);
   const [socketConnected, setSocketConnected] = useState<boolean>(false);
-  const [socketError, setSocketError] = useState<string | null>(null);
+  const [socketInfo, setSocketInfo] = useState<string | null>(null);
+
+  // loading / errors
+  const [loading, setLoading] = useState<boolean>(true);
+  const [softError, setSoftError] = useState<string | null>(null);
 
-  const [liveAlerts, setLiveAlerts] = useState<Alert[]>([]);
+  // modal trade
+  const [tradeOpen, setTradeOpen] = useState<boolean>(false);
+  const [tradeSide, setTradeSide] = useState<TradeSide>("BUY");
+  const [tradeQty, setTradeQty] = useState<string>("0.01");
+  const [tradeInfo, setTradeInfo] = useState<string | null>(null);
 
-  const [lastRefreshMs, setLastRefreshMs] = useState<number | null>(null);
-  const [refreshing, setRefreshing] = useState<boolean>(false);
+  const currency: "EUR" | "USD" = settings?.currency === "USD" ? "USD" : "EUR";
+  const pair = useMemo(() => `${selectedCrypto}/${currency}`, [selectedCrypto, currency]);
 
-  // ✅ Prix (API-only) pour TOP 3 assets
-  const [assetPrices, setAssetPrices] = useState<Record<string, number>>({});
-  const [assetPricesError, setAssetPricesError] = useState<string | null>(null);
+  const selectedQty = useMemo(() => {
+    const a = portfolio.assets.find((x) => normalizeSymbol(x.symbol) === selectedCrypto);
+    return a?.quantity ?? 0;
+  }, [portfolio, selectedCrypto]);
 
-  const navigation = useNavigation();
+  const urgentAlert = useMemo(() => {
+    if (liveAlerts.length === 0) return null;
 
-  /**
-   * Top 3 assets (par quantité, puis symbole — simple et défendable)
-   * NOTE: si tu veux une vraie logique "top valeur", il faut les prix de tous les assets.
-   */
-  const topAssets: PortfolioAsset[] = useMemo(() => {
-    if (!portfolio) return [];
-
-    const copy = [...portfolio.assets];
-    copy.sort((a, b) => b.quantity - a.quantity || a.symbol.localeCompare(b.symbol));
-    return copy.slice(0, 3);
-  }, [portfolio]);
-
-  const remainingCount = useMemo(() => {
-    if (!portfolio) return 0;
-    return Math.max(0, portfolio.assets.length - topAssets.length);
-  }, [portfolio, topAssets]);
-
-  /**
-   * Charge prix API-only pour les TOP 3 assets
-   */
-  const loadTopAssetPrices = useCallback(async (assets: PortfolioAsset[], currency: string) => {
-    setAssetPricesError(null);
-
-    if (assets.length === 0) {
-      setAssetPrices({});
-      return;
+    const critical = liveAlerts.find((a) => String(a.alertLevel).toUpperCase() === "CRITICAL");
+    if (critical) return critical;
+
+    const warning = liveAlerts.find((a) => String(a.alertLevel).toUpperCase() === "WARNING");
+    if (warning) return warning;
+
+    return liveAlerts[0];
+  }, [liveAlerts]);
+
+  const fetchPrice = useCallback(
+    async (p: string) => {
+      const res = await getCurrentPrice(p);
+      setCurrentPrice(res.price);
+      setLastPriceUpdateMs(res.timestampMs ?? Date.now());
+    },
+    []
+  );
+
+  const refreshAll = useCallback(async () => {
+    setSoftError(null);
+
+    const session = await loadSession();
+    const uid = session?.userId ?? null;
+    setUserId(uid);
+
+    const s = await loadSettings();
+    setSettings(s);
+
+    // Local portfolio (fallback + base)
+    const localPortfolio = await loadPortfolio();
+    setPortfolio(localPortfolio);
+
+    // Adresse wallet (local, par userId)
+    if (uid) {
+      const addr = (await AsyncStorage.getItem(walletAddressKey(uid))) ?? "";
+      setWalletAddress(addr);
+    } else {
+      setWalletAddress("");
     }
 
+    // Prix (API)
     try {
-      const entries = await Promise.all(
-        assets.map(async (a) => {
-          const pair = `${a.symbol}/${currency}`;
-          const cur = await getCurrentPrice(pair);
-          return [a.symbol, cur.price] as const;
-        })
-      );
-
-      const map: Record<string, number> = {};
-      for (const [sym, pr] of entries) map[sym] = pr;
-      setAssetPrices(map);
-    } catch (e: any) {
-      setAssetPrices({});
-      setAssetPricesError(e?.message ?? "Impossible de charger les prix (API).");
+      await fetchPrice(`${selectedCrypto}/${s.currency === "USD" ? "USD" : "EUR"}`);
+    } catch {
+      setSoftError("Prix indisponible pour le moment (API).");
     }
-  }, []);
 
-  /**
-   * Chargement initial (focus)
-   * - dashboard
-   * - settings
-   * - portfolio
-   * + prix (API-only) sur top 3
-   */
+    // Wallet (API) -> fusion pour ne pas écraser ETH/LTC locaux
+    if (uid) {
+      try {
+        const apiPortfolio = await getPortfolioFromApi();
+        const merged = mergePortfolios(localPortfolio, apiPortfolio);
+        setPortfolio(merged);
+        await savePortfolio(merged);
+      } catch {
+        // pas bloquant
+        setSoftError((prev) => prev ?? "Wallet indisponible pour le moment (API).");
+      }
+    }
+
+    // Alert history (API) + socket live ensuite
+    if (uid) {
+      try {
+        const history = await getAlertHistory();
+        // On met en store + state
+        for (const a of history) alertStore.add(a);
+        setLiveAlerts(history.slice(0, 50));
+      } catch {
+        // pas bloquant
+      }
+    }
+  }, [fetchPrice, selectedCrypto]);
+
   useFocusEffect(
     useCallback(() => {
-      let isActive = true;
+      let active = true;
 
-      async function loadData() {
+      (async () => {
         try {
-          setError(null);
           setLoading(true);
-
-          const [dashboardData, userSettings, portfolioData] = await Promise.all([
-            getDashboardSummary(),
-            loadSettings(),
-            loadPortfolio(),
-          ]);
-
-          if (!isActive) return;
-
-          setSummary(dashboardData);
-          setSettings(userSettings);
-          setPortfolio(portfolioData);
-          setLastRefreshMs(Date.now());
-
-          // prix API-only sur top 3
-          const copy = [...portfolioData.assets];
-          copy.sort((a, b) => b.quantity - a.quantity || a.symbol.localeCompare(b.symbol));
-          const top = copy.slice(0, 3);
-          await loadTopAssetPrices(top, userSettings.currency);
-        } catch {
-          if (isActive) setError("Impossible de charger le dashboard.");
+          await refreshAll();
         } finally {
-          if (isActive) setLoading(false);
+          if (active) setLoading(false);
         }
-      }
-
-      void loadData();
+      })();
 
       return () => {
-        isActive = false;
+        active = false;
       };
-    }, [loadTopAssetPrices])
+    }, [refreshAll])
   );
 
-  /**
-   * Refresh auto (si activé)
-   */
-  useEffect(() => {
-    if (!settings) return;
-    if (settings.refreshMode !== "auto") return;
-
-    let cancelled = false;
-
-    const intervalId = setInterval(async () => {
-      try {
-        const data = await getDashboardSummary();
-        if (!cancelled) {
-          setSummary(data);
-          setLastRefreshMs(Date.now());
-        }
-      } catch {
-        if (!cancelled) setError("Erreur lors du rafraîchissement automatique.");
-      }
-    }, 5000);
-
-    return () => {
-      cancelled = true;
-      clearInterval(intervalId);
-    };
-  }, [settings]);
-
-  /**
-   * Refresh manuel
-   */
-  const handleManualRefresh = async () => {
-    try {
-      setRefreshing(true);
-      const data = await getDashboardSummary();
-      setSummary(data);
-      setLastRefreshMs(Date.now());
-      setError(null);
-
-      // refresh prix top assets aussi (API-only)
-      if (settings) {
-        await loadTopAssetPrices(topAssets, settings.currency);
-      }
-    } catch {
-      setError("Erreur lors de l'actualisation.");
-    } finally {
-      setRefreshing(false);
-    }
-  };
-
-  /**
-   * Socket.IO (non bloquant)
-   * - userId = session.userId
-   */
+  // Socket : alert live (non bloquant)
   useEffect(() => {
     let unsub: null | (() => void) = null;
-    let active = true;
+    let alive = true;
 
-    async function initSocket() {
-      if (!settings) return;
-
-      setSocketError(null);
+    (async () => {
+      setSocketInfo(null);
+      setSocketConnected(false);
 
       const session = await loadSession();
-      const userId = session?.userId;
+      const uid = session?.userId;
 
-      if (!userId) {
-        setSocketConnected(false);
-        setSocketError("Socket désactivé : session absente.");
+      if (!uid) {
+        setSocketInfo("Socket désactivé : session absente.");
         return;
       }
 
       try {
-        socketService.connect(SERVER_URL, userId);
-        if (!active) return;
+        socketService.connect(SERVER_URL, uid);
+        if (!alive) return;
         setSocketConnected(true);
       } catch {
-        if (!active) return;
-        setSocketConnected(false);
-        setSocketError("Socket : paramètres invalides (URL ou userId).");
+        if (!alive) return;
+        setSocketInfo("Socket indisponible (URL ou serveur).");
         return;
       }
 
-      unsub = socketService.onAlert((alert) => {
-        alertStore.add(alert);
-        setLiveAlerts((prev) => [alert, ...prev].slice(0, 100));
+      unsub = socketService.onAlert((a) => {
+        alertStore.add(a);
+        setLiveAlerts((prev) => [a, ...prev].slice(0, 100));
 
-        if (settings.notificationsEnabled) {
+        // notifications si activées
+        if (settings?.notificationsEnabled) {
           void (async () => {
             try {
-              await showAlertNotification(alert);
-            } catch (e) {
-              console.log("⚠️ Notification error:", e);
+              await showAlertNotification(a);
+            } catch {
+              // silence
             }
           })();
         }
       });
 
+      // Optionnel
       socketService.ping();
-    }
-
-    void initSocket();
+    })();
 
     return () => {
-      active = false;
+      alive = false;
       if (unsub) unsub();
       socketService.disconnect();
       setSocketConnected(false);
     };
   }, [settings]);
 
-  /**
-   * Alerte "urgente" (priorité : CRITICAL > WARNING > INFO)
-   * Le dashboard n'affiche qu'une seule alerte (la plus importante).
-   */
-  const urgentAlert: Alert | null = useMemo(() => {
-    if (liveAlerts.length === 0) return null;
+  const handleSaveWalletAddress = useCallback(async () => {
+    setWalletAddressInfo(null);
 
-    const critical = liveAlerts.filter((a) => a.alertLevel === "CRITICAL");
-    if (critical.length > 0) return critical[0];
+    if (!userId) {
+      setWalletAddressInfo("Impossible : session absente.");
+      return;
+    }
 
-    const warning = liveAlerts.filter((a) => a.alertLevel === "WARNING");
-    if (warning.length > 0) return warning[0];
+    const trimmed = walletAddress.trim();
+    if (trimmed.length < 6) {
+      setWalletAddressInfo("Adresse trop courte. Vérifie la saisie.");
+      return;
+    }
 
-    return liveAlerts[0];
-  }, [liveAlerts]);
+    await AsyncStorage.setItem(walletAddressKey(userId), trimmed);
+    setWalletAddressInfo("Adresse enregistrée ✅");
+  }, [walletAddress, userId]);
 
-  /**
-   * Valeur portefeuille (partielle = top3), API-only
-   */
-  const portfolioPartialValue = useMemo(() => {
-    if (!portfolio) return null;
+  const openTrade = useCallback((side: TradeSide) => {
+    setTradeSide(side);
+    setTradeQty("0.01");
+    setTradeInfo(null);
+    setTradeOpen(true);
+  }, []);
 
-    let sum = 0;
-    let hasAny = false;
+  const confirmTrade = useCallback(async () => {
+    setTradeInfo(null);
 
-    for (const a of topAssets) {
-      const price = assetPrices[a.symbol];
-      if (typeof price !== "number") continue;
-      hasAny = true;
-      sum += a.quantity * price;
+    const qty = Number(tradeQty.replace(",", ".").trim());
+    if (!Number.isFinite(qty) || qty <= 0) {
+      setTradeInfo("Quantité invalide (ex: 0.01).");
+      return;
     }
 
-    return hasAny ? sum : null;
-  }, [portfolio, topAssets, assetPrices]);
+    const symbol = selectedCrypto;
+    const current = portfolio.assets.find((a) => normalizeSymbol(a.symbol) === symbol);
+    const currentQty = current?.quantity ?? 0;
 
-  if (loading) {
-    return (
-      <View style={ui.centered}>
-        <Text>Chargement du dashboard…</Text>
-      </View>
-    );
-  }
+    let nextQty = currentQty;
 
-  if (error && (!summary || !settings)) {
-    return (
-      <View style={ui.centered}>
-        <Text style={styles.errorText}>{error}</Text>
-      </View>
-    );
-  }
+    if (tradeSide === "BUY") {
+      nextQty = currentQty + qty;
+    } else {
+      if (qty > currentQty) {
+        setTradeInfo(`Vente impossible : tu n'as que ${currentQty.toFixed(6)} ${symbol}.`);
+        return;
+      }
+      nextQty = currentQty - qty;
+    }
+
+    const nextAssets = portfolio.assets
+      .filter((a) => normalizeSymbol(a.symbol) !== symbol)
+      .concat(nextQty > 0 ? [{ symbol, quantity: nextQty }] : [])
+      .sort((a, b) => normalizeSymbol(a.symbol).localeCompare(normalizeSymbol(b.symbol)));
 
-  if (!summary || !settings || !portfolio) {
+    const nextPortfolio: PortfolioState = {
+      assets: nextAssets,
+      updatedAtMs: Date.now(),
+    };
+
+    // Aujourd’hui : local (simulation)
+    await savePortfolio(nextPortfolio);
+    setPortfolio(nextPortfolio);
+
+    setTradeOpen(false);
+
+    RNAlert.alert("Transaction enregistrée ✅", `${tradeSide} ${qty} ${symbol}`);
+  }, [tradeQty, tradeSide, selectedCrypto, portfolio]);
+
+  const handleManualRefreshPrice = useCallback(async () => {
+    try {
+      setSoftError(null);
+      await fetchPrice(pair);
+    } catch {
+      setSoftError("Prix indisponible pour le moment (API).");
+    }
+  }, [fetchPrice, pair]);
+
+  if (loading) {
     return (
-      <View style={ui.centered}>
-        <Text>Initialisation…</Text>
+      <View style={styles.centered}>
+        <ActivityIndicator />
+        <Text style={{ marginTop: 10 }}>Chargement…</Text>
       </View>
     );
   }
 
-  const Chevron = () => (
-    <Ionicons
-      name="chevron-forward-outline"
-      size={18}
-      color="#0f172a"
-      style={{ opacity: 0.35 }}
-    />
-  );
-
   return (
     <SafeAreaView style={styles.safeArea}>
-      <ScrollView
-        contentContainerStyle={[ui.container, compact && styles.containerCompact]}
-        showsVerticalScrollIndicator={false}
-      >
-        {error && (
-          <View style={[ui.banner, styles.bannerWarning, compact && styles.bannerCompact]}>
-            <Text style={[ui.bannerText, styles.bannerWarningText]} numberOfLines={2}>
-              {error}
-            </Text>
+      <ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
+        {!!softError && (
+          <View style={styles.banner}>
+            <Text style={styles.bannerText}>{softError}</Text>
           </View>
         )}
 
-        {/* 1) CONSEILLER */}
-        <View style={[ui.card, compact && styles.cardCompact]}>
-          <Text style={[ui.title, compact && styles.titleCompact]}>Conseiller</Text>
-
-          <Text style={[ui.bigCenter, compact && styles.bigCompact]}>{summary.decision}</Text>
+        {/* COMPTE UTILISATEUR */}
+        <View style={styles.card}>
+          <View style={styles.rowBetween}>
+            <Text style={styles.title}>Compte utilisateur</Text>
+            <Text style={styles.muted}>{socketConnected ? "Socket: ON ✅" : "Socket: OFF ⚠️"}</Text>
+          </View>
 
-          <Text style={[ui.muted, styles.centerText]} numberOfLines={compact ? 2 : 3}>
-            Pourquoi ? {summary.reason}
+          <Text style={styles.muted}>
+            UserId : <Text style={styles.bold}>{userId ?? "—"}</Text>
           </Text>
 
-          <Text style={[ui.muted, { marginTop: compact ? 6 : 10 }]} numberOfLines={1}>
-            Stratégie : <Text style={styles.boldInline}>{settings.selectedStrategyKey}</Text>
-          </Text>
+          {!!socketInfo && <Text style={[styles.muted, { marginTop: 6 }]}>{socketInfo}</Text>}
+        </View>
 
-          <TouchableOpacity
-            style={[ui.button, styles.fullButton, compact && styles.buttonCompact]}
-            onPress={() => navigation.navigate("Strategy" as never)}
-          >
-            <Text style={ui.buttonText}>Sélectionner stratégie</Text>
+        {/* CHOIX CRYPTO + ADRESSE */}
+        <View style={styles.card}>
+          <Text style={styles.title}>Choisir une cryptomonnaie</Text>
+
+          <View style={styles.cryptoRow}>
+            {CRYPTOS.map((c) => {
+              const active = c === selectedCrypto;
+              return (
+                <TouchableOpacity
+                  key={c}
+                  style={[styles.cryptoBtn, active && styles.cryptoBtnActive]}
+                  onPress={async () => {
+                    setSelectedCrypto(c);
+                    try {
+                      await fetchPrice(`${c}/${currency}`);
+                    } catch {
+                      setSoftError("Prix indisponible pour le moment (API).");
+                    }
+                  }}
+                >
+                  <Text style={[styles.cryptoText, active && styles.cryptoTextActive]}>{c}</Text>
+                </TouchableOpacity>
+              );
+            })}
+          </View>
+
+          <Text style={[styles.muted, { marginTop: 12 }]}>Adresse du portefeuille</Text>
+          <TextInput
+            value={walletAddress}
+            onChangeText={(t) => {
+              setWalletAddressInfo(null);
+              setWalletAddress(t);
+            }}
+            placeholder="Entrez une adresse crypto"
+            style={styles.input}
+            autoCapitalize="none"
+            autoCorrect={false}
+          />
+
+          <TouchableOpacity style={styles.secondaryButton} onPress={handleSaveWalletAddress}>
+            <Text style={styles.secondaryButtonText}>Enregistrer l’adresse</Text>
           </TouchableOpacity>
+
+          {!!walletAddressInfo && <Text style={[styles.muted, { marginTop: 8 }]}>{walletAddressInfo}</Text>}
         </View>
 
-        {/* 2) PORTEFEUILLE */}
-        <TouchableOpacity activeOpacity={0.85} onPress={() => navigation.navigate("Wallet" as never)}>
-          <View style={[ui.card, compact && styles.cardCompact]}>
-            <View style={styles.headerRow}>
-              <Text style={[ui.title, compact && styles.titleCompact]}>Portefeuille</Text>
-              <Chevron />
-            </View>
+        {/* SOLDE + PRIX (comme le Web) */}
+        <View style={styles.rowGap}>
+          <View style={[styles.card, styles.half]}>
+            <Text style={styles.title}>Solde</Text>
+            <Text style={styles.bigValue}>
+              {selectedQty.toFixed(6)} {selectedCrypto}
+            </Text>
+            <Text style={styles.muted}>Source : wallet local (+ API BTC fusionné)</Text>
+          </View>
 
-            <View style={ui.rowBetween}>
-              <Text style={ui.value}>Valeur (top 3) :</Text>
-              <Text style={ui.valueBold}>
-                {portfolioPartialValue !== null ? `${portfolioPartialValue.toFixed(2)} ${settings.currency}` : "—"}
-              </Text>
+          <View style={[styles.card, styles.half]}>
+            <View style={styles.rowBetween}>
+              <Text style={styles.title}>Prix</Text>
+              <TouchableOpacity onPress={handleManualRefreshPrice} style={styles.refreshBtn}>
+                <Text style={styles.refreshText}>Actualiser</Text>
+              </TouchableOpacity>
             </View>
 
-            {!!assetPricesError && (
-              <Text style={[ui.muted, { marginTop: 6 }]} numberOfLines={2}>
-                ⚠️ {assetPricesError}
-              </Text>
-            )}
+            <Text style={styles.bigValue}>
+              {(currentPrice ?? 0).toFixed(2)} {currency}
+            </Text>
 
-            {portfolio.assets.length === 0 ? (
-              <Text style={[ui.muted, { marginTop: 8 }]}>
-                Aucun asset (ajoute BTC/ETH/SOL dans Portefeuille)
-              </Text>
-            ) : (
-              <View style={{ marginTop: 8 }}>
-                {topAssets.map((a) => (
-                  <View key={a.symbol} style={[ui.rowBetween, { marginTop: 4 }]}>
-                    <Text style={ui.muted}>{a.symbol}</Text>
-                    <Text style={ui.valueBold}>{a.quantity.toFixed(6)}</Text>
-                  </View>
-                ))}
-
-                {remainingCount > 0 && (
-                  <Text style={[ui.muted, { marginTop: 6 }]} numberOfLines={1}>
-                    +{remainingCount} autre(s) asset(s)
-                  </Text>
-                )}
-              </View>
-            )}
+            <Text style={styles.muted}>
+              Pair : {pair} — Maj : {lastPriceUpdateMs ? new Date(lastPriceUpdateMs).toLocaleTimeString() : "—"}
+            </Text>
           </View>
-        </TouchableOpacity>
+        </View>
 
-        {/* 3) URGENCE */}
-        <TouchableOpacity activeOpacity={0.85} onPress={() => navigation.navigate("Alerts" as never)}>
-          <View style={[ui.card, compact && styles.cardCompact]}>
-            <View style={styles.headerRow}>
-              <Text style={[ui.title, compact && styles.titleCompact]}>Alertes</Text>
-              <Chevron />
-            </View>
+        {/* SIGNAL (minimal) */}
+        <TouchableOpacity style={styles.card} onPress={() => navigation.navigate("Alerts" as never)} activeOpacity={0.85}>
+          <View style={styles.rowBetween}>
+            <Text style={styles.title}>Signal du marché</Text>
+            <Text style={styles.muted}>Ouvrir alertes</Text>
+          </View>
 
-            {urgentAlert ? (
-              <View style={[styles.urgentBox, compact && styles.urgentBoxCompact]}>
-                <Text style={styles.urgentTitle} numberOfLines={2}>
-                  {urgentAlert.alertLevel} : {urgentAlert.reason}
-                </Text>
-                <Text style={ui.muted} numberOfLines={1}>
-                  {urgentAlert.action} {urgentAlert.pair} — {(urgentAlert.confidence * 100).toFixed(0)}%
-                </Text>
-              </View>
-            ) : (
-              <Text style={ui.muted}>Aucune alerte pour le moment.</Text>
-            )}
+          {urgentAlert ? (
+            <>
+              <Text style={styles.signalAction}>{String(urgentAlert.action ?? "HOLD")}</Text>
+              <Text style={styles.muted} numberOfLines={2}>
+                {String(urgentAlert.alertLevel ?? "INFO")} — {String(urgentAlert.reason ?? urgentAlert.message ?? "—")}
+              </Text>
+              <Text style={styles.muted}>
+                {String(urgentAlert.pair ?? "")} — Confiance{" "}
+                {typeof urgentAlert.confidence === "number" ? `${Math.round(urgentAlert.confidence * 100)}%` : "—"}
+              </Text>
+            </>
+          ) : (
+            <Text style={styles.muted}>Aucune alerte pour le moment.</Text>
+          )}
+        </TouchableOpacity>
 
-            <Text style={[ui.muted, { marginTop: compact ? 6 : 8 }]} numberOfLines={1}>
-              Socket : {socketConnected ? "connecté ✅" : "déconnecté ⚠️"}
-              {socketError ? ` — ${socketError}` : ""}
-            </Text>
+        {/* STRATÉGIE (bouton) */}
+        <TouchableOpacity style={styles.card} onPress={() => navigation.navigate("Strategy" as never)} activeOpacity={0.85}>
+          <View style={styles.rowBetween}>
+            <Text style={styles.title}>Stratégie</Text>
+            <Text style={styles.muted}>Configurer</Text>
           </View>
+
+          <Text style={styles.muted} numberOfLines={2}>
+            (L’écran Stratégie reste inchangé — ici on ne fait que naviguer.)
+          </Text>
         </TouchableOpacity>
 
-        {/* 4) PRIX BTC */}
-        <TouchableOpacity activeOpacity={0.85} onPress={() => navigation.navigate("History" as never)}>
-          <View style={[ui.card, compact && styles.cardCompact]}>
-            <View style={styles.priceHeaderRow}>
-              <View style={styles.headerRow}>
-                <Text style={[ui.title, compact && styles.titleCompact]}>Prix BTC</Text>
-                <Chevron />
-              </View>
+        {/* ACTIONS (Acheter / Vendre) */}
+        <View style={styles.card}>
+          <Text style={styles.title}>Actions</Text>
 
-              <TouchableOpacity
-                style={[
-                  styles.refreshBtn,
-                  refreshing && styles.refreshBtnDisabled,
-                  compact && styles.refreshBtnCompact,
-                ]}
-                onPress={handleManualRefresh}
-                disabled={refreshing}
-              >
-                <Text style={styles.refreshBtnText}>{refreshing ? "…" : "Actualiser"}</Text>
-              </TouchableOpacity>
-            </View>
+          <View style={styles.buySellRow}>
+            <TouchableOpacity style={styles.buyBtn} onPress={() => openTrade("BUY")}>
+              <Text style={styles.buySellText}>Acheter</Text>
+            </TouchableOpacity>
 
-            <Text style={ui.muted} numberOfLines={1}>
-              Dernière maj : {lastRefreshMs ? new Date(lastRefreshMs).toLocaleTimeString() : "—"}
-            </Text>
+            <TouchableOpacity style={styles.sellBtn} onPress={() => openTrade("SELL")}>
+              <Text style={styles.buySellText}>Vendre</Text>
+            </TouchableOpacity>
+          </View>
 
-            <View style={[styles.priceCard, compact && styles.priceCardCompact]}>
-              <View style={ui.rowBetween}>
-                <Text style={ui.value}>Prix BTC</Text>
-                <Text style={styles.priceBig}>
-                  {summary.price.toFixed(2)} {settings.currency}
+          <Text style={[styles.muted, { marginTop: 10 }]} numberOfLines={2}>
+            Note : Acheter/Vendre = simulation (registre local). Pas de trading réel.
+          </Text>
+        </View>
+
+        {/* MODAL BUY/SELL */}
+        <Modal visible={tradeOpen} transparent animationType="fade" onRequestClose={() => setTradeOpen(false)}>
+          <View style={styles.modalBackdrop}>
+            <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : undefined} style={styles.modalWrap}>
+              <View style={styles.modalCard}>
+                <Text style={styles.modalTitle}>
+                  {tradeSide === "BUY" ? "Acheter" : "Vendre"} {selectedCrypto}
                 </Text>
+
+                <Text style={styles.muted}>
+                  Prix : {(currentPrice ?? 0).toFixed(2)} {currency}
+                </Text>
+
+                <Text style={[styles.muted, { marginTop: 12 }]}>Quantité</Text>
+                <TextInput
+                  value={tradeQty}
+                  onChangeText={(t) => {
+                    setTradeInfo(null);
+                    setTradeQty(t);
+                  }}
+                  keyboardType="decimal-pad"
+                  placeholder="0.01"
+                  style={styles.input}
+                />
+
+                {!!tradeInfo && <Text style={styles.modalError}>{tradeInfo}</Text>}
+
+                <View style={styles.modalButtonsRow}>
+                  <TouchableOpacity style={[styles.modalBtn, styles.modalBtnSecondary]} onPress={() => setTradeOpen(false)}>
+                    <Text style={styles.modalBtnSecondaryText}>Annuler</Text>
+                  </TouchableOpacity>
+
+                  <TouchableOpacity style={[styles.modalBtn, styles.modalBtnPrimary]} onPress={() => void confirmTrade()}>
+                    <Text style={styles.modalBtnPrimaryText}>Confirmer</Text>
+                  </TouchableOpacity>
+                </View>
               </View>
-            </View>
+            </KeyboardAvoidingView>
           </View>
+        </Modal>
+
+        {/* Bouton refresh global */}
+        <TouchableOpacity style={[styles.secondaryButton, { marginTop: 4 }]} onPress={() => void refreshAll()}>
+          <Text style={styles.secondaryButtonText}>Tout actualiser</Text>
         </TouchableOpacity>
       </ScrollView>
     </SafeAreaView>
@@ -500,126 +553,102 @@ export default function DashboardScreen() {
 }
 
 const styles = StyleSheet.create({
-  safeArea: {
-    flex: 1,
-    backgroundColor: ui.screen.backgroundColor,
-  },
+  safeArea: { flex: 1, backgroundColor: "#0b1220" },
+  container: { padding: 16, paddingBottom: 28 },
 
-  containerCompact: {
-    padding: 12,
-  },
+  centered: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#0b1220" },
 
-  cardCompact: {
+  banner: {
+    borderWidth: 1,
+    borderColor: "#f59e0b",
+    backgroundColor: "rgba(245,158,11,0.12)",
     padding: 12,
-    marginBottom: 10,
+    borderRadius: 12,
+    marginBottom: 12,
   },
+  bannerText: { color: "#f59e0b", fontWeight: "800" },
 
-  titleCompact: {
-    marginBottom: 6,
-  },
-
-  bigCompact: {
-    fontSize: 24,
-    marginVertical: 4,
-  },
-
-  bannerWarning: {
-    borderColor: "#ca8a04",
-  },
-  bannerWarningText: {
-    color: "#ca8a04",
-  },
-  bannerCompact: {
-    padding: 10,
-    marginBottom: 10,
+  card: {
+    backgroundColor: "#ffffff",
+    borderRadius: 16,
+    padding: 14,
+    marginBottom: 12,
+    borderWidth: 1,
+    borderColor: "#e5e7eb",
   },
 
-  errorText: {
-    color: "#dc2626",
-    fontWeight: "900",
-  },
+  rowBetween: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
+  rowGap: { flexDirection: "row", gap: 12, marginBottom: 12 },
+  half: { flex: 1, marginBottom: 0 },
 
-  centerText: {
-    textAlign: "center",
-  },
+  title: { fontSize: 16, fontWeight: "900", color: "#0f172a" },
+  muted: { marginTop: 6, color: "#475569", fontWeight: "600" },
+  bold: { fontWeight: "900", color: "#0f172a" },
 
-  boldInline: {
-    fontWeight: "900",
-    color: "#0f172a",
-  },
+  bigValue: { marginTop: 10, fontSize: 22, fontWeight: "900", color: "#0f172a" },
+  signalAction: { marginTop: 10, fontSize: 24, fontWeight: "900", color: "#0f172a" },
 
-  fullButton: {
-    flexGrow: 0,
-    flexBasis: "auto",
-    width: "100%",
-    marginTop: 12,
-  },
-  buttonCompact: {
+  cryptoRow: { flexDirection: "row", gap: 10, marginTop: 10 },
+  cryptoBtn: {
+    flex: 1,
     paddingVertical: 10,
-    marginTop: 10,
-  },
-
-  headerRow: {
-    flexDirection: "row",
-    justifyContent: "space-between",
+    borderRadius: 12,
+    borderWidth: 1,
+    borderColor: "#e5e7eb",
     alignItems: "center",
+    backgroundColor: "#fff",
   },
+  cryptoBtnActive: { borderColor: "#0f172a" },
+  cryptoText: { fontWeight: "900", color: "#0f172a", opacity: 0.7 },
+  cryptoTextActive: { opacity: 1 },
 
-  urgentBox: {
+  input: {
     borderWidth: 1,
     borderColor: "#e5e7eb",
-    borderRadius: 10,
-    padding: 10,
-    backgroundColor: "#ffffff",
-  },
-  urgentBoxCompact: {
-    padding: 8,
-  },
-  urgentTitle: {
-    fontWeight: "900",
+    borderRadius: 12,
+    paddingHorizontal: 12,
+    paddingVertical: 10,
+    marginTop: 8,
+    backgroundColor: "#fff",
     color: "#0f172a",
   },
 
-  priceHeaderRow: {
-    flexDirection: "row",
-    justifyContent: "space-between",
+  secondaryButton: {
+    marginTop: 10,
+    paddingVertical: 12,
+    borderRadius: 12,
+    borderWidth: 1,
+    borderColor: "#e5e7eb",
     alignItems: "center",
-    marginBottom: 8,
+    backgroundColor: "#fff",
   },
+  secondaryButtonText: { fontWeight: "900", color: "#0f172a", opacity: 0.9 },
 
   refreshBtn: {
     paddingHorizontal: 12,
     paddingVertical: 8,
-    borderRadius: 10,
+    borderRadius: 12,
     borderWidth: 1,
     borderColor: "#e5e7eb",
     backgroundColor: "#fff",
   },
-  refreshBtnCompact: {
-    paddingVertical: 6,
-  },
-  refreshBtnDisabled: {
-    opacity: 0.6,
-  },
-  refreshBtnText: {
-    fontWeight: "900",
-    color: "#0f172a",
-  },
-
-  priceCard: {
-    marginTop: 10,
-    borderWidth: 1,
-    borderColor: "#e5e7eb",
-    borderRadius: 10,
-    padding: 12,
-    backgroundColor: "#ffffff",
-  },
-  priceCardCompact: {
-    paddingVertical: 10,
-  },
-  priceBig: {
-    fontSize: 22,
-    fontWeight: "900",
-    color: "#0f172a",
-  },
+  refreshText: { fontWeight: "900", color: "#0f172a" },
+
+  buySellRow: { flexDirection: "row", gap: 10, marginTop: 10 },
+  buyBtn: { flex: 1, backgroundColor: "#16a34a", paddingVertical: 12, borderRadius: 12, alignItems: "center" },
+  sellBtn: { flex: 1, backgroundColor: "#dc2626", paddingVertical: 12, borderRadius: 12, alignItems: "center" },
+  buySellText: { color: "#fff", fontWeight: "900" },
+
+  modalBackdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.35)", justifyContent: "center", padding: 16 },
+  modalWrap: { width: "100%" },
+  modalCard: { backgroundColor: "#fff", borderRadius: 16, padding: 16, borderWidth: 1, borderColor: "#e5e7eb" },
+  modalTitle: { fontSize: 18, fontWeight: "900", color: "#0f172a" },
+  modalError: { marginTop: 10, fontWeight: "800", color: "#dc2626" },
+
+  modalButtonsRow: { flexDirection: "row", gap: 10, marginTop: 14 },
+  modalBtn: { flex: 1, paddingVertical: 12, borderRadius: 12, alignItems: "center" },
+  modalBtnSecondary: { backgroundColor: "#fff", borderWidth: 1, borderColor: "#e5e7eb" },
+  modalBtnSecondaryText: { fontWeight: "900", color: "#0f172a" },
+  modalBtnPrimary: { backgroundColor: "#0f172a" },
+  modalBtnPrimaryText: { fontWeight: "900", color: "#fff" },
 });
\ No newline at end of file
index 0e51f280b6dacec3dd2cdf5fa533c94dc1d563b1..b0f282be580b8e79bc45e1901153f06cd7018462 100644 (file)
@@ -4,27 +4,23 @@ import { loadSession } from "../../utils/sessionStorage";
 import { alertStore } from "../alertStore";
 
 /**
- * alertsApi (API-first)
- * --------------------
- * - Lecture events serveur (optionnel) : GET /api/alerts/events
- * - Clear : local (car pas d'endpoint "clear" dans le contrat)
+ * alertsApi
+ * ---------
+ * Aligné Web :
+ * - GET /api/alerts/history?userId=...
+ * Réponse : Alert[]
  */
-export async function getAlertEvents(limit = 10): Promise<Alert[]> {
+
+export async function getAlertHistory(): Promise<Alert[]> {
   const session = await loadSession();
   const userId = session?.userId;
-  if (!userId) throw new Error("Session absente : impossible de charger les alertes.");
+  if (!userId) throw new Error("Session absente : impossible de charger l'historique des alertes.");
 
-  const data = await apiGet<{ events: Alert[] }>(
-    `/alerts/events?userId=${encodeURIComponent(userId)}&limit=${encodeURIComponent(String(limit))}`
-  );
+  const data = await apiGet<any>(`/alerts/history?userId=${encodeURIComponent(userId)}`);
 
-  return data.events ?? [];
+  return Array.isArray(data) ? (data as Alert[]) : [];
 }
 
-/**
- * Clear local : vide la liste affichée (alertStore).
- * (Le contrat API ne prévoit pas de suppression globale côté serveur.)
- */
 export async function clearAlertsLocal(): Promise<void> {
   alertStore.clear?.();
 }
\ No newline at end of file
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..347dd624989238c1fd8462e79c64c1b202d3dc14 100644 (file)
@@ -0,0 +1,87 @@
+import type { AuthUser } from "../../models/AuthUser";
+import type { Session } from "../../utils/sessionStorage";
+
+import { createUser, verifyLogin, findUserById } from "../../utils/authUsersStorage";
+import { loadSession, saveSession, clearSession } from "../../utils/sessionStorage";
+
+/**
+ * authApi.ts
+ * ----------
+ * Façade d'authentification.
+ *
+ * Aujourd'hui (Step local) :
+ * - login/register/logout en local (AsyncStorage)
+ *
+ * Demain (API ready) :
+ * - tu pourras remplacer l'implémentation par :
+ *   POST /api/auth/login
+ *   POST /api/auth/register
+ *   GET  /api/auth/me
+ *   POST /api/auth/logout
+ *
+ * L'UI ne change pas : elle appelle toujours authApi.
+ */
+
+export type AuthResult =
+  | { ok: true; user: AuthUser; session: Session }
+  | { ok: false; message: string };
+
+export async function register(params: {
+  email: string;
+  username: string;
+  displayName?: string;
+  password: string;
+}): Promise<AuthResult> {
+  const res = await createUser(params);
+  if (!res.ok) return { ok: false, message: res.message };
+
+  const session: Session = {
+    userId: res.user.userId,
+    email: res.user.email,
+    createdAtMs: Date.now(),
+  };
+
+  await saveSession(session);
+
+  return { ok: true, user: res.user, session };
+}
+
+export async function login(params: {
+  login: string; // email OU username
+  password: string;
+}): Promise<AuthResult> {
+  const res = await verifyLogin(params);
+  if (!res.ok) return { ok: false, message: res.message };
+
+  const session: Session = {
+    userId: res.user.userId,
+    email: res.user.email,
+    createdAtMs: Date.now(),
+  };
+
+  await saveSession(session);
+
+  return { ok: true, user: res.user, session };
+}
+
+export async function logout(): Promise<void> {
+  await clearSession();
+}
+
+/**
+ * Retourne l'utilisateur courant (si session active).
+ * Utile pour afficher profil / sécuriser certaines actions.
+ */
+export async function getCurrentUser(): Promise<AuthUser | null> {
+  const session = await loadSession();
+  if (!session?.userId) return null;
+
+  return await findUserById(session.userId);
+}
+
+/**
+ * Juste la session (pratique pour guards).
+ */
+export async function getSession(): Promise<Session | null> {
+  return await loadSession();
+}
\ No newline at end of file
index 8956e3b011964f7e16a00019c8ed60cc74fe5ad2..a699996e95578703043d445ede018e71f8c9a3c0 100644 (file)
@@ -1,43 +1,71 @@
 import { API_BASE_URL } from "../../config/env";
 
 /**
- * Petit helper HTTP (fetch) :
- * - force JSON
- * - vérifie le format { ok: true, data } / { ok:false, error }
- * - jette une erreur claire si l’API répond mal ou si HTTP != 2xx
+ * HTTP helper (fetch)
+ * -------------------
+ * Compatible avec 2 formats :
+ * A) "wrap" : { ok:true, data: ... } / { ok:false, error:{message} }
+ * B) "raw"  : { price: ... } / { balance: ... } / Alert[]
  */
-export async function apiGet<T>(path: string): Promise<T> {
-  const res = await fetch(`${API_BASE_URL}${path}`, { method: "GET" });
 
-  const json = await res.json().catch(() => null);
+async function parseJsonSafe(res: Response) {
+  const text = await res.text();
+  try {
+    return text ? JSON.parse(text) : null;
+  } catch {
+    return null;
+  }
+}
+
+function buildUrl(path: string) {
+  const p = path.startsWith("/") ? path : `/${path}`;
+  return `${API_BASE_URL}${p}`;
+}
 
-  if (!res.ok) {
-    const msg = json?.error?.message ?? `HTTP ${res.status}`;
+function unwrapOrRaw<T>(json: any): T {
+  // Format "wrap"
+  if (json && typeof json === "object" && "ok" in json) {
+    if (json.ok === true) return json.data as T;
+    const msg = json?.error?.message ?? "Réponse API invalide (ok=false)";
     throw new Error(msg);
   }
-  if (!json || json.ok !== true) {
-    const msg = json?.error?.message ?? "Réponse API invalide (ok=false)";
+
+  // Format "raw"
+  return json as T;
+}
+
+export async function apiGet<T>(path: string): Promise<T> {
+  const res = await fetch(buildUrl(path), {
+    method: "GET",
+    headers: { Accept: "application/json" },
+  });
+
+  const json = await parseJsonSafe(res);
+
+  if (!res.ok) {
+    const msg = json?.error?.message ?? json?.message ?? `HTTP ${res.status}`;
     throw new Error(msg);
   }
-  return json.data as T;
+
+  return unwrapOrRaw<T>(json);
 }
 
 export async function apiPost<T>(path: string, body: unknown): Promise<T> {
-  const res = await fetch(`${API_BASE_URL}${path}`, {
+  const res = await fetch(buildUrl(path), {
     method: "POST",
-    headers: { "Content-Type": "application/json" },
+    headers: {
+      Accept: "application/json",
+      "Content-Type": "application/json",
+    },
     body: JSON.stringify(body),
   });
 
-  const json = await res.json().catch(() => null);
+  const json = await parseJsonSafe(res);
 
   if (!res.ok) {
-    const msg = json?.error?.message ?? `HTTP ${res.status}`;
+    const msg = json?.error?.message ?? json?.message ?? `HTTP ${res.status}`;
     throw new Error(msg);
   }
-  if (!json || json.ok !== true) {
-    const msg = json?.error?.message ?? "Réponse API invalide (ok=false)";
-    throw new Error(msg);
-  }
-  return json.data as T;
+
+  return unwrapOrRaw<T>(json);
 }
\ No newline at end of file
index 30eb15754f451e6e198cac59fb69c8012cc49a9b..09bba8d3fc17029cd0184153c60a21bdca94fc11 100644 (file)
@@ -1,13 +1,14 @@
 import { apiGet } from "./http";
 
 /**
- * priceApi (API-only)
- * -------------------
- * Contrat :
- * - GET /api/price/current?pair=BTC/EUR
+ * priceApi
+ * --------
+ * Web utilise :
+ * - GET /api/prices/current?pair=BTC/EUR
+ * Réponse attendue côté web : { price: number, ... }
  *
- * Retour attendu (à ajuster si le backend diffère) :
- * { ok:true, data:{ pair, timestamp_ms, current_price } }
+ * On reste robuste :
+ * - accepte aussi d'anciens formats si jamais.
  */
 
 export type PriceCurrent = {
@@ -17,15 +18,27 @@ export type PriceCurrent = {
 };
 
 export async function getCurrentPrice(pair: string): Promise<PriceCurrent> {
-  const data = await apiGet<{
-    pair: string;
-    timestamp_ms: number;
-    current_price: number;
-  }>(`/price/current?pair=${encodeURIComponent(pair)}`);
+  const data = await apiGet<any>(`/prices/current?pair=${encodeURIComponent(pair)}`);
+
+  // Format web : { price: number }
+  const price =
+    (typeof data?.price === "number" ? data.price : null) ??
+    (typeof data?.current_price === "number" ? data.current_price : null) ??
+    (typeof data?.data?.price === "number" ? data.data.price : null) ??
+    (typeof data?.data?.current_price === "number" ? data.data.current_price : null);
+
+  if (typeof price !== "number" || !Number.isFinite(price)) {
+    throw new Error("Prix invalide (API).");
+  }
+
+  const ts =
+    (typeof data?.timestampMs === "number" ? data.timestampMs : null) ??
+    (typeof data?.timestamp_ms === "number" ? data.timestamp_ms : null) ??
+    Date.now();
 
   return {
-    pair: data.pair,
-    timestampMs: Number(data.timestamp_ms),
-    price: Number(data.current_price),
+    pair,
+    timestampMs: Number(ts),
+    price: Number(price),
   };
 }
\ No newline at end of file
index b6986bceb1a96ac5f59769d09d9007c72b9120a2..89170c11e86875e33620e736ab2fa1e497c2a562 100644 (file)
@@ -1,52 +1,50 @@
-import { apiPost, apiGet } from "./http";
+import { apiGet } from "./http";
 import { loadSession } from "../../utils/sessionStorage";
 import type { PortfolioState } from "../../models/Portfolio";
 
 /**
- * walletApi (API-only)
- * --------------------
- * - GET /api/wallets?userId=...   (à confirmer)
- * - POST /api/wallets/upsert
+ * walletApi
+ * ---------
+ * Aligné Web :
+ * - GET /api/wallet/:userId
+ * Réponse attendue : { balance: number }
  *
- * NOTE: reset = upsert d'un portefeuille vide (pas besoin d'un endpoint dédié)
+ * Le web affiche le solde en BTC.
+ * Côté mobile, on convertit en PortfolioState (assets).
  */
 
-function nowMs() {
-  return Date.now();
-}
+type WalletResponse = {
+  balance?: number;
+  // si un jour ils passent multi-assets, on ne casse pas :
+  balances?: Record<string, number>;
+};
 
 export async function getPortfolio(): Promise<PortfolioState> {
   const session = await loadSession();
   const userId = session?.userId;
   if (!userId) throw new Error("Session absente : impossible de charger le portefeuille.");
 
-  const data = await apiGet<{ portfolio: PortfolioState }>(
-    `/wallets?userId=${encodeURIComponent(userId)}`
-  );
-
-  if (!data.portfolio) throw new Error("Réponse API invalide : portfolio manquant.");
-  return data.portfolio;
-}
-
-export async function upsertPortfolio(next: PortfolioState): Promise<PortfolioState> {
-  const session = await loadSession();
-  const userId = session?.userId;
-  if (!userId) throw new Error("Session absente : impossible de sauvegarder le portefeuille.");
-
-  const data = await apiPost<{ portfolio: PortfolioState }>(`/wallets/upsert`, {
-    userId,
-    portfolio: next,
-  });
-
-  if (!data.portfolio) throw new Error("Réponse API invalide : portfolio manquant.");
-  return data.portfolio;
-}
-
-/**
- * Reset portefeuille (API-only)
- * => on sauvegarde un portefeuille vide via /wallets/upsert
- */
-export async function resetPortfolio(): Promise<PortfolioState> {
-  const empty: PortfolioState = { assets: [], updatedAtMs: nowMs() };
-  return await upsertPortfolio(empty);
+  const data = await apiGet<WalletResponse>(`/wallet/${encodeURIComponent(userId)}`);
+
+  // Format actuel web : balance BTC
+  if (typeof data?.balance === "number" && Number.isFinite(data.balance)) {
+    return {
+      assets: [{ symbol: "BTC", quantity: data.balance }],
+      updatedAtMs: Date.now(),
+    };
+  }
+
+  // Format futur possible : balances { BTC:..., ETH:..., LTC:... }
+  const balances = data?.balances;
+  if (balances && typeof balances === "object") {
+    const assets = Object.entries(balances)
+      .filter(([, qty]) => typeof qty === "number" && Number.isFinite(qty) && qty > 0)
+      .map(([symbol, qty]) => ({ symbol: symbol.toUpperCase(), quantity: qty }))
+      .sort((a, b) => a.symbol.localeCompare(b.symbol));
+
+    return { assets, updatedAtMs: Date.now() };
+  }
+
+  // fallback safe
+  return { assets: [], updatedAtMs: Date.now() };
 }
\ No newline at end of file
index 27bf531a496546875ca9923fd8cc609f87840192..4d93f28078e8ba58f89406b5052086fa939b5dfa 100644 (file)
@@ -1,43 +1,97 @@
 import { io, Socket } from "socket.io-client";
 import type { Alert } from "../types/Alert";
 
+/**
+ * socketService.ts
+ * ----------------
+ * Objectif :
+ * - Connexion Socket.IO via le Gateway (duckdns ou local)
+ * - Auth par event "auth" (payload = userId)
+ * - Réception des alertes via event "alert"
+ *
+ * Important :
+ * - Le Gateway doit proxy /socket.io/* vers alerts-service.
+ * - En prod (https), socket.io bascule en wss automatiquement.
+ */
+
 class SocketService {
   private socket: Socket | null = null;
   private listeners = new Set<(alert: Alert) => void>();
+  private currentUserId: string | null = null;
+  private currentServerUrl: string | null = null;
 
   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;
+    // Si on est déjà connecté au même serveur avec le même userId -> rien à faire
+    if (
+      this.socket &&
+      this.socket.connected &&
+      this.currentServerUrl === serverUrl &&
+      this.currentUserId === userId
+    ) {
+      return;
+    }
+
+    // Si on change de serveur ou de userId, on repart proprement
+    if (this.socket) {
+      this.disconnect();
+    }
+
+    this.currentServerUrl = serverUrl;
+    this.currentUserId = userId;
 
     this.socket = io(serverUrl, {
-      transports: ["websocket", "polling"], // plus fiable sur mobile
+      // Très important en environnement proxy
+      path: "/socket.io",
+
+      // Mobile : websocket + fallback polling
+      transports: ["websocket", "polling"],
+
       reconnection: true,
-      reconnectionAttempts: 5,
+      reconnectionAttempts: 10,
+      reconnectionDelay: 800,
+      reconnectionDelayMax: 3000,
       timeout: 10000,
     });
 
+    const emitAuth = () => {
+      if (!this.socket || !this.currentUserId) return;
+      this.socket.emit("auth", this.currentUserId);
+    };
+
     this.socket.on("connect", () => {
       console.log("✅ Socket connecté:", this.socket?.id);
-      // tuto de ton camarade : auth via userId
-      this.socket?.emit("auth", userId);
+      emitAuth();
     });
 
+    // Si le serveur supporte le message
     this.socket.on("auth_success", (data: any) => {
       console.log("✅ Auth success:", data?.message ?? data);
     });
 
+    // Alertes live
     this.socket.on("alert", (alert: Alert) => {
       for (const cb of this.listeners) cb(alert);
     });
 
-    this.socket.on("connect_error", (err) => {
+    // Debug utile
+    this.socket.on("connect_error", (err: any) => {
       console.log("❌ Socket connect_error:", err?.message ?? err);
     });
 
-    this.socket.on("disconnect", (reason) => {
+    this.socket.on("error", (err: any) => {
+      console.log("❌ Socket error:", err?.message ?? err);
+    });
+
+    this.socket.on("reconnect", (attempt: number) => {
+      console.log("🔁 Socket reconnect:", attempt);
+      // Après reconnexion, on renvoie auth (certains backends oublient la session socket)
+      emitAuth();
+    });
+
+    this.socket.on("disconnect", (reason: string) => {
       console.log("⚠️ Socket disconnect:", reason);
     });
   }
@@ -61,6 +115,8 @@ class SocketService {
     this.socket.removeAllListeners();
     this.socket.disconnect();
     this.socket = null;
+    this.currentUserId = null;
+    this.currentServerUrl = null;
     this.listeners.clear();
   }
 }
index e747535a2b2800cc208761b98df0c71814887706..ac89884886bf8f5fb83a1d19ce1112a059abfb8a 100644 (file)
@@ -1,24 +1,28 @@
 /**
- * Alert
- * -----
- * Contrat mobile (Socket.IO / API).
- * On garde des enums clairs comme demandé :
- * - alertLevel : CRITICAL / WARNING / INFO
- * - action : BUY / SELL / HOLD / STOP_LOSS
+ * Alert.ts
+ * --------
+ * Format d'alerte utilisé par :
+ * - Socket.IO (event "alert")
+ * - REST history : GET /api/alerts/history?userId=...
  *
- * id est optionnel : utile pour React (keyExtractor),
- * mais le serveur peut ne pas l'envoyer au début.
+ * Le Web utilise parfois `alert.message`.
+ * Donc on rend ce champ optionnel (pour compatibilité).
  */
+
+export type AlertLevel = "CRITICAL" | "WARNING" | "INFO";
+export type AlertAction = "BUY" | "SELL" | "HOLD" | "STOP_LOSS";
+
 export interface Alert {
-  id?: string;
+  action?: AlertAction;            // BUY / SELL / HOLD / STOP_LOSS
+  pair?: string;                   // ex: BTC/EUR
+  confidence?: number;             // 0..1
+  reason?: string;                 // texte explicatif
 
-  action: "BUY" | "SELL" | "HOLD" | "STOP_LOSS";
-  pair: string;            // ex: "BTC/EUR"
-  confidence: number;      // 0..1
-  reason: string;
+  // ✅ Compatibilité Web (script.js affiche alert.message si présent)
+  message?: string;
 
-  alertLevel: "INFO" | "WARNING" | "CRITICAL";
-  timestamp: number;       // Unix ms
+  alertLevel?: AlertLevel;         // CRITICAL / WARNING / INFO
+  timestamp?: number;              // ms
 
-  price?: number;
+  price?: number;                  // prix au moment de l’alerte
 }
\ No newline at end of file