1588 lines
49 KiB
C
1588 lines
49 KiB
C
|
||
|
||
// === Início de: ./src/index.css ===
|
||
@import "tailwindcss";
|
||
|
||
// === Fim de: ./src/index.css ===
|
||
|
||
|
||
// === Início de: ./src/App.css ===
|
||
#root {
|
||
max-width: 1280px;
|
||
margin: 0 auto;
|
||
padding: 2rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.logo {
|
||
height: 6em;
|
||
padding: 1.5em;
|
||
will-change: filter;
|
||
transition: filter 300ms;
|
||
}
|
||
.logo:hover {
|
||
filter: drop-shadow(0 0 2em #646cffaa);
|
||
}
|
||
.logo.react:hover {
|
||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||
}
|
||
|
||
@keyframes logo-spin {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: no-preference) {
|
||
a:nth-of-type(2) .logo {
|
||
animation: logo-spin infinite 20s linear;
|
||
}
|
||
}
|
||
|
||
.card {
|
||
padding: 2em;
|
||
}
|
||
|
||
.read-the-docs {
|
||
color: #888;
|
||
}
|
||
|
||
// === Fim de: ./src/App.css ===
|
||
|
||
|
||
// === Início de: ./src/main.jsx ===
|
||
import React, { Suspense, lazy } from 'react';
|
||
import { createRoot } from 'react-dom/client';
|
||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||
import AppLayout from './App';
|
||
import './index.css';
|
||
|
||
import { AuthProvider } from './contexts/AuthContext';
|
||
import { ToastProvider } from './components/ToastContext';
|
||
import { ChargersProvider } from './contexts/ChargersContext';
|
||
import ProtectedRoute from './components/ProtectedRoute';
|
||
|
||
const PageLoading = () => (
|
||
<div className="min-h-screen flex items-center justify-center text-gray-500">
|
||
<span className="animate-pulse">Loading...</span>
|
||
</div>
|
||
);
|
||
|
||
const ChargersPage = lazy(() => import('./pages/ChargersPage'));
|
||
const ChargerDashboardPage = lazy(() => import('./pages/ChargerDashboardPage'));
|
||
const HistoryPage = lazy(() => import('./pages/HistoryPage'));
|
||
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
|
||
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
||
|
||
const container = document.getElementById('root');
|
||
if (!container) throw new Error('Root element not found');
|
||
|
||
const root = createRoot(container);
|
||
root.render(
|
||
<React.StrictMode>
|
||
<BrowserRouter> {/* ✅ Coloque BrowserRouter primeiro */}
|
||
<AuthProvider> {/* ✅ Agora dentro de BrowserRouter */}
|
||
<ToastProvider>
|
||
<ChargersProvider>
|
||
<Suspense fallback={<PageLoading />}>
|
||
<Routes>
|
||
<Route path="/login" element={<LoginPage />} />
|
||
|
||
<Route
|
||
path="/"
|
||
element={
|
||
<ProtectedRoute>
|
||
<AppLayout />
|
||
</ProtectedRoute>
|
||
}
|
||
>
|
||
<Route index element={<ChargersPage />} />
|
||
<Route path="charger/:chargerId" element={<ChargerDashboardPage />} />
|
||
<Route path="dashboard" element={<ChargerDashboardPage />} />
|
||
<Route path="history" element={<HistoryPage />} />
|
||
<Route path="settings" element={<SettingsPage />} />
|
||
</Route>
|
||
|
||
<Route path="chargers" element={<Navigate to="/" replace />} />
|
||
|
||
<Route
|
||
path="*"
|
||
element={
|
||
<div className="min-h-screen flex flex-col items-center justify-center text-gray-400">
|
||
<h1 className="text-4xl font-bold mb-2">404</h1>
|
||
<p>Page not found</p>
|
||
<a href="/" className="mt-3 text-blue-500 underline">Go back home</a>
|
||
</div>
|
||
}
|
||
/>
|
||
</Routes>
|
||
</Suspense>
|
||
</ChargersProvider>
|
||
</ToastProvider>
|
||
</AuthProvider>
|
||
</BrowserRouter>
|
||
</React.StrictMode>
|
||
);
|
||
|
||
// === Fim de: ./src/main.jsx ===
|
||
|
||
|
||
// === Início de: ./src/App.jsx ===
|
||
import { Outlet } from 'react-router-dom';
|
||
import BottomNav from './components/BottomNav';
|
||
|
||
function AppLayout() {
|
||
return (
|
||
<main
|
||
className="min-h-screen bg-gray-100 dark:bg-slate-900 pb-24 pt-4 px-2 sm:px-4"
|
||
role="main"
|
||
>
|
||
<div className="max-w-2xl mx-auto w-full">
|
||
<Outlet />
|
||
</div>
|
||
<BottomNav />
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export default AppLayout;
|
||
|
||
// === Fim de: ./src/App.jsx ===
|
||
|
||
|
||
// === Início de: ./src/hooks/useChargers.js ===
|
||
// src/hooks/useChargers.js
|
||
import { useEffect, useState } from "react";
|
||
import { getAuthHeader } from "@/utils/apiAuthHeader";
|
||
|
||
export function useChargers() {
|
||
const [chargers, setChargers] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
|
||
useEffect(() => {
|
||
setLoading(true);
|
||
fetch("/api/chargers", {
|
||
headers: getAuthHeader(),
|
||
})
|
||
.then(res => {
|
||
if (!res.ok) throw new Error("Erro ao buscar carregadores");
|
||
return res.json();
|
||
})
|
||
.then(json => {
|
||
setChargers(json.data || []);
|
||
setError("");
|
||
})
|
||
.catch(() => setError("Falha ao carregar carregadores"))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
return { chargers, setChargers, loading, error };
|
||
}
|
||
|
||
// === Fim de: ./src/hooks/useChargers.js ===
|
||
|
||
|
||
// === Início de: ./src/hooks/useSchedules.js ===
|
||
// src/hooks/useSchedules.js
|
||
import { useState, useEffect } from 'react';
|
||
import { getAuthHeader } from '@/utils/apiAuthHeader';
|
||
|
||
// Agora recebe chargerId!
|
||
export function useSchedules(chargerId) {
|
||
const [schedules, setSchedules] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
|
||
useEffect(() => {
|
||
if (!chargerId) return; // Só busca se houver carregador selecionado
|
||
setLoading(true);
|
||
fetch(`/api/chargers/${chargerId}/schedule`, {
|
||
headers: getAuthHeader(),
|
||
})
|
||
.then(res => {
|
||
if (res.status === 401 || res.status === 403) {
|
||
setError('Sessão expirada. Faça login novamente.');
|
||
return { data: [] };
|
||
}
|
||
return res.json();
|
||
})
|
||
.then(json => {
|
||
setSchedules(json.data || []);
|
||
setError('');
|
||
})
|
||
.catch(() => setError('Falha ao carregar agendamentos'))
|
||
.finally(() => setLoading(false));
|
||
}, [chargerId]); // <-- Atualiza quando o chargerId mudar
|
||
|
||
return { schedules, setSchedules, loading, error };
|
||
}
|
||
|
||
// === Fim de: ./src/hooks/useSchedules.js ===
|
||
|
||
|
||
// === Início de: ./src/hooks/useChargerStatus.js ===
|
||
|
||
// === Fim de: ./src/hooks/useChargerStatus.js ===
|
||
|
||
|
||
// === Início de: ./src/hooks/useApi.js ===
|
||
// src/hooks/useApi.js
|
||
import { useState } from 'react';
|
||
import { getAuthHeader } from '@/utils/apiAuthHeader';
|
||
|
||
export default function useApi() {
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
|
||
const request = async (url, options = {}) => {
|
||
setLoading(true);
|
||
setError('');
|
||
|
||
try {
|
||
const res = await fetch(url, {
|
||
...options,
|
||
headers: {
|
||
...getAuthHeader(),
|
||
...(options.headers || {})
|
||
},
|
||
});
|
||
|
||
if (res.status === 401 || res.status === 403) {
|
||
setError('Sessão expirada. Faça login novamente.');
|
||
throw new Error('Sessão expirada');
|
||
}
|
||
|
||
const data = await res.json();
|
||
|
||
if (!res.ok) throw new Error(data.message || 'Erro na API');
|
||
return data;
|
||
} catch (err) {
|
||
setError(err.message);
|
||
throw err;
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return { request, loading, error };
|
||
}
|
||
|
||
// === Fim de: ./src/hooks/useApi.js ===
|
||
|
||
|
||
// === Início de: ./src/hooks/useCharger.js ===
|
||
// src/hooks/useCharger.js
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { getAuthHeader } from '@/utils/apiAuthHeader';
|
||
|
||
export function useCharger(chargerId) {
|
||
const [chargerData, setChargerData] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
|
||
useEffect(() => {
|
||
if (!chargerId) return;
|
||
|
||
const fetchCharger = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch(`/api/chargers/${chargerId}/status`, {
|
||
headers: getAuthHeader(),
|
||
});
|
||
|
||
if (!res.ok) throw new Error('Failed to fetch charger data');
|
||
|
||
const json = await res.json();
|
||
setChargerData(json.data);
|
||
setError('');
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchCharger();
|
||
}, [chargerId]);
|
||
|
||
return { chargerData, loading, error };
|
||
}
|
||
|
||
// === Fim de: ./src/hooks/useCharger.js ===
|
||
|
||
|
||
// === Início de: ./src/hooks/useHistory.js ===
|
||
// src/hooks/useHistory.js
|
||
import { useState, useEffect } from 'react';
|
||
import { getAuthHeader } from '@/utils/apiAuthHeader';
|
||
|
||
// Agora recebe chargerId!
|
||
export function useHistory(chargerId) {
|
||
const [histories, setHistory] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
|
||
useEffect(() => {
|
||
if (!chargerId) return; // Só busca se houver carregador selecionado
|
||
setLoading(true);
|
||
fetch(`/api/chargers/${chargerId}/history`, {
|
||
headers: getAuthHeader(),
|
||
})
|
||
.then(res => {
|
||
if (res.status === 401 || res.status === 403) {
|
||
setError('Sessão expirada. Faça login novamente.');
|
||
return { data: [] };
|
||
}
|
||
return res.json();
|
||
})
|
||
.then(json => {
|
||
setHistory(json.data || []);
|
||
setError('');
|
||
})
|
||
.catch(() => setError('Falha ao carregar agendamentos'))
|
||
.finally(() => setLoading(false));
|
||
}, [chargerId]); // <-- Atualiza quando o chargerId mudar
|
||
|
||
return { histories, setHistory, loading, error };
|
||
}
|
||
|
||
// === Fim de: ./src/hooks/useHistory.js ===
|
||
|
||
|
||
// === Início de: ./src/utils/apiAuthHeader.js ===
|
||
export function getAuthHeader() {
|
||
if (typeof window === 'undefined') return {};
|
||
const token = window.localStorage?.getItem('token');
|
||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||
}
|
||
|
||
// === Fim de: ./src/utils/apiAuthHeader.js ===
|
||
|
||
|
||
// === Início de: ./src/utils/apiFetch.js ===
|
||
// utils/apiFetch.js
|
||
import { getAuthHeader } from './apiAuthHeader';
|
||
|
||
export async function apiFetch(url, options = {}) {
|
||
const res = await fetch(url, {
|
||
...options,
|
||
headers: {
|
||
...getAuthHeader(),
|
||
...(options.headers || {}),
|
||
},
|
||
});
|
||
|
||
const json = await res.json();
|
||
if (!res.ok || !json.success) {
|
||
throw new Error(json.message || 'Erro na requisição');
|
||
}
|
||
return json.data;
|
||
}
|
||
|
||
// === Fim de: ./src/utils/apiFetch.js ===
|
||
|
||
|
||
// === Início de: ./src/components/ProtectedRoute.jsx ===
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { Navigate, useLocation } from 'react-router-dom';
|
||
|
||
// Uso: <ProtectedRoute> <ComponentePrivado /> </ProtectedRoute>
|
||
export default function ProtectedRoute({ children }) {
|
||
const { isAuthenticated } = useAuth();
|
||
const location = useLocation();
|
||
|
||
if (!isAuthenticated) {
|
||
// redireciona para /login, guardando a rota atual para possível redirect pós-login
|
||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||
}
|
||
|
||
return children;
|
||
}
|
||
|
||
// === Fim de: ./src/components/ProtectedRoute.jsx ===
|
||
|
||
|
||
// === Início de: ./src/components/BottomNav.jsx ===
|
||
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
|
||
import { Zap, Calendar, Settings, BatteryCharging } from 'lucide-react';
|
||
import { motion } from 'framer-motion';
|
||
|
||
const tabs = [
|
||
{ label: 'Carregador', to: '/dashboard', icon: Zap },
|
||
{ label: 'Histórico', to: '/history', icon: Calendar },
|
||
{ label: 'Configuração', to: '/settings', icon: Settings },
|
||
{ label: 'Carregadores', to: '/', icon: BatteryCharging },
|
||
];
|
||
|
||
export default function BottomNav() {
|
||
return (
|
||
<motion.nav
|
||
initial={{ y: 40, opacity: 0 }}
|
||
animate={{ y: 0, opacity: 1 }}
|
||
transition={{ type: 'spring', bounce: 0.16, duration: 0.55 }}
|
||
className="fixed bottom-0 left-0 right-0 bg-white dark:bg-slate-900 border-t border-gray-100 dark:border-slate-800 shadow-md flex z-50"
|
||
aria-label="Navegação principal"
|
||
>
|
||
{tabs.map(({ label, to, icon: Icon }) => {
|
||
const resolved = useResolvedPath(to);
|
||
const match = useMatch({ path: resolved.pathname, end: true });
|
||
|
||
return (
|
||
<Link
|
||
key={to}
|
||
to={to}
|
||
aria-label={label}
|
||
className={`flex flex-1 flex-col items-center justify-center py-2.5 px-2 transition font-medium group
|
||
${match ? 'text-blue-700 dark:text-blue-400 font-semibold' : 'text-gray-400 dark:text-gray-500'}
|
||
active:bg-gray-100 dark:active:bg-slate-800`}
|
||
tabIndex={0}
|
||
>
|
||
<motion.div
|
||
whileTap={{ scale: 0.85 }}
|
||
className={`rounded-full flex items-center justify-center mb-0.5
|
||
${match ? 'bg-blue-50 dark:bg-blue-950' : ''}
|
||
transition`}
|
||
style={{ width: 38, height: 38 }}
|
||
>
|
||
<Icon
|
||
size={match ? 26 : 22}
|
||
className={match ? 'stroke-2' : 'stroke-1.5'}
|
||
/>
|
||
</motion.div>
|
||
<span
|
||
className={`text-[13px] transition
|
||
${match ? 'font-semibold text-blue-600 dark:text-blue-400' : 'text-gray-500 dark:text-gray-400'}`}
|
||
>
|
||
{label}
|
||
</span>
|
||
</Link>
|
||
);
|
||
})}
|
||
</motion.nav>
|
||
);
|
||
}
|
||
|
||
// === Fim de: ./src/components/BottomNav.jsx ===
|
||
|
||
|
||
// === Início de: ./src/components/ToastContext.jsx ===
|
||
// src/components/ToastContext.jsx
|
||
import { createContext, useContext, useState } from 'react';
|
||
|
||
const ToastContext = createContext();
|
||
|
||
export function ToastProvider({ children }) {
|
||
const [toast, setToast] = useState(null);
|
||
|
||
function showToast(message, type = 'success') {
|
||
setToast({ message, type });
|
||
setTimeout(() => setToast(null), 2500);
|
||
}
|
||
|
||
return (
|
||
<ToastContext.Provider value={showToast}>
|
||
{children}
|
||
{toast && (
|
||
<div className={`fixed bottom-6 right-6 px-4 py-3 rounded shadow-xl z-50 text-white ${toast.type === 'error' ? 'bg-red-600' : 'bg-blue-600'}`}>
|
||
{toast.message}
|
||
</div>
|
||
)}
|
||
</ToastContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useToast() {
|
||
return useContext(ToastContext);
|
||
}
|
||
|
||
// === Fim de: ./src/components/ToastContext.jsx ===
|
||
|
||
|
||
// === Início de: ./src/components/ui/SectionCard.jsx ===
|
||
// src/components/ui/SectionCard.jsx
|
||
import { motion } from 'framer-motion';
|
||
|
||
export function SectionCard({ title, icon, children }) {
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4 }}
|
||
className="bg-white rounded-2xl shadow-xl border border-gray-200 p-6"
|
||
>
|
||
<div className="flex items-center gap-2 mb-5">
|
||
{icon && <div className="text-blue-600">{icon}</div>}
|
||
<h2 className="text-xl font-bold text-gray-800">{title}</h2>
|
||
</div>
|
||
<div className="space-y-4">
|
||
{children}
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
// === Fim de: ./src/components/ui/SectionCard.jsx ===
|
||
|
||
|
||
// === Início de: ./src/pages/ChargersPage.jsx ===
|
||
// src/pages/ChargersPage.jsx
|
||
import React, { useState } from 'react';
|
||
import { Plus, Loader2, X, Trash2 } from 'lucide-react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { useChargersContext } from '@/contexts/ChargersContext';
|
||
import { useToast } from '@/components/ToastContext';
|
||
|
||
export default function ChargersPage() {
|
||
const { chargers, selectedCharger, selectChargerById, fetchChargers, loading, error } = useChargersContext();
|
||
const [deletingId, setDeletingId] = useState(null);
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const toast = useToast();
|
||
|
||
const handleOpenAdd = () => setShowAdd(true);
|
||
const handleCloseAdd = () => setShowAdd(false);
|
||
|
||
const handleSelect = (charger) => {
|
||
selectChargerById(charger.id);
|
||
};
|
||
|
||
const handleDelete = async (id) => {
|
||
if (!window.confirm('Are you sure you want to delete this charger? This action cannot be undone.')) return;
|
||
setDeletingId(id);
|
||
try {
|
||
const res = await fetch(`/api/chargers/${id}`, { method: 'DELETE' });
|
||
if (!res.ok) throw new Error('Failed to delete charger');
|
||
toast('Charger deleted', 'success');
|
||
await fetchChargers();
|
||
} catch (err) {
|
||
console.error(err);
|
||
toast(err.message, 'error');
|
||
} finally {
|
||
setDeletingId(null);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-10">
|
||
<Loader2 className="animate-spin text-blue-500" size={32} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return <p className="text-red-500">{error}</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto p-4 pb-24">
|
||
<div className="flex items-center justify-between mb-5">
|
||
<h1 className="text-2xl font-bold">My Chargers</h1>
|
||
<button
|
||
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg shadow hover:bg-blue-700 transition"
|
||
onClick={handleOpenAdd}
|
||
>
|
||
<Plus size={20} /> New Charger
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
{chargers.length > 0 ? (
|
||
chargers.map((charger) => (
|
||
<motion.div
|
||
key={charger.id}
|
||
className={`bg-white rounded-xl shadow-md border p-5 flex flex-col gap-2 cursor-pointer relative transition ${
|
||
selectedCharger?.id === charger.id ? 'border-blue-500 bg-blue-50' : ''
|
||
}`}
|
||
onClick={() => handleSelect(charger)}
|
||
>
|
||
<button
|
||
className="absolute top-3 right-3 text-gray-400 hover:text-red-500 z-10"
|
||
onClick={(e) => { e.stopPropagation(); handleDelete(charger.id); }}
|
||
disabled={deletingId === charger.id}
|
||
>
|
||
{deletingId === charger.id ? <Loader2 className="animate-spin" size={18} /> : <Trash2 size={18} />}
|
||
</button>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span
|
||
className={`inline-block w-2 h-2 rounded-full ${
|
||
charger.status === 'online'
|
||
? 'bg-green-500'
|
||
: charger.status === 'standby'
|
||
? 'bg-yellow-400'
|
||
: 'bg-gray-400'
|
||
}`}
|
||
/>
|
||
<span className="font-semibold text-lg">{charger.name}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
Pairing code: <span className="font-mono">{charger.pairingCode}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-500">Location: {charger.location}</div>
|
||
<div className="text-xs text-gray-400">Last activity: {charger.lastActivity}</div>
|
||
</motion.div>
|
||
))
|
||
) : (
|
||
<p className="text-gray-500">No chargers found</p>
|
||
)}
|
||
</div>
|
||
<AnimatePresence>
|
||
{showAdd && <AddChargerModal onClose={handleCloseAdd} onSaved={fetchChargers} />}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AddChargerModal({ onClose, onSaved }) {
|
||
const [name, setName] = useState('');
|
||
const [location, setLocation] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const toast = useToast();
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch('/api/chargers', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, location }),
|
||
});
|
||
if (!res.ok) throw new Error('Failed to add charger');
|
||
toast('Charger added', 'success');
|
||
onSaved();
|
||
onClose();
|
||
} catch (err) {
|
||
console.error(err);
|
||
toast(err.message, 'error');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center"
|
||
>
|
||
<motion.div
|
||
initial={{ scale: 0.94, y: 40, opacity: 0 }}
|
||
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||
exit={{ scale: 0.92, y: 40, opacity: 0 }}
|
||
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-sm relative"
|
||
>
|
||
<button
|
||
className="absolute top-3 right-3 text-gray-500 hover:text-gray-800"
|
||
onClick={onClose}
|
||
aria-label="Close modal"
|
||
>
|
||
<X />
|
||
</button>
|
||
<h2 className="text-xl font-bold mb-4">New Charger</h2>
|
||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Name</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
required
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Location</label>
|
||
<input
|
||
type="text"
|
||
value={location}
|
||
onChange={(e) => setLocation(e.target.value)}
|
||
required
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||
/>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
disabled={saving}
|
||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded-lg transition flex items-center justify-center gap-2"
|
||
>
|
||
{saving && <Loader2 className="animate-spin" size={18} />} Save
|
||
</button>
|
||
</form>
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
// === Fim de: ./src/pages/ChargersPage.jsx ===
|
||
|
||
|
||
// === Início de: ./src/pages/HistoryPage.jsx ===
|
||
// src/pages/HistoryPage.jsx
|
||
import React, { useState, useEffect } from 'react';
|
||
import { History } from 'lucide-react';
|
||
import { SectionCard } from '@/components/ui/SectionCard';
|
||
import { useHistory } from '@/hooks/useHistory';
|
||
import { useChargersContext } from '@/contexts/ChargersContext';
|
||
import {
|
||
BarChart,
|
||
Bar,
|
||
XAxis,
|
||
YAxis,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
ReferenceLine,
|
||
} from 'recharts';
|
||
|
||
export default function HistoryPage() {
|
||
const { selectedCharger } = useChargersContext();
|
||
const chargerId = selectedCharger?.id;
|
||
const { histories, loading, error } = useHistory(chargerId);
|
||
|
||
// History/demo state
|
||
const [viewMode, setViewMode] = useState('Week');
|
||
const [chartData] = useState([
|
||
{ day: 'Mon', kwh: 12 },
|
||
{ day: 'Tue', kwh: 15 },
|
||
{ day: 'Wed', kwh: 13 },
|
||
{ day: 'Thu', kwh: 0 },
|
||
{ day: 'Fri', kwh: 14 },
|
||
{ day: 'Sat', kwh: 18 },
|
||
{ day: 'Sun', kwh: 12 },
|
||
]);
|
||
const [sessions] = useState([
|
||
{ date: '27 Oct', time: '19:30', kwh: 12, duration: '2h 03m', cost: '€2,40' },
|
||
{ date: '25 Oct', time: '07:15', kwh: 8, duration: '1h 20m', cost: '€1,60' },
|
||
]);
|
||
|
||
const totalKwh = chartData.reduce((sum, d) => sum + d.kwh, 0).toFixed(2);
|
||
const totalCost = (totalKwh * 0.2).toFixed(2);
|
||
|
||
return (
|
||
<div className="max-w-md mx-auto p-4 space-y-6">
|
||
{/* === Histórico === */}
|
||
<SectionCard title="Histórico" icon={<History size={20} />}>
|
||
<div className="flex gap-2 mb-4">
|
||
{['Week', 'Month', 'Year'].map(mode => (
|
||
<button
|
||
key={mode}
|
||
type="button"
|
||
onClick={() => setViewMode(mode)}
|
||
className={`px-3 py-1 rounded-full border text-sm transition ${viewMode === mode ? 'bg-green-600 text-white border-green-600' : 'bg-white text-gray-700 border-gray-300'}`}
|
||
>{mode}</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<div className="text-sm text-gray-500">Total energy</div>
|
||
<div className="text-2xl font-bold">{totalKwh} kWh</div>
|
||
<div className="text-sm text-gray-500">Spent €{totalCost}</div>
|
||
</div>
|
||
|
||
<div style={{ width: '100%', height: 160 }}>
|
||
<ResponsiveContainer>
|
||
<BarChart data={chartData}>
|
||
<XAxis dataKey="day" tickLine={false} />
|
||
<YAxis hide />
|
||
<Tooltip formatter={val => `${val} kWh`} />
|
||
<ReferenceLine y={chartData.reduce((s, d) => s + d.kwh, 0) / chartData.length} stroke="#999" strokeDasharray="3 3" />
|
||
<Bar dataKey="kwh" fill="#2563EB" radius={[4, 4, 0, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-center mt-2 text-sm text-gray-500 gap-4">
|
||
<button type="button">←</button>
|
||
<span>23 – 29 Oct 2023</span>
|
||
<button type="button">→</button>
|
||
</div>
|
||
|
||
<div className="mt-6 flex justify-between items-center">
|
||
<span className="font-medium text-gray-800">Recent sessions</span>
|
||
<button className="text-green-600 text-sm">View all</button>
|
||
</div>
|
||
<ul className="mt-2 space-y-2">
|
||
{sessions.map((s, i) => (
|
||
<li key={i} className="flex justify-between items-center">
|
||
<div className="text-sm">
|
||
<div>{s.date} · {s.time}</div>
|
||
<div className="text-gray-500 text-xs">{s.kwh} kWh · {s.duration}</div>
|
||
</div>
|
||
<div className="font-medium">{s.cost}</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</SectionCard>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// === Fim de: ./src/pages/HistoryPage.jsx ===
|
||
|
||
|
||
// === Início de: ./src/pages/ChargerDashboardPage.jsx ===
|
||
import React, { useEffect, useState } from 'react';
|
||
import {
|
||
Play,
|
||
Square,
|
||
Zap,
|
||
BatteryCharging,
|
||
Clock,
|
||
Activity,
|
||
Loader2,
|
||
} from 'lucide-react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { SectionCard } from '@/components/ui/SectionCard';
|
||
import { useToast } from '@/components/ToastContext';
|
||
import { useChargersContext } from '@/contexts/ChargersContext';
|
||
import { getAuthHeader } from '@/utils/apiAuthHeader';
|
||
|
||
export default function ChargerDashboardPage() {
|
||
const { selectedCharger } = useChargersContext();
|
||
const chargerId = selectedCharger?.id;
|
||
const [chargerData, setChargerData] = useState(null);
|
||
const [ampLimit, setAmpLimit] = useState(6);
|
||
const [maxAmps, setMaxAmps] = useState(32);
|
||
const [error, setError] = useState('');
|
||
const [loading, setLoading] = useState(true);
|
||
const [busy, setBusy] = useState(false);
|
||
const toast = useToast();
|
||
|
||
useEffect(() => {
|
||
if (!chargerId) {
|
||
setError('Charger not found!');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
let canceled = false;
|
||
const fetchStatus = async () => {
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const res = await fetch(`/api/chargers/${chargerId}/status`, {
|
||
headers: getAuthHeader(), // ✅ Com token
|
||
});
|
||
|
||
const json = await res.json();
|
||
if (!res.ok || !json.success) {
|
||
throw new Error(json.message || 'Failed to fetch status');
|
||
}
|
||
if (!canceled) {
|
||
setChargerData(json.data);
|
||
setAmpLimit(json.data.ampLimit ?? 6);
|
||
setMaxAmps(json.data.maxAmps ?? 32);
|
||
}
|
||
} catch (err) {
|
||
if (!canceled) setError(err.message);
|
||
} finally {
|
||
if (!canceled) setLoading(false);
|
||
}
|
||
};
|
||
|
||
fetchStatus();
|
||
const interval = setInterval(fetchStatus, 8000);
|
||
return () => {
|
||
canceled = true;
|
||
clearInterval(interval);
|
||
};
|
||
}, [chargerId]);
|
||
|
||
const handleAction = async (action) => {
|
||
if (!chargerId) return;
|
||
setBusy(true);
|
||
try {
|
||
const res = await fetch(`/api/chargers/${chargerId}/${action}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...getAuthHeader(), // ✅ Corrigido aqui
|
||
},
|
||
body: JSON.stringify({ ampLimit }),
|
||
});
|
||
|
||
const json = await res.json();
|
||
if (!res.ok || !json.success) {
|
||
throw new Error(json.message || 'Failed to send command');
|
||
}
|
||
|
||
setChargerData(json.data);
|
||
toast(action === 'start' ? 'Charging started' : 'Charging stopped', 'success');
|
||
setError('');
|
||
} catch (err) {
|
||
setError(err.message);
|
||
toast(err.message, 'error');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto p-4 pb-24">
|
||
<SectionCard
|
||
title={chargerData?.name ? `Charger: ${chargerData.name}` : 'Charger'}
|
||
icon={<Zap className="text-blue-600" />}
|
||
>
|
||
{error && (
|
||
<motion.p
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="text-red-500 text-sm mb-4"
|
||
aria-live="polite"
|
||
>
|
||
{error}
|
||
</motion.p>
|
||
)}
|
||
|
||
{loading || busy ? (
|
||
<div className="flex flex-col items-center justify-center py-8">
|
||
<Loader2 className="animate-spin text-blue-500" size={40} />
|
||
<span className="text-blue-600 mt-2">
|
||
{busy ? 'Sending command...' : 'Loading status...'}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
chargerData && (
|
||
<>
|
||
<AnimatePresence mode="wait">
|
||
<motion.div
|
||
key={chargerData.currentPower}
|
||
initial={{ scale: 0.85, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
exit={{ scale: 0.85, opacity: 0 }}
|
||
transition={{ duration: 0.5 }}
|
||
className="relative w-40 h-40 mx-auto mb-8 select-none"
|
||
>
|
||
<div
|
||
className={`absolute inset-0 rounded-full border-8 ${
|
||
chargerData.currentPower > 7
|
||
? 'border-yellow-400 opacity-60'
|
||
: 'border-blue-200 opacity-30'
|
||
}`}
|
||
/>
|
||
<div
|
||
className={`absolute inset-2 rounded-full border-8 ${
|
||
chargerData.currentPower > 7
|
||
? 'border-yellow-500'
|
||
: 'border-blue-600'
|
||
} flex flex-col items-center justify-center text-blue-700`}
|
||
>
|
||
<motion.div
|
||
initial={{ rotate: -10 }}
|
||
animate={{ rotate: [0, 12, -12, 0] }}
|
||
transition={{
|
||
duration: 1,
|
||
repeat: Infinity,
|
||
repeatType: 'mirror',
|
||
ease: 'easeInOut',
|
||
}}
|
||
className={`mb-1 ${
|
||
chargerData.currentPower > 7 ? 'text-yellow-500' : 'text-blue-600'
|
||
}`}
|
||
>
|
||
<Zap size={32} />
|
||
</motion.div>
|
||
<span className="text-4xl font-extrabold drop-shadow">
|
||
{chargerData.currentPower} kW
|
||
</span>
|
||
<span className="text-xs text-gray-400 font-medium mt-1 tracking-wide">
|
||
Power
|
||
</span>
|
||
</div>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
|
||
<div className="grid gap-5 text-sm text-gray-700 mb-8">
|
||
<InfoItem
|
||
icon={<BatteryCharging className="text-blue-500" />}
|
||
label="Status"
|
||
value={chargerData.status}
|
||
/>
|
||
<InfoItem
|
||
icon={<Clock className="text-gray-500" />}
|
||
label="Time Remaining"
|
||
value={chargerData.timeRemaining}
|
||
/>
|
||
<InfoItem
|
||
icon={<Zap className="text-yellow-500" />}
|
||
label="Power"
|
||
value={<span className="text-lg font-bold text-yellow-600">{chargerData.currentPower} kW</span>}
|
||
/>
|
||
<InfoItem
|
||
icon={<Activity className="text-indigo-500" />}
|
||
label="Mode"
|
||
value={chargerData.mode?.toUpperCase()}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mb-10">
|
||
<label
|
||
htmlFor="amp-range"
|
||
className="flex items-center gap-3 text-base font-medium text-gray-800 mb-4"
|
||
>
|
||
<Zap className="text-gray-600" size={20} />
|
||
Max Current: <span className="text-blue-700 font-semibold">{ampLimit} A</span>
|
||
</label>
|
||
<input
|
||
id="amp-range"
|
||
type="range"
|
||
min={6}
|
||
max={maxAmps}
|
||
value={ampLimit}
|
||
onChange={(e) => setAmpLimit(Number(e.target.value))}
|
||
className="w-full accent-blue-600"
|
||
disabled={busy}
|
||
/>
|
||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||
<span>6 A</span>
|
||
<span>{maxAmps} A</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-center gap-4 mt-8">
|
||
<motion.button
|
||
whileTap={{ scale: 0.95 }}
|
||
onClick={() => handleAction('start')}
|
||
disabled={busy}
|
||
className="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded-xl shadow font-medium flex items-center gap-2"
|
||
>
|
||
<Play size={18} /> Start
|
||
</motion.button>
|
||
<motion.button
|
||
whileTap={{ scale: 0.95 }}
|
||
onClick={() => handleAction('stop')}
|
||
disabled={busy}
|
||
className="bg-red-500 hover:bg-red-600 text-white px-5 py-2 rounded-xl shadow font-medium flex items-center gap-2"
|
||
>
|
||
<Square size={18} /> Stop
|
||
</motion.button>
|
||
</div>
|
||
</>
|
||
)
|
||
)}
|
||
</SectionCard>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InfoItem({ icon, label, value }) {
|
||
return (
|
||
<div className="grid grid-cols-2 items-center gap-2 py-1.5">
|
||
<div className="flex items-center gap-2 text-gray-600 font-medium">
|
||
{icon}
|
||
<span>{label}</span>
|
||
</div>
|
||
<div className="text-right text-base font-semibold">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// === Fim de: ./src/pages/ChargerDashboardPage.jsx ===
|
||
|
||
|
||
// === Início de: ./src/pages/SettingsPage.jsx ===
|
||
// src/pages/SettingsPage.jsx
|
||
import React, { useState } from 'react';
|
||
import { Settings as SettingsIcon, Power, Loader2, CalendarDays } from 'lucide-react';
|
||
import { SectionCard } from '@/components/ui/SectionCard';
|
||
import { motion } from 'framer-motion';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { useSchedules } from '@/hooks/useSchedules';
|
||
import { useToast } from '@/components/ToastContext';
|
||
import { useChargersContext } from '@/contexts/ChargersContext';
|
||
|
||
export default function SettingsPage() {
|
||
const { logout } = useAuth();
|
||
const toast = useToast();
|
||
|
||
// Settings state
|
||
const [energyRate, setEnergyRate] = useState(0.5);
|
||
const [batteryCapacity, setBatteryCapacity] = useState(60);
|
||
const [chargeLimit, setChargeLimit] = useState(80);
|
||
const [theme, setTheme] = useState('light');
|
||
const [loadingDiag, setLoadingDiag] = useState(false);
|
||
|
||
const handleDiagnostic = () => {
|
||
setLoadingDiag(true);
|
||
setTimeout(() => {
|
||
setLoadingDiag(false);
|
||
toast('Diagnostic complete: Charger OK!', 'success');
|
||
}, 1200);
|
||
};
|
||
|
||
// Agendamentos state
|
||
const { selectedCharger } = useChargersContext();
|
||
const chargerId = selectedCharger?.id;
|
||
const { schedules, setSchedules, loading, error } = useSchedules(chargerId);
|
||
const [startTime, setStartTime] = useState('');
|
||
const [targetPercentage, setTargetPercentage] = useState(80);
|
||
|
||
const handleAddSchedule = (e) => {
|
||
e.preventDefault();
|
||
if (!startTime) return;
|
||
setSchedules([...schedules, { startTime, targetPercentage }]);
|
||
toast('Agendamento adicionado!', 'success');
|
||
setStartTime('');
|
||
setTargetPercentage(80);
|
||
};
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto p-4 space-y-6">
|
||
{/* Agendamentos */}
|
||
<SectionCard title="Agendamentos" icon={<CalendarDays size={20} />}>
|
||
<form onSubmit={handleAddSchedule} className="space-y-4 mb-4">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Início</label>
|
||
<input
|
||
type="datetime-local"
|
||
value={startTime}
|
||
onChange={e => setStartTime(e.target.value)}
|
||
className="w-full border px-3 py-2 rounded-lg"
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Carga desejada (%)</label>
|
||
<input
|
||
type="number" min={1} max={100}
|
||
value={targetPercentage}
|
||
onChange={e => setTargetPercentage(parseInt(e.target.value))}
|
||
className="w-full border px-3 py-2 rounded-lg"
|
||
required
|
||
/>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
className="w-full bg-green-600 text-white py-2 rounded-lg"
|
||
>Salvar Agendamento</button>
|
||
</form>
|
||
{loading ? <p>Carregando...</p> : error ? <p className="text-red-500">{error}</p> : (
|
||
<ul className="space-y-2">
|
||
{schedules.length === 0 ? (
|
||
<li className="text-gray-500">Nenhum agendamento</li>
|
||
) : (
|
||
schedules.map((item, i) => (
|
||
<li key={i} className="bg-white p-3 shadow rounded-lg">
|
||
{item.startTime} — {item.targetPercentage}%
|
||
</li>
|
||
))
|
||
)}
|
||
</ul>
|
||
)}
|
||
</SectionCard>
|
||
|
||
{/* Settings */}
|
||
<SectionCard title="Settings" icon={<SettingsIcon className="text-blue-600" /> }>
|
||
<div className="space-y-5">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Energy rate (USD/kWh)</label>
|
||
<input
|
||
type="number" step="0.01" min={0}
|
||
value={energyRate}
|
||
onChange={e => setEnergyRate(parseFloat(e.target.value))}
|
||
className="w-full border px-4 py-2 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Battery capacity (kWh)</label>
|
||
<input
|
||
type="number" min={1}
|
||
value={batteryCapacity}
|
||
onChange={e => setBatteryCapacity(parseInt(e.target.value))}
|
||
className="w-full border px-4 py-2 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Charge limit (%)</label>
|
||
<input
|
||
type="number" min={1} max={100}
|
||
value={chargeLimit}
|
||
onChange={e => setChargeLimit(Math.min(100, Math.max(1, parseInt(e.target.value))))}
|
||
className="w-full border px-4 py-2 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Theme</label>
|
||
<select
|
||
value={theme}
|
||
onChange={e => setTheme(e.target.value)}
|
||
className="w-full border px-4 py-2 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="light">Light</option>
|
||
<option value="dark">Dark</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Quick Diagnostic</label>
|
||
<motion.button
|
||
whileTap={{ scale: 0.96 }}
|
||
onClick={handleDiagnostic}
|
||
disabled={loadingDiag}
|
||
className="w-full bg-blue-600 text-white py-2 rounded-lg flex items-center justify-center gap-2"
|
||
>
|
||
{loadingDiag ? <Loader2 className="animate-spin" size={18}/> : 'Run Diagnostic'}
|
||
</motion.button>
|
||
</div>
|
||
<motion.button
|
||
whileTap={{ scale: 0.96 }}
|
||
onClick={logout}
|
||
className="w-full bg-red-500 text-white py-2 rounded-lg"
|
||
>Logout</motion.button>
|
||
</div>
|
||
</SectionCard>
|
||
</div>
|
||
);
|
||
}
|
||
// === Fim de: ./src/pages/SettingsPage.jsx ===
|
||
|
||
|
||
// === Início de: ./src/pages/LoginPage.jsx ===
|
||
import { useState, useRef } from 'react';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { useToast } from '@/components/ToastContext';
|
||
import { Loader2 } from 'lucide-react';
|
||
|
||
export default function Login() {
|
||
const { login } = useAuth();
|
||
const showToast = useToast();
|
||
const [username, setUsername] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const userInput = useRef(null);
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setLoading(true);
|
||
|
||
try {
|
||
const res = await fetch('/api/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data?.error || 'Erro ao fazer login');
|
||
login(data.token); // ✅ usa o token corretamente
|
||
} catch (err) {
|
||
showToast(err.message, 'error');
|
||
setLoading(false);
|
||
userInput.current?.focus();
|
||
}
|
||
};
|
||
|
||
function handleForgotPassword(e) {
|
||
e.preventDefault();
|
||
showToast('Link de recuperação em breve!', 'success');
|
||
// Aqui você pode abrir modal, redirecionar, ou acionar fluxo real de recuperação
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||
<div className="bg-white shadow-xl rounded-2xl p-8 w-full max-w-md">
|
||
<h1 className="text-2xl font-bold text-center mb-6 text-gray-800">EV Station Login</h1>
|
||
<form onSubmit={handleSubmit} className="space-y-5">
|
||
<div>
|
||
<label htmlFor="username" className="block text-sm font-medium mb-1 text-gray-700">Usuário</label>
|
||
<input
|
||
id="username"
|
||
ref={userInput}
|
||
type="text"
|
||
placeholder="Digite seu usuário"
|
||
value={username}
|
||
onChange={e => setUsername(e.target.value)}
|
||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
|
||
required
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="password" className="block text-sm font-medium mb-1 text-gray-700">Senha</label>
|
||
<input
|
||
id="password"
|
||
type="password"
|
||
placeholder="Digite sua senha"
|
||
value={password}
|
||
onChange={e => setPassword(e.target.value)}
|
||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="flex justify-between items-center text-sm mt-1">
|
||
<button
|
||
type="button"
|
||
onClick={handleForgotPassword}
|
||
className="text-blue-600 hover:underline focus:outline-none"
|
||
tabIndex={0}
|
||
>
|
||
Esqueci a senha?
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className={`w-full flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-lg transition duration-200 ${
|
||
loading ? 'opacity-70 cursor-not-allowed' : ''
|
||
}`}
|
||
>
|
||
{loading && <Loader2 className="animate-spin" size={20} />}
|
||
Entrar
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// === Fim de: ./src/pages/LoginPage.jsx ===
|
||
|
||
|
||
// === Início de: ./src/contexts/AuthContext.jsx ===
|
||
import { createContext, useContext, useState, useEffect } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
// Utilitário para decodificar JWT sem biblioteca externa
|
||
function parseJwt(token) {
|
||
try {
|
||
return JSON.parse(atob(token.split('.')[1]));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const AuthContext = createContext();
|
||
|
||
export function AuthProvider({ children }) {
|
||
const navigate = useNavigate();
|
||
const [token, setToken] = useState(() => localStorage.getItem('token') || '');
|
||
const [user, setUser] = useState(() => {
|
||
const stored = localStorage.getItem('token');
|
||
return stored ? parseJwt(stored) : null;
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!token) return;
|
||
|
||
const payload = parseJwt(token);
|
||
if (!payload) {
|
||
logout();
|
||
return;
|
||
}
|
||
|
||
setUser(payload);
|
||
|
||
// Expiração do token
|
||
const expiresAt = payload.exp * 1000;
|
||
const now = Date.now();
|
||
const timeout = expiresAt - now;
|
||
|
||
if (timeout <= 0) {
|
||
logout();
|
||
} else {
|
||
const timer = setTimeout(() => {
|
||
logout();
|
||
}, timeout);
|
||
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [token]);
|
||
|
||
const login = (newToken) => {
|
||
const payload = parseJwt(newToken);
|
||
if (!payload) {
|
||
console.error("Token inválido");
|
||
return;
|
||
}
|
||
|
||
localStorage.setItem('token', newToken);
|
||
setToken(newToken);
|
||
setUser(payload);
|
||
navigate('/');
|
||
};
|
||
|
||
const logout = () => {
|
||
localStorage.removeItem('token');
|
||
setToken('');
|
||
setUser(null);
|
||
navigate('/login');
|
||
};
|
||
|
||
const isAuthenticated = !!token;
|
||
|
||
return (
|
||
<AuthContext.Provider value={{ token, user, login, logout, isAuthenticated }}>
|
||
{children}
|
||
</AuthContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useAuth() {
|
||
const ctx = useContext(AuthContext);
|
||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||
return ctx;
|
||
}
|
||
|
||
// === Fim de: ./src/contexts/AuthContext.jsx ===
|
||
|
||
|
||
// === Início de: ./src/contexts/ChargersContext.jsx ===
|
||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||
import useApi from '@/hooks/useApi';
|
||
|
||
const ChargersContext = createContext();
|
||
|
||
export function ChargersProvider({ children }) {
|
||
const { request, loading: apiLoading, error: apiError } = useApi();
|
||
const [chargers, setChargers] = useState([]);
|
||
const [selectedCharger, setSelectedCharger] = useState(null);
|
||
|
||
// Busca lista de carregadores e seleciona o primeiro se necessário
|
||
const fetchChargers = async () => {
|
||
try {
|
||
const response = await request('/api/chargers');
|
||
const list = response.data || [];
|
||
setChargers(list);
|
||
if (!selectedCharger && list.length > 0) {
|
||
setSelectedCharger(list[0]);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error fetching chargers:', err);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchChargers();
|
||
}, []);
|
||
|
||
// Seleciona um carregador por ID
|
||
const selectChargerById = (id) => {
|
||
const charger = chargers.find(c => c.id === id) || null;
|
||
setSelectedCharger(charger);
|
||
};
|
||
|
||
return (
|
||
<ChargersContext.Provider value={{
|
||
chargers,
|
||
selectedCharger,
|
||
selectChargerById,
|
||
fetchChargers,
|
||
loading: apiLoading,
|
||
error: apiError,
|
||
}}>
|
||
{children}
|
||
</ChargersContext.Provider>
|
||
);
|
||
}
|
||
|
||
export function useChargersContext() {
|
||
const context = useContext(ChargersContext);
|
||
if (!context) throw new Error('useChargersContext must be used within a ChargersProvider');
|
||
return context;
|
||
}
|
||
|
||
// === Fim de: ./src/contexts/ChargersContext.jsx ===
|
||
|
||
|
||
// === Início de: ./vite.config.js ===
|
||
import { defineConfig } from 'vite';
|
||
import react from '@vitejs/plugin-react';
|
||
import tailwindcss from '@tailwindcss/vite';
|
||
import { VitePWA } from 'vite-plugin-pwa';
|
||
import path from 'path'; // 👈 necessário para resolver caminhos
|
||
|
||
export default defineConfig({
|
||
base: '/',
|
||
plugins: [
|
||
react(),
|
||
tailwindcss(),
|
||
VitePWA({
|
||
registerType: 'autoUpdate',
|
||
includeAssets: ['icons/icon-192.png', 'icons/icon-512.png', 'favicon.ico'],
|
||
manifest: {
|
||
name: 'EV Station Controller',
|
||
short_name: 'EVStation',
|
||
start_url: '/',
|
||
display: 'standalone',
|
||
background_color: '#ffffff',
|
||
theme_color: '#0f172a',
|
||
icons: [
|
||
{
|
||
src: 'icons/icon-192.png',
|
||
sizes: '192x192',
|
||
type: 'image/png',
|
||
},
|
||
{
|
||
src: 'icons/icon-512.png',
|
||
sizes: '512x512',
|
||
type: 'image/png',
|
||
},
|
||
],
|
||
},
|
||
}),
|
||
],
|
||
resolve: {
|
||
alias: {
|
||
'@': path.resolve(__dirname, 'src'), // 👈 alias @ → ./src
|
||
},
|
||
},
|
||
server: {
|
||
proxy: {
|
||
'/api': 'http://localhost:4000',
|
||
},
|
||
},
|
||
build: {
|
||
sourcemap: false,
|
||
minify: 'esbuild',
|
||
target: 'esnext',
|
||
outDir: 'dist',
|
||
},
|
||
});
|
||
|
||
// === Fim de: ./vite.config.js ===
|
||
|
||
|
||
// === Início de: ./package.json ===
|
||
{
|
||
"name": "ev-pwa",
|
||
"private": true,
|
||
"version": "0.0.0",
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "vite build",
|
||
"lint": "eslint .",
|
||
"preview": "vite preview"
|
||
},
|
||
"dependencies": {
|
||
"framer-motion": "^12.18.1",
|
||
"lucide-react": "^0.515.0",
|
||
"react": "^19.1.0",
|
||
"react-dom": "^19.1.0",
|
||
"react-router-dom": "^7.6.2",
|
||
"recharts": "^2.15.3"
|
||
},
|
||
"devDependencies": {
|
||
"@eslint/js": "^9.25.0",
|
||
"@tailwindcss/cli": "^4.1.10",
|
||
"@tailwindcss/vite": "^4.1.10",
|
||
"@types/react": "^19.1.2",
|
||
"@types/react-dom": "^19.1.2",
|
||
"@vitejs/plugin-react": "^4.4.1",
|
||
"autoprefixer": "^10.4.21",
|
||
"eslint": "^9.25.0",
|
||
"eslint-plugin-react-hooks": "^5.2.0",
|
||
"eslint-plugin-react-refresh": "^0.4.19",
|
||
"globals": "^16.0.0",
|
||
"postcss": "^8.5.5",
|
||
"tailwindcss": "^4.1.10",
|
||
"vite": "^6.3.5",
|
||
"vite-plugin-pwa": "^1.0.0"
|
||
}
|
||
}
|
||
|
||
// === Fim de: ./package.json ===
|