From 6760759cb67ddd1efef86fb0c5067a8698c4c833 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 13 Apr 2026 21:23:02 +0200 Subject: [PATCH] feat: add WiFi setup wizard and device detail dashboard (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SetupRobotPage: 4-step wizard (AP connect → WiFi scan → connect → pairing code) - DeviceDetailPage: health metrics dashboard + log viewer with filters - Robot local API client for captive portal communication (192.168.4.1) - API endpoints for health reports, alerts, and device logs - Dashboard: clickable device cards linking to detail, "Nouveau robot" button Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/App.tsx | 18 + apps/frontend/src/lib/api.ts | 75 +++ apps/frontend/src/lib/robot-local-api.ts | 112 ++++ apps/frontend/src/pages/DashboardPage.tsx | 57 +- apps/frontend/src/pages/DeviceDetailPage.tsx | 441 +++++++++++++++ apps/frontend/src/pages/SetupRobotPage.tsx | 534 +++++++++++++++++++ 6 files changed, 1212 insertions(+), 25 deletions(-) create mode 100644 apps/frontend/src/lib/robot-local-api.ts create mode 100644 apps/frontend/src/pages/DeviceDetailPage.tsx create mode 100644 apps/frontend/src/pages/SetupRobotPage.tsx 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) +

+ +
+
+ + + + + + + + + + + + + {reports.map((r, i) => ( + + + + + + + + + ))} + +
HeureCPURAMDisqueLoadWiFi
{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} +

+
+ +
{ + e.preventDefault(); + void handleWifiConnect(); + }} + className="flex flex-col gap-4" + > + setWifiPassword(e.target.value)} + error={wifiError} + autoFocus + placeholder="Mot de passe du réseau WiFi" + /> + +
+ + +
+
+
+ )} + + {/* ── 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. +

+
+ +
{ + e.preventDefault(); + void submitPairing(); + }} + className="flex flex-col gap-4" + > +
+ {digits.map((d, i) => ( + { + inputsRef.current[i] = el; + }} + inputMode="numeric" + pattern="\d*" + maxLength={1} + value={d} + onChange={(e) => setDigitAt(i, e.target.value)} + onKeyDown={(e) => onKeyDown(i, e)} + onFocus={(e) => e.target.select()} + className="h-14 w-12 rounded-xl border border-slate-700 bg-slate-900/60 text-center text-2xl font-semibold tabular-nums text-slate-100 shadow-inner focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40" + aria-label={`Chiffre ${i + 1}`} + /> + ))} +
+ + {pairingError && ( +
+ {pairingError} +
+ )} + + +
+
+ )} + + {/* Bottom link */} +
+ + ← Retour au tableau de bord + +
+ +
+ ); +}