diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx
index 7737f29..4b38b49 100644
--- a/apps/frontend/src/App.tsx
+++ b/apps/frontend/src/App.tsx
@@ -5,6 +5,8 @@ import { DashboardPage } from './pages/DashboardPage';
import { LoginPage } from './pages/LoginPage';
import { PairRobotPage } from './pages/PairRobotPage';
import { RegisterPage } from './pages/RegisterPage';
+import { SetupRobotPage } from './pages/SetupRobotPage';
+import { DeviceDetailPage } from './pages/DeviceDetailPage';
/**
* Router.
@@ -45,6 +47,22 @@ export function App() {
}
/>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
} />
diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts
index d6d893d..3f6263e 100644
--- a/apps/frontend/src/lib/api.ts
+++ b/apps/frontend/src/lib/api.ts
@@ -62,6 +62,58 @@ export interface PairingConfirmResult {
deviceName: string;
}
+export interface HealthReport {
+ cpuTempCelsius: number;
+ memoryUsedMb: number;
+ memoryTotalMb: number;
+ diskUsedPercent: number;
+ loadAvg1m: number;
+ heapUsedMb: number;
+ heapTotalMb: number;
+ uptimeSeconds: number;
+ wifiSsid: string | null;
+ wifiSignalDbm: number | null;
+ clientVersion: string;
+ nodeVersion: string;
+ reportedAt: string;
+}
+
+export interface HealthReportsResponse {
+ deviceId: string;
+ reports: HealthReport[];
+}
+
+export interface HealthAlertsResponse {
+ deviceId: string;
+ alerts: string[];
+ healthy: boolean;
+}
+
+export interface LogEntry {
+ level: number;
+ levelLabel: string;
+ time: number;
+ msg: string;
+ name?: string;
+ [key: string]: unknown;
+}
+
+export interface LogsResponse {
+ deviceId: string;
+ logs: LogEntry[];
+ total: number;
+}
+
+export interface LogQueryParams {
+ level?: number;
+ logger?: string;
+ since?: string;
+ until?: string;
+ search?: string;
+ limit?: number;
+ offset?: number;
+}
+
// ─── Error ──────────────────────────────────────────────────────────
export class ApiError extends Error {
@@ -230,4 +282,27 @@ export const api = {
body: { code },
});
},
+
+ // Health
+ async getHealthReports(deviceId: string, limit = 20): Promise {
+ return request(`/devices/${deviceId}/health/reports?limit=${limit}`);
+ },
+
+ async getHealthAlerts(deviceId: string): Promise {
+ return request(`/devices/${deviceId}/health/alerts`);
+ },
+
+ // Logs
+ async getDeviceLogs(deviceId: string, params: LogQueryParams = {}): Promise {
+ const searchParams = new URLSearchParams();
+ if (params.level !== undefined) searchParams.set('level', String(params.level));
+ if (params.logger) searchParams.set('logger', params.logger);
+ if (params.since) searchParams.set('since', params.since);
+ if (params.until) searchParams.set('until', params.until);
+ if (params.search) searchParams.set('search', params.search);
+ if (params.limit !== undefined) searchParams.set('limit', String(params.limit));
+ if (params.offset !== undefined) searchParams.set('offset', String(params.offset));
+ const qs = searchParams.toString();
+ return request(`/devices/${deviceId}/logs${qs ? `?${qs}` : ''}`);
+ },
};
diff --git a/apps/frontend/src/lib/robot-local-api.ts b/apps/frontend/src/lib/robot-local-api.ts
new file mode 100644
index 0000000..3bd4f40
--- /dev/null
+++ b/apps/frontend/src/lib/robot-local-api.ts
@@ -0,0 +1,112 @@
+/**
+ * Client for the Ti-Pote robot's local HTTP API (captive portal).
+ *
+ * When the robot boots without WiFi, it creates an AP (Access Point)
+ * named "Ti-Pote" at 192.168.4.1:80. The desktop app connects to
+ * this AP and uses these endpoints to configure WiFi.
+ */
+
+const DEFAULT_ROBOT_URL = 'http://192.168.4.1';
+
+export interface RobotStatus {
+ ready: boolean;
+ robotName: string;
+}
+
+export interface WifiStatus {
+ connected: boolean;
+ ssid: string | null;
+}
+
+export interface WifiNetwork {
+ ssid: string;
+ signal: number;
+ security: string;
+}
+
+export interface WifiConnectResult {
+ success: boolean;
+ error?: string;
+}
+
+/**
+ * Create a robot local API client.
+ * @param baseUrl Override the robot's AP address (useful for dev/testing)
+ */
+export function createRobotLocalApi(baseUrl = DEFAULT_ROBOT_URL) {
+ const url = baseUrl.replace(/\/$/, '');
+
+ async function get(path: string): Promise {
+ const res = await fetch(`${url}${path}`, {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ });
+ if (!res.ok) throw new Error(`Robot API error: ${res.status} ${res.statusText}`);
+ return res.json() as Promise;
+ }
+
+ async function post(path: string, body: unknown): Promise {
+ const res = await fetch(`${url}${path}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) throw new Error(`Robot API error: ${res.status} ${res.statusText}`);
+ return res.json() as Promise;
+ }
+
+ return {
+ /**
+ * Check if the robot is reachable and get its name.
+ */
+ async getStatus(): Promise {
+ return get('/api/status');
+ },
+
+ /**
+ * Check if the robot is connected to WiFi.
+ */
+ async getWifiStatus(): Promise {
+ return get('/api/wifi/status');
+ },
+
+ /**
+ * Scan for available WiFi networks.
+ */
+ async scanWifi(): Promise {
+ return get('/api/wifi/scan');
+ },
+
+ /**
+ * Attempt to connect the robot to a WiFi network.
+ * WARNING: After calling this, the robot will disconnect from AP mode.
+ * The app will lose connection to the robot.
+ */
+ async connectWifi(ssid: string, password: string): Promise {
+ return post('/api/wifi/connect', { ssid, password });
+ },
+
+ /**
+ * Try to detect if we're connected to the robot's AP.
+ * Returns true if the robot's local API is reachable.
+ */
+ async isReachable(): Promise {
+ try {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 3000);
+ const res = await fetch(`${url}/api/status`, {
+ signal: controller.signal,
+ });
+ clearTimeout(timeout);
+ return res.ok;
+ } catch {
+ return false;
+ }
+ },
+ };
+}
+
+export type RobotLocalApi = ReturnType;
diff --git a/apps/frontend/src/pages/DashboardPage.tsx b/apps/frontend/src/pages/DashboardPage.tsx
index 67acaf8..5b73706 100644
--- a/apps/frontend/src/pages/DashboardPage.tsx
+++ b/apps/frontend/src/pages/DashboardPage.tsx
@@ -50,8 +50,11 @@ export function DashboardPage() {
+
+
+
-
+
@@ -78,8 +81,8 @@ export function DashboardPage() {
Allume ton Ti-Pote puis associe-le avec le code qui s'affichera.
-
-
+
+
)}
@@ -87,29 +90,33 @@ export function DashboardPage() {
{!loading && !error && devices && devices.length > 0 && (
{devices.map((d) => (
-
-
-
-
{d.name}
-
- {d.id}
-
+
+
+
+
+
+ {d.name}
+
+
+ {d.id}
+
+
+
-
-
-
-
-
- Firmware :
- - {d.firmwareVersion || '—'}
-
-
-
- Vu pour la dernière fois :
- -
- {d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'jamais'}
-
-
-
-
+
+
+
- Firmware :
+ - {d.firmwareVersion || '—'}
+
+
+
- Vu pour la dernière fois :
+ -
+ {d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'jamais'}
+
+
+
+
+
))}
)}
diff --git a/apps/frontend/src/pages/DeviceDetailPage.tsx b/apps/frontend/src/pages/DeviceDetailPage.tsx
new file mode 100644
index 0000000..2bf2a5d
--- /dev/null
+++ b/apps/frontend/src/pages/DeviceDetailPage.tsx
@@ -0,0 +1,441 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Link, useParams } from 'react-router-dom';
+import { Button, Card, StatusBadge } from '../components/ui';
+import {
+ api,
+ ApiError,
+ type DeviceSummary,
+ type HealthReport,
+ type LogEntry,
+} from '../lib/api';
+
+// ─── Level helpers ────────────────────────────────────────────────
+
+const LEVEL_COLORS: Record = {
+ trace: 'text-slate-500',
+ debug: 'text-slate-400',
+ info: 'text-blue-400',
+ warn: 'text-amber-400',
+ error: 'text-red-400',
+ fatal: 'text-red-500 font-bold',
+};
+
+const LEVEL_OPTIONS = [
+ { value: 0, label: 'Tous' },
+ { value: 20, label: 'Debug+' },
+ { value: 30, label: 'Info+' },
+ { value: 40, label: 'Warn+' },
+ { value: 50, label: 'Error+' },
+];
+
+// ─── Component ────────────────────────────────────────────────────
+
+export function DeviceDetailPage() {
+ const { deviceId } = useParams<{ deviceId: string }>();
+ const [tab, setTab] = useState<'health' | 'logs'>('health');
+
+ // Device info
+ const [device, setDevice] = useState(null);
+
+ // Health
+ const [reports, setReports] = useState([]);
+ const [alerts, setAlerts] = useState([]);
+ const [healthLoading, setHealthLoading] = useState(true);
+
+ // Logs
+ const [logs, setLogs] = useState([]);
+ const [logsTotal, setLogsTotal] = useState(0);
+ const [logsLoading, setLogsLoading] = useState(false);
+ const [logLevel, setLogLevel] = useState(0);
+ const [logSearch, setLogSearch] = useState('');
+
+ const [error, setError] = useState(null);
+
+ // ── Fetch device info ──
+
+ useEffect(() => {
+ if (!deviceId) return;
+ api
+ .listDevices()
+ .then((devices) => {
+ const d = devices.find((x) => x.id === deviceId);
+ if (d) setDevice(d);
+ })
+ .catch(() => {});
+ }, [deviceId]);
+
+ // ── Fetch health data ──
+
+ const fetchHealth = useCallback(async () => {
+ if (!deviceId) return;
+ setHealthLoading(true);
+ setError(null);
+ try {
+ const [reportsRes, alertsRes] = await Promise.all([
+ api.getHealthReports(deviceId, 20),
+ api.getHealthAlerts(deviceId),
+ ]);
+ setReports(reportsRes.reports);
+ setAlerts(alertsRes.alerts);
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : 'Erreur réseau');
+ } finally {
+ setHealthLoading(false);
+ }
+ }, [deviceId]);
+
+ useEffect(() => {
+ if (tab === 'health') fetchHealth();
+ }, [tab, fetchHealth]);
+
+ // ── Fetch logs ──
+
+ const fetchLogs = useCallback(async () => {
+ if (!deviceId) return;
+ setLogsLoading(true);
+ try {
+ const res = await api.getDeviceLogs(deviceId, {
+ level: logLevel || undefined,
+ search: logSearch || undefined,
+ limit: 100,
+ });
+ setLogs(res.logs);
+ setLogsTotal(res.total);
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : 'Erreur réseau');
+ } finally {
+ setLogsLoading(false);
+ }
+ }, [deviceId, logLevel, logSearch]);
+
+ useEffect(() => {
+ if (tab === 'logs') fetchLogs();
+ }, [tab, fetchLogs]);
+
+ // ── Helpers ──
+
+ function formatUptime(seconds: number): string {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ if (h > 24) {
+ const d = Math.floor(h / 24);
+ return `${d}j ${h % 24}h`;
+ }
+ return `${h}h ${m}m`;
+ }
+
+ function formatTime(iso: string): string {
+ return new Date(iso).toLocaleString('fr-FR', {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+ }
+
+ const latest = reports[0] ?? null;
+
+ // ─── Render ─────────────────────────────────────────────────────
+
+ return (
+
+ {/* Header */}
+
+
+
+ ←
+
+
+
+ {device?.name ?? 'Robot'}
+
+
{deviceId}
+
+
+ {device && }
+
+
+ {/* Tabs */}
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* ── Health tab ── */}
+ {tab === 'health' && (
+ <>
+ {/* Alerts */}
+ {alerts.length > 0 && (
+
+ ⚠ Alertes
+
+ {alerts.map((a, i) => (
+ -
+ • {a}
+
+ ))}
+
+
+ )}
+
+ {/* Live metrics */}
+ {healthLoading && !latest && (
+
Chargement...
+ )}
+
+ {latest && (
+
+ = 70}
+ critical={latest.cpuTempCelsius >= 80}
+ />
+ = 0.8}
+ critical={(latest.memoryUsedMb / latest.memoryTotalMb) >= 0.9}
+ />
+ = 80}
+ critical={latest.diskUsedPercent >= 90}
+ />
+
+ = 2}
+ critical={latest.loadAvg1m >= 3}
+ />
+
+
+
+
+ )}
+
+ {/* History */}
+ {reports.length > 1 && (
+
+
+
+ Historique ({reports.length} rapports)
+
+
+
+
+
+
+
+ | Heure |
+ CPU |
+ RAM |
+ Disque |
+ Load |
+ WiFi |
+
+
+
+ {reports.map((r, i) => (
+
+ | {formatTime(r.reportedAt)} |
+ {r.cpuTempCelsius}°C |
+ {r.memoryUsedMb.toFixed(0)} MB |
+ {r.diskUsedPercent.toFixed(0)}% |
+ {r.loadAvg1m.toFixed(2)} |
+ {r.wifiSignalDbm ?? '—'} dBm |
+
+ ))}
+
+
+
+
+ )}
+ >
+ )}
+
+ {/* ── Logs tab ── */}
+ {tab === 'logs' && (
+ <>
+ {/* Filters */}
+
+
+
+ setLogSearch(e.target.value)}
+ className="flex-1 rounded-xl border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-500 focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40"
+ />
+
+
+
+
+ {logsTotal} log{logsTotal > 1 ? 's' : ''}
+
+
+
+ {/* Log entries */}
+
+
+ {logsLoading && logs.length === 0 && (
+
Chargement...
+ )}
+
+ {!logsLoading && logs.length === 0 && (
+
Aucun log trouvé
+ )}
+
+ {logs.map((log, i) => {
+ const levelColor = LEVEL_COLORS[log.levelLabel] ?? 'text-slate-400';
+ return (
+
+
+ {new Date(log.time).toLocaleTimeString('fr-FR')}
+
+
+ {log.levelLabel}
+
+
+ {log.name ?? '—'}
+
+ {log.msg}
+
+ );
+ })}
+
+
+ >
+ )}
+
+ );
+}
+
+// ─── MetricCard ───────────────────────────────────────────────────
+
+function MetricCard({
+ label,
+ value,
+ sub,
+ percent,
+ warn = false,
+ critical = false,
+}: {
+ label: string;
+ value: string;
+ sub?: string;
+ percent?: number;
+ warn?: boolean;
+ critical?: boolean;
+}) {
+ const borderColor = critical
+ ? 'border-red-500/30'
+ : warn
+ ? 'border-amber-500/30'
+ : 'border-slate-800';
+
+ const valueColor = critical
+ ? 'text-red-400'
+ : warn
+ ? 'text-amber-400'
+ : 'text-slate-100';
+
+ return (
+
+ {label}
+ {value}
+ {sub && {sub}
}
+ {percent !== undefined && (
+
+ )}
+
+ );
+}
diff --git a/apps/frontend/src/pages/SetupRobotPage.tsx b/apps/frontend/src/pages/SetupRobotPage.tsx
new file mode 100644
index 0000000..fce20a0
--- /dev/null
+++ b/apps/frontend/src/pages/SetupRobotPage.tsx
@@ -0,0 +1,534 @@
+import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
+import { Link, useNavigate } from 'react-router-dom';
+import { Button, Card, Input } from '../components/ui';
+import { api, ApiError } from '../lib/api';
+import {
+ createRobotLocalApi,
+ type RobotLocalApi,
+ type WifiNetwork,
+} from '../lib/robot-local-api';
+
+// ─── Types ─────────────────────────────────────────────────────────
+
+type Step = 'connect-ap' | 'wifi-select' | 'wifi-password' | 'wifi-connecting' | 'pairing';
+
+// ─── Component ─────────────────────────────────────────────────────
+
+export function SetupRobotPage() {
+ const navigate = useNavigate();
+ const [step, setStep] = useState('connect-ap');
+ const [robotApi] = useState(() => createRobotLocalApi());
+ const [robotName, setRobotName] = useState('Ti-Pote');
+
+ // WiFi state
+ const [networks, setNetworks] = useState([]);
+ const [scanning, setScanning] = useState(false);
+ const [selectedSsid, setSelectedSsid] = useState('');
+ const [wifiPassword, setWifiPassword] = useState('');
+ const [wifiError, setWifiError] = useState(null);
+ const [wifiConnecting, setWifiConnecting] = useState(false);
+
+ // Pairing state
+ const CODE_LENGTH = 6;
+ const [digits, setDigits] = useState(() => Array.from({ length: CODE_LENGTH }, () => ''));
+ const [pairingSubmitting, setPairingSubmitting] = useState(false);
+ const [pairingError, setPairingError] = useState(null);
+ const [pairingSuccess, setPairingSuccess] = useState<{ deviceId: string; deviceName: string } | null>(null);
+ const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
+ const code = useMemo(() => digits.join(''), [digits]);
+ const isCodeComplete = code.length === CODE_LENGTH && digits.every((d) => /\d/.test(d));
+
+ // ── Step 1: Detect robot AP ──────────────────────────────────────
+
+ const [detecting, setDetecting] = useState(false);
+ const [detected, setDetected] = useState(false);
+
+ const detectRobot = useCallback(async () => {
+ setDetecting(true);
+ try {
+ const reachable = await robotApi.isReachable();
+ if (reachable) {
+ const status = await robotApi.getStatus();
+ setRobotName(status.robotName);
+ setDetected(true);
+ } else {
+ setDetected(false);
+ }
+ } catch {
+ setDetected(false);
+ } finally {
+ setDetecting(false);
+ }
+ }, [robotApi]);
+
+ // Auto-detect every 3s on step 1
+ useEffect(() => {
+ if (step !== 'connect-ap') return;
+ detectRobot();
+ const interval = setInterval(detectRobot, 3000);
+ return () => clearInterval(interval);
+ }, [step, detectRobot]);
+
+ // ── Step 2: Scan WiFi ────────────────────────────────────────────
+
+ const scanNetworks = useCallback(async () => {
+ setScanning(true);
+ try {
+ const results = await robotApi.scanWifi();
+ // Sort by signal strength, deduplicate by SSID
+ const unique = new Map();
+ for (const n of results) {
+ if (n.ssid && (!unique.has(n.ssid) || unique.get(n.ssid)!.signal < n.signal)) {
+ unique.set(n.ssid, n);
+ }
+ }
+ setNetworks(
+ Array.from(unique.values()).sort((a, b) => b.signal - a.signal),
+ );
+ } catch {
+ setNetworks([]);
+ } finally {
+ setScanning(false);
+ }
+ }, [robotApi]);
+
+ useEffect(() => {
+ if (step === 'wifi-select') {
+ scanNetworks();
+ }
+ }, [step, scanNetworks]);
+
+ // ── Step 3: Connect WiFi ─────────────────────────────────────────
+
+ async function handleWifiConnect() {
+ setWifiError(null);
+ setWifiConnecting(true);
+ setStep('wifi-connecting');
+
+ try {
+ const result = await robotApi.connectWifi(selectedSsid, wifiPassword);
+ if (result.success) {
+ // Robot is now on user's WiFi — we lost AP connection
+ // Wait a moment then move to pairing step
+ setTimeout(() => setStep('pairing'), 3000);
+ } else {
+ setWifiError(result.error || 'Connexion échouée');
+ setStep('wifi-password');
+ }
+ } catch {
+ // Likely lost connection because AP went down (expected on success)
+ // Move to pairing after a delay
+ setTimeout(() => setStep('pairing'), 3000);
+ } finally {
+ setWifiConnecting(false);
+ }
+ }
+
+ // ── Step 4: Pairing ──────────────────────────────────────────────
+
+ function setDigitAt(index: number, value: string) {
+ const cleaned = value.replace(/\D/g, '').slice(0, 1);
+ setDigits((prev) => {
+ const next = [...prev];
+ next[index] = cleaned;
+ return next;
+ });
+ if (cleaned && index < CODE_LENGTH - 1) {
+ inputsRef.current[index + 1]?.focus();
+ }
+ }
+
+ function onKeyDown(index: number, e: KeyboardEvent) {
+ if (e.key === 'Backspace' && !digits[index] && index > 0) {
+ inputsRef.current[index - 1]?.focus();
+ } else if (e.key === 'ArrowLeft' && index > 0) {
+ inputsRef.current[index - 1]?.focus();
+ } else if (e.key === 'ArrowRight' && index < CODE_LENGTH - 1) {
+ inputsRef.current[index + 1]?.focus();
+ } else if (e.key === 'Enter' && isCodeComplete) {
+ void submitPairing();
+ }
+ }
+
+ function onPaste(e: ClipboardEvent) {
+ const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
+ if (!pasted) return;
+ e.preventDefault();
+ const next = Array.from({ length: CODE_LENGTH }, (_, i) => pasted[i] ?? '');
+ setDigits(next);
+ const lastFilled = Math.min(pasted.length, CODE_LENGTH) - 1;
+ inputsRef.current[lastFilled < 0 ? 0 : lastFilled]?.focus();
+ }
+
+ async function submitPairing() {
+ setPairingError(null);
+ setPairingSubmitting(true);
+ try {
+ const result = await api.confirmPairing(code);
+ setPairingSuccess(result);
+ } catch (err) {
+ if (err instanceof ApiError) {
+ if (err.status === 400) {
+ setPairingError('Code invalide ou expiré. Vérifie le code affiché sur le robot.');
+ } else {
+ setPairingError(err.message || "Erreur lors de l'association.");
+ }
+ } else {
+ setPairingError('Impossible de joindre le serveur. Reconnecte-toi à ton WiFi habituel.');
+ }
+ } finally {
+ setPairingSubmitting(false);
+ }
+ }
+
+ // ── Signal icon helper ───────────────────────────────────────────
+
+ function signalIcon(signal: number): string {
+ if (signal >= 70) return '▂▄▆█';
+ if (signal >= 50) return '▂▄▆░';
+ if (signal >= 30) return '▂▄░░';
+ return '▂░░░';
+ }
+
+ // ── Step progress indicator ──────────────────────────────────────
+
+ const steps = [
+ { key: 'connect-ap', label: 'Connexion' },
+ { key: 'wifi-select', label: 'WiFi' },
+ { key: 'pairing', label: 'Association' },
+ ] as const;
+
+ const currentStepIndex = (() => {
+ if (step === 'connect-ap') return 0;
+ if (step === 'wifi-select' || step === 'wifi-password' || step === 'wifi-connecting') return 1;
+ return 2;
+ })();
+
+ // ─── Render ──────────────────────────────────────────────────────
+
+ // Success screen
+ if (pairingSuccess) {
+ return (
+
+
+
+ 🎉
+
+
+ {robotName} est prêt !
+
+
+ Le robot est connecté au WiFi et associé à ton foyer.
+
+ {pairingSuccess.deviceId}
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Step indicator */}
+
+ {steps.map((s, i) => (
+
+
+ {i < currentStepIndex ? '✓' : i + 1}
+
+
+ {s.label}
+
+ {i < steps.length - 1 && (
+
+ )}
+
+ ))}
+
+
+ {/* ── Step 1: Connect to AP ── */}
+ {step === 'connect-ap' && (
+
+
+ 📡
+
+
+
Connecte-toi au robot
+
+ Ouvre les paramètres WiFi de ton ordinateur et connecte-toi au réseau :
+
+
+ Ti-Pote
+
+
+ Pas de mot de passe requis
+
+
+
+
+ {detecting && !detected && (
+
+
+ Recherche du robot...
+
+ )}
+
+ {detected && (
+
+ ✓
+ Robot détecté : {robotName}
+
+ )}
+
+
+
+
+ )}
+
+ {/* ── Step 2: WiFi selection ── */}
+ {step === 'wifi-select' && (
+
+
+
Choisis ton réseau WiFi
+
+ Le robot se connectera à ce réseau pour accéder à internet.
+
+
+
+
+
+ {networks.length} réseau{networks.length > 1 ? 'x' : ''} trouvé{networks.length > 1 ? 's' : ''}
+
+
+
+
+ {scanning && networks.length === 0 ? (
+
+
+ Scan en cours...
+
+ ) : (
+
+ {networks.map((n) => (
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+ {/* ── Step 2b: WiFi password ── */}
+ {step === 'wifi-password' && (
+
+
+
Mot de passe WiFi
+
+ Réseau : {selectedSsid}
+
+
+
+
+
+ )}
+
+ {/* ── Step 2c: Connecting... ── */}
+ {step === 'wifi-connecting' && (
+
+
+
+
Connexion en cours...
+
+ Le robot se connecte au réseau {selectedSsid}.
+
+
+ Le hotspot va se fermer. Reconnecte-toi à ton WiFi habituel.
+
+
+
+ )}
+
+ {/* ── Step 3: Pairing code ── */}
+ {step === 'pairing' && (
+
+
+
+ 🔗
+
+
Associer le robot
+
+ Saisis le code à 6 chiffres affiché sur {robotName}
+
+
+
+
+
💡 Reconnecte-toi à ton WiFi
+
+ Le robot est maintenant sur ton WiFi. Reconnecte ton ordinateur à ton réseau
+ habituel pour pouvoir communiquer avec le serveur.
+
+
+
+
+
+ )}
+
+ {/* Bottom link */}
+
+
+ ← Retour au tableau de bord
+
+
+
+
+ );
+}