agrega contenido completo de ecotrack
This commit is contained in:
1
ecotrack
1
ecotrack
Submodule ecotrack deleted from 82eb245071
24
ecotrack/.gitignore
vendored
Normal file
24
ecotrack/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
ecotrack/README.md
Normal file
16
ecotrack/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
21
ecotrack/eslint.config.js
Normal file
21
ecotrack/eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
])
|
||||
13
ecotrack/index.html
Normal file
13
ecotrack/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3811
ecotrack/package-lock.json
generated
Normal file
3811
ecotrack/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
ecotrack/package.json
Normal file
30
ecotrack/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.1",
|
||||
"firebase": "^12.13.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
1
ecotrack/public/favicon.svg
Normal file
1
ecotrack/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
24
ecotrack/public/icons.svg
Normal file
24
ecotrack/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
103
ecotrack/src/App.jsx
Normal file
103
ecotrack/src/App.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom'
|
||||
import { auth } from './firebase'
|
||||
import { onAuthStateChanged } from 'firebase/auth'
|
||||
import { doc, getDoc } from 'firebase/firestore'
|
||||
import { db } from './firebase'
|
||||
import { ETAProvider } from './context/ETAContext'
|
||||
import Dashboard from './screens/Dashboard'
|
||||
import SeparationGuide from './screens/SeparationGuide'
|
||||
import Reports from './screens/Reports'
|
||||
import Login from './screens/Login'
|
||||
|
||||
export default function App() {
|
||||
const [usuario, setUsuario] = useState(null) // { uid, email, colonia }
|
||||
const [checking, setChecking] = useState(true) // mientras Firebase verifica sesión
|
||||
|
||||
// Escucha si ya hay sesión activa (persiste aunque recargues la página)
|
||||
useEffect(() => {
|
||||
const unsub = onAuthStateChanged(auth, async (firebaseUser) => {
|
||||
if (firebaseUser) {
|
||||
// Recuperar colonia desde Firestore
|
||||
const snap = await getDoc(doc(db, 'usuarios', firebaseUser.uid))
|
||||
const colonia = snap.exists() ? snap.data().colonia : 'Zona Centro'
|
||||
setUsuario({ uid: firebaseUser.uid, email: firebaseUser.email, colonia })
|
||||
} else {
|
||||
setUsuario(null)
|
||||
}
|
||||
setChecking(false)
|
||||
})
|
||||
return () => unsub()
|
||||
}, [])
|
||||
|
||||
function handleLogout() {
|
||||
auth.signOut()
|
||||
setUsuario(null)
|
||||
}
|
||||
|
||||
// Pantalla de carga mientras Firebase verifica sesión
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#0a0e14' }}>
|
||||
<div style={{ width: 32, height: 32, border: '3px solid rgba(61,154,255,0.2)', borderTop: '3px solid #3d9aff', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Si no hay sesión → mostrar Login
|
||||
if (!usuario) {
|
||||
return <Login onLogin={setUsuario} />
|
||||
}
|
||||
|
||||
// App principal con sesión activa
|
||||
return (
|
||||
<ETAProvider colonia={usuario.colonia}>
|
||||
<BrowserRouter>
|
||||
{/* Barra de navegación */}
|
||||
<nav style={navStyle}>
|
||||
<NavLink to="/" end style={({ isActive }) => tabStyle(isActive)}>Dashboard</NavLink>
|
||||
<NavLink to="/guia" style={({ isActive }) => tabStyle(isActive)}>Separación</NavLink>
|
||||
<NavLink to="/reportes" style={({ isActive }) => tabStyle(isActive)}>Reportes</NavLink>
|
||||
{/* Botón de cerrar sesión */}
|
||||
<div
|
||||
onClick={handleLogout}
|
||||
style={{ ...tabStyle(false), marginLeft: 'auto', cursor: 'pointer' }}
|
||||
>
|
||||
Salir
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main style={{ maxWidth: 480, margin: '0 auto', padding: '0 0 32px' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/guia" element={<SeparationGuide />} />
|
||||
<Route path="/reportes" element={<Reports />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</BrowserRouter>
|
||||
</ETAProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const navStyle = {
|
||||
display: 'flex',
|
||||
background: '#131820',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
padding: '0 16px',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
}
|
||||
|
||||
const tabStyle = (isActive) => ({
|
||||
padding: '12px 14px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: isActive ? '#3d9aff' : '#6b7f96',
|
||||
borderBottom: isActive ? '2px solid #3d9aff' : '2px solid transparent',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'color 0.2s',
|
||||
})
|
||||
78
ecotrack/src/api/etaService.js
Normal file
78
ecotrack/src/api/etaService.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import coloniasData from '../data/colonias-rutas.json'
|
||||
import notifData from '../data/notificaciones.json'
|
||||
|
||||
export async function fetchAllData() {
|
||||
const res = await fetch('http://localhost:8000/rutas')
|
||||
const rutas = await res.json()
|
||||
|
||||
const rutasFormateadas = rutas.map(r => ({
|
||||
routeId: r.routeId,
|
||||
name: r.name,
|
||||
truckId: r.truckId,
|
||||
status: r.status,
|
||||
currentPositionId: r.posicionActual,
|
||||
positions: Array.from({ length: r.totalNodos }, (_, i) => ({
|
||||
positionId: i + 1,
|
||||
})),
|
||||
}))
|
||||
|
||||
return {
|
||||
rutas: rutasFormateadas,
|
||||
colonias: coloniasData,
|
||||
notificaciones: notifData,
|
||||
}
|
||||
}
|
||||
|
||||
export function calcularETA(colonia, { rutas, colonias, notificaciones }) {
|
||||
const coloniaInfo = colonias.find(
|
||||
c => c.colonia.toLowerCase() === colonia.toLowerCase()
|
||||
)
|
||||
if (!coloniaInfo) return { error: 'Colonia no encontrada' }
|
||||
|
||||
const ruta = rutas.find(r => r.routeId === coloniaInfo.routeId)
|
||||
if (!ruta) return { error: 'Ruta no disponible' }
|
||||
|
||||
// Usa el posicionActual del simulador directamente
|
||||
const posIdActual = ruta.currentPositionId ?? 1
|
||||
const totalNodos = ruta.positions.length
|
||||
|
||||
// Calcula ETA basado en nodos restantes
|
||||
// Cada nodo tarda ~90 segundos en el simulador
|
||||
const nodosRestantes = Math.max(0, totalNodos - posIdActual)
|
||||
const etaMinutos = nodosRestantes * 10 // 10 seg por nodo en demo = ~1 min por nodo
|
||||
const margen = 3
|
||||
|
||||
const ahora = new Date()
|
||||
const desde = new Date(ahora.getTime() + Math.max(0, etaMinutos - margen) * 60000)
|
||||
const hasta = new Date(ahora.getTime() + (etaMinutos + margen) * 60000)
|
||||
const fmt = d => d.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
let status = 'en_ruta'
|
||||
if (posIdActual >= 8) status = 'finalizado'
|
||||
else if (posIdActual >= 4) status = 'cerca'
|
||||
else if (posIdActual <= 1) status = 'iniciando'
|
||||
|
||||
let notificacion = null
|
||||
if (posIdActual <= 1) {
|
||||
notificacion = notificaciones.find(n => n.triggerEvent === 'ROUTE_START')
|
||||
} else if (posIdActual === 4) {
|
||||
notificacion = notificaciones.find(n => n.triggerEvent === 'TRUCK_PROXIMITY')
|
||||
} else if (posIdActual >= 8) {
|
||||
notificacion = notificaciones.find(n => n.triggerEvent === 'ROUTE_COMPLETED')
|
||||
}
|
||||
|
||||
return {
|
||||
routeId: ruta.routeId,
|
||||
routeName: ruta.name,
|
||||
colonia: coloniaInfo.colonia,
|
||||
horarioOficial: coloniaInfo.horarioEstimado,
|
||||
status,
|
||||
truckStatus: ruta.status,
|
||||
posicionActual: posIdActual,
|
||||
totalNodos,
|
||||
progresoPct: Math.round((posIdActual / 8) * 100),
|
||||
etaMinutos,
|
||||
etaVentana: { desde: fmt(desde), hasta: fmt(hasta) },
|
||||
notificacion: notificacion?.pushPayload ?? null,
|
||||
}
|
||||
}
|
||||
63
ecotrack/src/components/ETACard.jsx
Normal file
63
ecotrack/src/components/ETACard.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
export default function ETACard({ etaData }) {
|
||||
const { etaVentana, etaMinutos, status, routeName, horarioOficial, progresoPct } = etaData
|
||||
const isNear = status === 'cerca'
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<div style={styles.label}>⏱ Tiempo estimado de llegada</div>
|
||||
|
||||
<div style={styles.time}>
|
||||
{etaVentana.desde} – {etaVentana.hasta}
|
||||
</div>
|
||||
|
||||
<div style={styles.window}>
|
||||
El camión llega a tu zona en aprox.{' '}
|
||||
<strong style={{ color: isNear ? '#f59e0b' : '#e8edf5' }}>
|
||||
{etaMinutos} min
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div style={styles.track}>
|
||||
<div style={{
|
||||
...styles.trackFill,
|
||||
width: `${progresoPct}%`,
|
||||
background: isNear
|
||||
? 'linear-gradient(90deg, #f59e0b, #ef4444)'
|
||||
: 'linear-gradient(90deg, #3d9aff, #00d4a0)',
|
||||
}} />
|
||||
</div>
|
||||
<div style={styles.trackLabels}>
|
||||
<span>Salida · Relleno Sanitario</span>
|
||||
<span>{progresoPct}%</span>
|
||||
<span>Tu colonia</span>
|
||||
</div>
|
||||
|
||||
{isNear ? (
|
||||
<div style={{ ...styles.pill, background: 'rgba(245,158,11,0.12)', color: '#f59e0b', border: '1px solid rgba(245,158,11,0.25)' }}>
|
||||
<span style={styles.dot} /> ¡Camión cerca! · Prepara tus bolsas
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ ...styles.pill, background: 'rgba(0,212,160,0.12)', color: '#00d4a0', border: '1px solid rgba(0,212,160,0.25)' }}>
|
||||
<span style={{ ...styles.dot, background: '#00d4a0' }} /> En ruta · {routeName}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={styles.official}>
|
||||
Horario oficial: <strong>{horarioOficial}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
card: { background: 'linear-gradient(135deg, #0d1f3c 0%, #0a1628 100%)', border: '1px solid rgba(61,154,255,0.2)', borderRadius: 16, padding: '24px 20px', marginBottom: 12 },
|
||||
label: { fontSize: 11, fontWeight: 500, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#3d9aff', marginBottom: 8 },
|
||||
time: { fontSize: 28, fontWeight: 600, color: '#e8edf5', lineHeight: 1.1, marginBottom: 6 },
|
||||
window: { fontSize: 13, color: '#6b7f96', marginBottom: 16, lineHeight: 1.5 },
|
||||
track: { background: 'rgba(255,255,255,0.07)', borderRadius: 8, height: 8, marginBottom: 8, overflow: 'hidden' },
|
||||
trackFill: { height: '100%', borderRadius: 8, transition: 'width 1.5s ease' },
|
||||
trackLabels: { display: 'flex', justifyContent: 'space-between', fontSize: 10, color: '#6b7f96', marginBottom: 14 },
|
||||
pill: { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 12px', borderRadius: 20, fontSize: 11, fontWeight: 500, marginBottom: 10 },
|
||||
dot: { width: 6, height: 6, borderRadius: '50%', background: '#f59e0b', flexShrink: 0 },
|
||||
official: { fontSize: 11, color: '#3d5570', marginTop: 4 },
|
||||
}
|
||||
61
ecotrack/src/components/RouteProgress.jsx
Normal file
61
ecotrack/src/components/RouteProgress.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
const NODES = [
|
||||
{ label: 'Salida' },
|
||||
{ label: 'Tramo 1' },
|
||||
{ label: 'Tramo 2' },
|
||||
{ label: 'Tramo 3' },
|
||||
{ label: 'Tramo 4' },
|
||||
{ label: 'Tramo 5' },
|
||||
{ label: 'Tramo 6' },
|
||||
{ label: 'Final' },
|
||||
]
|
||||
|
||||
export default function RouteProgress({ posicionActual = 1, totalNodos = 8, progresoPct = 0 }) {
|
||||
// El simulador usa índice desde 1, el array desde 0
|
||||
const idx = posicionActual - 1
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.title}>Progreso de ruta · sin ubicación exacta</div>
|
||||
|
||||
<div style={styles.track}>
|
||||
{NODES.map((node, i) => (
|
||||
<div key={i} style={styles.nodeWrapper}>
|
||||
{i > 0 && (
|
||||
<div style={{
|
||||
...styles.connector,
|
||||
background: i <= idx ? '#00d4a0' : 'rgba(255,255,255,0.1)',
|
||||
}} />
|
||||
)}
|
||||
<div style={{
|
||||
...styles.dot,
|
||||
...(i < idx ? styles.dotDone : {}),
|
||||
...(i === idx ? styles.dotCurrent : {}),
|
||||
...(i > idx ? styles.dotPending : {}),
|
||||
}}>
|
||||
{i < idx ? '✓' : i === idx ? '🚛' : i + 1}
|
||||
</div>
|
||||
<div style={styles.label}>{node.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={styles.disclaimer}>
|
||||
La posición exacta del camión es privada · avance: {progresoPct}%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: { background: '#131820', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 12, padding: '14px 16px', marginBottom: 10 },
|
||||
title: { fontSize: 11, fontWeight: 500, letterSpacing: '0.06em', textTransform: 'uppercase', color: '#6b7f96', marginBottom: 14 },
|
||||
track: { display: 'flex', alignItems: 'flex-start', overflowX: 'auto', paddingBottom: 4 },
|
||||
nodeWrapper: { display: 'flex', flexDirection: 'column', alignItems: 'center', flex: 1, position: 'relative', minWidth: 44 },
|
||||
connector: { position: 'absolute', top: 13, right: '50%', width: '100%', height: 2, transition: 'background 0.5s' },
|
||||
dot: { width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, fontWeight: 600, position: 'relative', zIndex: 1, transition: 'all 0.5s', marginBottom: 6 },
|
||||
dotDone: { background: '#00d4a0', color: '#0a1e15' },
|
||||
dotCurrent: { background: '#3d9aff', color: '#fff', boxShadow: '0 0 0 4px rgba(61,154,255,0.25)' },
|
||||
dotPending: { background: '#1f2d42', color: '#6b7f96', border: '1px solid rgba(255,255,255,0.14)' },
|
||||
label: { fontSize: 9, color: '#6b7f96', textAlign: 'center', lineHeight: 1.3, whiteSpace: 'pre-line' },
|
||||
disclaimer: { fontSize: 10, color: '#3d5570', textAlign: 'center', marginTop: 10, fontStyle: 'italic' },
|
||||
}
|
||||
53
ecotrack/src/components/WarningCard.jsx
Normal file
53
ecotrack/src/components/WarningCard.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// Tarjeta de mensaje preventivo/informativo
|
||||
|
||||
export default function WarningCard({ type = 'warning', title, message }) {
|
||||
const colors = {
|
||||
warning: {
|
||||
bg: 'rgba(245,158,11,0.07)',
|
||||
border: 'rgba(245,158,11,0.2)',
|
||||
title: '#f59e0b',
|
||||
text: '#c9a855',
|
||||
},
|
||||
info: {
|
||||
bg: 'rgba(61,154,255,0.07)',
|
||||
border: 'rgba(61,154,255,0.2)',
|
||||
title: '#3d9aff',
|
||||
text: '#7aa8d4',
|
||||
},
|
||||
success: {
|
||||
bg: 'rgba(0,212,160,0.07)',
|
||||
border: 'rgba(0,212,160,0.2)',
|
||||
title: '#00d4a0',
|
||||
text: '#5ec8a8',
|
||||
},
|
||||
}
|
||||
|
||||
const c = colors[type] || colors.warning
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: c.bg,
|
||||
border: `1px solid ${c.border}`,
|
||||
borderRadius: 12,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: c.title,
|
||||
marginBottom: 6,
|
||||
}}>
|
||||
{title}
|
||||
</div>
|
||||
<p style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: c.text,
|
||||
margin: 0,
|
||||
}}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
ecotrack/src/context/ETAContext.jsx
Normal file
110
ecotrack/src/context/ETAContext.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { fetchAllData, calcularETA } from '../api/etaService'
|
||||
|
||||
const ETAContext = createContext(null)
|
||||
|
||||
export function ETAProvider({ children, colonia }) {
|
||||
const [etaData, setEtaData] = useState(null)
|
||||
const [rawData, setRawData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [lastFetch, setLastFetch] = useState(null)
|
||||
|
||||
// Guardamos el último posicionActual para no mandar la misma notificación dos veces
|
||||
const lastNotifiedPos = useRef(null)
|
||||
|
||||
async function pedirPermiso() {
|
||||
if (!('Notification' in window)) return
|
||||
if (Notification.permission === 'default') {
|
||||
await Notification.requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
function mandarNotificacion(titulo, cuerpo) {
|
||||
if (!('Notification' in window)) return
|
||||
if (Notification.permission !== 'granted') return
|
||||
new Notification(titulo, {
|
||||
body: cuerpo,
|
||||
icon: '/vite.svg',
|
||||
})
|
||||
}
|
||||
|
||||
function revisarYNotificar(eta) {
|
||||
const pos = eta.posicionActual
|
||||
|
||||
// No notificar dos veces el mismo nodo
|
||||
if (lastNotifiedPos.current === pos) return
|
||||
lastNotifiedPos.current = pos
|
||||
|
||||
// Nodo 1 — ruta iniciada
|
||||
if (pos === 1) {
|
||||
mandarNotificacion(
|
||||
'🚛 ¡Ruta Iniciada!',
|
||||
'El camión recolector ha salido. Asegúrate de tener listos tus residuos.'
|
||||
)
|
||||
}
|
||||
|
||||
// Nodo 4 — camión cercano
|
||||
if (pos === 4) {
|
||||
mandarNotificacion(
|
||||
'⚠️ Camión Cercano',
|
||||
'El camión está a menos de 15 minutos de tu domicilio. ¡Saca tus bolsas ahora!'
|
||||
)
|
||||
}
|
||||
|
||||
// Nodo 8 — ruta finalizada
|
||||
if (pos >= 8) {
|
||||
mandarNotificacion(
|
||||
'✅ Servicio Finalizado',
|
||||
'El camión de tu sector ha concluido su jornada de recolección.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const data = await fetchAllData()
|
||||
setRawData(data)
|
||||
|
||||
const eta = calcularETA(colonia, data)
|
||||
if (eta.error) throw new Error(eta.error)
|
||||
|
||||
setEtaData(eta)
|
||||
setLastFetch(new Date())
|
||||
|
||||
// Revisar si hay que mandar notificación
|
||||
revisarYNotificar(eta)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [colonia])
|
||||
|
||||
useEffect(() => {
|
||||
// Pedir permiso de notificaciones al cargar
|
||||
pedirPermiso()
|
||||
refresh()
|
||||
const interval = setInterval(refresh, 2 * 60 * 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [refresh])
|
||||
|
||||
const cambiarColonia = useCallback((nuevaColonia) => {
|
||||
if (!rawData) return
|
||||
const eta = calcularETA(nuevaColonia, rawData)
|
||||
if (!eta.error) setEtaData(eta)
|
||||
}, [rawData])
|
||||
|
||||
return (
|
||||
<ETAContext.Provider value={{ etaData, loading, error, lastFetch, refresh, cambiarColonia }}>
|
||||
{children}
|
||||
</ETAContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useETA() {
|
||||
const ctx = useContext(ETAContext)
|
||||
if (!ctx) throw new Error('useETA debe usarse dentro de <ETAProvider>')
|
||||
return ctx
|
||||
}
|
||||
9
ecotrack/src/data/colonias-rutas.json
Normal file
9
ecotrack/src/data/colonias-rutas.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{ "colonia": "Zona Centro", "routeId": "RUTA-01", "horarioEstimado": "Matutino (06:30 - 07:15)" },
|
||||
{ "colonia": "Las Arboledas", "routeId": "RUTA-01", "horarioEstimado": "Matutino (07:00 - 07:30)" },
|
||||
{ "colonia": "Trojes", "routeId": "RUTA-13", "horarioEstimado": "Matutino (06:40 - 07:10)" },
|
||||
{ "colonia": "San Juanico", "routeId": "RUTA-03", "horarioEstimado": "Matutino (06:45 - 07:15)" },
|
||||
{ "colonia": "Los Olivos", "routeId": "RUTA-04", "horarioEstimado": "Matutino (07:00 - 07:40)" },
|
||||
{ "colonia": "Rancho Seco", "routeId": "RUTA-05", "horarioEstimado": "Vespertino (14:15 - 15:00)" },
|
||||
{ "colonia": "Las Insurgentes", "routeId": "RUTA-12", "horarioEstimado": "Matutino (06:35 - 07:10)" }
|
||||
]
|
||||
26
ecotrack/src/data/notificaciones.json
Normal file
26
ecotrack/src/data/notificaciones.json
Normal file
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"triggerEvent": "ROUTE_START",
|
||||
"condition": "Cuando positionId cambia de 1 a 2",
|
||||
"pushPayload": {
|
||||
"title": "¡Ruta Iniciada!",
|
||||
"body": "El camión recolector ha salido del Relleno Sanitario rumbo a tu sector. Asegúrate de tener listos tus residuos."
|
||||
}
|
||||
},
|
||||
{
|
||||
"triggerEvent": "TRUCK_PROXIMITY",
|
||||
"condition": "Cuando positionId llega a 4 (punto previo al destino)",
|
||||
"pushPayload": {
|
||||
"title": "Camión Cercano",
|
||||
"body": "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera."
|
||||
}
|
||||
},
|
||||
{
|
||||
"triggerEvent": "ROUTE_COMPLETED",
|
||||
"condition": "Cuando positionId llega a 8 (retorno al basurero)",
|
||||
"pushPayload": {
|
||||
"title": "Servicio Finalizado",
|
||||
"body": "El camión de tu sector ha concluido su jornada de recolección diaria."
|
||||
}
|
||||
}
|
||||
]
|
||||
242
ecotrack/src/data/rutas.json
Normal file
242
ecotrack/src/data/rutas.json
Normal file
@@ -0,0 +1,242 @@
|
||||
[
|
||||
{
|
||||
"routeId": "RUTA-01",
|
||||
"name": "Zona Centro - Las Arboledas",
|
||||
"truckId": 101,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5185, "lng": -100.8450, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5215, "lng": -100.8142, "speed": 22, "timestamp": "2026-05-22T06:25:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5212, "lng": -100.8175, "speed": 15, "timestamp": "2026-05-22T06:38:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5210, "lng": -100.8210, "speed": 0, "timestamp": "2026-05-22T06:50:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5235, "lng": -100.8212, "speed": 18, "timestamp": "2026-05-22T07:05:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5260, "lng": -100.8215, "speed": 20, "timestamp": "2026-05-22T07:18:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:40:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-02",
|
||||
"name": "Sector Norte - Av. Tecnológico",
|
||||
"truckId": 102,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:05:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5280, "lng": -100.8135, "speed": 38, "timestamp": "2026-05-22T06:18:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5410, "lng": -100.8130, "speed": 25, "timestamp": "2026-05-22T06:30:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5445, "lng": -100.8132, "speed": 12, "timestamp": "2026-05-22T06:45:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5480, "lng": -100.8135, "speed": 0, "timestamp": "2026-05-22T06:58:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5515, "lng": -100.8138, "speed": 15, "timestamp": "2026-05-22T07:10:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5540, "lng": -100.8110, "speed": 22, "timestamp": "2026-05-22T07:25:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:50:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-03",
|
||||
"name": "Sector Poniente - San Juanico",
|
||||
"truckId": 103,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5250, "lng": -100.8510, "speed": 42, "timestamp": "2026-05-22T06:20:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5290, "lng": -100.8320, "speed": 20, "timestamp": "2026-05-22T06:35:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5315, "lng": -100.8355, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5340, "lng": -100.8390, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5362, "lng": -100.8425, "speed": 10, "timestamp": "2026-05-22T07:15:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5330, "lng": -100.8430, "speed": 18, "timestamp": "2026-05-22T07:28:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 35, "timestamp": "2026-05-22T07:45:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-04",
|
||||
"name": "Oriente - Los Olivos",
|
||||
"truckId": 104,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5260, "lng": -100.8010, "speed": 45, "timestamp": "2026-05-22T06:30:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5295, "lng": -100.7890, "speed": 24, "timestamp": "2026-05-22T06:45:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5320, "lng": -100.7850, "speed": 12, "timestamp": "2026-05-22T06:58:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5350, "lng": -100.7790, "speed": 0, "timestamp": "2026-05-22T07:12:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5310, "lng": -100.7760, "speed": 15, "timestamp": "2026-05-22T07:25:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5270, "lng": -100.7820, "speed": 26, "timestamp": "2026-05-22T07:38:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 48, "timestamp": "2026-05-22T07:58:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-05",
|
||||
"name": "Sector Sur - Rancho Seco",
|
||||
"truckId": 105,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:20:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5050, "lng": -100.8620, "speed": 35, "timestamp": "2026-05-22T06:32:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5020, "lng": -100.8350, "speed": 22, "timestamp": "2026-05-22T06:45:00Z" },
|
||||
{ "positionId": 4, "lat": 20.4995, "lng": -100.8210, "speed": 14, "timestamp": "2026-05-22T06:58:00Z" },
|
||||
{ "positionId": 5, "lat": 20.4970, "lng": -100.8150, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5010, "lng": -100.8120, "speed": 16, "timestamp": "2026-05-22T07:22:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5060, "lng": -100.8160, "speed": 25, "timestamp": "2026-05-22T07:35:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:55:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-06",
|
||||
"name": "Norte Extremo - Rumbos de Roque",
|
||||
"truckId": 106,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:00:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5380, "lng": -100.8380, "speed": 40, "timestamp": "2026-05-22T06:15:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5610, "lng": -100.8370, "speed": 30, "timestamp": "2026-05-22T06:30:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5750, "lng": -100.8360, "speed": 15, "timestamp": "2026-05-22T06:45:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5820, "lng": -100.8350, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5780, "lng": -100.8310, "speed": 20, "timestamp": "2026-05-22T07:15:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5650, "lng": -100.8320, "speed": 28, "timestamp": "2026-05-22T07:30:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:55:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-07",
|
||||
"name": "Nororiente - Ciudad Industrial",
|
||||
"truckId": 107,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:10:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5350, "lng": -100.8050, "speed": 44, "timestamp": "2026-05-22T06:24:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5450, "lng": -100.7950, "speed": 25, "timestamp": "2026-05-22T06:38:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5480, "lng": -100.7850, "speed": 18, "timestamp": "2026-05-22T06:52:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5510, "lng": -100.7750, "speed": 0, "timestamp": "2026-05-22T07:05:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5460, "lng": -100.7720, "speed": 12, "timestamp": "2026-05-22T07:18:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5390, "lng": -100.7820, "speed": 30, "timestamp": "2026-05-22T07:30:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:52:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-08",
|
||||
"name": "Suroriente - Universidad Latina",
|
||||
"truckId": 108,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:15:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5180, "lng": -100.8310, "speed": 38, "timestamp": "2026-05-22T06:28:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5245, "lng": -100.7980, "speed": 30, "timestamp": "2026-05-22T06:42:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5210, "lng": -100.7995, "speed": 14, "timestamp": "2026-05-22T06:55:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5175, "lng": -100.8010, "speed": 0, "timestamp": "2026-05-22T07:08:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5140, "lng": -100.8030, "speed": 18, "timestamp": "2026-05-22T07:20:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5110, "lng": -100.8055, "speed": 22, "timestamp": "2026-05-22T07:32:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 40, "timestamp": "2026-05-22T07:54:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-09",
|
||||
"name": "Poniente - Hospital General",
|
||||
"truckId": 109,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:02:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5210, "lng": -100.8650, "speed": 45, "timestamp": "2026-05-22T06:12:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5260, "lng": -100.8520, "speed": 26, "timestamp": "2026-05-22T06:24:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5275, "lng": -100.8490, "speed": 12, "timestamp": "2026-05-22T06:36:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5285, "lng": -100.8460, "speed": 0, "timestamp": "2026-05-22T06:48:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5250, "lng": -100.8470, "speed": 15, "timestamp": "2026-05-22T07:00:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5220, "lng": -100.8550, "speed": 32, "timestamp": "2026-05-22T07:12:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:30:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-10",
|
||||
"name": "Eje Juan Pablo II - Sede UG Sur",
|
||||
"truckId": 110,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:22:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5015, "lng": -100.8520, "speed": 40, "timestamp": "2026-05-22T06:34:00Z" },
|
||||
{ "positionId": 3, "lat": 20.4990, "lng": -100.8390, "speed": 28, "timestamp": "2026-05-22T06:46:00Z" },
|
||||
{ "positionId": 4, "lat": 20.4950, "lng": -100.8320, "speed": 18, "timestamp": "2026-05-22T06:58:00Z" },
|
||||
{ "positionId": 5, "lat": 20.4920, "lng": -100.8280, "speed": 0, "timestamp": "2026-05-22T07:10:00Z" },
|
||||
{ "positionId": 6, "lat": 20.4945, "lng": -100.8240, "speed": 14, "timestamp": "2026-05-22T07:22:00Z" },
|
||||
{ "positionId": 7, "lat": 20.4980, "lng": -100.8300, "speed": 30, "timestamp": "2026-05-22T07:34:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 38, "timestamp": "2026-05-22T07:52:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-11",
|
||||
"name": "Zona de Oro - Torres Landa",
|
||||
"truckId": 111,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:04:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5240, "lng": -100.8350, "speed": 36, "timestamp": "2026-05-22T06:16:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5280, "lng": -100.8250, "speed": 22, "timestamp": "2026-05-22T06:29:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5295, "lng": -100.8210, "speed": 10, "timestamp": "2026-05-22T06:42:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5310, "lng": -100.8170, "speed": 0, "timestamp": "2026-05-22T06:55:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5290, "lng": -100.8140, "speed": 16, "timestamp": "2026-05-22T07:08:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5260, "lng": -100.8220, "speed": 28, "timestamp": "2026-05-22T07:21:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 42, "timestamp": "2026-05-22T07:42:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-12",
|
||||
"name": "Nororiente - Las Insurgentes",
|
||||
"truckId": 112,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:08:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5280, "lng": -100.8080, "speed": 40, "timestamp": "2026-05-22T06:22:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5320, "lng": -100.7980, "speed": 24, "timestamp": "2026-05-22T06:35:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5340, "lng": -100.7940, "speed": 15, "timestamp": "2026-05-22T06:48:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5360, "lng": -100.7900, "speed": 0, "timestamp": "2026-05-22T07:00:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5310, "lng": -100.7920, "speed": 12, "timestamp": "2026-05-22T07:12:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5270, "lng": -100.8020, "speed": 26, "timestamp": "2026-05-22T07:25:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 44, "timestamp": "2026-05-22T07:48:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-13",
|
||||
"name": "Sector Norte - Trojes e Irrigación",
|
||||
"truckId": 113,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:12:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5360, "lng": -100.8190, "speed": 35, "timestamp": "2026-05-22T06:26:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5420, "lng": -100.8080, "speed": 28, "timestamp": "2026-05-22T06:40:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5440, "lng": -100.8040, "speed": 14, "timestamp": "2026-05-22T06:54:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5460, "lng": -100.8000, "speed": 0, "timestamp": "2026-05-22T07:06:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5410, "lng": -100.8020, "speed": 18, "timestamp": "2026-05-22T07:18:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5370, "lng": -100.8120, "speed": 25, "timestamp": "2026-05-22T07:30:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 39, "timestamp": "2026-05-22T07:54:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-14",
|
||||
"name": "Sur Poniente - La Toscana",
|
||||
"truckId": 114,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:16:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5150, "lng": -100.8580, "speed": 42, "timestamp": "2026-05-22T06:28:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5140, "lng": -100.8390, "speed": 26, "timestamp": "2026-05-22T06:41:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5125, "lng": -100.8310, "speed": 16, "timestamp": "2026-05-22T06:54:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5110, "lng": -100.8250, "speed": 0, "timestamp": "2026-05-22T07:06:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5135, "lng": -100.8280, "speed": 12, "timestamp": "2026-05-22T07:18:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5160, "lng": -100.8420, "speed": 32, "timestamp": "2026-05-22T07:30:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 45, "timestamp": "2026-05-22T07:51:00Z" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"routeId": "RUTA-15",
|
||||
"name": "Norponiente - Camino a San José de Celaya",
|
||||
"truckId": 115,
|
||||
"status": "EN_RUTA",
|
||||
"positions": [
|
||||
{ "positionId": 1, "lat": 20.5111, "lng": -100.9037, "speed": 0, "timestamp": "2026-05-22T06:18:00Z" },
|
||||
{ "positionId": 2, "lat": 20.5320, "lng": -100.8590, "speed": 38, "timestamp": "2026-05-22T06:31:00Z" },
|
||||
{ "positionId": 3, "lat": 20.5390, "lng": -100.8480, "speed": 24, "timestamp": "2026-05-22T06:44:00Z" },
|
||||
{ "positionId": 4, "lat": 20.5420, "lng": -100.8440, "speed": 15, "timestamp": "2026-05-22T06:57:00Z" },
|
||||
{ "positionId": 5, "lat": 20.5450, "lng": -100.8410, "speed": 0, "timestamp": "2026-05-22T07:09:00Z" },
|
||||
{ "positionId": 6, "lat": 20.5410, "lng": -100.8430, "speed": 14, "timestamp": "2026-05-22T07:21:00Z" },
|
||||
{ "positionId": 7, "lat": 20.5360, "lng": -100.8520, "speed": 28, "timestamp": "2026-05-22T07:33:00Z" },
|
||||
{ "positionId": 8, "lat": 20.5111, "lng": -100.9037, "speed": 41, "timestamp": "2026-05-22T07:54:00Z" }
|
||||
]
|
||||
}
|
||||
]
|
||||
16
ecotrack/src/firebase.js
Normal file
16
ecotrack/src/firebase.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import { getAuth } from 'firebase/auth'
|
||||
import { getFirestore } from 'firebase/firestore'
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyA8NgJJvw9XInVsXm7snHBEzUIhbH7Wpf4",
|
||||
authDomain: "basura-celaya.firebaseapp.com",
|
||||
projectId: "basura-celaya",
|
||||
storageBucket: "basura-celaya.firebasestorage.app",
|
||||
messagingSenderId: "755435968282",
|
||||
appId: "1:755435968282:web:9c14ffbfb824806a59bea3"
|
||||
}
|
||||
|
||||
const app = initializeApp(firebaseConfig)
|
||||
export const auth = getAuth(app)
|
||||
export const db = getFirestore(app)
|
||||
39
ecotrack/src/index.css
Normal file
39
ecotrack/src/index.css
Normal file
@@ -0,0 +1,39 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap');
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #0a0e14;
|
||||
--surface: #131820;
|
||||
--surface2: #1a2332;
|
||||
--surface3: #1f2d42;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border2: rgba(255,255,255,0.14);
|
||||
--text: #e8edf5;
|
||||
--muted: #6b7f96;
|
||||
--accent: #3d9aff;
|
||||
--accent2: #00d4a0;
|
||||
--warn: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--organic: #22c55e;
|
||||
--recycle: #3b82f6;
|
||||
--sanitary: #a855f7;
|
||||
--special: #f97316;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar personalizado */
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||||
10
ecotrack/src/main.jsx
Normal file
10
ecotrack/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
113
ecotrack/src/screens/Dashboard.jsx
Normal file
113
ecotrack/src/screens/Dashboard.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useETA } from '../context/ETAContext'
|
||||
import ETACard from '../components/ETACard'
|
||||
import WarningCard from '../components/WarningCard'
|
||||
import RouteProgress from '../components/RouteProgress'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { etaData, loading, error, lastFetch, refresh } = useETA()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={styles.centered}>
|
||||
<div style={styles.spinner} />
|
||||
<p style={styles.loadingText}>Obteniendo ETA...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<WarningCard
|
||||
type="warning"
|
||||
title="⚠ Sin conexión al servidor"
|
||||
message={`No se pudo obtener el ETA. Verifica tu conexión. Error: ${error}`}
|
||||
/>
|
||||
<button onClick={refresh} style={styles.retryBtn}>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!etaData) {
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<WarningCard
|
||||
type="info"
|
||||
title="ℹ Sin información de ruta"
|
||||
message="No hay rutas activas en este momento. Vuelve a intentarlo más tarde."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
|
||||
{/* Colonia y ruta */}
|
||||
<div style={styles.addressBadge}>
|
||||
<div style={styles.addressIcon}>📍</div>
|
||||
<div>
|
||||
<div style={styles.addressMain}>{etaData.colonia}</div>
|
||||
<div style={styles.addressSub}>{etaData.routeName} · Celaya, Gto.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ETA principal */}
|
||||
<ETACard etaData={etaData} />
|
||||
|
||||
{/* Notificación activa */}
|
||||
{etaData.notificacion && (
|
||||
<WarningCard
|
||||
type={etaData.status === 'cerca' ? 'warning' : 'info'}
|
||||
title={etaData.notificacion.title}
|
||||
message={etaData.notificacion.body}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mensajes preventivos */}
|
||||
<WarningCard
|
||||
type="warning"
|
||||
title="⚠ Aviso de Seguridad"
|
||||
message="No saques la basura antes de tiempo ni intentes alcanzar el vehículo en movimiento. Espera que el camión se detenga completamente frente a tu domicilio."
|
||||
/>
|
||||
|
||||
<WarningCard
|
||||
type="info"
|
||||
title="🔒 Privacidad garantizada"
|
||||
message="Este sistema no muestra el mapa ni la ubicación exacta del camión. Solo recibes la ventana de tiempo estimada para tu zona registrada."
|
||||
/>
|
||||
|
||||
{/* Progreso de ruta */}
|
||||
<RouteProgress
|
||||
posicionActual={etaData.posicionActual}
|
||||
totalNodos={etaData.totalNodos}
|
||||
progresoPct={etaData.progresoPct}
|
||||
/>
|
||||
|
||||
{/* Última actualización */}
|
||||
{lastFetch && (
|
||||
<p style={styles.lastUpdate}>
|
||||
Actualizado: {lastFetch.toLocaleTimeString('es-MX')} ·{' '}
|
||||
<span onClick={refresh} style={{ color: '#3d9aff', cursor: 'pointer' }}>
|
||||
Actualizar
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: { padding: 16, paddingBottom: 32 },
|
||||
centered: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '60vh', gap: 16 },
|
||||
spinner: { width: 32, height: 32, border: '3px solid rgba(61,154,255,0.2)', borderTop: '3px solid #3d9aff', borderRadius: '50%', animation: 'spin 1s linear infinite' },
|
||||
loadingText: { fontSize: 13, color: '#6b7f96' },
|
||||
addressBadge:{ background: '#131820', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, padding: '10px 14px', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 10 },
|
||||
addressIcon: { width: 32, height: 32, background: 'rgba(61,154,255,0.1)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, flexShrink: 0 },
|
||||
addressMain: { fontWeight: 500, color: '#e8edf5', fontSize: 12 },
|
||||
addressSub: { color: '#6b7f96', fontSize: 11, marginTop: 1 },
|
||||
retryBtn: { width: '100%', padding: 12, borderRadius: 10, border: '1px solid rgba(61,154,255,0.3)', background: 'rgba(61,154,255,0.08)', color: '#3d9aff', fontFamily: "'DM Sans', sans-serif", fontSize: 14, fontWeight: 500, cursor: 'pointer', marginTop: 8 },
|
||||
lastUpdate: { fontSize: 11, color: '#3d5570', textAlign: 'center', marginTop: 12 },
|
||||
}
|
||||
169
ecotrack/src/screens/Login.jsx
Normal file
169
ecotrack/src/screens/Login.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState } from 'react'
|
||||
import { auth, db } from '../firebase'
|
||||
import { createUserWithEmailAndPassword, signInWithEmailAndPassword } from 'firebase/auth'
|
||||
import { doc, setDoc, getDoc } from 'firebase/firestore'
|
||||
|
||||
const COLONIAS = [
|
||||
'Zona Centro',
|
||||
'Las Arboledas',
|
||||
'Trojes',
|
||||
'San Juanico',
|
||||
'Los Olivos',
|
||||
'Rancho Seco',
|
||||
'Las Insurgentes',
|
||||
]
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [modo, setModo] = useState('login') // 'login' | 'registro'
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [colonia, setColonia] = useState(COLONIAS[0])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleLogin() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const cred = await signInWithEmailAndPassword(auth, email, password)
|
||||
// Leer la colonia guardada en Firestore
|
||||
const snap = await getDoc(doc(db, 'usuarios', cred.user.uid))
|
||||
const coloniaGuardada = snap.exists() ? snap.data().colonia : COLONIAS[0]
|
||||
onLogin({ uid: cred.user.uid, email: cred.user.email, colonia: coloniaGuardada })
|
||||
} catch (e) {
|
||||
setError('Correo o contraseña incorrectos')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegistro() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const cred = await createUserWithEmailAndPassword(auth, email, password)
|
||||
// Guardar colonia del usuario en Firestore
|
||||
await setDoc(doc(db, 'usuarios', cred.user.uid), {
|
||||
email: cred.user.email,
|
||||
colonia,
|
||||
creadoEn: new Date().toISOString(),
|
||||
})
|
||||
onLogin({ uid: cred.user.uid, email: cred.user.email, colonia })
|
||||
} catch (e) {
|
||||
if (e.code === 'auth/email-already-in-use') setError('Este correo ya está registrado')
|
||||
else if (e.code === 'auth/weak-password') setError('La contraseña debe tener al menos 6 caracteres')
|
||||
else setError('Error al crear cuenta')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<div style={styles.card}>
|
||||
{/* Logo */}
|
||||
<div style={styles.logo}>🚛</div>
|
||||
<div style={styles.title}>EcoTrack Celaya</div>
|
||||
<div style={styles.subtitle}>Sistema de Notificación de Recolección</div>
|
||||
|
||||
{/* Tabs login / registro */}
|
||||
<div style={styles.tabs}>
|
||||
<div
|
||||
onClick={() => { setModo('login'); setError('') }}
|
||||
style={{ ...styles.tab, ...(modo === 'login' ? styles.tabActive : {}) }}
|
||||
>
|
||||
Iniciar sesión
|
||||
</div>
|
||||
<div
|
||||
onClick={() => { setModo('registro'); setError('') }}
|
||||
style={{ ...styles.tab, ...(modo === 'registro' ? styles.tabActive : {}) }}
|
||||
>
|
||||
Registrarse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campos */}
|
||||
<div style={styles.group}>
|
||||
<label style={styles.label}>Correo electrónico</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="tucorreo@gmail.com"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
style={styles.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={styles.group}>
|
||||
<label style={styles.label}>Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
style={styles.input}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Colonia — solo en registro */}
|
||||
{modo === 'registro' && (
|
||||
<div style={styles.group}>
|
||||
<label style={styles.label}>Tu colonia</label>
|
||||
<select
|
||||
value={colonia}
|
||||
onChange={e => setColonia(e.target.value)}
|
||||
style={styles.select}
|
||||
>
|
||||
{COLONIAS.map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={styles.hint}>
|
||||
Selecciona la colonia donde vives para recibir el ETA correcto
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
{/* Botón principal */}
|
||||
<button
|
||||
onClick={modo === 'login' ? handleLogin : handleRegistro}
|
||||
disabled={loading || !email || !password}
|
||||
style={{
|
||||
...styles.btn,
|
||||
opacity: (loading || !email || !password) ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? 'Cargando...'
|
||||
: modo === 'login' ? 'Entrar' : 'Crear cuenta'}
|
||||
</button>
|
||||
|
||||
<div style={styles.privacidad}>
|
||||
🔒 Tus datos son privados y solo se usan para calcular el ETA de tu colonia
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: { minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16, background: '#0a0e14' },
|
||||
card: { width: '100%', maxWidth: 380, background: '#131820', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 20, padding: '32px 24px' },
|
||||
logo: { fontSize: 40, textAlign: 'center', marginBottom: 8 },
|
||||
title: { fontSize: 20, fontWeight: 600, color: '#e8edf5', textAlign: 'center', marginBottom: 4 },
|
||||
subtitle: { fontSize: 12, color: '#6b7f96', textAlign: 'center', marginBottom: 24 },
|
||||
tabs: { display: 'flex', background: '#0a0e14', borderRadius: 10, padding: 4, marginBottom: 24, gap: 4 },
|
||||
tab: { flex: 1, padding: '8px 0', textAlign: 'center', fontSize: 13, fontWeight: 500, color: '#6b7f96', borderRadius: 8, cursor: 'pointer', transition: 'all 0.2s' },
|
||||
tabActive: { background: '#1a2332', color: '#3d9aff' },
|
||||
group: { marginBottom: 14 },
|
||||
label: { fontSize: 11, fontWeight: 500, letterSpacing: '0.05em', textTransform: 'uppercase', color: '#6b7f96', display: 'block', marginBottom: 6 },
|
||||
input: { width: '100%', background: '#1a2332', border: '1px solid rgba(255,255,255,0.14)', borderRadius: 8, color: '#e8edf5', fontFamily: "'DM Sans', sans-serif", fontSize: 14, padding: '11px 14px', outline: 'none' },
|
||||
select: { width: '100%', background: '#1a2332', border: '1px solid rgba(255,255,255,0.14)', borderRadius: 8, color: '#e8edf5', fontFamily: "'DM Sans', sans-serif", fontSize: 14, padding: '11px 14px', outline: 'none', appearance: 'none' },
|
||||
hint: { fontSize: 11, color: '#3d5570', marginTop: 5, lineHeight: 1.4 },
|
||||
error: { background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: 8, padding: '10px 12px', fontSize: 12, color: '#ef4444', marginBottom: 12 },
|
||||
btn: { width: '100%', padding: 14, borderRadius: 10, border: 'none', background: '#3d9aff', color: '#fff', fontFamily: "'DM Sans', sans-serif", fontSize: 15, fontWeight: 600, cursor: 'pointer', marginBottom: 16, transition: 'opacity 0.2s' },
|
||||
privacidad: { fontSize: 11, color: '#3d5570', textAlign: 'center', lineHeight: 1.5 },
|
||||
}
|
||||
202
ecotrack/src/screens/Reports.jsx
Normal file
202
ecotrack/src/screens/Reports.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { db, auth } from '../firebase'
|
||||
import { collection, addDoc, query, where, orderBy, getDocs } from 'firebase/firestore'
|
||||
import { useETA } from '../context/ETAContext'
|
||||
|
||||
const TIPOS = [
|
||||
'No pasó el camión',
|
||||
'Retraso excesivo',
|
||||
'Basura no recogida',
|
||||
'Derrame en la vía',
|
||||
'Conducta del operador',
|
||||
'Otro',
|
||||
]
|
||||
|
||||
export default function Reports() {
|
||||
const { etaData } = useETA()
|
||||
const [tipoSelected, setTipoSelected] = useState(TIPOS[0])
|
||||
const [descripcion, setDescripcion] = useState('')
|
||||
const [enviando, setEnviando] = useState(false)
|
||||
const [toast, setToast] = useState(false)
|
||||
const [reportes, setReportes] = useState([])
|
||||
const [cargando, setCargando] = useState(true)
|
||||
|
||||
// Cargar reportes previos del usuario desde Firestore
|
||||
useEffect(() => {
|
||||
async function cargarReportes() {
|
||||
if (!auth.currentUser) return
|
||||
try {
|
||||
const q = query(
|
||||
collection(db, 'reportes'),
|
||||
where('uid', '==', auth.currentUser.uid),
|
||||
orderBy('creadoEn', 'desc')
|
||||
)
|
||||
const snap = await getDocs(q)
|
||||
setReportes(snap.docs.map(d => ({ id: d.id, ...d.data() })))
|
||||
} catch (e) {
|
||||
console.error('Error cargando reportes:', e)
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}
|
||||
cargarReportes()
|
||||
}, [toast]) // recarga cuando se envía uno nuevo
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!auth.currentUser) return
|
||||
setEnviando(true)
|
||||
try {
|
||||
// Guardar en Firestore — reporte real
|
||||
await addDoc(collection(db, 'reportes'), {
|
||||
uid: auth.currentUser.uid,
|
||||
email: auth.currentUser.email,
|
||||
colonia: etaData?.colonia ?? 'Sin colonia',
|
||||
routeId: etaData?.routeId ?? '',
|
||||
tipo: tipoSelected,
|
||||
descripcion,
|
||||
estado: 'abierto',
|
||||
creadoEn: new Date().toISOString(),
|
||||
})
|
||||
setDescripcion('')
|
||||
setTipoSelected(TIPOS[0])
|
||||
setToast(true)
|
||||
setTimeout(() => setToast(false), 3000)
|
||||
} catch (e) {
|
||||
alert('Error al enviar el reporte. Intenta de nuevo.')
|
||||
} finally {
|
||||
setEnviando(false)
|
||||
}
|
||||
}
|
||||
|
||||
function tiempoRelativo(iso) {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const min = Math.floor(diff / 60000)
|
||||
if (min < 60) return `Hace ${min} min`
|
||||
const hrs = Math.floor(min / 60)
|
||||
if (hrs < 24) return `Hace ${hrs} h`
|
||||
return `Hace ${Math.floor(hrs / 24)} días`
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitle}>Buzón de Reportes</div>
|
||||
<div style={styles.headerSub}>Tu reporte se envía al municipio de Celaya</div>
|
||||
</div>
|
||||
|
||||
{/* Formulario */}
|
||||
<div style={styles.form}>
|
||||
<div style={styles.formTitle}>📋 Nuevo Reporte</div>
|
||||
|
||||
<div style={styles.group}>
|
||||
<label style={styles.label}>Tipo de incidencia</label>
|
||||
<div style={styles.chips}>
|
||||
{TIPOS.map(tipo => (
|
||||
<div
|
||||
key={tipo}
|
||||
onClick={() => setTipoSelected(tipo)}
|
||||
style={{
|
||||
...styles.chip,
|
||||
background: tipoSelected === tipo ? 'rgba(61,154,255,0.15)' : 'transparent',
|
||||
borderColor: tipoSelected === tipo ? '#3d9aff' : 'rgba(255,255,255,0.14)',
|
||||
color: tipoSelected === tipo ? '#3d9aff' : '#6b7f96',
|
||||
}}
|
||||
>
|
||||
{tipo}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.group}>
|
||||
<label style={styles.label}>Colonia</label>
|
||||
<input
|
||||
readOnly
|
||||
value={etaData?.colonia ?? 'Cargando...'}
|
||||
style={{ ...styles.input, color: '#6b7f96' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={styles.group}>
|
||||
<label style={styles.label}>Descripción (opcional)</label>
|
||||
<textarea
|
||||
style={styles.textarea}
|
||||
placeholder="Describe brevemente lo que ocurrió..."
|
||||
value={descripcion}
|
||||
onChange={e => setDescripcion(e.target.value)}
|
||||
maxLength={300}
|
||||
/>
|
||||
<div style={styles.charCount}>{descripcion.length}/300</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={enviando}
|
||||
style={{ ...styles.submitBtn, opacity: enviando ? 0.7 : 1 }}
|
||||
>
|
||||
{enviando ? 'Enviando...' : 'Enviar Reporte'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Reportes previos */}
|
||||
<div style={styles.historyTitle}>Mis reportes</div>
|
||||
|
||||
{cargando && <div style={styles.muted}>Cargando...</div>}
|
||||
|
||||
{!cargando && reportes.length === 0 && (
|
||||
<div style={styles.muted}>Aún no has enviado reportes</div>
|
||||
)}
|
||||
|
||||
{reportes.map(r => (
|
||||
<div key={r.id} style={styles.reportItem}>
|
||||
<div style={{
|
||||
...styles.reportIcon,
|
||||
background: r.estado === 'resuelto' ? 'rgba(0,212,160,0.12)' : 'rgba(245,158,11,0.12)',
|
||||
}}>
|
||||
{r.estado === 'resuelto' ? '✅' : '⏰'}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={styles.reportTipo}>{r.tipo}</div>
|
||||
<div style={styles.reportMeta}>{tiempoRelativo(r.creadoEn)} · {r.colonia}</div>
|
||||
</div>
|
||||
<span style={{
|
||||
...styles.badge,
|
||||
background: r.estado === 'resuelto' ? 'rgba(0,212,160,0.15)' : 'rgba(245,158,11,0.15)',
|
||||
color: r.estado === 'resuelto' ? '#00d4a0' : '#f59e0b',
|
||||
}}>
|
||||
{r.estado === 'resuelto' ? 'Resuelto' : 'Abierto'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{toast && (
|
||||
<div style={styles.toast}>✅ Reporte enviado correctamente</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: { padding: 16, paddingBottom: 32, position: 'relative' },
|
||||
header: { marginBottom: 16 },
|
||||
headerTitle: { fontSize: 18, fontWeight: 600, color: '#e8edf5', marginBottom: 2 },
|
||||
headerSub: { fontSize: 12, color: '#6b7f96' },
|
||||
form: { background: '#131820', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 14, padding: 16, marginBottom: 16 },
|
||||
formTitle: { fontSize: 14, fontWeight: 600, color: '#e8edf5', marginBottom: 14 },
|
||||
group: { marginBottom: 14 },
|
||||
label: { fontSize: 11, fontWeight: 500, letterSpacing: '0.05em', textTransform: 'uppercase', color: '#6b7f96', display: 'block', marginBottom: 8 },
|
||||
chips: { display: 'flex', flexWrap: 'wrap', gap: 6 },
|
||||
chip: { padding: '5px 12px', borderRadius: 20, fontSize: 11, fontWeight: 500, border: '1px solid', cursor: 'pointer', transition: 'all 0.15s' },
|
||||
input: { width: '100%', background: '#1a2332', border: '1px solid rgba(255,255,255,0.14)', borderRadius: 8, color: '#e8edf5', fontFamily: "'DM Sans', sans-serif", fontSize: 13, padding: '10px 12px', outline: 'none' },
|
||||
textarea: { width: '100%', background: '#1a2332', border: '1px solid rgba(255,255,255,0.14)', borderRadius: 8, color: '#e8edf5', fontFamily: "'DM Sans', sans-serif", fontSize: 13, padding: '10px 12px', outline: 'none', resize: 'none', minHeight: 80, lineHeight: 1.5 },
|
||||
charCount: { fontSize: 10, color: '#3d5570', textAlign: 'right', marginTop: 4 },
|
||||
submitBtn: { width: '100%', padding: 13, borderRadius: 10, border: 'none', background: '#3d9aff', color: '#fff', fontFamily: "'DM Sans', sans-serif", fontSize: 14, fontWeight: 600, cursor: 'pointer' },
|
||||
historyTitle:{ fontSize: 11, fontWeight: 500, letterSpacing: '0.06em', textTransform: 'uppercase', color: '#6b7f96', marginBottom: 10 },
|
||||
muted: { fontSize: 13, color: '#3d5570', textAlign: 'center', padding: '20px 0' },
|
||||
reportItem: { background: '#131820', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, padding: '12px 14px', marginBottom: 8, display: 'flex', gap: 10, alignItems: 'center' },
|
||||
reportIcon: { width: 32, height: 32, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 15, flexShrink: 0 },
|
||||
reportTipo: { fontSize: 12, fontWeight: 500, color: '#e8edf5', marginBottom: 2 },
|
||||
reportMeta: { fontSize: 11, color: '#6b7f96' },
|
||||
badge: { padding: '2px 8px', borderRadius: 6, fontSize: 10, fontWeight: 600, flexShrink: 0 },
|
||||
toast: { position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)', background: '#00d4a0', color: '#0a1e15', padding: '10px 20px', borderRadius: 20, fontSize: 13, fontWeight: 600, whiteSpace: 'nowrap', zIndex: 999 },
|
||||
}
|
||||
228
ecotrack/src/screens/SeparationGuide.jsx
Normal file
228
ecotrack/src/screens/SeparationGuide.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// Datos de cada categoría — funciona sin internet
|
||||
const CATEGORIES = [
|
||||
{
|
||||
id: 'organic',
|
||||
emoji: '🌿',
|
||||
name: 'Orgánico',
|
||||
color: '#22c55e',
|
||||
bg: 'rgba(34,197,94,0.1)',
|
||||
border: 'rgba(34,197,94,0.2)',
|
||||
examples: 'Restos de comida, cáscaras, pasto',
|
||||
items: [
|
||||
'Restos de frutas y verduras',
|
||||
'Cáscaras de huevo y café usado',
|
||||
'Residuos de jardín (pasto, hojas secas)',
|
||||
'Restos de comida no procesada',
|
||||
'Pan, tortillas y cereales en mal estado',
|
||||
'Usar bolsa biodegradable o recipiente con tapa',
|
||||
],
|
||||
tip: 'Puedes hacer composta en casa con estos residuos. Reduce hasta 30% tu basura.',
|
||||
},
|
||||
{
|
||||
id: 'recycle',
|
||||
emoji: '♻️',
|
||||
name: 'Reciclable',
|
||||
color: '#3b82f6',
|
||||
bg: 'rgba(59,130,246,0.1)',
|
||||
border: 'rgba(59,130,246,0.2)',
|
||||
examples: 'Plástico, papel, vidrio, metal',
|
||||
items: [
|
||||
'Botellas PET limpias y aplastadas',
|
||||
'Cartón y papel secos (sin grasa)',
|
||||
'Latas de aluminio y hojalata',
|
||||
'Vidrio sin tapa ni residuos de comida',
|
||||
'Envases Tetra Pak limpios y abiertos',
|
||||
'NO mezclar con residuos orgánicos',
|
||||
],
|
||||
tip: 'Enjuaga los envases antes de desecharlos. El reciclaje sucio no se puede procesar.',
|
||||
},
|
||||
{
|
||||
id: 'sanitary',
|
||||
emoji: '🚫',
|
||||
name: 'Sanitario',
|
||||
color: '#a855f7',
|
||||
bg: 'rgba(168,85,247,0.1)',
|
||||
border: 'rgba(168,85,247,0.2)',
|
||||
examples: 'Pañales, gasas, toallas húmedas',
|
||||
items: [
|
||||
'Pañales desechables',
|
||||
'Toallas sanitarias y tampones',
|
||||
'Gasas y vendajes usados',
|
||||
'Toallas húmedas (no biodegradables)',
|
||||
'Papel sanitario y kleenex',
|
||||
'Embolsar bien y anudar antes de desechar',
|
||||
],
|
||||
tip: 'Este residuo va al relleno sanitario. No se puede reciclar ni compostar.',
|
||||
},
|
||||
{
|
||||
id: 'special',
|
||||
emoji: '⚠️',
|
||||
name: 'Especial / RAEE',
|
||||
color: '#f97316',
|
||||
bg: 'rgba(249,115,22,0.1)',
|
||||
border: 'rgba(249,115,22,0.2)',
|
||||
examples: 'Pilas, electrónicos, aceite, medicamentos',
|
||||
items: [
|
||||
'Pilas y baterías (llevar a punto de acopio)',
|
||||
'Electrónicos y cables en desuso',
|
||||
'Aceite de cocina usado (en botella cerrada)',
|
||||
'Medicamentos vencidos (farmacias participantes)',
|
||||
'Pintura, thinner y químicos del hogar',
|
||||
'Focos ahorradores y tubos fluorescentes',
|
||||
],
|
||||
tip: 'NUNCA tires estos al camión normal. Busca el punto de acopio más cercano en tu municipio.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function SeparationGuide() {
|
||||
const [selected, setSelected] = useState(CATEGORIES[0])
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitle}>Guía de Separación</div>
|
||||
<div style={styles.headerSub}>Disponible sin internet</div>
|
||||
</div>
|
||||
|
||||
{/* Grid de categorías */}
|
||||
<div style={styles.grid}>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
onClick={() => setSelected(cat)}
|
||||
style={{
|
||||
...styles.card,
|
||||
background: cat.bg,
|
||||
border: `1px solid ${selected.id === cat.id ? cat.color : cat.border}`,
|
||||
boxShadow: selected.id === cat.id ? `0 0 0 2px ${cat.color}33` : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={styles.cardEmoji}>{cat.emoji}</div>
|
||||
<div style={{ ...styles.cardName, color: cat.color }}>{cat.name}</div>
|
||||
<div style={styles.cardExamples}>{cat.examples}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detalle de la categoría seleccionada */}
|
||||
<div style={styles.detail}>
|
||||
<div style={styles.detailHeader}>
|
||||
<span style={{ fontSize: 20 }}>{selected.emoji}</span>
|
||||
<span style={{ ...styles.detailTitle, color: selected.color }}>
|
||||
{selected.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul style={styles.list}>
|
||||
{selected.items.map((item, i) => (
|
||||
<li key={i} style={styles.listItem}>
|
||||
<span style={{ ...styles.dot, background: selected.color }} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Tip adicional */}
|
||||
<div style={{
|
||||
...styles.tip,
|
||||
background: selected.bg,
|
||||
border: `1px solid ${selected.border}`,
|
||||
}}>
|
||||
<span style={{ color: selected.color, fontWeight: 600 }}>💡 Tip: </span>
|
||||
<span style={{ color: '#a0b4c8' }}>{selected.tip}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: '#e8edf5',
|
||||
marginBottom: 2,
|
||||
},
|
||||
headerSub: {
|
||||
fontSize: 12,
|
||||
color: '#6b7f96',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 10,
|
||||
marginBottom: 14,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 14,
|
||||
padding: '16px 14px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
},
|
||||
cardEmoji: {
|
||||
fontSize: 28,
|
||||
marginBottom: 8,
|
||||
},
|
||||
cardName: {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
marginBottom: 4,
|
||||
},
|
||||
cardExamples: {
|
||||
fontSize: 11,
|
||||
color: '#6b7f96',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
detail: {
|
||||
background: '#131820',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
},
|
||||
detailHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
},
|
||||
detailTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
},
|
||||
list: {
|
||||
listStyle: 'none',
|
||||
marginBottom: 12,
|
||||
},
|
||||
listItem: {
|
||||
fontSize: 12,
|
||||
color: '#8a9db5',
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
dot: {
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
marginTop: 5,
|
||||
},
|
||||
tip: {
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
}
|
||||
7
ecotrack/vite.config.js
Normal file
7
ecotrack/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user