Files
ev-pwa/projeto_parte1.c
2025-06-17 17:46:21 +01:00

1588 lines
49 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// === 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 ===