Compare commits

14 Commits

Author SHA1 Message Date
Diego Mireles
295bb0a6b8 feat: add route 80 2026-05-23 12:36:09 -06:00
Diego Mireles
01f01ebd0a feat: add more routes 2026-05-23 12:24:52 -06:00
Diego Mireles
ad1bf1af3d feat: Add feedback for administrator 2026-05-23 09:30:38 -06:00
Diego Mireles
5833063053 feat: Improve the administrator profile 2026-05-23 08:58:23 -06:00
Diego Mireles
5b8711cdf0 feat: rename app 2026-05-23 07:41:56 -06:00
Diego Mireles
b6addb411a feat: add notification push 2026-05-23 07:34:21 -06:00
Diego Mireles
7de53482b1 feat: add calendar 2026-05-23 06:53:28 -06:00
Diego Mireles
d280b3865e feat: add UI screens design 2026-05-23 05:00:54 -06:00
Diego Mireles
131eeacbd2 feat(frontend): register, guide, feedback, addresses & status polling
- Add register screen with onboarding redirect to address validation
- Add waste separation guide screen with 4 categories and offline tips
  (organicos, reciclables, sanitarios, especiales) plus preventive
  messaging banner
- Add feedback submission screen with 4 types and 1-5 star rating
- Add address screen: list colonias, pick one, validate against backend
- Switch from pull-to-refresh GPS hack to periodic polling of
  /tracking/status (30s) — backend now drives the simulation
- Filter notifications by logged-in user.id (tunnel-view on client side)
- Add register/logout/address actions to profile screen
- Hide login/register/feedback/addresses from tab bar (href: null)
- Set API_URL to LAN IP for physical phone testing over hotspot
2026-05-23 02:34:13 -06:00
Diego Mireles
59fcad643a feat(backend): tracking simulator, RBAC status, feedback & addresses
- Add automatic route simulator (30s tick) that advances trucks and
  dispatches notifications without needing client-driven pull
- Add GET /api/tracking/status protected by JWT for tunnel-view
  (each user only sees their own route + own inbox)
- Add POST /api/tracking/reset-demo to wipe in-memory state without
  restarting the server (useful for repeated demos)
- Add feedback module (POST /api/feedback, GET /api/feedback/me) with
  4 feedback types and optional rating
- Add addresses module: GET /colonias, GET/PUT /me with colonia
  validation against the catalog (rejects unknown colonias)
- Add in-memory repos for route-state and notification inbox
- Auto-register new users in the service mock with default route on
  register/login so they receive notifications immediately
2026-05-23 02:33:54 -06:00
Diego Mireles
53c345d984 refactors 2026-05-23 00:08:28 -06:00
Diego Mireles
39bd572955 feat: add api implementation 2026-05-23 00:07:00 -06:00
imsophis
3297f3d9fa Merge branch 'main' of https://git.onlinces.net/onlinces/hackathon-opti-1a67c90779374919b2f9ca0914dcd4b4 2026-05-22 21:15:41 -06:00
imsophis
829aaf82a6 feat: improve alerts screen ui 2026-05-22 21:14:16 -06:00
87 changed files with 6607 additions and 178 deletions

313
backend/README.md Normal file
View File

@@ -0,0 +1,313 @@
# Backend — OptiRuta
API REST en **Node.js + Express + TypeScript** con **Prisma 7** sobre **PostgreSQL**.
> Para el README general del proyecto, ver [../README.md](../README.md).
---
## Tabla de contenidos
- [Stack](#stack)
- [Estructura](#estructura)
- [Arquitectura Clean](#arquitectura-clean)
- [Variables de entorno](#variables-de-entorno)
- [Modelo de datos (Prisma)](#modelo-de-datos-prisma)
- [Cómo correr](#cómo-correr)
- [Módulos](#módulos)
- [El simulador](#el-simulador)
- [Reglas de arquitectura](#reglas-de-arquitectura)
- [Troubleshooting](#troubleshooting)
---
## Stack
- **TypeScript** estricto (`exactOptionalPropertyTypes: true`)
- **Node.js 20+**, modules ESM
- **Express 5** para HTTP
- **Prisma 7** (cliente generado en `src/generated/prisma`)
- **PostgreSQL 15** vía Docker Compose
- **jsonwebtoken** para JWT, **bcryptjs** para hashes
- **tsx** como dev runner (sin Babel, sin reload manual)
---
## Estructura
```
backend/
├── docker-compose.yml ← Postgres en 5433:5432
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── prisma.config.ts
└── src/
├── app.ts ← entry point: conecta DB, seed, lanza simulador, server
├── config/
│ ├── env.ts ← lectura de variables del .env
│ ├── jwt.ts ← JwtAdapter (sign / validate)
│ └── bcrypt.ts ← BcryptAdapter (hash / compare)
├── data/
│ ├── cache/ ← caches en memoria (notification-cache, route-state, inbox)
│ ├── mocks/ ← rutas, colonias, users de prueba
│ ├── postgres/ ← Prisma client singleton
│ ├── repositories/ ← impls reales (Prisma)
│ ├── seed/ ← seed del admin
│ └── simulation/ ← RouteSimulator (cron interno)
├── domain/
│ ├── dtos/ ← DTOs con validators estáticos
│ ├── errors/ ← CustomError
│ ├── repositories/ ← interfaces abstractas
│ └── use-cases/ ← lógica de negocio
└── presentation/
├── admin/ ← controller + routes
├── auth/
├── addresses/
├── feedback/
├── middlewares/ ← AuthMiddleware (validate + requireAdmin)
├── tracking/
├── routes.ts ← AppRoutes: une todos los módulos
└── server.ts ← clase Server (Express setup)
```
---
## Arquitectura Clean
```
HTTP request
presentation (Express controller)
domain (use-case + DTO validator)
data (repository impl / mock / cache)
```
### Reglas
1. El **controller** solo recibe `req`, llama al use-case, responde JSON, maneja errores con `handleError()`.
2. Los **use-cases** llevan toda la lógica de negocio.
3. Los **DTOs** validan datos de entrada con métodos estáticos `static validate(data: unknown)`.
4. Los **repositories** son interfaces en `domain/repositories/`; las implementaciones reales viven en `data/`.
5. Los **errores** se manejan con `CustomError`: `badRequest`, `unauthorized`, `forbidden`, `notFound`, `conflict`, `internalServer`.
6. JWT solo se maneja desde `JwtAdapter`. Bcrypt solo desde `BcryptAdapter`.
7. Variables de entorno solo en `config/env.ts` (excepción: `.env`).
---
## Variables de entorno
Archivo `.env` (no se sube al repo):
```dotenv
PORT=8080
NODE_ENV=development
DATABASE_URL=postgresql://user:password@localhost:5433/optihack
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=optihack
JWT_SEED=your_super_secret_jwt_seed_here_change_in_production
JWT_EXPIRES_IN=7d
BCRYPT_ROUNDS=10
```
| Variable | Default | Notas |
|---|---|---|
| `PORT` | `3000` | Aquí usamos `8080` porque otra app ocupa 3000 |
| `DATABASE_URL` | — | Apunta a Postgres en 5433 (Docker mapping) |
| `JWT_SEED` | — | Requerido. Cambiar en producción |
| `JWT_EXPIRES_IN` | `7d` | Duración del token |
| `BCRYPT_ROUNDS` | `10` | Costo del hash |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | — | Usados por `docker-compose.yml` |
---
## Modelo de datos (Prisma)
```prisma
enum UserRole { USER ADMIN }
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String
phone String? @unique
role UserRole @default(USER)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
addresses Address[]
@@map("users")
}
model Address {
id Int @id @default(autoincrement())
userId Int
label String
street String
neighborhood String?
postalCode String?
latitude Float?
longitude Float?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("addresses")
}
```
> En el MVP solo usamos `User`. `Address` está modelada pero los domicilios se guardan en mocks por ahora — se puede migrar sin tocar los controllers.
---
## Cómo correr
### 1. Postgres con Docker
```powershell
docker compose up -d
docker ps # verifica que postgres:15.3 esté Up
```
### 2. Migraciones + cliente
```powershell
npm install
npx prisma migrate deploy
npx prisma generate
```
### 3. Dev
```powershell
npm run dev
```
Output esperado:
```
Database connected
Seed admin: creado admin@test.com con password "admin123" (cambiar en producción)
RouteSimulator started — tick every 30s
Server is running on port 8080
```
### 4. Production build
```powershell
npm run build # compila a dist/
npm start # corre node dist/app.js
```
### Scripts disponibles
| Script | Hace |
|---|---|
| `npm run dev` | `tsx watch src/app.ts` — hot reload |
| `npm run build` | `tsc` — emite a `dist/` |
| `npm start` | `node dist/app.js` |
---
## Módulos
### Auth (`/api/auth/*`)
- **Registro**: hashea password con `BcryptAdapter`, crea con Prisma, agrega al mock de service con `routeId` default = `RUTA-01`. Siempre rol `USER`.
- **Login**: compara password con bcrypt, genera JWT con `JwtAdapter.generate({ id, email })`. El login response incluye el `role` del DB.
- **GetMe**: protegido con `AuthMiddleware.validate`, devuelve datos del user logueado.
- Si un user legacy no estaba en el mock, el login lo agrega (idempotente con `upsertUser`).
### Tracking (`/api/tracking/*`)
- **POST `/gps-update`**: punto de entrada del simulador (o admin manual). Calcula ETA, evalúa notificaciones, escribe en `route-state` + `inbox`. Respeta cancelación.
- **GET `/status`**: protegido. Devuelve la **visión de túnel** del user — solo su ruta, su ETA, sus notificaciones.
- **POST `/reset-demo`**: limpia caches y estados en memoria sin reiniciar el server.
### Direcciones (`/api/addresses/*`)
- **GET `/colonias`**: catálogo público (mock).
- **GET `/me`**: dirección actual del user.
- **PUT `/me`**: cambia la colonia del user. Valida contra el catálogo; rechaza colonias inexistentes (cumple "validación de zona permitida" del reto).
### Feedback (`/api/feedback/*`)
- **POST**: envía feedback con tipo + mensaje + rating opcional. Tipos: `TRUCK_DID_NOT_PASS`, `RATING`, `SUGGESTION`, `OTHER`.
- **GET `/me`**: feedbacks del user logueado (RBAC).
### Admin (`/api/admin/*`)
Todos requieren `AuthMiddleware.requireAdmin`.
- **GET `/routes`**: lista todas las rutas con estado actual (status, currentPositionId, arrivalResult, cancelled).
- **POST `/routes/:routeId/cancel`**: cancela la ruta. El simulador la pausa. Los users de esa ruta reciben notificación.
- **POST `/routes/:routeId/resume`**: reanuda. Simulator vuelve a positionId 1 y se limpia el dedup cache.
---
## El simulador
`data/simulation/route-simulator.ts`
- Corre `setInterval` cada **30 segundos**
- Solo simula `RUTA-01` por defecto (configurable en `SIMULATED_ROUTE_IDS`)
- Cada tick: avanza el positionIndex de la ruta, dispara `ProcessGpsUpdateUseCase` con la posición correspondiente del mock
- Cuando el ciclo se completa (positionIndex wrap a 0), limpia el dedup cache para que la siguiente vuelta vuelva a emitir notificaciones
- Respeta el flag `cancelled` del estado de ruta: si está cancelada, no avanza
- El admin puede llamar `resetPosition(routeId)` para volver el ciclo a 0
```
positionId 1 → (sin notif, salida del relleno)
positionId 2 → ROUTE_START "¡Ruta Iniciada!"
positionId 3 → (sin notif, en camino)
positionId 4 → TRUCK_PROXIMITY "Camión Cercano" (≈ 12 min de llegada)
positionId 5 → TRUCK_ARRIVED "El camión ya está aquí" (ETA = 0, arrivalResult = ARRIVED)
positionId 8 → ROUTE_COMPLETED "Servicio Finalizado"
wrap → cache cleared, loop back
```
---
## Reglas de arquitectura
Las reglas que mantenemos a lo largo del proyecto:
1. **No lógica pesada en controllers.** Solo: recibir, validar, llamar use-case, responder.
2. **Lógica de negocio en use-cases.**
3. **Prisma solo en `data/repositories` o `data/postgres`.**
4. **Use-cases dependen de interfaces** (`domain/repositories/*`), nunca de Prisma.
5. **DTOs validan entrada** con `static validate()`.
6. **Errores con `CustomError`**.
7. **JWT solo via `JwtAdapter`. Bcrypt solo via `BcryptAdapter`**.
8. **No regresar passwords en ninguna respuesta**.
9. **Las rutas en `presentation`**. Middlewares en `presentation/middlewares`.
10. **Variables de entorno solo en `config/env.ts`**.
11. **No editar archivos generados** (`src/generated/prisma/*`).
---
## Troubleshooting
| Síntoma | Causa | Fix |
|---|---|---|
| `Can't reach database server at localhost:5432` | DATABASE_URL apunta a 5432 pero Docker mapea 5433 | Cambiar puerto en `.env` a `5433` |
| `Prisma client not found` | Cliente no generado | `npx prisma generate` |
| Server arranca pero `/api/...` da 404 | Olvidaste registrar la ruta en `presentation/routes.ts` | Agregar `router.use('/api/X', XRoutes.routes)` |
| Login devuelve 400 con error de Prisma | Tabla `users` no existe | `npx prisma migrate deploy` |
| Admin tab no aparece en el front | Login devolvió role distinto a "ADMIN" | Verifica que el seed haya corrido (mira el log de arranque) |
| Notificaciones repetidas no salen | Dedup cache | Esperar al wrap del simulador o llamar `POST /api/tracking/reset-demo` |
---
## Para extender
- Añadir nuevo módulo X: copia la estructura de `feedback/` (DTO → repo → use-case → controller → route → registrar en `AppRoutes`)
- Migrar un mock a DB real: cambia solo la **impl** en `data/`, las interfaces y use-cases no se tocan
- Conectar Redis: nueva impl de `NotificationCacheRepository` y `RouteStateRepository`, cambia el `new InMemory...` por `new Redis...` en el controller

View File

@@ -3,18 +3,31 @@
import "dotenv/config"; import "dotenv/config";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { AppRoutes } from "./presentation/routes.js"; import { AppRoutes } from "./presentation/routes.js";
import { TrackingRoutes } from "./presentation/tracking/routes.js";
import { Server } from "./presentation/server.js"; import { Server } from "./presentation/server.js";
import { prisma } from "./data/postgres/index.js"; import { prisma } from "./data/postgres/index.js";
import { seedAdmin } from "./data/seed/seed-admin.js";
async function main() { async function main() {
try { try {
await prisma.$connect(); await prisma.$connect();
console.log("Database connected"); console.log("Database connected");
// Garantiza que exista la cuenta admin del sistema
await seedAdmin();
// AppRoutes.routes debe leerse ANTES de pedir el simulator para que el
// controller singleton ya esté construido.
const routes = AppRoutes.routes;
const trackingController = TrackingRoutes.controllerInstance;
if (trackingController) {
trackingController.buildSimulator().start();
}
const server = new Server({ const server = new Server({
port: env.PORT, port: env.PORT,
routes: AppRoutes.routes, routes,
}); });
await server.start(); await server.start();

View File

@@ -0,0 +1,32 @@
/**
* notification-inbox.impl.ts
* Implementación en memoria del buzón de notificaciones por usuario.
*/
import type {
InboxNotification,
NotificationInboxRepository,
} from "../../domain/repositories/notification-inbox.repository.js";
export class InMemoryNotificationInbox implements NotificationInboxRepository {
private readonly inbox = new Map<number, InboxNotification[]>();
async addForUser(notification: InboxNotification): Promise<void> {
const list = this.inbox.get(notification.userId) ?? [];
list.unshift(notification);
this.inbox.set(notification.userId, list);
}
async getForUser(userId: number): Promise<InboxNotification[]> {
return this.inbox.get(userId) ?? [];
}
async clearForUser(userId: number): Promise<void> {
this.inbox.delete(userId);
}
/** Helper para reset de demo. */
async clearAll(): Promise<void> {
this.inbox.clear();
}
}

View File

@@ -0,0 +1,79 @@
/**
* route-state.impl.ts
* Implementación en memoria de RouteStateRepository.
*/
import type {
ArrivalResult,
RouteState,
RouteStateRepository,
} from "../../domain/repositories/route-state.repository.js";
const emptyEta = {
etaMinutes: -1,
arrivalWindow: { from: "--:--", to: "--:--" },
message: "Sin datos",
};
export class InMemoryRouteStateRepository implements RouteStateRepository {
private readonly states = new Map<string, RouteState>();
async get(routeId: string): Promise<RouteState | null> {
return this.states.get(routeId) ?? null;
}
async set(state: RouteState): Promise<void> {
this.states.set(state.routeId, state);
}
async getAll(): Promise<RouteState[]> {
return Array.from(this.states.values());
}
async clearAll(): Promise<void> {
this.states.clear();
}
async cancel(routeId: string, reason?: string): Promise<void> {
const existing = this.states.get(routeId);
const base: RouteState = existing ?? {
routeId,
currentPositionId: 0,
eta: emptyEta,
status: "ESPERA",
updatedAt: new Date().toISOString(),
arrivalResult: "PENDING",
cancelled: false,
};
const next: RouteState = {
...base,
cancelled: true,
status: "CANCELADO",
arrivalResult: "CANCELLED",
updatedAt: new Date().toISOString(),
};
if (reason !== undefined) next.cancelReason = reason;
this.states.set(routeId, next);
}
async resume(routeId: string): Promise<void> {
const existing = this.states.get(routeId);
if (!existing) return;
const next: RouteState = {
...existing,
cancelled: false,
status: "EN_RUTA",
arrivalResult: "PENDING",
updatedAt: new Date().toISOString(),
};
delete next.cancelReason;
this.states.set(routeId, next);
}
/** Helper para actualizar el arrivalResult sin reescribir el resto. */
async updateArrival(routeId: string, result: ArrivalResult): Promise<void> {
const existing = this.states.get(routeId);
if (!existing) return;
this.states.set(routeId, { ...existing, arrivalResult: result });
}
}

View File

@@ -14,4 +14,9 @@ export const colonias: Colonia[] = [
{ colonia: "Los Olivos", routeId: "RUTA-04", horarioEstimado: "Matutino (07:00 - 07:40)" }, { 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: "Rancho Seco", routeId: "RUTA-05", horarioEstimado: "Vespertino (14:15 - 15:00)" },
{ colonia: "Las Insurgentes", routeId: "RUTA-12", horarioEstimado: "Matutino (06:35 - 07:10)" }, { colonia: "Las Insurgentes", routeId: "RUTA-12", horarioEstimado: "Matutino (06:35 - 07:10)" },
// Colonias con calendario de separación específico (RUTA-40/65/80)
{ colonia: "Industrial Norte", routeId: "RUTA-40", horarioEstimado: "Matutino (06:00 - 07:00)" },
{ colonia: "Plaza Mayor", routeId: "RUTA-65", horarioEstimado: "Matutino (07:30 - 08:30)" },
{ colonia: "Fracc. Pinos Sur", routeId: "RUTA-80", horarioEstimado: "Matutino (07:00 - 08:00)" },
]; ];

View File

@@ -3,6 +3,7 @@
export const NotificationType = { export const NotificationType = {
ROUTE_START: "ROUTE_START", ROUTE_START: "ROUTE_START",
TRUCK_PROXIMITY: "TRUCK_PROXIMITY", TRUCK_PROXIMITY: "TRUCK_PROXIMITY",
TRUCK_ARRIVED: "TRUCK_ARRIVED",
ROUTE_COMPLETED: "ROUTE_COMPLETED", ROUTE_COMPLETED: "ROUTE_COMPLETED",
DELAY: "DELAY", DELAY: "DELAY",
MECHANICAL_FAILURE: "MECHANICAL_FAILURE", MECHANICAL_FAILURE: "MECHANICAL_FAILURE",
@@ -25,6 +26,10 @@ export const notificationPayloads: Record<NotificationType, NotificationPayload>
title: "Camión Cercano", 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.", body: "El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera.",
}, },
TRUCK_ARRIVED: {
title: "¡El camión ya está aquí!",
body: "El camión recolector llegó a tu zona. Saca tus residuos a la acera ahora.",
},
ROUTE_COMPLETED: { ROUTE_COMPLETED: {
title: "Servicio Finalizado", title: "Servicio Finalizado",
body: "El camión de tu sector ha concluido su jornada de recolección diaria.", body: "El camión de tu sector ha concluido su jornada de recolección diaria.",
@@ -43,5 +48,6 @@ export const notificationPayloads: Record<NotificationType, NotificationPayload>
export const positionTriggers: Record<number, NotificationType> = { export const positionTriggers: Record<number, NotificationType> = {
2: NotificationType.ROUTE_START, 2: NotificationType.ROUTE_START,
4: NotificationType.TRUCK_PROXIMITY, 4: NotificationType.TRUCK_PROXIMITY,
5: NotificationType.TRUCK_ARRIVED,
8: NotificationType.ROUTE_COMPLETED, 8: NotificationType.ROUTE_COMPLETED,
}; };

View File

@@ -256,4 +256,68 @@ export const routes: MockRoute[] = [
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 41, timestamp: "2026-05-22T07:54:00Z" }, { positionId: 8, lat: 20.5111, lng: -100.9037, speed: 41, timestamp: "2026-05-22T07:54:00Z" },
], ],
}, },
{
routeId: "RUTA-80",
name: "El Campanario - Galaxias del Parque",
truckId: 180,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:05:00Z" },
{ positionId: 2, lat: 20.5425, lng: -100.8720, speed: 42, timestamp: "2026-05-22T06:18:00Z" },
{ positionId: 3, lat: 20.5510, lng: -100.8550, speed: 28, timestamp: "2026-05-22T06:32:00Z" },
{ positionId: 4, lat: 20.5545, lng: -100.8480, speed: 16, timestamp: "2026-05-22T06:46:00Z" },
{ positionId: 5, lat: 20.5580, lng: -100.8410, speed: 0, timestamp: "2026-05-22T07:00:00Z" },
{ positionId: 6, lat: 20.5540, lng: -100.8360, speed: 15, timestamp: "2026-05-22T07:12:00Z" },
{ positionId: 7, lat: 20.5470, lng: -100.8480, speed: 26, timestamp: "2026-05-22T07:25:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:48:00Z" },
],
},
{
routeId: "RUTA-65",
name: "Arboledas 3ra Seccion - Av. Constituyentes",
truckId: 165,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:12:00Z" },
{ positionId: 2, lat: 20.4980, lng: -100.8790, speed: 36, timestamp: "2026-05-22T06:25:00Z" },
{ positionId: 3, lat: 20.4920, lng: -100.8620, speed: 25, timestamp: "2026-05-22T06:38:00Z" },
{ positionId: 4, lat: 20.4890, lng: -100.8500, speed: 14, timestamp: "2026-05-22T06:51:00Z" },
{ positionId: 5, lat: 20.4860, lng: -100.8380, speed: 0, timestamp: "2026-05-22T07:03:00Z" },
{ positionId: 6, lat: 20.4895, lng: -100.8340, speed: 18, timestamp: "2026-05-22T07:15:00Z" },
{ positionId: 7, lat: 20.4950, lng: -100.8480, speed: 24, timestamp: "2026-05-22T07:28:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 38, timestamp: "2026-05-22T07:50:00Z" },
],
},
{
routeId: "RUTA-40",
name: "Frac. Parque Central Alameda - Bosques de la Alameda",
truckId: 140,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T06:08:00Z" },
{ positionId: 2, lat: 20.5295, lng: -100.7650, speed: 48, timestamp: "2026-05-22T06:20:00Z" },
{ positionId: 3, lat: 20.5340, lng: -100.7500, speed: 30, timestamp: "2026-05-22T06:33:00Z" },
{ positionId: 4, lat: 20.5365, lng: -100.7420, speed: 18, timestamp: "2026-05-22T06:46:00Z" },
{ positionId: 5, lat: 20.5390, lng: -100.7350, speed: 0, timestamp: "2026-05-22T06:59:00Z" },
{ positionId: 6, lat: 20.5350, lng: -100.7380, speed: 16, timestamp: "2026-05-22T07:11:00Z" },
{ positionId: 7, lat: 20.5300, lng: -100.7520, speed: 28, timestamp: "2026-05-22T07:24:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 45, timestamp: "2026-05-22T07:49:00Z" },
],
},
{
routeId: "RUTA-80",
name: "Sector Residencial Sur - Fracc. Pinos Sur",
truckId: 180,
status: "EN_RUTA",
positions: [
{ positionId: 1, lat: 20.5111, lng: -100.9037, speed: 0, timestamp: "2026-05-22T07:00:00Z" },
{ positionId: 2, lat: 20.5050, lng: -100.8550, speed: 42, timestamp: "2026-05-22T07:12:00Z" },
{ positionId: 3, lat: 20.4980, lng: -100.8200, speed: 25, timestamp: "2026-05-22T07:25:00Z" },
{ positionId: 4, lat: 20.4920, lng: -100.8050, speed: 15, timestamp: "2026-05-22T07:38:00Z" },
{ positionId: 5, lat: 20.4880, lng: -100.7990, speed: 0, timestamp: "2026-05-22T07:50:00Z" },
{ positionId: 6, lat: 20.4910, lng: -100.7960, speed: 18, timestamp: "2026-05-22T08:05:00Z" },
{ positionId: 7, lat: 20.4955, lng: -100.8030, speed: 22, timestamp: "2026-05-22T08:18:00Z" },
{ positionId: 8, lat: 20.5111, lng: -100.9037, speed: 38, timestamp: "2026-05-22T08:40:00Z" },
],
},
]; ];

View File

@@ -8,6 +8,11 @@ export interface MockUser {
routeId: string; routeId: string;
} }
/**
* Lista de usuarios para el demo. Es mutable porque cuando un user nuevo
* se registra desde la app, se agrega aquí en memoria (con RUTA-01 default).
* En Bloque B1 (domicilios) cada user elegirá su colonia real.
*/
export const users: MockUser[] = [ export const users: MockUser[] = [
{ id: 1, name: "Ana López", email: "ana@test.com", colonia: "Zona Centro", routeId: "RUTA-01" }, { id: 1, name: "Ana López", email: "ana@test.com", colonia: "Zona Centro", routeId: "RUTA-01" },
{ id: 2, name: "Carlos Pérez", email: "carlos@test.com",colonia: "Las Arboledas", routeId: "RUTA-01" }, { id: 2, name: "Carlos Pérez", email: "carlos@test.com",colonia: "Las Arboledas", routeId: "RUTA-01" },
@@ -17,3 +22,16 @@ export const users: MockUser[] = [
{ id: 6, name: "Pedro Sánchez", email: "pedro@test.com", colonia: "Rancho Seco", routeId: "RUTA-05" }, { id: 6, name: "Pedro Sánchez", email: "pedro@test.com", colonia: "Rancho Seco", routeId: "RUTA-05" },
{ id: 7, name: "Sofía Gómez", email: "sofia@test.com", colonia: "Las Insurgentes", routeId: "RUTA-12" }, { id: 7, name: "Sofía Gómez", email: "sofia@test.com", colonia: "Las Insurgentes", routeId: "RUTA-12" },
]; ];
export const DEFAULT_ROUTE_ID = "RUTA-01";
export const DEFAULT_COLONIA = "Zona Centro";
/** Agrega un user al mock (idempotente por id). */
export const upsertUser = (user: MockUser): void => {
const existing = users.findIndex((u) => u.id === user.id);
if (existing >= 0) {
users[existing] = user;
} else {
users.push(user);
}
};

View File

@@ -16,17 +16,30 @@ export class AuthRepositoryImpl implements AuthRepository {
email: true, email: true,
password: true, password: true,
name: true, name: true,
role: true,
}, },
}); });
} }
async create(data: { name: string; email: string; password: string }) { async create(data: {
name: string;
email: string;
password: string;
role?: string;
}) {
return prisma.user.create({ return prisma.user.create({
data, data: {
name: data.name,
email: data.email,
password: data.password,
// role solo si fue pasado explícitamente; default = USER (en Prisma)
...(data.role ? { role: data.role as "USER" | "ADMIN" } : {}),
},
select: { select: {
id: true, id: true,
email: true, email: true,
name: true, name: true,
role: true,
}, },
}); });
} }

View File

@@ -0,0 +1,25 @@
/**
* feedback.repository.impl.ts
* Implementación en memoria del FeedbackRepository.
*/
import type {
FeedbackItem,
FeedbackRepository,
} from "../../domain/repositories/feedback.repository.js";
export class InMemoryFeedbackRepository implements FeedbackRepository {
private readonly items: FeedbackItem[] = [];
async save(item: FeedbackItem): Promise<void> {
this.items.unshift(item);
}
async listForUser(userId: number): Promise<FeedbackItem[]> {
return this.items.filter((i) => i.userId === userId);
}
async listAll(): Promise<FeedbackItem[]> {
return [...this.items];
}
}

View File

@@ -0,0 +1,51 @@
/**
* seed-admin.ts
*
* Crea (o repara) el usuario admin del sistema al arrancar el server.
*
* - Si NO existe admin@test.com → lo crea con role=ADMIN
* - Si SÍ existe pero su rol fue degradado → lo eleva de nuevo a ADMIN
*
* Idempotente: se puede ejecutar en cada arranque sin efectos secundarios.
*
* Para producción: mover las credenciales a env vars y/o ejecutar este seed
* solo una vez via comando manual, no en cada startup.
*/
import { prisma } from "../postgres/index.js";
import { BcryptAdapter } from "../../config/bcrypt.js";
const ADMIN_EMAIL = "admin@test.com";
const ADMIN_PASSWORD = "admin123";
const ADMIN_NAME = "Administrador";
export async function seedAdmin(): Promise<void> {
const existing = await prisma.user.findUnique({
where: { email: ADMIN_EMAIL },
});
if (existing) {
if (existing.role !== "ADMIN") {
await prisma.user.update({
where: { id: existing.id },
data: { role: "ADMIN" },
});
console.log(`Seed admin: promovido ${ADMIN_EMAIL} a ADMIN`);
}
return;
}
const hash = await BcryptAdapter.hash(ADMIN_PASSWORD);
await prisma.user.create({
data: {
name: ADMIN_NAME,
email: ADMIN_EMAIL,
password: hash,
role: "ADMIN",
},
});
console.log(
`Seed admin: creado ${ADMIN_EMAIL} con password "${ADMIN_PASSWORD}" (cambiar en producción)`,
);
}

View File

@@ -0,0 +1,105 @@
/**
* route-simulator.ts
* Simulador automático: cada N segundos avanza un positionId en cada ruta
* y dispara el ProcessGpsUpdateUseCase. Reemplaza al "pull to refresh" del
* frontend para que el flujo sea más realista en la demo.
*
* Cuando un camión termina su ciclo (positionId=8 → vuelve a 1), se limpia
* el cache de dedup para que las notificaciones se vuelvan a disparar en
* la siguiente vuelta. El inbox NO se limpia para conservar el historial.
*
* Solo simula RUTA-01 por defecto para enfocar la demo.
*/
import { routes } from "../mocks/routes.mock.js";
import type { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js";
import type { NotificationCacheRepository } from "../../domain/repositories/notification-cache.repository.js";
import type { RouteStateRepository } from "../../domain/repositories/route-state.repository.js";
const TICK_INTERVAL_MS = 30_000; // 30 segundos
const SIMULATED_ROUTE_IDS = ["RUTA-01", "RUTA-80"];
export class RouteSimulator {
private timer: NodeJS.Timeout | null = null;
private positionIndexByRoute = new Map<string, number>();
constructor(
private readonly processGpsUpdate: ProcessGpsUpdateUseCase,
private readonly dedupCache: NotificationCacheRepository,
private readonly routeState: RouteStateRepository,
) {}
start() {
if (this.timer) return;
// Inicializa cada ruta en positionId=1 inmediatamente
void this.tick();
this.timer = setInterval(() => {
void this.tick();
}, TICK_INTERVAL_MS);
console.log(
`RouteSimulator started — tick every ${TICK_INTERVAL_MS / 1000}s`,
);
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/**
* Reinicia el índice de posición de una ruta al inicio (positionId 1).
* Usado por el admin al reanudar una ruta cancelada para que el ciclo
* vuelva a comenzar desde cero.
*/
resetPosition(routeId: string): void {
this.positionIndexByRoute.set(routeId, 0);
}
private async tick() {
for (const routeId of SIMULATED_ROUTE_IDS) {
const route = routes.find((r) => r.routeId === routeId);
if (!route) continue;
// Si el admin canceló esta ruta, no avanzamos
const state = await this.routeState.get(routeId);
if (state?.cancelled) {
continue;
}
const index = this.positionIndexByRoute.get(routeId) ?? 0;
const position = route.positions[index];
if (!position) continue;
try {
await this.processGpsUpdate.execute({
truckId: String(route.truckId),
routeId: route.routeId,
lat: position.lat,
lng: position.lng,
speed: position.speed,
status: route.status,
positionId: position.positionId,
timestamp: position.timestamp,
});
} catch (err) {
console.error(`Simulator tick failed for ${routeId}:`, err);
}
// Avanza al siguiente; al terminar el array vuelve al inicio Y limpia
// el cache de dedup para que el próximo ciclo sí re-emita notificaciones.
const nextIndex = (index + 1) % route.positions.length;
if (nextIndex < index) {
await this.dedupCache.clear();
console.log(
`RouteSimulator: ${routeId} completó ciclo — cache de dedup limpiado`,
);
}
this.positionIndexByRoute.set(routeId, nextIndex);
}
}
}

View File

@@ -0,0 +1,28 @@
/**
* set-address.dto.ts
* DTO para asignar la colonia del domicilio del usuario.
*/
export interface SetAddressDto {
colonia: string;
street?: string;
}
export class SetAddressDtoValidator {
static validate(data: unknown): SetAddressDto {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid address payload");
}
const obj = data as Record<string, unknown>;
if (typeof obj.colonia !== "string" || obj.colonia.trim().length === 0) {
throw new Error("colonia is required");
}
const result: SetAddressDto = { colonia: obj.colonia.trim() };
if (typeof obj.street === "string" && obj.street.trim().length > 0) {
result.street = obj.street.trim();
}
return result;
}
}

View File

@@ -0,0 +1,63 @@
/**
* submit-feedback.dto.ts
* DTO para enviar retroalimentación sobre el servicio.
*/
export type FeedbackType =
| "TRUCK_DID_NOT_PASS"
| "RATING"
| "SUGGESTION"
| "OTHER";
export interface SubmitFeedbackDto {
type: FeedbackType;
message: string;
rating?: number; // 1-5, sólo cuando type === "RATING"
}
const VALID_TYPES: FeedbackType[] = [
"TRUCK_DID_NOT_PASS",
"RATING",
"SUGGESTION",
"OTHER",
];
export class SubmitFeedbackDtoValidator {
static validate(data: unknown): SubmitFeedbackDto {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid feedback payload");
}
const obj = data as Record<string, unknown>;
if (
typeof obj.type !== "string" ||
!VALID_TYPES.includes(obj.type as FeedbackType)
) {
throw new Error(`type must be one of: ${VALID_TYPES.join(", ")}`);
}
if (typeof obj.message !== "string" || obj.message.trim().length === 0) {
throw new Error("message is required");
}
let rating: number | undefined;
if (obj.rating !== undefined) {
if (
typeof obj.rating !== "number" ||
obj.rating < 1 ||
obj.rating > 5
) {
throw new Error("rating must be between 1 and 5");
}
rating = obj.rating;
}
const result: SubmitFeedbackDto = {
type: obj.type as FeedbackType,
message: obj.message.trim(),
};
if (rating !== undefined) result.rating = rating;
return result;
}
}

View File

@@ -1,3 +1,5 @@
export * from './auth/register-user.dto.js'; export * from './auth/register-user.dto.js';
export * from './auth/login-user.dto.js'; export * from './auth/login-user.dto.js';
export * from './tracking/gps-update.dto.js'; export * from './tracking/gps-update.dto.js';
export * from './feedback/submit-feedback.dto.js';
export * from './addresses/set-address.dto.js';

View File

@@ -17,6 +17,10 @@ export class CustomError extends Error {
return new CustomError(message, 401); return new CustomError(message, 401);
} }
static forbidden(message: string = "Forbidden"): CustomError {
return new CustomError(message, 403);
}
static notFound(message: string): CustomError { static notFound(message: string): CustomError {
return new CustomError(message, 404); return new CustomError(message, 404);
} }

View File

@@ -1,7 +1,7 @@
export interface AuthRepository { export interface AuthRepository {
findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string } | null>; findByEmail(email: string): Promise<{ id: number; email: string; password: string; name: string; role: string } | null>;
create(data: { name: string; email: string; password: string }): Promise<{ id: number; email: string; name: string }>; create(data: { name: string; email: string; password: string; role?: string }): Promise<{ id: number; email: string; name: string; role: string }>;
findById(id: number): Promise<{ id: number; email: string; name: string; role: string } | null>; findById(id: number): Promise<{ id: number; email: string; name: string; role: string } | null>;
} }

View File

@@ -0,0 +1,27 @@
/**
* feedback.repository.ts
* Buzón de retroalimentación.
* In-memory para el MVP. Cuando se migre a DB, se cambia la impl.
*/
import type { FeedbackType } from "../dtos/feedback/submit-feedback.dto.js";
export interface FeedbackItem {
id: string;
userId: number;
/** routeId del usuario en el momento del reporte. */
routeId: string | null;
/** Nombre + colonia del usuario para que el admin no haga lookup extra. */
userName?: string;
colonia?: string;
type: FeedbackType;
message: string;
rating?: number;
createdAt: string;
}
export interface FeedbackRepository {
save(item: FeedbackItem): Promise<void>;
listForUser(userId: number): Promise<FeedbackItem[]>;
listAll(): Promise<FeedbackItem[]>;
}

View File

@@ -0,0 +1,22 @@
/**
* notification-inbox.repository.ts
* Buzón de notificaciones por usuario.
* El simulador empuja notificaciones aquí; el endpoint GET /status las lee.
*/
import type { NotificationType } from "../../data/mocks/notification-types.mock.js";
export interface InboxNotification {
id: string;
userId: number;
type: NotificationType;
title: string;
body: string;
createdAt: string;
}
export interface NotificationInboxRepository {
addForUser(notification: InboxNotification): Promise<void>;
getForUser(userId: number): Promise<InboxNotification[]>;
clearForUser(userId: number): Promise<void>;
}

View File

@@ -0,0 +1,42 @@
/**
* route-state.repository.ts
* Guarda el estado operativo actual de cada ruta:
* - en qué positionId va el camión
* - cuál fue la última ETA calculada
* - cuándo fue la última actualización
*
* Esto vive en memoria para el MVP. Más adelante se puede mover a Redis.
*/
import type { EtaResult } from "../use-cases/notifications/calculate-eta.use-case.js";
/**
* Resultado de llegada del camión a la zona del usuario:
* PENDING - aún no llega o no se ha evaluado
* ARRIVED - el camión sí pasó (positionId >= 5)
* FAILED - hubo falla mecánica antes de llegar
* CANCELLED - admin canceló la ruta
*/
export type ArrivalResult = "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
export interface RouteState {
routeId: string;
currentPositionId: number;
eta: EtaResult;
status: string;
updatedAt: string;
arrivalResult: ArrivalResult;
/** True si el admin canceló esta ruta — el simulador la pausa. */
cancelled: boolean;
cancelReason?: string;
}
export interface RouteStateRepository {
get(routeId: string): Promise<RouteState | null>;
set(state: RouteState): Promise<void>;
getAll(): Promise<RouteState[]>;
/** Cancela la ruta (la pausa). El simulador deja de avanzarla. */
cancel(routeId: string, reason?: string): Promise<void>;
/** Reactiva una ruta previamente cancelada. */
resume(routeId: string): Promise<void>;
}

View File

@@ -0,0 +1,32 @@
/**
* get-my-address.use-case.ts
* Devuelve la dirección activa del usuario logueado.
* Se infiere del mock de servicio (colonia + routeId).
*/
import { CustomError } from "../../errors/custom.error.js";
import { users } from "../../../data/mocks/users.mock.js";
import { colonias } from "../../../data/mocks/colonias.mock.js";
export interface MyAddressResponse {
colonia: string;
routeId: string;
horarioEstimado: string | null;
}
export class GetMyAddressUseCase {
execute(userId: number): MyAddressResponse {
const user = users.find((u) => u.id === userId);
if (!user) {
throw CustomError.notFound("User not found in service mock");
}
const colonia = colonias.find((c) => c.colonia === user.colonia);
return {
colonia: user.colonia,
routeId: user.routeId,
horarioEstimado: colonia?.horarioEstimado ?? null,
};
}
}

View File

@@ -0,0 +1,12 @@
/**
* list-colonias.use-case.ts
* Devuelve las colonias disponibles para el catálogo del frontend.
*/
import { colonias, type Colonia } from "../../../data/mocks/colonias.mock.js";
export class ListColoniasUseCase {
execute(): Colonia[] {
return colonias;
}
}

View File

@@ -0,0 +1,54 @@
/**
* set-my-address.use-case.ts
* Asigna una colonia al usuario logueado. Valida que la colonia exista
* en el catálogo y resuelve el routeId asociado.
*
* Cumple "validación de domicilio dentro de zona permitida" del reto:
* si la colonia no está en el catálogo, no se acepta.
*/
import { SetAddressDtoValidator } from "../../dtos/index.js";
import { CustomError } from "../../errors/custom.error.js";
import { colonias } from "../../../data/mocks/colonias.mock.js";
import { upsertUser, users } from "../../../data/mocks/users.mock.js";
export interface SetMyAddressResponse {
colonia: string;
routeId: string;
horarioEstimado: string;
}
export class SetMyAddressUseCase {
execute(
userId: number,
userName: string,
userEmail: string,
data: unknown,
): SetMyAddressResponse {
const dto = SetAddressDtoValidator.validate(data);
const match = colonias.find(
(c) => c.colonia.toLowerCase() === dto.colonia.toLowerCase(),
);
if (!match) {
throw CustomError.badRequest(
`La colonia "${dto.colonia}" no está dentro de la zona de cobertura`,
);
}
const existing = users.find((u) => u.id === userId);
upsertUser({
id: userId,
name: existing?.name ?? userName,
email: existing?.email ?? userEmail,
colonia: match.colonia,
routeId: match.routeId,
});
return {
colonia: match.colonia,
routeId: match.routeId,
horarioEstimado: match.horarioEstimado,
};
}
}

View File

@@ -0,0 +1,47 @@
/**
* cancel-route.use-case.ts
* El admin cancela una ruta. El simulador pausa esa ruta y se notifica
* a todos los usuarios suscritos a la misma.
*/
import { CustomError } from "../../errors/custom.error.js";
import { routes as routeCatalog } from "../../../data/mocks/routes.mock.js";
import { users } from "../../../data/mocks/users.mock.js";
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
import type {
InboxNotification,
NotificationInboxRepository,
} from "../../repositories/notification-inbox.repository.js";
export class CancelRouteUseCase {
constructor(
private readonly routeState: RouteStateRepository,
private readonly inbox: NotificationInboxRepository,
) {}
async execute(routeId: string, reason?: string): Promise<void> {
const route = routeCatalog.find((r) => r.routeId === routeId);
if (!route) {
throw CustomError.notFound(`Route ${routeId} not found`);
}
await this.routeState.cancel(routeId, reason);
// Avisa a todos los usuarios de esa ruta
const reasonText = reason ? ` Motivo: ${reason}` : "";
const message = `La ruta matutina de tu sector fue cancelada.${reasonText} Se reprogramará para la tarde.`;
const subscribers = users.filter((u) => u.routeId === routeId);
for (const user of subscribers) {
const item: InboxNotification = {
id: `${user.id}-${routeId}-CANCELLED-${Date.now()}`,
userId: user.id,
type: "ROUTE_COMPLETED", // reusamos el tipo "info" del mock
title: "Ruta cancelada",
body: message,
createdAt: new Date().toISOString(),
};
await this.inbox.addForUser(item);
}
}
}

View File

@@ -0,0 +1,19 @@
/**
* list-all-feedback.use-case.ts
* Solo accesible por admin. Devuelve TODOS los reportes que han mandado
* los ciudadanos a través del buzón, ordenados del más reciente al más
* antiguo.
*/
import type {
FeedbackItem,
FeedbackRepository,
} from "../../repositories/feedback.repository.js";
export class ListAllFeedbackUseCase {
constructor(private readonly repository: FeedbackRepository) {}
async execute(): Promise<FeedbackItem[]> {
return this.repository.listAll();
}
}

View File

@@ -0,0 +1,45 @@
/**
* list-routes.use-case.ts
* Lista TODAS las rutas con su estado operativo actual.
* Solo accesible por administradores.
*/
import { routes as routeCatalog } from "../../../data/mocks/routes.mock.js";
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
export interface AdminRouteItem {
routeId: string;
name: string;
truckId: number;
status: string;
currentPositionId: number;
arrivalResult: string;
cancelled: boolean;
cancelReason?: string;
updatedAt?: string;
}
export class ListRoutesUseCase {
constructor(private readonly routeState: RouteStateRepository) {}
async execute(): Promise<AdminRouteItem[]> {
const states = await this.routeState.getAll();
const stateMap = new Map(states.map((s) => [s.routeId, s]));
return routeCatalog.map((r) => {
const s = stateMap.get(r.routeId);
const item: AdminRouteItem = {
routeId: r.routeId,
name: r.name,
truckId: r.truckId,
status: s?.status ?? "ESPERA",
currentPositionId: s?.currentPositionId ?? 0,
arrivalResult: s?.arrivalResult ?? "PENDING",
cancelled: s?.cancelled ?? false,
};
if (s?.cancelReason) item.cancelReason = s.cancelReason;
if (s?.updatedAt) item.updatedAt = s.updatedAt;
return item;
});
}
}

View File

@@ -0,0 +1,42 @@
/**
* resume-route.use-case.ts
* El admin reanuda una ruta cancelada.
*/
import { CustomError } from "../../errors/custom.error.js";
import { routes as routeCatalog } from "../../../data/mocks/routes.mock.js";
import { users } from "../../../data/mocks/users.mock.js";
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
import type {
InboxNotification,
NotificationInboxRepository,
} from "../../repositories/notification-inbox.repository.js";
export class ResumeRouteUseCase {
constructor(
private readonly routeState: RouteStateRepository,
private readonly inbox: NotificationInboxRepository,
) {}
async execute(routeId: string): Promise<void> {
const route = routeCatalog.find((r) => r.routeId === routeId);
if (!route) {
throw CustomError.notFound(`Route ${routeId} not found`);
}
await this.routeState.resume(routeId);
const subscribers = users.filter((u) => u.routeId === routeId);
for (const user of subscribers) {
const item: InboxNotification = {
id: `${user.id}-${routeId}-RESUMED-${Date.now()}`,
userId: user.id,
type: "ROUTE_START",
title: "Ruta reanudada",
body: "El servicio de recolección de tu sector fue reanudado.",
createdAt: new Date().toISOString(),
};
await this.inbox.addForUser(item);
}
}
}

View File

@@ -5,12 +5,19 @@ import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js"; import { CustomError } from "../../errors/custom.error.js";
import { BcryptAdapter } from "../../../config/bcrypt.js"; import { BcryptAdapter } from "../../../config/bcrypt.js";
import { JwtAdapter } from "../../../config/jwt.js"; import { JwtAdapter } from "../../../config/jwt.js";
import {
upsertUser,
users,
DEFAULT_ROUTE_ID,
DEFAULT_COLONIA,
} from "../../../data/mocks/users.mock.js";
export interface LoginUserResponse { export interface LoginUserResponse {
user: { user: {
id: number; id: number;
email: string; email: string;
name: string; name: string;
role: string;
}; };
token: string; token: string;
} }
@@ -34,6 +41,20 @@ export class LoginUserUseCase {
throw CustomError.unauthorized("Invalid credentials"); throw CustomError.unauthorized("Invalid credentials");
} }
// Si el user normal no estaba en el mock de servicio, lo agregamos con
// valores default. Los admins NO se agregan al mock porque no reciben
// notificaciones de ruta. Idempotente.
const inMock = users.find((u) => u.id === user.id);
if (!inMock && user.role !== "ADMIN") {
upsertUser({
id: user.id,
name: user.name,
email: user.email,
colonia: DEFAULT_COLONIA,
routeId: DEFAULT_ROUTE_ID,
});
}
// Generar JWT // Generar JWT
const token = JwtAdapter.generate({ const token = JwtAdapter.generate({
id: user.id, id: user.id,
@@ -45,6 +66,7 @@ export class LoginUserUseCase {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role,
}, },
token, token,
}; };

View File

@@ -5,6 +5,11 @@ import { AuthRepository } from "../../repositories/auth.repository.js";
import { CustomError } from "../../errors/custom.error.js"; import { CustomError } from "../../errors/custom.error.js";
import { BcryptAdapter } from "../../../config/bcrypt.js"; import { BcryptAdapter } from "../../../config/bcrypt.js";
import { JwtAdapter } from "../../../config/jwt.js"; import { JwtAdapter } from "../../../config/jwt.js";
import {
upsertUser,
DEFAULT_ROUTE_ID,
DEFAULT_COLONIA,
} from "../../../data/mocks/users.mock.js";
export interface RegisterUserResponse { export interface RegisterUserResponse {
user: { user: {
@@ -38,6 +43,17 @@ export class RegisterUserUseCase {
password: hashedPassword, password: hashedPassword,
}); });
// Registrar al user en el "mock de servicio" con ruta default,
// así el simulador le manda notificaciones desde el primer momento.
// (En Bloque B1 el user elegirá su colonia/ruta real.)
upsertUser({
id: user.id,
name: user.name,
email: user.email,
colonia: DEFAULT_COLONIA,
routeId: DEFAULT_ROUTE_ID,
});
// Generar JWT // Generar JWT
const token = JwtAdapter.generate({ const token = JwtAdapter.generate({
id: user.id, id: user.id,

View File

@@ -0,0 +1,17 @@
/**
* list-my-feedback.use-case.ts
* Devuelve solo los feedbacks del usuario logueado (RBAC por userId).
*/
import type {
FeedbackItem,
FeedbackRepository,
} from "../../repositories/feedback.repository.js";
export class ListMyFeedbackUseCase {
constructor(private readonly repository: FeedbackRepository) {}
async execute(userId: number): Promise<FeedbackItem[]> {
return this.repository.listForUser(userId);
}
}

View File

@@ -0,0 +1,40 @@
/**
* submit-feedback.use-case.ts
* Recibe el DTO, lo valida y guarda el feedback.
* El userId viene del JWT (req.user.id), no del body — RBAC.
* Adicionalmente, el use-case busca la ruta y colonia del user para que
* el admin pueda ver de qué ruta proviene cada reporte.
*/
import { SubmitFeedbackDtoValidator } from "../../dtos/index.js";
import type {
FeedbackItem,
FeedbackRepository,
} from "../../repositories/feedback.repository.js";
import { users } from "../../../data/mocks/users.mock.js";
export class SubmitFeedbackUseCase {
constructor(private readonly repository: FeedbackRepository) {}
async execute(userId: number, data: unknown): Promise<FeedbackItem> {
const dto = SubmitFeedbackDtoValidator.validate(data);
// Lookup contextual del user para enriquecer el reporte
const user = users.find((u) => u.id === userId);
const item: FeedbackItem = {
id: `${userId}-${Date.now()}`,
userId,
routeId: user?.routeId ?? null,
type: dto.type,
message: dto.message,
createdAt: new Date().toISOString(),
};
if (user?.name) item.userName = user.name;
if (user?.colonia) item.colonia = user.colonia;
if (dto.rating !== undefined) item.rating = dto.rating;
await this.repository.save(item);
return item;
}
}

View File

@@ -8,7 +8,9 @@ export interface EtaResult {
message: string; message: string;
} }
const PROXIMITY_POSITION_ID = 4; // El destino real (cuando el camión está en tu colonia) es positionId=5,
// donde speed=0. positionId=4 es el "punto de alerta de proximidad" (~12 min antes).
const ARRIVAL_POSITION_ID = 5;
const WINDOW_MARGIN_MIN = 5; const WINDOW_MARGIN_MIN = 5;
export class CalculateEtaUseCase { export class CalculateEtaUseCase {
@@ -28,11 +30,11 @@ export class CalculateEtaUseCase {
} }
const target = route.positions.find( const target = route.positions.find(
(p) => p.positionId === PROXIMITY_POSITION_ID, (p) => p.positionId === ARRIVAL_POSITION_ID,
); );
if (!target) { if (!target) {
throw CustomError.internalServer( throw CustomError.internalServer(
`Route ${route.routeId} has no proximity position`, `Route ${route.routeId} has no arrival position`,
); );
} }
@@ -50,6 +52,8 @@ export class CalculateEtaUseCase {
let message: string; let message: string;
if (etaMinutes < 0) { if (etaMinutes < 0) {
message = `El camión ya pasó por tu zona (aprox. ${arrivalWindow.from} - ${arrivalWindow.to}).`; message = `El camión ya pasó por tu zona (aprox. ${arrivalWindow.from} - ${arrivalWindow.to}).`;
} else if (etaMinutes === 0) {
message = "El camión ya está en tu zona. Saca tus residuos ahora.";
} else if (etaMinutes <= 15) { } else if (etaMinutes <= 15) {
message = `Llega en aproximadamente ${etaMinutes} minutos.`; message = `Llega en aproximadamente ${etaMinutes} minutos.`;
} else { } else {

View File

@@ -3,9 +3,14 @@ import { CalculateEtaUseCase, type EtaResult } from "./calculate-eta.use-case.js
import { import {
EvaluateNotificationUseCase, EvaluateNotificationUseCase,
type EvaluatedNotification, type EvaluatedNotification,
} from "../notifications/evaluate-notification.use-case.js"; } from "./evaluate-notification.use-case.js";
import { routes } from "../../../data/mocks/routes.mock.js"; import { routes } from "../../../data/mocks/routes.mock.js";
import { CustomError } from "../../errors/custom.error.js"; import { CustomError } from "../../errors/custom.error.js";
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
import type {
InboxNotification,
NotificationInboxRepository,
} from "../../repositories/notification-inbox.repository.js";
export interface ProcessGpsUpdateResponse { export interface ProcessGpsUpdateResponse {
message: string; message: string;
@@ -18,6 +23,8 @@ export class ProcessGpsUpdateUseCase {
constructor( constructor(
private readonly calculateEta: CalculateEtaUseCase, private readonly calculateEta: CalculateEtaUseCase,
private readonly evaluateNotification: EvaluateNotificationUseCase, private readonly evaluateNotification: EvaluateNotificationUseCase,
private readonly routeState: RouteStateRepository,
private readonly inbox: NotificationInboxRepository,
) {} ) {}
async execute(data: unknown): Promise<ProcessGpsUpdateResponse> { async execute(data: unknown): Promise<ProcessGpsUpdateResponse> {
@@ -28,9 +35,57 @@ export class ProcessGpsUpdateUseCase {
throw CustomError.notFound(`Route ${dto.routeId} not found`); throw CustomError.notFound(`Route ${dto.routeId} not found`);
} }
// Si el admin canceló esta ruta, no procesamos GPS updates
const existingState = await this.routeState.get(route.routeId);
if (existingState?.cancelled) {
return {
message: "Route cancelled — GPS update ignored",
truck: {
truckId: route.truckId,
routeId: route.routeId,
status: "CANCELADO",
},
eta: existingState.eta,
notifications: [],
};
}
const eta = this.calculateEta.execute(dto); const eta = this.calculateEta.execute(dto);
const notifications = await this.evaluateNotification.execute(dto); const notifications = await this.evaluateNotification.execute(dto);
// Derivar arrivalResult según positionId y status:
// - FALLA → FAILED
// - positionId >= 5 → ARRIVED (camión ya llegó a tu zona)
// - resto → PENDING
let arrivalResult: "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED" =
"PENDING";
if (dto.status === "FALLA") arrivalResult = "FAILED";
else if ((dto.positionId ?? 0) >= 5) arrivalResult = "ARRIVED";
// 1) Guardar el estado actual de la ruta
await this.routeState.set({
routeId: route.routeId,
currentPositionId: dto.positionId ?? 0,
eta,
status: dto.status,
updatedAt: dto.timestamp ?? new Date().toISOString(),
arrivalResult,
cancelled: false,
});
// 2) Empujar cada notificación al buzón del usuario destinatario
for (const n of notifications) {
const item: InboxNotification = {
id: `${n.userId}-${route.routeId}-${n.type}-${Date.now()}`,
userId: n.userId,
type: n.type,
title: n.title,
body: n.body,
createdAt: new Date().toISOString(),
};
await this.inbox.addForUser(item);
}
return { return {
message: "GPS update processed", message: "GPS update processed",
truck: { truck: {

View File

@@ -0,0 +1,65 @@
/**
* get-user-status.use-case.ts
* Devuelve la "visión de túnel" del usuario logueado:
* - SOLO la ruta asignada a su domicilio
* - SOLO sus propias notificaciones
* No expone rutas ajenas ni telemetría completa.
*/
import { CustomError } from "../../errors/custom.error.js";
import type { EtaResult } from "../notifications/calculate-eta.use-case.js";
import type { RouteStateRepository } from "../../repositories/route-state.repository.js";
import type {
InboxNotification,
NotificationInboxRepository,
} from "../../repositories/notification-inbox.repository.js";
import { users } from "../../../data/mocks/users.mock.js";
import { colonias } from "../../../data/mocks/colonias.mock.js";
export interface UserStatusResponse {
user: { id: number; name: string; colonia: string };
route: {
routeId: string;
currentPositionId: number;
status: string;
updatedAt: string;
horarioEstimado: string | null;
arrivalResult: "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
cancelled: boolean;
};
eta: EtaResult | null;
notifications: InboxNotification[];
}
export class GetUserStatusUseCase {
constructor(
private readonly routeState: RouteStateRepository,
private readonly inbox: NotificationInboxRepository,
) {}
async execute(userId: number): Promise<UserStatusResponse> {
const user = users.find((u) => u.id === userId);
if (!user) {
throw CustomError.notFound("User not registered in service mock");
}
const state = await this.routeState.get(user.routeId);
const notifications = await this.inbox.getForUser(userId);
const colonia = colonias.find((c) => c.colonia === user.colonia);
return {
user: { id: user.id, name: user.name, colonia: user.colonia },
route: {
routeId: user.routeId,
currentPositionId: state?.currentPositionId ?? 0,
status: state?.status ?? "ESPERA",
updatedAt: state?.updatedAt ?? new Date().toISOString(),
horarioEstimado: colonia?.horarioEstimado ?? null,
arrivalResult: state?.arrivalResult ?? "PENDING",
cancelled: state?.cancelled ?? false,
},
eta: state?.eta ?? null,
notifications,
};
}
}

View File

@@ -0,0 +1,66 @@
/**
* controller.ts (addresses)
*
* GET /api/addresses/colonias → catálogo público de colonias
* GET /api/addresses/me → dirección activa del usuario logueado
* PUT /api/addresses/me → asignar colonia al usuario logueado
*/
import { Request, Response } from "express";
import { AuthRequest } from "../middlewares/auth.middleware.js";
import { CustomError } from "../../domain/errors/custom.error.js";
import { ListColoniasUseCase } from "../../domain/use-cases/addresses/list-colonias.use-case.js";
import { GetMyAddressUseCase } from "../../domain/use-cases/addresses/get-my-address.use-case.js";
import { SetMyAddressUseCase } from "../../domain/use-cases/addresses/set-my-address.use-case.js";
export class AddressesController {
private listColonias = new ListColoniasUseCase();
private getMyAddress = new GetMyAddressUseCase();
private setMyAddress = new SetMyAddressUseCase();
colonias = (_req: Request, res: Response) => {
try {
const result = this.listColonias.execute();
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
myAddress = (req: AuthRequest, res: Response) => {
try {
if (!req.user) throw CustomError.unauthorized("User not authenticated");
const result = this.getMyAddress.execute(req.user.id);
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
setAddress = (req: AuthRequest, res: Response) => {
try {
if (!req.user) throw CustomError.unauthorized("User not authenticated");
const result = this.setMyAddress.execute(
req.user.id,
req.user.email, // como fallback de nombre
req.user.email,
req.body,
);
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
private handleError(error: unknown, res: Response): void {
if (error instanceof CustomError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
if (error instanceof Error) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: "Internal server error" });
}
}

View File

@@ -0,0 +1,16 @@
import { Router } from "express";
import { AddressesController } from "./controller.js";
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
export class AddressesRoutes {
static get routes(): Router {
const router = Router();
const controller = new AddressesController();
router.get("/colonias", controller.colonias);
router.get("/me", AuthMiddleware.validate, controller.myAddress);
router.put("/me", AuthMiddleware.validate, controller.setAddress);
return router;
}
}

View File

@@ -0,0 +1,100 @@
/**
* controller.ts (admin)
*
* GET /api/admin/routes - listar todas las rutas + estado
* POST /api/admin/routes/:id/cancel - cancelar ruta (con motivo opcional)
* POST /api/admin/routes/:id/resume - reanudar ruta cancelada
*
* Todos los endpoints requieren AuthMiddleware.validate + requireAdmin.
*/
import { Response } from "express";
import { AuthRequest } from "../middlewares/auth.middleware.js";
import { CustomError } from "../../domain/errors/custom.error.js";
import { ListRoutesUseCase } from "../../domain/use-cases/admin/list-routes.use-case.js";
import { CancelRouteUseCase } from "../../domain/use-cases/admin/cancel-route.use-case.js";
import { ResumeRouteUseCase } from "../../domain/use-cases/admin/resume-route.use-case.js";
import { ListAllFeedbackUseCase } from "../../domain/use-cases/admin/list-all-feedback.use-case.js";
import { TrackingController } from "../tracking/controller.js";
import { FeedbackController } from "../feedback/controller.js";
export class AdminController {
private listRoutes = new ListRoutesUseCase(
TrackingController.getRouteStateRepository(),
);
private cancelRoute = new CancelRouteUseCase(
TrackingController.getRouteStateRepository(),
TrackingController.getNotificationInbox(),
);
private resumeRoute = new ResumeRouteUseCase(
TrackingController.getRouteStateRepository(),
TrackingController.getNotificationInbox(),
);
private listAllFeedback = new ListAllFeedbackUseCase(
FeedbackController.getRepository(),
);
routes = async (_req: AuthRequest, res: Response) => {
try {
const result = await this.listRoutes.execute();
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
feedback = async (_req: AuthRequest, res: Response) => {
try {
const result = await this.listAllFeedback.execute();
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
cancel = async (req: AuthRequest, res: Response) => {
try {
const routeId = String(req.params.routeId ?? "");
if (!routeId) throw CustomError.badRequest("routeId is required");
const reason =
typeof req.body?.reason === "string" ? req.body.reason : undefined;
await this.cancelRoute.execute(routeId, reason);
// Resetea el índice del simulador para que al reanudar vuelva a 1
TrackingController.getSimulator()?.resetPosition(routeId);
res.status(200).json({ message: "Route cancelled", routeId });
} catch (error) {
this.handleError(error, res);
}
};
resume = async (req: AuthRequest, res: Response) => {
try {
const routeId = String(req.params.routeId ?? "");
if (!routeId) throw CustomError.badRequest("routeId is required");
await this.resumeRoute.execute(routeId);
// Al reanudar: simulator vuelve a positionId 1 + limpia dedup cache
// para que las notificaciones del ciclo nuevo se vuelvan a emitir.
TrackingController.getSimulator()?.resetPosition(routeId);
await TrackingController.getDedupCache().clear();
res.status(200).json({ message: "Route resumed", routeId });
} catch (error) {
this.handleError(error, res);
}
};
private handleError(error: unknown, res: Response): void {
if (error instanceof CustomError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
if (error instanceof Error) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: "Internal server error" });
}
}

View File

@@ -0,0 +1,20 @@
import { Router } from "express";
import { AdminController } from "./controller.js";
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
export class AdminRoutes {
static get routes(): Router {
const router = Router();
const controller = new AdminController();
router.use(AuthMiddleware.validate, AuthMiddleware.requireAdmin);
router.get("/routes", controller.routes);
router.post("/routes/:routeId/cancel", controller.cancel);
router.post("/routes/:routeId/resume", controller.resume);
router.get("/feedback", controller.feedback);
return router;
}
}

View File

@@ -0,0 +1,56 @@
/**
* controller.ts (feedback)
* POST /api/feedback → enviar retroalimentación (auth)
* GET /api/feedback/me → listar mis feedbacks (auth)
*/
import { Response } from "express";
import { AuthRequest } from "../middlewares/auth.middleware.js";
import { CustomError } from "../../domain/errors/custom.error.js";
import { SubmitFeedbackUseCase } from "../../domain/use-cases/feedback/submit-feedback.use-case.js";
import { ListMyFeedbackUseCase } from "../../domain/use-cases/feedback/list-my-feedback.use-case.js";
import { InMemoryFeedbackRepository } from "../../data/repositories/feedback.repository.impl.js";
export class FeedbackController {
private static repository = new InMemoryFeedbackRepository();
private submit = new SubmitFeedbackUseCase(FeedbackController.repository);
private listMine = new ListMyFeedbackUseCase(FeedbackController.repository);
/** Expone el repo singleton al módulo admin. */
static getRepository() {
return FeedbackController.repository;
}
create = async (req: AuthRequest, res: Response) => {
try {
if (!req.user) throw CustomError.unauthorized("User not authenticated");
const result = await this.submit.execute(req.user.id, req.body);
res.status(201).json(result);
} catch (error) {
this.handleError(error, res);
}
};
myFeedback = async (req: AuthRequest, res: Response) => {
try {
if (!req.user) throw CustomError.unauthorized("User not authenticated");
const result = await this.listMine.execute(req.user.id);
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
private handleError(error: unknown, res: Response): void {
if (error instanceof CustomError) {
res.status(error.statusCode).json({ error: error.message });
return;
}
if (error instanceof Error) {
res.status(400).json({ error: error.message });
return;
}
res.status(500).json({ error: "Internal server error" });
}
}

View File

@@ -0,0 +1,15 @@
import { Router } from "express";
import { FeedbackController } from "./controller.js";
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
export class FeedbackRoutes {
static get routes(): Router {
const router = Router();
const controller = new FeedbackController();
router.post("/", AuthMiddleware.validate, controller.create);
router.get("/me", AuthMiddleware.validate, controller.myFeedback);
return router;
}
}

View File

@@ -1,18 +1,23 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { JwtAdapter, JwtPayload } from "../../config/jwt.js"; import { JwtAdapter } from "../../config/jwt.js";
import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js"; import { AuthRepositoryImpl } from "../../data/repositories/auth.repository.impl.js";
import { CustomError } from "../../domain/errors/custom.error.js"; import { CustomError } from "../../domain/errors/custom.error.js";
export interface AuthUser {
id: number;
email: string;
role: string;
}
export interface AuthRequest extends Request { export interface AuthRequest extends Request {
user?: JwtPayload; user?: AuthUser;
} }
export class AuthMiddleware { export class AuthMiddleware {
static async validate(req: AuthRequest, res: Response, next: NextFunction) { static async validate(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) { if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw CustomError.unauthorized("Missing or invalid Authorization header"); throw CustomError.unauthorized("Missing or invalid Authorization header");
@@ -20,21 +25,19 @@ export class AuthMiddleware {
const token = authHeader.substring(7); const token = authHeader.substring(7);
const payload = JwtAdapter.validate(token); const payload = JwtAdapter.validate(token);
if (!payload) { if (!payload) {
throw CustomError.unauthorized("Invalid token"); throw CustomError.unauthorized("Invalid token");
} }
const repository = new AuthRepositoryImpl(); const repository = new AuthRepositoryImpl();
const user = await repository.findById(payload.id); const user = await repository.findById(payload.id);
if (!user) { if (!user) {
throw CustomError.unauthorized("User not found"); throw CustomError.unauthorized("User not found");
} }
// Exponemos id + email + role (no name) en req.user.
req.user = payload; req.user = { id: user.id, email: user.email, role: user.role };
next(); next();
} catch (error) { } catch (error) {
@@ -44,4 +47,16 @@ export class AuthMiddleware {
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
/** Requiere que el usuario logueado tenga role=ADMIN. Debe encadenarse
* después de `validate`. */
static requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
if (req.user.role !== "ADMIN") {
return res.status(403).json({ error: "Admin role required" });
}
next();
}
} }

View File

@@ -1,13 +1,21 @@
import { Router } from "express"; import { Router } from "express";
import { AuthRoutes } from "./auth/routes.js"; import { AuthRoutes } from "./auth/routes.js";
import { TrackingRoutes } from "./tracking/routes.js"; import { TrackingRoutes } from "./tracking/routes.js";
import { FeedbackRoutes } from "./feedback/routes.js";
import { AddressesRoutes } from "./addresses/routes.js";
import { AdminRoutes } from "./admin/routes.js";
export class AppRoutes { export class AppRoutes {
static get routes(): Router { static get routes(): Router {
const router = Router(); const router = Router();
router.use('/api/auth', AuthRoutes.routes); router.use('/api/auth', AuthRoutes.routes);
// Importante: el módulo admin DEPENDE de los singletons de tracking
// (route-state, inbox). Por eso tracking debe registrarse ANTES que admin.
router.use('/api/tracking', TrackingRoutes.routes); router.use('/api/tracking', TrackingRoutes.routes);
router.use('/api/admin', AdminRoutes.routes);
router.use('/api/feedback', FeedbackRoutes.routes);
router.use('/api/addresses', AddressesRoutes.routes);
return router; return router;
} }

View File

@@ -1,18 +1,78 @@
/**
* controller.ts (tracking)
*
* Dos endpoints:
* POST /gps-update → interno (simulador / admin). No expone datos del user.
* GET /status → protegido por AuthMiddleware. Devuelve SOLO la
* información del usuario logueado (visión de túnel).
*/
import { Request, Response } from "express"; import { Request, Response } from "express";
import { AuthRequest } from "../middlewares/auth.middleware.js";
import { CustomError } from "../../domain/errors/custom.error.js"; import { CustomError } from "../../domain/errors/custom.error.js";
import { CalculateEtaUseCase } from "../../domain/use-cases/notifications/calculate-eta.use-case.js"; import { CalculateEtaUseCase } from "../../domain/use-cases/notifications/calculate-eta.use-case.js";
import { EvaluateNotificationUseCase } from "../../domain/use-cases/notifications/evaluate-notification.use-case.js"; import { EvaluateNotificationUseCase } from "../../domain/use-cases/notifications/evaluate-notification.use-case.js";
import { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js"; import { ProcessGpsUpdateUseCase } from "../../domain/use-cases/notifications/process-gps-update.use-case.js";
import { GetUserStatusUseCase } from "../../domain/use-cases/tracking/get-user-status.use-case.js";
import { InMemoryNotificationCache } from "../../data/cache/notification-cache.impl.js"; import { InMemoryNotificationCache } from "../../data/cache/notification-cache.impl.js";
import { InMemoryRouteStateRepository } from "../../data/cache/route-state.impl.js";
import { InMemoryNotificationInbox } from "../../data/cache/notification-inbox.impl.js";
import { RouteSimulator } from "../../data/simulation/route-simulator.js";
export class TrackingController { export class TrackingController {
private cache = new InMemoryNotificationCache(); // Singletons del módulo (viven mientras corre el server)
private static dedupCache = new InMemoryNotificationCache();
private static routeState = new InMemoryRouteStateRepository();
private static inbox = new InMemoryNotificationInbox();
private calculateEta = new CalculateEtaUseCase(); private calculateEta = new CalculateEtaUseCase();
private evaluateNotification = new EvaluateNotificationUseCase(this.cache); private evaluateNotification = new EvaluateNotificationUseCase(
TrackingController.dedupCache,
);
private processGpsUpdate = new ProcessGpsUpdateUseCase( private processGpsUpdate = new ProcessGpsUpdateUseCase(
this.calculateEta, this.calculateEta,
this.evaluateNotification, this.evaluateNotification,
TrackingController.routeState,
TrackingController.inbox,
); );
private getUserStatus = new GetUserStatusUseCase(
TrackingController.routeState,
TrackingController.inbox,
);
/** Referencia al simulador para que el módulo admin pueda reiniciar posiciones. */
private static simulatorInstance: RouteSimulator | null = null;
/** Permite que app.ts lance el simulador usando estos mismos singletons. */
buildSimulator(): RouteSimulator {
const sim = new RouteSimulator(
this.processGpsUpdate,
TrackingController.dedupCache,
TrackingController.routeState,
);
TrackingController.simulatorInstance = sim;
return sim;
}
/** Expone el route-state singleton al módulo admin. */
static getRouteStateRepository() {
return TrackingController.routeState;
}
/** Expone el inbox singleton al módulo admin. */
static getNotificationInbox() {
return TrackingController.inbox;
}
/** Expone el dedup cache al módulo admin (para limpiarlo en resume). */
static getDedupCache() {
return TrackingController.dedupCache;
}
/** Expone el simulador (puede ser null si aún no se construyó). */
static getSimulator(): RouteSimulator | null {
return TrackingController.simulatorInstance;
}
gpsUpdate = async (req: Request, res: Response) => { gpsUpdate = async (req: Request, res: Response) => {
try { try {
@@ -23,6 +83,35 @@ export class TrackingController {
} }
}; };
myStatus = async (req: AuthRequest, res: Response) => {
try {
if (!req.user) throw CustomError.unauthorized("User not authenticated");
const result = await this.getUserStatus.execute(req.user.id);
res.status(200).json(result);
} catch (error) {
this.handleError(error, res);
}
};
/**
* POST /reset-demo
* Borra cache de dedup, inbox y estado de rutas.
* Permite repetir la demo sin reiniciar el server. Solo para hackathon.
*/
resetDemo = async (_req: Request, res: Response) => {
try {
await TrackingController.dedupCache.clear();
await TrackingController.inbox.clearAll();
await TrackingController.routeState.clearAll();
res
.status(200)
.json({ message: "Demo state reset — simulator continues running" });
} catch (error) {
this.handleError(error, res);
}
};
private handleError(error: unknown, res: Response): void { private handleError(error: unknown, res: Response): void {
if (error instanceof CustomError) { if (error instanceof CustomError) {
res.status(error.statusCode).json({ error: error.message }); res.status(error.statusCode).json({ error: error.message });

View File

@@ -1,13 +1,24 @@
import { Router } from "express"; import { Router } from "express";
import { TrackingController } from "./controller.js"; import { TrackingController } from "./controller.js";
import { AuthMiddleware } from "../middlewares/auth.middleware.js";
export class TrackingRoutes { export class TrackingRoutes {
static controllerInstance: TrackingController | null = null;
static get routes(): Router { static get routes(): Router {
const router = Router(); const router = Router();
const controller = new TrackingController(); const controller = new TrackingController();
TrackingRoutes.controllerInstance = controller;
// Interno: simulador o admin. Sin auth para que el demo sea simple.
router.post("/gps-update", controller.gpsUpdate); router.post("/gps-update", controller.gpsUpdate);
// Visión de túnel: SOLO con JWT, SOLO datos del usuario logueado.
router.get("/status", AuthMiddleware.validate, controller.myStatus);
// Reset del estado en memoria (solo para repetir la demo).
router.post("/reset-demo", controller.resetDemo);
return router; return router;
} }
} }

409
frontend/INTEGRATION.md Normal file
View File

@@ -0,0 +1,409 @@
# Guía Frontend — Integración con el Backend
App: Expo Router + React Native + TypeScript.
Backend: Express en `http://localhost:3000` (ya está listo, no hay que tocarlo).
> Esta guía está pensada para probar en **emulador de Android**.
> Si prueban en otro entorno, sólo cambien la `API_URL` (sección 1).
---
## 0. Endpoints del backend
| Método | Endpoint | Para qué |
|---|---|---|
| POST | `/api/auth/register` | Crear cuenta |
| POST | `/api/auth/login` | Iniciar sesión y obtener JWT |
| GET | `/api/auth/me` | Datos del usuario (requiere token) |
| POST | `/api/tracking/gps-update` | Recibe posición del camión, regresa ETA + notificaciones |
---
## 1. Configurar la URL del backend
Crear `frontend/src/config/api.ts`:
```ts
// IPs según el entorno:
// Android Emulator → http://10.0.2.2:3000 (default aquí)
// iOS simulator → http://localhost:3000
// Dispositivo real → http://<IP-de-tu-laptop>:3000 (ej. 192.168.1.20)
// Expo Go web → http://localhost:3000
export const API_URL = "http://10.0.2.2:3000";
```
> `10.0.2.2` es la dirección especial del emulador de Android para alcanzar el `localhost` de la laptop. No usen `localhost` desde el emulador, no funciona.
> Si prueban en un celular físico, sáquen la IP de la laptop con `ipconfig` (Windows) y pongan algo como `http://192.168.1.20:3000`. La laptop y el celular tienen que estar en el **mismo Wi-Fi**.
---
## 2. Cliente HTTP simple
Crear `frontend/src/lib/api.ts`:
```ts
import { API_URL } from "../config/api";
let authToken: string | null = null;
export const setAuthToken = (token: string | null) => {
authToken = token;
};
export const apiFetch = async <T>(
path: string,
options: RequestInit = {},
): Promise<T> => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return body as T;
};
```
---
## 3. Servicios por feature
### `frontend/src/services/auth.service.ts`
```ts
import { apiFetch, setAuthToken } from "../lib/api";
export interface AuthUser {
id: number;
email: string;
name: string;
}
export const register = (data: { name: string; email: string; password: string }) =>
apiFetch<{ id: number; email: string; name: string }>("/api/auth/register", {
method: "POST",
body: JSON.stringify(data),
});
export const login = async (data: { email: string; password: string }) => {
const res = await apiFetch<{ user: AuthUser; token: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify(data),
});
setAuthToken(res.token);
return res;
};
export const getMe = () =>
apiFetch<AuthUser & { role: string }>("/api/auth/me");
```
### `frontend/src/services/tracking.service.ts`
```ts
import { apiFetch } from "../lib/api";
export type NotificationType =
| "ROUTE_START"
| "TRUCK_PROXIMITY"
| "ROUTE_COMPLETED"
| "DELAY"
| "MECHANICAL_FAILURE";
export interface GpsUpdatePayload {
truckId: string;
routeId: string;
lat: number;
lng: number;
speed: number;
status: "EN_RUTA" | "DETENIDO" | "FINALIZADO" | "FALLA";
positionId?: number;
timestamp?: string;
}
export interface EtaResult {
etaMinutes: number;
arrivalWindow: { from: string; to: string };
message: string;
}
export interface BackendNotification {
userId: number;
type: NotificationType;
title: string;
body: string;
}
export interface GpsUpdateResponse {
message: string;
truck: { truckId: number; routeId: string; status: string };
eta: EtaResult;
notifications: BackendNotification[];
}
export const sendGpsUpdate = (payload: GpsUpdatePayload) =>
apiFetch<GpsUpdateResponse>("/api/tracking/gps-update", {
method: "POST",
body: JSON.stringify(payload),
});
```
---
## 4. Mapear los tipos del backend al componente `AlertItem`
`AlertItem` ya acepta `started | near | danger | completed`. Crear `frontend/src/lib/notification-mapper.ts`:
```ts
import type { NotificationType } from "../services/tracking.service";
export const notificationTypeToAlertType = (
type: NotificationType,
): "started" | "near" | "danger" | "completed" => {
switch (type) {
case "ROUTE_START": return "started";
case "TRUCK_PROXIMITY": return "near";
case "ROUTE_COMPLETED": return "completed";
case "DELAY": return "danger";
case "MECHANICAL_FAILURE": return "danger";
}
};
```
---
## 5. Pantalla de Login (`src/app/login.tsx`)
```tsx
import { useState } from "react";
import { View, Text, StyleSheet, Alert } from "react-native";
import { useRouter } from "expo-router";
import InputField from "../components/InputField";
import PrimaryButton from "../components/PrimaryButton";
import { login } from "../services/auth.service";
export default function LoginScreen() {
const [email, setEmail] = useState("ana@test.com");
const [password, setPassword] = useState("123456");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleLogin = async () => {
try {
setLoading(true);
await login({ email, password });
router.replace("/");
} catch (err) {
Alert.alert("Error", err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Iniciar sesión</Text>
<InputField placeholder="Email" value={email} onChangeText={setEmail} />
<InputField placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
<PrimaryButton title={loading ? "Cargando..." : "Entrar"} onPress={handleLogin} />
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: "center" },
title: { fontSize: 24, fontWeight: "bold", marginBottom: 20 },
});
```
---
## 6. Pantalla principal con ETA en tiempo real (`src/app/index.tsx`)
```tsx
import { useEffect, useState } from "react";
import { ScrollView, RefreshControl, View } from "react-native";
import EtaCard from "../components/EtaCard";
import {
sendGpsUpdate,
type GpsUpdateResponse,
type BackendNotification,
} from "../services/tracking.service";
// Para la demo: el frontend simula el avance del camión.
// En producción esto vendría de un GPS real → backend → app vía websockets/polling.
const SIMULATION_STEPS = [
{ 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: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:40:00Z" },
];
export default function HomeScreen() {
const [stepIndex, setStepIndex] = useState(0);
const [data, setData] = useState<GpsUpdateResponse | null>(null);
const [notifications, setNotifications] = useState<BackendNotification[]>([]);
const fetchNext = async () => {
const step = SIMULATION_STEPS[stepIndex % SIMULATION_STEPS.length];
try {
const res = await sendGpsUpdate({
truckId: "101",
routeId: "RUTA-01",
status: "EN_RUTA",
...step,
});
setData(res);
setNotifications((prev) => [...res.notifications, ...prev]);
setStepIndex((i) => i + 1);
} catch (err) {
console.error(err);
}
};
useEffect(() => { fetchNext(); }, []);
return (
<ScrollView refreshControl={<RefreshControl refreshing={false} onRefresh={fetchNext} />}>
<EtaCard
minutes={data?.eta.etaMinutes ?? 0}
status={data?.eta.message ?? "Cargando..."}
/>
<View>
{/* Mapear notifications a <AlertItem /> aquí si quieren mostrarlas en la home también */}
</View>
</ScrollView>
);
}
```
> **Truco del demo:** cada "pull to refresh" avanza el camión un paso. Sin tocar el backend, pueden mostrar las 5 fases del recorrido.
---
## 7. Pantalla de Alertas (`src/app/alerts.tsx`)
```tsx
import { FlatList, View } from "react-native";
import AlertItem from "../components/Alertltem";
import { notificationTypeToAlertType } from "../lib/notification-mapper";
import type { BackendNotification } from "../services/tracking.service";
type Props = { notifications: BackendNotification[] };
export default function AlertsScreen({ notifications }: Props) {
return (
<View style={{ flex: 1, padding: 16 }}>
<FlatList
data={notifications}
keyExtractor={(_, i) => String(i)}
renderItem={({ item }) => (
<AlertItem
title={item.title}
description={item.body}
time="ahora"
type={notificationTypeToAlertType(item.type)}
/>
)}
/>
</View>
);
}
```
> **Compartir notificaciones entre pantallas:** lo más simple para el demo es subirlas a un `Context` o Zustand. Si tienen tiempo. Para hackathon, pueden duplicar la lógica o pasarlas por params.
---
## 8. Cómo probar el flujo completo en el emulador de Android
### Pre-requisitos
- Backend corriendo: `npm run dev` dentro de `/backend`. Debe imprimir `Server is running on port 3000`.
- Android Studio con AVD (emulador) abierto.
- Frontend con dependencias instaladas: `npm install` dentro de `/frontend`.
### Pasos
1. **Levantar el frontend:**
```powershell
cd frontend
npm run android
```
Eso abre Expo y lanza la app en el emulador.
2. **Crear un usuario de prueba** (sólo la primera vez). Desde Postman:
```
POST http://localhost:3000/api/auth/register
Content-Type: application/json
{ "name": "Ana López", "email": "ana@test.com", "password": "123456" }
```
(Ana ya está en el mock con `routeId: "RUTA-01"`, así que recibirá las notificaciones del camión 101.)
3. **En la app del emulador**, iniciar sesión con:
- Email: `ana@test.com`
- Password: `123456`
4. **En la pantalla principal**, hacer **pull-to-refresh** repetidamente. Cada pull avanza el camión:
| Paso | positionId | Notificación esperada |
|---|---|---|
| 1 | 1 | (ninguna) |
| 2 | 2 | "¡Ruta Iniciada!" |
| 3 | 3 | (ninguna) |
| 4 | 4 | "Camión Cercano" |
| 5 | 8 | "Servicio Finalizado" |
5. **Pantalla de alertas** debe mostrar las notificaciones acumuladas con los iconos y colores correctos.
---
## 9. Problemas comunes en Android emulator
| Síntoma | Causa | Solución |
|---|---|---|
| `Network request failed` | Apunta a `localhost` | Usar `http://10.0.2.2:3000` en `API_URL` |
| `TypeError: Failed to fetch` (en web) | Falta CORS | Avisar al del backend para que active `cors()` |
| 401 al hacer login | Usuario no existe | Registrar primero con Postman (sección 8, paso 2) |
| `notifications: []` aunque cambies positionId | El cache ya envió esa noti antes | Reiniciar el backend (`Ctrl+C` y `npm run dev`) |
| ETA negativo o raro | El `timestamp` que envías está atrás del target | Es normal, el mensaje sale como "El camión ya pasó..." |
---
## 10. Checklist mínimo
- [ ] `src/config/api.ts` con `http://10.0.2.2:3000`
- [ ] `src/lib/api.ts` (fetch wrapper)
- [ ] `src/services/auth.service.ts`
- [ ] `src/services/tracking.service.ts`
- [ ] `src/lib/notification-mapper.ts`
- [ ] Pantalla de login funcionando con `ana@test.com`
- [ ] Pantalla principal mostrando `EtaCard` con datos reales
- [ ] Pantalla de alertas mostrando `AlertItem` con notificaciones reales
- [ ] Probar las 5 fases del recorrido con pull-to-refresh
---
## 11. Lo que NO necesitan hacer
- ❌ Push notifications reales (Firebase / Expo Notifications) — sólo lista in-app
- ❌ Mapas con GPS real — los datos vienen del mock del backend
- ❌ AsyncStorage para el token — para el demo basta con estado en memoria
- ❌ Modificar nada del `/backend`

View File

@@ -1,56 +1,293 @@
# Welcome to your Expo app 👋 # Frontend — OptiRuta
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). App móvil en **Expo SDK 56 + React Native 0.85 + TypeScript** con **expo-router**.
## Get started > Para el README general, ver [../README.md](../README.md).
> Para la guía de integración con el backend (compañeros), ver [INTEGRATION.md](INTEGRATION.md).
1. Install dependencies ---
```bash ## Tabla de contenidos
npm install
```
2. Start the app - [Stack](#stack)
- [Estructura](#estructura)
- [Cómo correr](#cómo-correr)
- [Configurar la URL del backend](#configurar-la-url-del-backend)
- [Pantallas](#pantallas)
- [Estado global (AppContext)](#estado-global-appcontext)
- [Comunicación con el backend](#comunicación-con-el-backend)
- [Componentes reutilizables](#componentes-reutilizables)
- [Roles y navegación](#roles-y-navegación)
- [Notificaciones](#notificaciones)
- [Troubleshooting](#troubleshooting)
```bash ---
npx expo start
```
In the output, you'll find options to open the app in a ## Stack
- [development build](https://docs.expo.dev/develop/development-builds/introduction/) - **Expo SDK 56** (Sept 2025)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) - **React Native 0.85** + **React 19**
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) - **TypeScript** estricto
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo - **expo-router 56** — navegación basada en archivos
- **@expo/vector-icons** — iconos Ionicons
- **expo-image** — imágenes optimizadas
- Sin librerías de estado externas (solo Context)
- Sin librerías de UI externas (StyleSheet nativo)
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). ---
## Get a fresh project ## Estructura
When you're ready, run: ```
frontend/
```bash ├── app.json ← config de Expo (name="OptiRuta", slug, etc.)
npm run reset-project ├── tsconfig.json ← paths "@/*" → "./src/*"
├── assets/
│ ├── images/ ← icons del SO, splash
│ └── illustrations/ ← ilustraciones del UI (camión, botes, etc.)
└── src/
├── app/ ← rutas de expo-router (cada archivo = pantalla)
│ ├── _layout.tsx ← Tabs + AppProvider + Toast
│ ├── index.tsx ← Home (ETA + estado de ruta)
│ ├── login.tsx
│ ├── register.tsx
│ ├── addresses.tsx ← validación de domicilio
│ ├── alerts.tsx ← timeline de notificaciones
│ ├── feedback.tsx ← buzón de retro
│ ├── guide.tsx ← calendario + guía de separación
│ ├── admin.tsx ← panel admin
│ └── profile.tsx ← perfil + ajustes
├── components/
│ ├── EtaCard.tsx
│ ├── Alertltem.tsx ← (sic) typo histórico, no romper
│ ├── InputField.tsx
│ ├── PrimaryButton.tsx
│ ├── QuickAction.tsx
│ ├── SectionTitle.tsx
│ ├── StatusBadge.tsx
│ ├── CollectionCalendar.tsx
│ └── NotificationToast.tsx
├── config/
│ └── api.ts ← URL del backend (cambia según dispositivo)
├── constants/
│ ├── colors.ts
│ └── theme.ts
├── context/
│ └── AppContext.tsx ← estado global + polling
├── data/
│ └── mocks/
│ └── routes.mock.ts ← catálogo de routeId/name (para el calendario)
├── hooks/
├── lib/
│ ├── api.ts ← apiFetch (wrapper de fetch + auth token)
│ ├── notifications.ts ← (preparado para Dev Build; no se importa en Go)
│ └── notification-mapper.ts
└── services/
├── auth.service.ts
├── tracking.service.ts
├── addresses.service.ts
├── feedback.service.ts
└── admin.service.ts
``` ```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. ---
### Other setup steps ## Cómo correr
- To set up ESLint for linting, run `npx expo lint`, or follow our guide on ["Using ESLint and Prettier"](https://docs.expo.dev/guides/using-eslint/) ```powershell
- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/) cd frontend
- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/) npm install
npx expo start -c # -c limpia caché de Metro
```
## Learn more Después:
- `a` → Android emulator
- `i` → iOS simulator (solo Mac)
- `w` → web
- Escanear QR con Expo Go en celular físico
To learn more about developing your project with Expo, look at the following resources: ---
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). ## Configurar la URL del backend
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community Edita `src/config/api.ts`:
Join our community of developers creating universal apps. ```ts
// Para Android emulator local:
export const API_URL = "http://10.0.2.2:8080";
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. // Para celular físico (mismo Wi-Fi o hotspot):
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. // export const API_URL = "http://192.168.X.X:8080";
```
| Entorno | URL |
|---|---|
| Android Studio emulator | `http://10.0.2.2:8080` |
| iOS Simulator | `http://localhost:8080` |
| Celular físico Android/iOS (mismo Wi-Fi) | `http://<IP-LAN>:8080` |
| Celular físico (hotspot) | `http://<IP-LAN-hotspot>:8080` |
| Web | `http://localhost:8080` |
> Si estás en red de escuela/oficina con AP isolation, usa hotspot del celular.
---
## Pantallas
| Ruta | Archivo | Rol | Descripción |
|---|---|---|---|
| `/` | `index.tsx` | USER | Home: título + hero del camión + card ETA + banner estado de ruta + banner preventivo + 4 acciones rápidas + consejo |
| `/login` | `login.tsx` | público | Login con email + password |
| `/register` | `register.tsx` | público | Registro con nombre, email, password ≥ 6 caracteres |
| `/addresses` | `addresses.tsx` | USER | Lista de colonias + selector + guardar (PUT /me) |
| `/alerts` | `alerts.tsx` | USER | Hero + estado de ruta + timeline vertical con dots de color por severidad |
| `/guide` | `guide.tsx` | USER | Calendario semanal + 4 cards (Orgánicos, Reciclables, Sanitarios, Especiales) + Recuerda |
| `/feedback` | `feedback.tsx` | USER | Tipo de mensaje (4 opciones) + estrellas si es rating + textarea |
| `/profile` | `profile.tsx` | USER | Avatar circular + cards Tu zona/Ruta + opciones (Mi domicilio, Buzón, Reiniciar demo, Ayuda, Cerrar sesión) + Tu impacto |
| `/admin` | `admin.tsx` | ADMIN | Reportes del día + lista de rutas + cancelar/reanudar |
---
## Estado global (AppContext)
`src/context/AppContext.tsx`
Proveedor único que expone:
```ts
interface AppContextValue {
user: AuthUser | null; // { id, name, email, role }
eta: EtaResult | null;
notifications: InboxNotification[];
route: RouteState | null;
loading: boolean;
toast: InboxNotification | null;
dismissToast: () => void;
login: (email, password) => Promise<void>;
register: (name, email, password) => Promise<void>;
logout: () => void;
refreshStatus: () => Promise<void>;
}
```
### Polling
- Arranca cuando `user` ≠ null Y `user.role` ≠ "ADMIN"
- Cada **30 segundos** llama `GET /api/tracking/status`
- Limpia el interval cuando el user cierra sesión
- Detecta notificaciones nuevas (vía Set de IDs ya vistos) y dispara el `toast`
### Logout
- Borra token con `setAuthToken(null)`
- Resetea user, eta, route, notifications, toast
- Limpia el Set de IDs ya vistos
---
## Comunicación con el backend
### `lib/api.ts`
```ts
let authToken: string | null = null;
export const setAuthToken = (token: string | null) => { authToken = token; };
export const apiFetch = async <T>(path: string, options: RequestInit = {}): Promise<T> => {
const headers = { "Content-Type": "application/json", ...options.headers };
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as T;
};
```
El token se setea automáticamente tras `login` o `register` (dentro de `auth.service.ts`).
### Services (capa fina sobre apiFetch)
- **auth.service.ts** — register, login, getMe
- **tracking.service.ts** — getMyStatus, sendGpsUpdate, resetDemo
- **addresses.service.ts** — listColonias, getMyAddress, setMyAddress
- **feedback.service.ts** — submitFeedback, listMyFeedback
- **admin.service.ts** — listAllRoutes, cancelRoute, resumeRoute
---
## Componentes reutilizables
| Componente | Para qué |
|---|---|
| `EtaCard` | Card grande del ETA con "X min" gigante + ventana |
| `AlertItem` (`Alertltem.tsx`) | Item de notificación con icon + título + body + tiempo + badge por tipo |
| `InputField` | Wrap de TextInput con estilos del proyecto |
| `PrimaryButton` | Botón principal verde |
| `QuickAction` | Card cuadrada con icon circular + título (usada en home) |
| `SectionTitle` | Título grande de sección |
| `StatusBadge` | (no usado actualmente) |
| `CollectionCalendar` | Calendario mensual con colores por día según ruta del user |
| `NotificationToast` | Banner verde animado que cae cuando llega una notif nueva |
---
## Roles y navegación
### USER
- Ve las tabs: Inicio, Alertas, Guía, Perfil
- Polling activo
- Al loguearse, lo redirige a `/` (Home)
- Si entra a `/` sin login → redirect a `/login`
- Si entra a una pantalla protegida sin domicilio validado → algunas muestran CTA "Validar domicilio"
### ADMIN
- Ve solo la tab: **Admin**
- Las demás tabs del user están ocultas (`href: null`)
- NO hace polling de `/status`
- Al loguearse desde `/login` aterriza en `/` y desde ahí redirect a `/admin`
- Sus acciones (cancelar / reanudar) refrescan el listado cada 15 s
---
## Notificaciones
### En foreground (Toast in-app)
- Cuando llega una notificación nueva del polling, se setea `toast` en el AppContext
- El componente `NotificationToast` anima un banner verde que cae desde arriba
- Se autodescarta a los 5 segundos
- Funciona en cualquier pantalla porque vive en `_layout.tsx`
### Push del SO con app cerrada
- **No funciona en Expo Go SDK 53+** — Expo removió el soporte
- El módulo `expo-notifications` está instalado y el helper `lib/notifications.ts` está listo
- Para activarlo en producción → crear Development Build con EAS
---
## Troubleshooting
| Síntoma | Causa | Fix |
|---|---|---|
| `Network request failed` | `API_URL` apunta a dirección inalcanzable desde el dispositivo | Usa `10.0.2.2:8080` (emulador) o IP LAN (físico) |
| `Failed to fetch` en navegador web | CORS | Activar `cors()` middleware en el backend |
| 401 al cargar `/status` | Token vencido o no enviado | Logout + login otra vez |
| "Cannot find module ../config/api.js" | Importaste con `.js` (estilo backend) | En frontend, importa sin extensión: `from "../config/api"` |
| `Unable to resolve module` | Metro caché stale | `npx expo start -c` |
| App muestra "12 min / En Camino" hardcoded | Te perdiste de actualizar `EtaCard` con props del backend | Verificar que home pasa `minutes` y `status` |
| Versión de SDK incompatible | Expo Go diferente al del proyecto (56) | Actualizar Expo Go en App Store / Play Store |
| Tab Admin no aparece | Login devolvió role ≠ "ADMIN" | Verifica logueado con `admin@test.com / admin123` |
---
## Para extender
- **Nueva pantalla**: crea archivo en `src/app/X.tsx`. expo-router la detecta. Si no quieres que aparezca en el tab bar, agrégala con `options={{ href: null }}` en `_layout.tsx`.
- **Nuevo service**: crea `services/X.service.ts` que use `apiFetch`. Importa donde lo necesites.
- **Nuevo dato global**: agrega al `AppContext` y expón en el value.
- **Imágenes nuevas**: ponlas en `assets/illustrations/` y úsalas con `require()`.
---
## Frase de arquitectura para la exposición
> "El frontend hace polling controlado cada 30 s al endpoint `/api/tracking/status`. Esto evita sobrecargar el servidor y es predecible. El AppContext detecta notificaciones nuevas con un Set de IDs ya vistos y dispara un Toast in-app. Para push del SO con app cerrada migraríamos a Development Build y expo-notifications real, pero la decisión de Expo Go nos limita en esta etapa."

View File

@@ -1,7 +1,7 @@
{ {
"expo": { "expo": {
"name": "frontend", "name": "OptiRuta",
"slug": "frontend", "slug": "optiruta",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

View File

@@ -17,6 +17,7 @@
"expo-glass-effect": "~56.0.4", "expo-glass-effect": "~56.0.4",
"expo-image": "~56.0.8", "expo-image": "~56.0.8",
"expo-linking": "~56.0.11", "expo-linking": "~56.0.11",
"expo-notifications": "~56.0.12",
"expo-router": "~56.2.5", "expo-router": "~56.2.5",
"expo-splash-screen": "~56.0.9", "expo-splash-screen": "~56.0.9",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
@@ -3094,6 +3095,12 @@
} }
} }
}, },
"node_modules/badgin": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
"license": "MIT"
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -3836,6 +3843,15 @@
} }
} }
}, },
"node_modules/expo-application": {
"version": "56.0.3",
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz",
"integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-asset": { "node_modules/expo-asset": {
"version": "56.0.13", "version": "56.0.13",
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.13.tgz", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.13.tgz",
@@ -4000,6 +4016,24 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-notifications": {
"version": "56.0.12",
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.12.tgz",
"integrity": "sha512-ZGFeA6vs1dt+9IcFtriIf2sEgBSEXGZ6OnWIYzUkdYqKpJFv1/zigUyquAMEvGbAAjGC0Uwf8qXNYJc1pyxFfA==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.10.0",
"abort-controller": "^3.0.0",
"badgin": "^1.1.5",
"expo-application": "~56.0.3",
"expo-constants": "~56.0.14"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-router": { "node_modules/expo-router": {
"version": "56.2.5", "version": "56.2.5",
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.5.tgz", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.5.tgz",

View File

@@ -12,6 +12,7 @@
"expo-glass-effect": "~56.0.4", "expo-glass-effect": "~56.0.4",
"expo-image": "~56.0.8", "expo-image": "~56.0.8",
"expo-linking": "~56.0.11", "expo-linking": "~56.0.11",
"expo-notifications": "~56.0.12",
"expo-router": "~56.2.5", "expo-router": "~56.2.5",
"expo-splash-screen": "~56.0.9", "expo-splash-screen": "~56.0.9",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",

View File

@@ -1,7 +1,13 @@
import { View } from "react-native";
import { Tabs } from "expo-router"; import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { AppProvider, useApp } from "../context/AppContext";
import NotificationToast from "../components/NotificationToast";
function AppTabs() {
const { user } = useApp();
const isAdmin = user?.role === "ADMIN";
export default function Layout() {
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
@@ -15,26 +21,15 @@ export default function Layout() {
bottom: 5, bottom: 5,
left: 20, left: 20,
right: 20, right: 20,
height: 82, height: 82,
borderRadius: 25, borderRadius: 25,
backgroundColor: "#FFFFFF", backgroundColor: "#FFFFFF",
borderTopWidth: 0, borderTopWidth: 0,
shadowColor: "#000", shadowColor: "#000",
shadowOffset: { shadowOffset: { width: 0, height: 10 },
width: 0,
height: 10,
},
shadowOpacity: 0.08, shadowOpacity: 0.08,
shadowRadius: 10, shadowRadius: 10,
elevation: 10, elevation: 10,
paddingHorizontal: 10, paddingHorizontal: 10,
}, },
@@ -53,10 +48,12 @@ export default function Layout() {
}, },
}} }}
> >
{/* Tabs de USER — escondidas para ADMIN */}
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{
title: "Inicio", title: "Inicio",
href: isAdmin ? null : undefined,
tabBarIcon: ({ focused, color }) => ( tabBarIcon: ({ focused, color }) => (
<Ionicons <Ionicons
name={focused ? "home" : "home-outline"} name={focused ? "home" : "home-outline"}
@@ -71,6 +68,7 @@ export default function Layout() {
name="alerts" name="alerts"
options={{ options={{
title: "Alertas", title: "Alertas",
href: isAdmin ? null : undefined,
tabBarIcon: ({ focused, color }) => ( tabBarIcon: ({ focused, color }) => (
<Ionicons <Ionicons
name={focused ? "notifications" : "notifications-outline"} name={focused ? "notifications" : "notifications-outline"}
@@ -85,6 +83,7 @@ export default function Layout() {
name="guide" name="guide"
options={{ options={{
title: "Guía", title: "Guía",
href: isAdmin ? null : undefined,
tabBarIcon: ({ focused, color }) => ( tabBarIcon: ({ focused, color }) => (
<Ionicons <Ionicons
name={focused ? "leaf" : "leaf-outline"} name={focused ? "leaf" : "leaf-outline"}
@@ -99,6 +98,7 @@ export default function Layout() {
name="profile" name="profile"
options={{ options={{
title: "Perfil", title: "Perfil",
href: isAdmin ? null : undefined,
tabBarIcon: ({ focused, color }) => ( tabBarIcon: ({ focused, color }) => (
<Ionicons <Ionicons
name={focused ? "person" : "person-outline"} name={focused ? "person" : "person-outline"}
@@ -108,6 +108,39 @@ export default function Layout() {
), ),
}} }}
/> />
{/* Tab de ADMIN — solo visible si el user logueado es admin */}
<Tabs.Screen
name="admin"
options={{
title: "Admin",
href: isAdmin ? undefined : null,
tabBarIcon: ({ focused, color }) => (
<Ionicons
name={focused ? "shield-checkmark" : "shield-checkmark-outline"}
size={focused ? 28 : 24}
color={color}
/>
),
}}
/>
{/* Pantallas siempre escondidas del tab bar */}
<Tabs.Screen name="login" options={{ href: null }} />
<Tabs.Screen name="register" options={{ href: null }} />
<Tabs.Screen name="feedback" options={{ href: null }} />
<Tabs.Screen name="addresses" options={{ href: null }} />
</Tabs> </Tabs>
); );
} }
export default function Layout() {
return (
<AppProvider>
<View style={{ flex: 1 }}>
<NotificationToast />
<AppTabs />
</View>
</AppProvider>
);
}

View File

@@ -0,0 +1,196 @@
import { useEffect, useState } from "react";
import {
ScrollView,
View,
Text,
StyleSheet,
Pressable,
ActivityIndicator,
Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import SectionTitle from "../components/SectionTitle";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext";
import {
listColonias,
getMyAddress,
setMyAddress,
type Colonia,
type MyAddress,
} from "../services/addresses.service";
export default function AddressesScreen() {
const { user, refreshStatus } = useApp();
const router = useRouter();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [colonias, setColonias] = useState<Colonia[]>([]);
const [myAddress, setMy] = useState<MyAddress | null>(null);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
if (!user) return;
void (async () => {
try {
const [c, mine] = await Promise.all([listColonias(), getMyAddress()]);
setColonias(c);
setMy(mine);
setSelected(mine.colonia);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
})();
}, [user]);
if (!user) return <Redirect href="/login" />;
const handleSave = async () => {
if (!selected) {
Alert.alert("Selecciona una colonia.");
return;
}
try {
setSaving(true);
const result = await setMyAddress(selected);
setMy(result);
await refreshStatus();
Alert.alert(
"Domicilio validado",
`Tu zona es ${result.colonia}. Horario: ${result.horarioEstimado}`,
);
router.replace("/");
} catch (err) {
Alert.alert(
"No se pudo guardar",
err instanceof Error ? err.message : "Error desconocido",
);
} finally {
setSaving(false);
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
>
<SectionTitle title="Mi domicilio" />
<Text style={styles.subtitle}>
Selecciona la colonia donde quieres recibir avisos de recolección.
Solo verás la ruta que cubre tu zona.
</Text>
{myAddress && (
<View style={styles.currentBox}>
<Text style={styles.currentLabel}>Domicilio actual</Text>
<Text style={styles.currentValue}>{myAddress.colonia}</Text>
<Text style={styles.currentMeta}>
Ruta {myAddress.routeId}
{myAddress.horarioEstimado
? `${myAddress.horarioEstimado}`
: ""}
</Text>
</View>
)}
<Text style={styles.label}>Colonias disponibles</Text>
{loading ? (
<ActivityIndicator color="#0E8A61" style={{ marginTop: 20 }} />
) : (
colonias.map((c) => {
const active = selected === c.colonia;
return (
<Pressable
key={c.colonia}
onPress={() => setSelected(c.colonia)}
style={[
styles.optionRow,
active && {
borderColor: "#0E8A61",
borderWidth: 2,
},
]}
>
<Ionicons
name={active ? "radio-button-on" : "radio-button-off"}
size={22}
color={active ? "#0E8A61" : "#9CA3AF"}
/>
<View style={{ marginLeft: 12, flex: 1 }}>
<Text style={styles.optionTitle}>{c.colonia}</Text>
<Text style={styles.optionMeta}>
Ruta {c.routeId} {c.horarioEstimado}
</Text>
</View>
</Pressable>
);
})
)}
<View style={{ height: 16 }} />
<PrimaryButton
title={saving ? "Guardando..." : "Validar domicilio"}
onPress={handleSave}
/>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
subtitle: {
color: "#6B7280",
fontSize: 14,
marginBottom: 16,
marginLeft: 12,
},
currentBox: {
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 16,
marginBottom: 18,
marginHorizontal: 2,
borderLeftWidth: 4,
borderLeftColor: "#0E8A61",
},
currentLabel: {
fontSize: 11,
color: "#6B7280",
fontWeight: "700",
marginBottom: 4,
textTransform: "uppercase",
},
currentValue: { fontSize: 18, fontWeight: "800", color: "#1F2937" },
currentMeta: { fontSize: 13, color: "#6B7280", marginTop: 4 },
label: {
fontSize: 13,
fontWeight: "700",
color: "#374151",
marginBottom: 10,
marginLeft: 12,
},
optionRow: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF",
borderRadius: 12,
padding: 14,
marginBottom: 10,
borderColor: "transparent",
borderWidth: 2,
},
optionTitle: { fontSize: 15, fontWeight: "700", color: "#1F2937" },
optionMeta: { fontSize: 12, color: "#6B7280", marginTop: 2 },
});

572
frontend/src/app/admin.tsx Normal file
View File

@@ -0,0 +1,572 @@
/**
* admin.tsx
*
* Panel de administración: lista las rutas y permite cancelarlas /
* reanudarlas. Solo accesible para usuarios con role=ADMIN.
*/
import { useEffect, useState, useCallback } from "react";
import {
ScrollView,
View,
Text,
Pressable,
StyleSheet,
Alert,
ActivityIndicator,
RefreshControl,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect } from "expo-router";
import { COLORS } from "../constants/colors";
import { useApp } from "../context/AppContext";
import {
cancelRoute,
listAllFeedback,
listAllRoutes,
resumeRoute,
type AdminFeedbackItem,
type AdminRouteItem,
type FeedbackType,
} from "../services/admin.service";
const arrivalLabel: Record<string, { label: string; color: string }> = {
PENDING: { label: "Pendiente", color: "#9CA3AF" },
ARRIVED: { label: "Llegó", color: "#22C55E" },
FAILED: { label: "Falla", color: "#EF4444" },
CANCELLED: { label: "Cancelada", color: "#F59E0B" },
};
export default function AdminScreen() {
const { user, logout } = useApp();
const [routes, setRoutes] = useState<AdminRouteItem[]>([]);
const [feedback, setFeedback] = useState<AdminFeedbackItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchAll = useCallback(async () => {
try {
const [routesData, feedbackData] = await Promise.all([
listAllRoutes(),
listAllFeedback(),
]);
setRoutes(routesData);
setFeedback(feedbackData);
} catch (err) {
Alert.alert(
"Error",
err instanceof Error ? err.message : "No se pudieron cargar los datos",
);
} finally {
setLoading(false);
}
}, []);
// Alias para compatibilidad con el resto del archivo
const fetchRoutes = fetchAll;
useEffect(() => {
if (!user || user.role !== "ADMIN") return;
void fetchAll();
const interval = setInterval(() => void fetchAll(), 15_000);
return () => clearInterval(interval);
}, [user, fetchAll]);
if (!user) return <Redirect href="/login" />;
if (user.role !== "ADMIN") return <Redirect href="/" />;
const handleCancel = (routeId: string) => {
Alert.alert(
"Cancelar ruta",
`¿Cancelar ${routeId}? Los usuarios suscritos serán notificados.`,
[
{ text: "No", style: "cancel" },
{
text: "Sí, cancelar",
style: "destructive",
onPress: async () => {
try {
await cancelRoute(routeId, "Cancelada por administración");
void fetchRoutes();
} catch (err) {
Alert.alert(
"Error",
err instanceof Error ? err.message : "No se pudo cancelar",
);
}
},
},
],
);
};
const handleResume = (routeId: string) => {
Alert.alert("Reanudar ruta", `¿Reactivar ${routeId}?`, [
{ text: "No", style: "cancel" },
{
text: "Sí",
onPress: async () => {
try {
await resumeRoute(routeId);
void fetchRoutes();
} catch (err) {
Alert.alert(
"Error",
err instanceof Error ? err.message : "No se pudo reanudar",
);
}
},
},
]);
};
return (
<SafeAreaView
style={{ flex: 1, backgroundColor: COLORS.background }}
edges={["top"]}
>
<View style={styles.headerRow}>
<View style={{ flex: 1 }}>
<Text style={styles.title}>Panel de Administración</Text>
<Text style={styles.subtitle}>
{user.email} · {routes.length} rutas
</Text>
</View>
<Pressable onPress={logout} style={styles.logoutBtn} hitSlop={10}>
<Ionicons name="log-out-outline" size={22} color="#EF4444" />
</Pressable>
</View>
{loading ? (
<ActivityIndicator color="#0E8A61" style={{ marginTop: 40 }} />
) : (
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 130 }}
refreshControl={
<RefreshControl refreshing={loading} onRefresh={fetchRoutes} />
}
>
{/* === Reportes del día === */}
<Text style={styles.sectionTitle}>REPORTES DEL DÍA</Text>
<View style={styles.reportsGrid}>
{(() => {
const arrived = routes.filter(
(r) => r.arrivalResult === "ARRIVED",
).length;
const failed = routes.filter(
(r) => r.arrivalResult === "FAILED",
).length;
const cancelled = routes.filter(
(r) => r.cancelled || r.arrivalResult === "CANCELLED",
).length;
const pending = routes.filter(
(r) => r.arrivalResult === "PENDING" && !r.cancelled,
).length;
const items = [
{
key: "arrived",
label: "Llegaron",
value: arrived,
icon: "checkmark-circle" as const,
color: "#22C55E",
},
{
key: "pending",
label: "En curso",
value: pending,
icon: "time-outline" as const,
color: "#3B82F6",
},
{
key: "failed",
label: "Con falla",
value: failed,
icon: "warning" as const,
color: "#EF4444",
},
{
key: "cancelled",
label: "Canceladas",
value: cancelled,
icon: "close-circle" as const,
color: "#F59E0B",
},
];
return items.map((it) => (
<View key={it.key} style={styles.reportCard}>
<View
style={[
styles.reportIcon,
{ backgroundColor: `${it.color}20` },
]}
>
<Ionicons name={it.icon} size={22} color={it.color} />
</View>
<Text style={[styles.reportValue, { color: it.color }]}>
{it.value}
</Text>
<Text style={styles.reportLabel}>{it.label}</Text>
</View>
));
})()}
</View>
{/* === Lista de rutas === */}
<Text style={styles.sectionTitle}>RUTAS</Text>
{routes.map((r) => {
const arrival = arrivalLabel[r.arrivalResult] ?? arrivalLabel.PENDING;
return (
<View key={r.routeId} style={styles.card}>
<View style={styles.cardHeader}>
<View style={{ flex: 1 }}>
<Text style={styles.routeId}>{r.routeId}</Text>
<Text style={styles.routeName}>{r.name}</Text>
</View>
<View
style={[
styles.badge,
{ backgroundColor: `${arrival.color}25` },
]}
>
<Text
style={[styles.badgeText, { color: arrival.color }]}
>
{arrival.label}
</Text>
</View>
</View>
<View style={styles.metaRow}>
<Text style={styles.metaText}>
Camión #{r.truckId} · Posición {r.currentPositionId}/8 ·{" "}
{r.status}
</Text>
</View>
{r.cancelled ? (
<Pressable
style={[styles.actionBtn, { backgroundColor: "#22C55E" }]}
onPress={() => handleResume(r.routeId)}
>
<Ionicons name="play" size={16} color="#FFFFFF" />
<Text style={styles.actionText}>Reanudar</Text>
</Pressable>
) : (
<Pressable
style={[styles.actionBtn, { backgroundColor: "#EF4444" }]}
onPress={() => handleCancel(r.routeId)}
>
<Ionicons name="close-circle" size={16} color="#FFFFFF" />
<Text style={styles.actionText}>Cancelar ruta</Text>
</Pressable>
)}
{r.cancelReason ? (
<Text style={styles.reasonText}>
Motivo: {r.cancelReason}
</Text>
) : null}
</View>
);
})}
{/* === Reportes de ciudadanos === */}
<Text style={[styles.sectionTitle, { marginTop: 14 }]}>
REPORTES DE CIUDADANOS ({feedback.length})
</Text>
{feedback.length === 0 ? (
<View style={styles.emptyFeedback}>
<Ionicons name="chatbubbles-outline" size={28} color="#9CA3AF" />
<Text style={styles.emptyFeedbackText}>
Aún no hay reportes ciudadanos. Aparecerán aquí cuando los
usuarios usen el buzón.
</Text>
</View>
) : (
feedback.map((f) => {
const meta = feedbackMeta[f.type] ?? feedbackMeta.OTHER;
const when = new Date(f.createdAt).toLocaleString("es-MX", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
return (
<View key={f.id} style={styles.feedbackCard}>
<View style={styles.feedbackHeader}>
<View
style={[
styles.feedbackIcon,
{ backgroundColor: `${meta.color}20` },
]}
>
<Ionicons
name={meta.icon}
size={18}
color={meta.color}
/>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.feedbackType}>{meta.label}</Text>
<Text style={styles.feedbackMeta}>
{f.userName ?? `Usuario #${f.userId}`}
{f.colonia ? ` · ${f.colonia}` : ""} · {when}
</Text>
</View>
{f.routeId ? (
<View style={styles.routeChip}>
<Ionicons
name="bus-outline"
size={11}
color="#0E8A61"
/>
<Text style={styles.routeChipText}>{f.routeId}</Text>
</View>
) : (
<View
style={[
styles.routeChip,
{ backgroundColor: "#F3F4F6" },
]}
>
<Text
style={[styles.routeChipText, { color: "#9CA3AF" }]}
>
Sin ruta
</Text>
</View>
)}
</View>
{f.rating ? (
<View style={styles.starsRow}>
{[1, 2, 3, 4, 5].map((n) => (
<Ionicons
key={n}
name={n <= (f.rating ?? 0) ? "star" : "star-outline"}
size={14}
color="#F59E0B"
style={{ marginRight: 2 }}
/>
))}
</View>
) : null}
<Text style={styles.feedbackMessage}>{f.message}</Text>
</View>
);
})
)}
</ScrollView>
)}
</SafeAreaView>
);
}
const feedbackMeta: Record<
FeedbackType,
{ label: string; icon: keyof typeof Ionicons.glyphMap; color: string }
> = {
TRUCK_DID_NOT_PASS: {
label: "El camión no pasó",
icon: "alert-circle-outline",
color: "#EF4444",
},
RATING: {
label: "Calificación del servicio",
icon: "star-outline",
color: "#F59E0B",
},
SUGGESTION: {
label: "Sugerencia",
icon: "bulb-outline",
color: "#3B82F6",
},
OTHER: {
label: "Otro",
icon: "chatbubble-outline",
color: "#6B7280",
},
};
const styles = StyleSheet.create({
headerRow: {
flexDirection: "row",
alignItems: "center",
padding: 20,
paddingBottom: 12,
},
title: { fontSize: 24, fontWeight: "800", color: "#0F172A" },
subtitle: { fontSize: 12, color: "#6B7280", marginTop: 2 },
logoutBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#FEF2F2",
justifyContent: "center",
alignItems: "center",
},
card: {
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 14,
marginBottom: 12,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 6,
elevation: 2,
},
cardHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
routeId: { fontSize: 16, fontWeight: "800", color: "#0F172A" },
routeName: { fontSize: 12, color: "#6B7280", marginTop: 2 },
badge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
},
badgeText: { fontSize: 11, fontWeight: "700" },
metaRow: { marginBottom: 12 },
metaText: { fontSize: 12, color: "#374151" },
actionBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 10,
borderRadius: 10,
},
actionText: {
color: "#FFFFFF",
fontWeight: "700",
fontSize: 13,
marginLeft: 6,
},
reasonText: {
fontSize: 11,
color: "#F59E0B",
marginTop: 8,
fontStyle: "italic",
},
sectionTitle: {
fontSize: 11,
fontWeight: "800",
color: "#6B7280",
letterSpacing: 1,
marginBottom: 10,
marginTop: 4,
},
reportsGrid: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 18,
},
reportCard: {
flex: 1,
backgroundColor: "#FFFFFF",
borderRadius: 12,
padding: 10,
marginHorizontal: 3,
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
reportIcon: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: "center",
alignItems: "center",
marginBottom: 6,
},
reportValue: {
fontSize: 22,
fontWeight: "800",
},
reportLabel: {
fontSize: 10,
fontWeight: "700",
color: "#374151",
marginTop: 2,
textAlign: "center",
},
emptyFeedback: {
alignItems: "center",
padding: 20,
backgroundColor: "#FFFFFF",
borderRadius: 12,
marginBottom: 12,
},
emptyFeedbackText: {
fontSize: 12,
color: "#6B7280",
textAlign: "center",
marginTop: 8,
},
feedbackCard: {
backgroundColor: "#FFFFFF",
borderRadius: 12,
padding: 12,
marginBottom: 10,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 4,
elevation: 1,
},
feedbackHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
feedbackIcon: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
marginRight: 10,
},
feedbackType: {
fontSize: 13,
fontWeight: "800",
color: "#0F172A",
},
feedbackMeta: {
fontSize: 10,
color: "#6B7280",
marginTop: 1,
},
routeChip: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 999,
marginLeft: 6,
},
routeChipText: {
fontSize: 11,
fontWeight: "800",
color: "#0E8A61",
marginLeft: 4,
},
starsRow: {
flexDirection: "row",
marginBottom: 6,
},
feedbackMessage: {
fontSize: 13,
color: "#374151",
lineHeight: 18,
},
});

View File

@@ -1,15 +1,392 @@
import { View, Text } from "react-native"; import { ScrollView, View, Text, StyleSheet, Image } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect } from "expo-router";
import { COLORS } from "../constants/colors";
import { useApp } from "../context/AppContext";
import type { NotificationType } from "../services/tracking.service";
type Severity = "info" | "attention" | "important" | "critical";
const meta: Record<
NotificationType,
{
severity: Severity;
icon: keyof typeof Ionicons.glyphMap;
color: string;
bg: string;
badge: string;
}
> = {
ROUTE_START: {
severity: "info",
icon: "navigate",
color: "#3B82F6",
bg: "#EFF6FF",
badge: "Informativo",
},
TRUCK_PROXIMITY: {
severity: "attention",
icon: "time-outline",
color: "#F59E0B",
bg: "#FEF3C7",
badge: "Atención",
},
TRUCK_ARRIVED: {
severity: "important",
icon: "notifications",
color: "#22C55E",
bg: "#ECFDF5",
badge: "Importante",
},
ROUTE_COMPLETED: {
severity: "info",
icon: "checkmark-done",
color: "#6B7280",
bg: "#F3F4F6",
badge: "Informativo",
},
DELAY: {
severity: "critical",
icon: "warning-outline",
color: "#EF4444",
bg: "#FEF2F2",
badge: "Crítico",
},
MECHANICAL_FAILURE: {
severity: "critical",
icon: "warning",
color: "#EF4444",
bg: "#FEF2F2",
badge: "Crítico",
},
};
const STATUS_LABEL: Record<string, { label: string; color: string }> = {
EN_RUTA: { label: "ACTIVA", color: "#22C55E" },
DETENIDO: { label: "DETENIDO", color: "#F59E0B" },
FALLA: { label: "FALLA", color: "#EF4444" },
FINALIZADO: { label: "FINALIZADO", color: "#6B7280" },
ESPERA: { label: "EN ESPERA", color: "#6B7280" },
};
export default function AlertsScreen() { export default function AlertsScreen() {
const { user, notifications, route } = useApp();
if (!user) return <Redirect href="/login" />;
const statusInfo =
STATUS_LABEL[route?.status ?? "ESPERA"] ?? STATUS_LABEL.ESPERA;
return ( return (
<View <SafeAreaView
style={{ style={{ flex: 1, backgroundColor: COLORS.background }}
flex: 1, edges={["top"]}
justifyContent: "center",
alignItems: "center",
}}
> >
<Text>Alertas</Text> <ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 130 }}
>
{/* Hero con título + count + camion image en banner azul */}
<View style={styles.heroSection}>
<View style={styles.heroTextWrap}>
<Text style={styles.headerTitle}>Alertas</Text>
<Text style={styles.headerCount}>
{notifications.length}{" "}
{notifications.length === 1
? "notificación"
: "notificaciones"}
</Text>
</View>
<View style={styles.heroBanner}>
<Image
source={require("../../assets/illustrations/truck-city.png")}
style={styles.truckImage}
resizeMode="cover"
/>
</View>
</View>
{/* Estado de ruta */}
<View
style={[
styles.statusBanner,
{ backgroundColor: `${statusInfo.color}15` },
]}
>
<View
style={[
styles.statusIcon,
{ backgroundColor: statusInfo.color },
]}
>
<Ionicons name="bus-outline" size={20} color="#FFFFFF" />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.statusTitle, { color: statusInfo.color }]}>
ESTADO DE RUTA: {statusInfo.label}
</Text>
<Text style={styles.statusBody}>
{statusInfo.label === "ACTIVA"
? "El servicio de recolección sigue en curso."
: "Sin servicio activo en este momento."}
</Text>
</View>
</View>
{/* Timeline de notificaciones */}
{notifications.length === 0 ? (
<View style={styles.emptyBox}>
<Ionicons name="leaf-outline" size={40} color="#9CA3AF" />
<Text style={styles.emptyText}>
Aún no hay alertas. Las recibirás cuando el camión avance en
tu ruta.
</Text>
</View>
) : (
<View style={{ paddingHorizontal: 20, marginTop: 12 }}>
{notifications.map((n, idx) => {
const m = meta[n.type];
const isLast = idx === notifications.length - 1;
const timeStr = new Date(n.createdAt).toLocaleTimeString(
"es-MX",
{ hour: "2-digit", minute: "2-digit" },
);
return (
<View key={n.id} style={styles.timelineRow}>
{/* Columna izquierda: dot + línea */}
<View style={styles.timelineLeft}>
<View
style={[
styles.timelineDot,
{ backgroundColor: m.color },
]}
>
<Ionicons name={m.icon} size={14} color="#FFFFFF" />
</View>
{!isLast && (
<View
style={[
styles.timelineLine,
{ backgroundColor: `${m.color}50` },
]}
/>
)}
<Text style={styles.timelineTime}>{timeStr}</Text>
</View>
{/* Card */}
<View
style={[
styles.notifCard,
{ backgroundColor: m.bg },
]}
>
<View style={styles.notifHeader}>
<Text style={styles.notifTitle}>{n.title}</Text>
<View
style={[
styles.badge,
{ backgroundColor: `${m.color}25` },
]}
>
<Text style={[styles.badgeText, { color: m.color }]}>
{m.badge}
</Text>
</View>
</View>
<Text style={styles.notifBody}>{n.body}</Text>
</View>
</View> </View>
); );
})}
</View>
)}
{/* Consejo del día */}
<View style={styles.tipCard}>
<View style={styles.tipIcon}>
<Ionicons name="leaf" size={20} color="#0E8A61" />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.tipTitle}>Consejo del día</Text>
<Text style={styles.tipBody}>
Separa correctamente tus residuos. Tu acción hace la diferencia.
</Text>
</View>
<Image
source={require("../../assets/illustrations/bins-cute.png")}
style={styles.tipImage}
resizeMode="contain"
/>
</View>
</ScrollView>
</SafeAreaView>
);
} }
const styles = StyleSheet.create({
heroSection: {
backgroundColor: "#DBEAFE",
paddingBottom: 0,
marginBottom: 20,
},
heroTextWrap: {
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: 8,
},
heroBanner: {
height: 150,
width: "100%",
backgroundColor: "#DBEAFE",
overflow: "hidden",
},
headerTitle: {
fontSize: 32,
fontWeight: "800",
color: "#0F172A",
},
headerCount: {
fontSize: 14,
color: "#6B7280",
marginTop: 2,
},
truckImage: {
width: "100%",
height: "100%",
},
statusBanner: {
flexDirection: "row",
alignItems: "center",
marginHorizontal: 20,
padding: 14,
borderRadius: 14,
marginBottom: 4,
},
statusIcon: {
width: 38,
height: 38,
borderRadius: 19,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
statusTitle: {
fontSize: 13,
fontWeight: "800",
},
statusBody: {
fontSize: 12,
color: "#374151",
marginTop: 2,
},
emptyBox: {
alignItems: "center",
padding: 40,
marginHorizontal: 20,
marginTop: 16,
backgroundColor: "#FFFFFF",
borderRadius: 14,
},
emptyText: {
fontSize: 13,
color: "#6B7280",
textAlign: "center",
marginTop: 10,
},
timelineRow: {
flexDirection: "row",
marginBottom: 14,
},
timelineLeft: {
width: 56,
alignItems: "center",
},
timelineDot: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: "center",
alignItems: "center",
},
timelineLine: {
flex: 1,
width: 2,
marginTop: 2,
marginBottom: 2,
},
timelineTime: {
fontSize: 10,
color: "#9CA3AF",
marginTop: 4,
},
notifCard: {
flex: 1,
borderRadius: 14,
padding: 14,
marginLeft: 4,
},
notifHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 6,
},
notifTitle: {
fontSize: 14,
fontWeight: "800",
color: "#0F172A",
flex: 1,
paddingRight: 8,
},
badge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 8,
},
badgeText: {
fontSize: 10,
fontWeight: "700",
},
notifBody: {
fontSize: 13,
color: "#374151",
lineHeight: 18,
},
tipCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
borderRadius: 16,
padding: 14,
marginHorizontal: 20,
marginTop: 16,
},
tipIcon: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
tipTitle: {
fontSize: 14,
fontWeight: "800",
color: "#065F46",
marginBottom: 4,
},
tipBody: {
fontSize: 12,
color: "#065F46",
lineHeight: 16,
},
tipImage: {
width: 70,
height: 70,
marginLeft: 8,
},
});

View File

@@ -0,0 +1,221 @@
import { useState } from "react";
import {
ScrollView,
View,
Text,
TextInput,
StyleSheet,
Pressable,
Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import SectionTitle from "../components/SectionTitle";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext";
import {
submitFeedback,
type FeedbackType,
} from "../services/feedback.service";
type Option = {
type: FeedbackType;
label: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
};
const OPTIONS: Option[] = [
{
type: "TRUCK_DID_NOT_PASS",
label: "El camión no pasó",
icon: "alert-circle-outline",
color: "#EF4444",
},
{
type: "RATING",
label: "Calificar el servicio",
icon: "star-outline",
color: "#F59E0B",
},
{
type: "SUGGESTION",
label: "Sugerencia",
icon: "bulb-outline",
color: "#3B82F6",
},
{
type: "OTHER",
label: "Otro",
icon: "chatbubble-outline",
color: "#6B7280",
},
];
export default function FeedbackScreen() {
const { user } = useApp();
const router = useRouter();
const [selectedType, setSelectedType] = useState<FeedbackType>(
"TRUCK_DID_NOT_PASS",
);
const [message, setMessage] = useState("");
const [rating, setRating] = useState<number | undefined>(undefined);
const [submitting, setSubmitting] = useState(false);
if (!user) return <Redirect href="/login" />;
const handleSubmit = async () => {
if (!message.trim()) {
Alert.alert("Mensaje vacío", "Cuéntanos qué pasó.");
return;
}
try {
setSubmitting(true);
const payload: { type: FeedbackType; message: string; rating?: number } =
{ type: selectedType, message };
if (selectedType === "RATING" && rating) payload.rating = rating;
await submitFeedback(payload);
Alert.alert("Gracias", "Tu retroalimentación fue enviada.");
setMessage("");
setRating(undefined);
router.back();
} catch (err) {
Alert.alert("Error", err instanceof Error ? err.message : "No se pudo enviar.");
} finally {
setSubmitting(false);
}
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
>
<SectionTitle title="Buzón de retroalimentación" />
<Text style={styles.subtitle}>
Reporta incidencias o califica el servicio. Tu mensaje llega
directamente al equipo operativo.
</Text>
<Text style={styles.label}>Tipo de mensaje</Text>
<View style={styles.optionsGrid}>
{OPTIONS.map((opt) => {
const active = selectedType === opt.type;
return (
<Pressable
key={opt.type}
onPress={() => setSelectedType(opt.type)}
style={[
styles.optionCard,
active && { borderColor: opt.color, borderWidth: 2 },
]}
>
<Ionicons name={opt.icon} size={24} color={opt.color} />
<Text style={styles.optionLabel}>{opt.label}</Text>
</Pressable>
);
})}
</View>
{selectedType === "RATING" && (
<>
<Text style={styles.label}>Calificación</Text>
<View style={styles.starsRow}>
{[1, 2, 3, 4, 5].map((n) => (
<Pressable key={n} onPress={() => setRating(n)}>
<Ionicons
name={rating && n <= rating ? "star" : "star-outline"}
size={34}
color="#F59E0B"
style={{ marginRight: 8 }}
/>
</Pressable>
))}
</View>
</>
)}
<Text style={styles.label}>Mensaje</Text>
<TextInput
style={styles.textArea}
placeholder="Cuéntanos qué pasó..."
placeholderTextColor="#9CA3AF"
value={message}
onChangeText={setMessage}
multiline
numberOfLines={5}
/>
<View style={{ height: 16 }} />
<PrimaryButton
title={submitting ? "Enviando..." : "Enviar"}
onPress={handleSubmit}
/>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
subtitle: {
color: "#6B7280",
fontSize: 14,
marginBottom: 20,
marginLeft: 12,
},
label: {
fontSize: 13,
fontWeight: "700",
color: "#374151",
marginTop: 8,
marginBottom: 10,
marginLeft: 12,
},
optionsGrid: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "space-between",
},
optionCard: {
width: "48%",
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 14,
marginBottom: 12,
borderColor: "transparent",
borderWidth: 2,
shadowColor: "#000",
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.05,
shadowRadius: 6,
elevation: 2,
},
optionLabel: {
fontSize: 13,
fontWeight: "600",
color: "#1F2937",
marginTop: 8,
},
starsRow: {
flexDirection: "row",
marginLeft: 12,
marginBottom: 10,
},
textArea: {
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 14,
minHeight: 110,
fontSize: 14,
color: "#1F2937",
textAlignVertical: "top",
marginHorizontal: 2,
},
});

View File

@@ -1,15 +1,313 @@
import { View, Text } from "react-native"; import { ScrollView, View, Text, StyleSheet, Image } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { COLORS } from "../constants/colors";
import CollectionCalendar from "../components/CollectionCalendar";
type Category = {
key: string;
title: string;
color: string;
bg: string;
icon: keyof typeof Ionicons.glyphMap;
image: any;
examples: string[];
tip: string;
};
const CATEGORIES: Category[] = [
{
key: "organico",
title: "Orgánicos",
color: "#22C55E",
bg: "#ECFDF5",
icon: "leaf",
image: require("../../assets/illustrations/organic-food.png"),
examples: [
"Restos de comida (frutas, verduras, cáscaras)",
"Bolsitas de té, residuos de café",
"Hojas, ramas y poda de jardín",
],
tip: "Escurre líquidos antes de tirar. Idealmente compostable.",
},
{
key: "reciclable",
title: "Reciclables",
color: "#3B82F6",
bg: "#EFF6FF",
icon: "refresh-circle",
image: require("../../assets/illustrations/pet-bottles.png"),
examples: [
"Botellas y envases PET limpios",
"Cartón y papel seco",
"Latas de aluminio, vidrio sin romper",
],
tip: "Limpia y aplasta los envases para ocupar menos espacio.",
},
{
key: "sanitario",
title: "Sanitarios",
color: "#6B7280",
bg: "#F3F4F6",
icon: "medkit",
image: require("../../assets/illustrations/sanitary.png"),
examples: [
"Pañales y toallas femeninas",
"Papel higiénico usado",
"Curaciones y cubrebocas",
],
tip: "Sepáralos siempre. NO van con orgánicos ni reciclables.",
},
{
key: "especial",
title: "Especiales",
color: "#EF4444",
bg: "#FEF2F2",
icon: "warning",
image: require("../../assets/illustrations/battery-meds.png"),
examples: [
"Pilas y baterías",
"Electrónicos viejos",
"Aceite de cocina usado",
"Medicamentos caducados",
],
tip: "NO los tires con la basura común. Llévalos a un centro de acopio.",
},
];
export default function GuideScreen() { export default function GuideScreen() {
return ( return (
<View <SafeAreaView
style={{ style={{ flex: 1, backgroundColor: COLORS.background }}
flex: 1, edges={["top"]}
justifyContent: "center",
alignItems: "center",
}}
> >
<Text>Guía</Text> <ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 20, paddingBottom: 130 }}
>
{/* Calendario de recolección (primer elemento) */}
<CollectionCalendar />
{/* Header con título grande + imagen */}
<View style={styles.headerRow}>
<View style={{ flex: 1, paddingRight: 8 }}>
<Text style={styles.headerTitle}>Guía de{"\n"}separación</Text>
<Text style={styles.headerSubtitle}>
Separar correctamente reduce contaminación y ayuda a la ruta de
recolección.
</Text>
<Text style={styles.headerOffline}>Funciona sin conexión.</Text>
</View> </View>
<Image
source={require("../../assets/illustrations/recycling.png")}
style={styles.heroImage}
resizeMode="contain"
/>
</View>
{CATEGORIES.map((cat) => (
<View key={cat.key} style={styles.card}>
{/* Icono circular */}
<View
style={[
styles.iconWrap,
{ backgroundColor: cat.bg },
]}
>
<Ionicons name={cat.icon} size={28} color={cat.color} />
</View>
{/* Contenido */}
<View style={styles.cardContent}>
<View style={styles.cardTitleRow}>
<Text style={[styles.cardTitle, { color: cat.color }]}>
{cat.title}
</Text>
<Ionicons name="chevron-forward" size={18} color="#9CA3AF" />
</View>
{cat.examples.map((ex, i) => (
<Text key={i} style={styles.example}>
{ex}
</Text>
))}
<View
style={[
styles.tipBox,
{ backgroundColor: cat.bg },
]}
>
<Ionicons name="bulb" size={14} color={cat.color} />
<Text style={[styles.tipText, { color: cat.color }]}>
{cat.tip}
</Text>
</View>
</View>
{/* Imagen ilustrativa por categoría */}
<Image
source={cat.image}
style={styles.categoryImage}
resizeMode="contain"
/>
</View>
))}
{/* Recuerda */}
<View style={styles.preventiveBox}>
<View style={{ flex: 1 }}>
<View style={styles.preventiveHeader}>
<View style={styles.preventiveIcon}>
<Ionicons name="notifications" size={20} color="#FFFFFF" />
</View>
<Text style={styles.preventiveTitle}>Recuerda</Text>
</View>
{[
"Saca tu basura sólo dentro del horario.",
"No persigas al camión, es peligroso.",
"Si tu camión no pasó, usa el buzón.",
].map((t, i) => (
<View key={i} style={styles.preventiveRow}>
<Ionicons name="checkmark-circle" size={14} color="#92400E" />
<Text style={styles.preventiveBody}>{t}</Text>
</View>
))}
</View>
<Image
source={require("../../assets/illustrations/truck-banner.png")}
style={styles.preventiveImage}
resizeMode="contain"
/>
</View>
</ScrollView>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({
headerRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 20,
paddingHorizontal: 4,
},
headerTitle: {
fontSize: 30,
fontWeight: "800",
color: "#0F172A",
lineHeight: 34,
marginBottom: 8,
},
headerSubtitle: {
fontSize: 13,
color: "#1E40AF",
lineHeight: 18,
marginBottom: 4,
},
headerOffline: {
fontSize: 13,
color: "#0E8A61",
fontWeight: "700",
},
heroImage: {
width: 120,
height: 120,
},
card: {
flexDirection: "row",
alignItems: "flex-start",
backgroundColor: "#FFFFFF",
borderRadius: 18,
padding: 14,
marginBottom: 14,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 3,
},
iconWrap: {
width: 50,
height: 50,
borderRadius: 25,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
cardContent: {
flex: 1,
paddingRight: 4,
},
cardTitleRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
},
cardTitle: { fontSize: 18, fontWeight: "800" },
example: { fontSize: 12, color: "#4B5563", marginBottom: 2 },
tipBox: {
flexDirection: "row",
alignItems: "center",
marginTop: 8,
padding: 8,
borderRadius: 8,
},
tipText: {
fontSize: 11,
fontWeight: "600",
marginLeft: 6,
flex: 1,
lineHeight: 14,
},
categoryImage: {
width: 64,
height: 80,
marginLeft: 4,
alignSelf: "center",
},
preventiveBox: {
flexDirection: "row",
alignItems: "center",
marginTop: 8,
padding: 14,
backgroundColor: "#FEF3C7",
borderRadius: 14,
},
preventiveHeader: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
preventiveIcon: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "#F59E0B",
justifyContent: "center",
alignItems: "center",
marginRight: 10,
},
preventiveTitle: {
fontSize: 16,
fontWeight: "800",
color: "#92400E",
},
preventiveRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 4,
},
preventiveBody: {
fontSize: 12,
color: "#92400E",
marginLeft: 6,
flex: 1,
lineHeight: 16,
},
preventiveImage: {
width: 90,
height: 90,
marginLeft: 4,
},
});

View File

@@ -1,15 +1,494 @@
import { View, Text } from "react-native"; import { useEffect } from "react";
import {
ScrollView,
View,
Text,
Image,
Pressable,
StyleSheet,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import { useApp } from "../context/AppContext";
type QuickAction = {
key: string;
title: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
bg: string;
onPress: () => void;
};
export default function HomeScreen() { export default function HomeScreen() {
const { user, eta, route, loading, refreshStatus } = useApp();
const router = useRouter();
useEffect(() => {
if (user && !eta) {
void refreshStatus();
}
}, [user]);
if (!user) return <Redirect href="/login" />;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (user.role === "ADMIN") return <Redirect href={"/admin" as any} />;
const minutes = eta ? Math.max(0, eta.etaMinutes) : null;
const windowText = eta?.arrivalWindow
? `${eta.arrivalWindow.from} - ${eta.arrivalWindow.to} a.m.`
: "Calculando...";
// Si la ruta fue cancelada por admin → eso manda
// Si el simulador marcó FAILED → falla mecánica
// Si el simulador marcó ARRIVED → ya pasó el camión
// Si no, mostramos el status genérico
const arrival = route?.arrivalResult ?? "PENDING";
const cancelled = route?.cancelled ?? false;
const statusOK = !cancelled && arrival !== "FAILED";
const statusColor = cancelled
? "#F59E0B"
: arrival === "FAILED"
? "#EF4444"
: arrival === "ARRIVED"
? "#22C55E"
: "#22C55E";
const statusLabel = cancelled
? "CANCELADA"
: arrival === "FAILED"
? "CON FALLA"
: arrival === "ARRIVED"
? "EL CAMIÓN YA PASÓ"
: route?.status === "EN_RUTA"
? "ESTABLE"
: "EN ESPERA";
const statusBody = cancelled
? "La ruta matutina fue cancelada. Se reprogramará para la tarde."
: arrival === "FAILED"
? "El camión presenta una falla mecánica."
: arrival === "ARRIVED"
? "El camión recolector ya pasó por tu zona."
: statusOK
? "Tu recolección sigue en horario normal."
: "Hay incidencias en la ruta.";
const quickActions: QuickAction[] = [
{
key: "alerts",
title: "Alertas",
icon: "notifications-outline",
color: "#22C55E",
bg: "#ECFDF5",
onPress: () => router.push("/alerts"),
},
{
key: "guide",
title: "Guía",
icon: "leaf-outline",
color: "#3B82F6",
bg: "#EFF6FF",
onPress: () => router.push("/guide"),
},
{
key: "feedback",
title: "Reportar",
icon: "chatbubble-outline",
color: "#EF4444",
bg: "#FEF2F2",
onPress: () => router.push("/feedback"),
},
{
key: "address",
title: "Domicilio",
icon: "home-outline",
color: "#8B5CF6",
bg: "#F5F3FF",
onPress: () => router.push("/addresses"),
},
];
return ( return (
<View <SafeAreaView
style={{ style={{ flex: 1, backgroundColor: COLORS.background }}
flex: 1, edges={["top"]}
justifyContent: "center",
alignItems: "center",
}}
> >
<Text>Inicio</Text> <ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 130 }}
refreshControl={undefined}
>
{/* Title */}
<View style={styles.titleRow}>
<View>
<Text style={styles.title}>OptiRuta</Text>
<Text style={styles.subtitle}>
Ruta {route?.routeId ?? "—"}
</Text>
</View> </View>
</View>
{/* Hero del camión */}
<View style={styles.heroBox}>
<Image
source={require("../../assets/illustrations/truck-city.png")}
style={styles.heroTruck}
resizeMode="cover"
/>
</View>
{/* Card ETA */}
<View style={styles.etaCard}>
<View style={styles.etaContent}>
<View style={styles.etaIcon}>
<Ionicons name="time-outline" size={22} color="#0E8A61" />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.etaLabel}>LLEGADA ESTIMADA</Text>
<Text style={styles.etaMinutes}>
{minutes !== null
? `${minutes} min`
: loading
? "..."
: "—"}
</Text>
<Text style={styles.etaWindowLabel}>Ventana de recolección</Text>
<View style={styles.windowPill}>
<Text style={styles.windowText}>{windowText}</Text>
</View>
</View>
<View style={styles.etaCircle}>
<View style={styles.etaCircleInner}>
<Ionicons name="bus-outline" size={28} color="#0E8A61" />
</View>
</View>
</View>
</View>
{/* Estado de ruta */}
<View style={styles.statusBanner}>
<View
style={[
styles.statusDot,
{ backgroundColor: statusColor },
]}
/>
<View style={{ flex: 1 }}>
<Text style={[styles.statusTitle, { color: statusColor }]}>
ESTADO DE RUTA: {statusLabel}
</Text>
<Text style={styles.statusBody}>{statusBody}</Text>
</View>
<View
style={[
styles.statusShield,
{ backgroundColor: `${statusColor}15` },
]}
>
<Ionicons
name="shield-checkmark-outline"
size={20}
color={statusColor}
/>
</View>
</View>
{/* Mensaje preventivo */}
<View style={styles.warningBanner}>
<View style={styles.warningIcon}>
<Ionicons name="alert" size={20} color="#FFFFFF" />
</View>
<Text style={styles.warningText}>
No saques tus residuos fuera del horario{"\n"}y no persigas al camión.
</Text>
<View style={styles.warningRight}>
<Ionicons name="walk-outline" size={20} color="#92400E" />
</View>
</View>
{/* Acciones rápidas */}
<Text style={styles.sectionTitle}>ACCIONES RÁPIDAS</Text>
<View style={styles.actionsGrid}>
{quickActions.map((a) => (
<Pressable
key={a.key}
style={styles.actionCard}
onPress={a.onPress}
>
<View style={[styles.actionIcon, { backgroundColor: a.bg }]}>
<Ionicons name={a.icon} size={26} color={a.color} />
</View>
<Text style={styles.actionTitle}>{a.title}</Text>
</Pressable>
))}
</View>
{/* Consejo rápido */}
<View style={styles.tipCard}>
<View style={styles.tipIcon}>
<Ionicons name="leaf" size={22} color="#0E8A61" />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.tipTitle}>Consejo rápido</Text>
<Text style={styles.tipBody}>
Compacta cartón y PET para ahorrar espacio y ayudar al medio
ambiente.
</Text>
</View>
<Image
source={require("../../assets/illustrations/pet-bottles.png")}
style={styles.tipImage}
resizeMode="contain"
/>
</View>
</ScrollView>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({
titleRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 20,
paddingTop: 8,
},
title: {
fontSize: 32,
fontWeight: "800",
color: "#0E8A61",
},
subtitle: {
fontSize: 14,
color: "#6B7280",
marginTop: 2,
},
heroBox: {
height: 180,
marginTop: 8,
backgroundColor: "#DBEAFE",
overflow: "hidden",
},
heroTruck: {
width: "100%",
height: "100%",
},
etaCard: {
marginHorizontal: 20,
marginTop: -30,
backgroundColor: "#ECFDF5",
borderRadius: 20,
padding: 18,
shadowColor: "#000",
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.08,
shadowRadius: 10,
elevation: 6,
},
etaContent: {
flexDirection: "row",
alignItems: "center",
},
etaIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
etaLabel: {
fontSize: 11,
fontWeight: "800",
color: "#065F46",
letterSpacing: 0.5,
},
etaMinutes: {
fontSize: 36,
fontWeight: "800",
color: "#0F172A",
marginTop: 4,
lineHeight: 38,
},
etaWindowLabel: {
fontSize: 11,
color: "#6B7280",
marginTop: 6,
},
windowPill: {
alignSelf: "flex-start",
backgroundColor: "#D1FAE5",
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 4,
marginTop: 4,
},
windowText: {
fontSize: 13,
color: "#065F46",
fontWeight: "700",
},
etaCircle: {
width: 88,
height: 88,
borderRadius: 44,
borderWidth: 4,
borderColor: "#22C55E",
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
etaCircleInner: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
},
statusBanner: {
flexDirection: "row",
alignItems: "center",
marginHorizontal: 20,
marginTop: 14,
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 14,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
statusDot: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
statusTitle: {
fontSize: 13,
fontWeight: "800",
},
statusBody: {
fontSize: 12,
color: "#374151",
marginTop: 2,
},
statusShield: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
warningBanner: {
flexDirection: "row",
alignItems: "center",
marginHorizontal: 20,
marginTop: 12,
backgroundColor: "#FEF3C7",
borderRadius: 14,
padding: 12,
},
warningIcon: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "#F59E0B",
justifyContent: "center",
alignItems: "center",
marginRight: 10,
},
warningText: {
flex: 1,
fontSize: 12,
color: "#92400E",
fontWeight: "600",
lineHeight: 16,
},
warningRight: {
marginLeft: 8,
},
sectionTitle: {
fontSize: 12,
fontWeight: "800",
color: "#6B7280",
letterSpacing: 1,
marginTop: 22,
marginBottom: 10,
marginLeft: 20,
},
actionsGrid: {
flexDirection: "row",
justifyContent: "space-between",
paddingHorizontal: 20,
},
actionCard: {
flex: 1,
backgroundColor: "#FFFFFF",
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
marginHorizontal: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
actionIcon: {
width: 50,
height: 50,
borderRadius: 25,
justifyContent: "center",
alignItems: "center",
marginBottom: 8,
},
actionTitle: {
fontSize: 12,
fontWeight: "700",
color: "#0F172A",
textAlign: "center",
},
tipCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
borderRadius: 16,
padding: 14,
marginHorizontal: 20,
marginTop: 16,
},
tipIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
tipTitle: {
fontSize: 14,
fontWeight: "800",
color: "#065F46",
marginBottom: 2,
},
tipBody: {
fontSize: 12,
color: "#065F46",
lineHeight: 16,
},
tipImage: {
width: 60,
height: 60,
marginLeft: 8,
},
});

View File

@@ -0,0 +1,89 @@
import { useState } from "react";
import { View, Text, StyleSheet, Alert, Pressable } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import InputField from "../components/InputField";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext";
export default function LoginScreen() {
const [email, setEmail] = useState("ana@test.com");
const [password, setPassword] = useState("123456");
const [submitting, setSubmitting] = useState(false);
const { login } = useApp();
const router = useRouter();
const handleLogin = async () => {
try {
setSubmitting(true);
await login(email, password);
router.replace("/");
} catch (err) {
Alert.alert("Error", err instanceof Error ? err.message : "Login failed");
} finally {
setSubmitting(false);
}
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Iniciar sesión</Text>
<Text style={styles.subtitle}>
Accede para ver el estado del camión en tu zona.
</Text>
<InputField
placeholder="Email"
value={email}
onChangeText={setEmail}
/>
<InputField
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<View style={{ height: 8 }} />
<PrimaryButton
title={submitting ? "Cargando..." : "Entrar"}
onPress={handleLogin}
/>
<Pressable
onPress={() => router.replace("/register")}
style={styles.linkWrap}
>
<Text style={styles.link}>
¿No tienes cuenta?{" "}
<Text style={styles.linkBold}>Regístrate</Text>
</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.background },
content: { flex: 1, padding: 24, justifyContent: "center" },
title: {
fontSize: 26,
fontWeight: "bold",
textAlign: "center",
marginBottom: 6,
},
subtitle: {
fontSize: 14,
color: "#6B7280",
textAlign: "center",
marginBottom: 22,
},
linkWrap: { marginTop: 18, alignItems: "center" },
link: { fontSize: 14, color: "#6B7280" },
linkBold: { color: "#0E8A61", fontWeight: "700" },
});

View File

@@ -1,15 +1,383 @@
import { View, Text } from "react-native"; import { useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
Image,
Pressable,
Alert,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Redirect, useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import { useApp } from "../context/AppContext";
import { getMyAddress, type MyAddress } from "../services/addresses.service";
import { apiFetch } from "../lib/api";
type Row = {
key: string;
title: string;
subtitle: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
onPress: () => void;
danger?: boolean;
};
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, logout } = useApp();
const router = useRouter();
const [myAddress, setMyAddress] = useState<MyAddress | null>(null);
useEffect(() => {
if (!user) return;
void getMyAddress().then(setMyAddress).catch(() => {});
}, [user]);
if (!user) return <Redirect href="/login" />;
const handleLogout = () => {
logout();
router.replace("/login");
};
const handleReset = async () => {
try {
await apiFetch<{ message: string }>("/api/tracking/reset-demo", {
method: "POST",
body: JSON.stringify({}),
});
Alert.alert("Demo reiniciada", "Las notificaciones se borraron.");
} catch (err) {
Alert.alert(
"No se pudo reiniciar",
err instanceof Error ? err.message : "Error",
);
}
};
const handleHelp = () => {
Alert.alert(
"Ayuda",
"OptiRuta te avisa cuando el camión recolector está cerca.\n\n" +
"• Tu dirección define la ruta que verás.\n" +
"• Las alertas se actualizan cada 30 segundos.\n" +
"• Usa el buzón si el camión no pasó.",
);
};
const rows: Row[] = [
{
key: "address",
title: "Mi domicilio",
subtitle: "Ver y administrar tus domicilios registrados",
icon: "home-outline",
color: "#0E8A61",
onPress: () => router.push("/addresses"),
},
{
key: "feedback",
title: "Buzón de retroalimentación",
subtitle: "Sugerencias, comentarios y reportes",
icon: "chatbubble-outline",
color: "#0E8A61",
onPress: () => router.push("/feedback"),
},
{
key: "reset",
title: "Reiniciar demo",
subtitle: "Restablecer la simulación y los datos",
icon: "refresh-outline",
color: "#0E8A61",
onPress: handleReset,
},
{
key: "help",
title: "Ayuda",
subtitle: "Preguntas frecuentes y soporte",
icon: "help-circle-outline",
color: "#0E8A61",
onPress: handleHelp,
},
{
key: "logout",
title: "Cerrar sesión",
subtitle: "Salir de tu cuenta",
icon: "log-out-outline",
color: "#EF4444",
onPress: handleLogout,
danger: true,
},
];
const initial = (user.name?.[0] ?? user.email[0]).toUpperCase();
return ( return (
<View <SafeAreaView
style={{ style={{ flex: 1, backgroundColor: COLORS.background }}
flex: 1, edges={["bottom"]}
justifyContent: "center",
alignItems: "center",
}}
> >
<Text>Perfil</Text> <ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 130 }}
>
{/* Header verde con avatar */}
<View style={styles.heroWrap}>
<View style={styles.heroBackground} />
<View style={styles.avatarOuter}>
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>{initial}</Text>
</View> </View>
</View>
</View>
<View style={styles.identityBox}>
<Text style={styles.name}>
Hola, {user.name} <Text style={{ color: "#22C55E" }}>🌿</Text>
</Text>
<View style={styles.emailRow}>
<Ionicons name="mail-outline" size={14} color="#6B7280" />
<Text style={styles.email}>{user.email}</Text>
</View>
</View>
{/* Zona + ruta */}
<Pressable
style={styles.zoneRow}
onPress={() => router.push("/addresses")}
>
<View style={styles.zoneCol}>
<Ionicons name="location-outline" size={22} color="#0E8A61" />
<View style={{ marginLeft: 8, flex: 1 }}>
<Text style={styles.zoneLabel}>Tu zona</Text>
<Text style={styles.zoneValue} numberOfLines={2}>
{myAddress?.colonia ?? "Sin asignar"}
</Text>
</View>
</View>
<View style={styles.zoneDivider} />
<View style={styles.zoneCol}>
<Ionicons name="bus-outline" size={22} color="#0E8A61" />
<View style={{ marginLeft: 8, flex: 1 }}>
<Text style={styles.zoneLabel}>Ruta asignada</Text>
<Text style={styles.zoneValue} numberOfLines={1}>
{myAddress?.routeId ?? "—"}
</Text>
</View>
</View>
<Ionicons
name="chevron-forward"
size={18}
color="#9CA3AF"
style={{ marginLeft: 4 }}
/>
</Pressable>
{/* Lista de opciones */}
<View style={{ paddingHorizontal: 16, marginTop: 4 }}>
{rows.map((r) => (
<Pressable key={r.key} style={styles.rowCard} onPress={r.onPress}>
<View
style={[
styles.rowIcon,
{ backgroundColor: `${r.color}15` },
]}
>
<Ionicons name={r.icon} size={22} color={r.color} />
</View>
<View style={{ flex: 1 }}>
<Text
style={[
styles.rowTitle,
r.danger && { color: "#EF4444" },
]}
>
{r.title}
</Text>
<Text style={styles.rowSubtitle}>{r.subtitle}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#9CA3AF" />
</Pressable>
))}
</View>
{/* Tu impacto cuenta */}
<View style={styles.impactCard}>
<View style={styles.impactLeaf}>
<Image
source={require("../../assets/illustrations/impact-leaf.png")}
style={{ width: 40, height: 40 }}
resizeMode="contain"
/>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.impactTitle}>Tu impacto cuenta</Text>
<Text style={styles.impactBody}>
Siguiendo los horarios y separando correctamente, haces tu ciudad
más limpia.
</Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({
heroWrap: {
height: 140,
backgroundColor: "#D1FAE5",
overflow: "visible",
marginBottom: 60,
},
heroBackground: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "#D1FAE5",
},
avatarOuter: {
position: "absolute",
bottom: -50,
alignSelf: "center",
width: 112,
height: 112,
borderRadius: 56,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 6,
},
avatarInner: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: "#0E8A61",
justifyContent: "center",
alignItems: "center",
},
avatarText: {
color: "#FFFFFF",
fontSize: 38,
fontWeight: "800",
},
identityBox: {
alignItems: "center",
marginBottom: 18,
},
name: {
fontSize: 22,
fontWeight: "800",
color: "#0F172A",
},
emailRow: {
flexDirection: "row",
alignItems: "center",
marginTop: 6,
},
email: {
fontSize: 13,
color: "#6B7280",
marginLeft: 6,
},
zoneRow: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
borderRadius: 16,
padding: 14,
marginHorizontal: 16,
marginBottom: 16,
},
zoneCol: {
flexDirection: "row",
alignItems: "center",
flex: 1,
},
zoneDivider: {
width: 1,
height: 36,
backgroundColor: "#A7F3D0",
marginHorizontal: 8,
},
zoneLabel: {
fontSize: 11,
color: "#065F46",
fontWeight: "700",
},
zoneValue: {
fontSize: 13,
color: "#0F172A",
fontWeight: "600",
marginTop: 2,
},
rowCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#FFFFFF",
borderRadius: 14,
padding: 14,
marginBottom: 10,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 6,
elevation: 2,
},
rowIcon: {
width: 42,
height: 42,
borderRadius: 21,
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
rowTitle: {
fontSize: 15,
fontWeight: "700",
color: "#0F172A",
},
rowSubtitle: {
fontSize: 12,
color: "#6B7280",
marginTop: 2,
},
impactCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#ECFDF5",
borderRadius: 16,
padding: 14,
marginHorizontal: 16,
marginTop: 8,
},
impactLeaf: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: "#FFFFFF",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
impactTitle: {
fontSize: 14,
fontWeight: "800",
color: "#065F46",
marginBottom: 4,
},
impactBody: {
fontSize: 12,
color: "#065F46",
lineHeight: 16,
},
});

View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import { View, Text, StyleSheet, Alert, Pressable } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { COLORS } from "../constants/colors";
import InputField from "../components/InputField";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext";
export default function RegisterScreen() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const { register } = useApp();
const router = useRouter();
const handleRegister = async () => {
if (!name.trim() || !email.trim() || !password.trim()) {
Alert.alert("Datos faltantes", "Completa todos los campos.");
return;
}
if (password.length < 6) {
Alert.alert("Password corto", "Debe tener al menos 6 caracteres.");
return;
}
try {
setSubmitting(true);
await register(name, email, password);
// Onboarding: tras registrar, pedir que valide su domicilio.
router.replace("/addresses");
} catch (err) {
Alert.alert(
"No se pudo crear la cuenta",
err instanceof Error ? err.message : "Error desconocido",
);
} finally {
setSubmitting(false);
}
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>Crear cuenta</Text>
<Text style={styles.subtitle}>
Registra tu correo para recibir alertas de recolección.
</Text>
<InputField placeholder="Nombre" value={name} onChangeText={setName} />
<InputField
placeholder="Email"
value={email}
onChangeText={setEmail}
/>
<InputField
placeholder="Password (mínimo 6 caracteres)"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<View style={{ height: 8 }} />
<PrimaryButton
title={submitting ? "Creando..." : "Registrarme"}
onPress={handleRegister}
/>
<Pressable
onPress={() => router.replace("/login")}
style={styles.linkWrap}
>
<Text style={styles.link}>
¿Ya tienes cuenta? <Text style={styles.linkBold}>Inicia sesión</Text>
</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: COLORS.background },
content: { flex: 1, padding: 24, justifyContent: "center" },
title: {
fontSize: 26,
fontWeight: "bold",
textAlign: "center",
marginBottom: 6,
},
subtitle: {
fontSize: 14,
color: "#6B7280",
textAlign: "center",
marginBottom: 22,
},
linkWrap: { marginTop: 18, alignItems: "center" },
link: { fontSize: 14, color: "#6B7280" },
linkBold: { color: "#0E8A61", fontWeight: "700" },
});

View File

@@ -1,59 +1,183 @@
import { View, Text, StyleSheet } from 'react-native'; import {
import { Colors, Spacing } from '../constants/theme'; View,
Text,
StyleSheet,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Colors, Spacing } from "../constants/theme";
type AlertItemProps = { type AlertItemProps = {
message: string; title: string;
type?: 'success' | 'warning' | 'danger'; description: string;
time: string;
type?: "started" | "near" | "danger" | "completed";
}; };
export default function AlertItem({ export default function AlertItem({
message, title,
type = 'success', description,
time,
type = "started",
}: AlertItemProps) { }: AlertItemProps) {
const theme = Colors.light; const theme = Colors.light;
const getColor = () => { const getColor = () => {
switch (type) { switch (type) {
case 'success': case "started":
return '#22C55E'; return "#B8C4BE";
case 'warning':
return '#FACC15'; case "near":
case 'danger': return "#22C55E";
return '#EF4444';
case "danger":
return "#EF4444";
case "completed":
return "#B8C4BE";
default: default:
return theme.textSecondary; return "#9CA3AF";
} }
}; };
const getIcon = () => {
switch (type) {
case "started":
return "navigate";
case "near":
return "time-outline";
case "danger":
return "warning-outline";
case "completed":
return "checkmark-done";
default:
return "notifications";
}
};
return ( return (
<View style={styles.container}> <View
<View style={[styles.bar, { backgroundColor: getColor() }]} /> style={[
<Text style={[styles.text, { color: theme.text }]}> styles.container,
{message} {
backgroundColor: "#FFFFFF",
},
]}
>
<View
style={[
styles.iconContainer,
{
backgroundColor: `${getColor()}20`,
},
]}
>
<Ionicons
name={getIcon()}
size={22}
color={getColor()}
/>
</View>
<View style={styles.content}>
<View style={styles.header}>
<Text
style={[
styles.title,
{
color: theme.text,
},
]}
>
{title}
</Text> </Text>
<Text style={styles.time}>
{time}
</Text>
</View>
<Text
style={[
styles.description,
{
color: theme.textSecondary,
},
]}
>
{description}
</Text>
</View>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flexDirection: 'row', flexDirection: "row",
padding: Spacing.three,
marginHorizontal: Spacing.three, padding: 18,
marginVertical: Spacing.one,
borderRadius: 12, marginBottom: 16,
backgroundColor: '#F0F0F3',
alignItems: 'center', borderRadius: 22,
alignItems: "flex-start",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 6,
}, },
bar: {
width: 6, shadowOpacity: 0.05,
height: '100%',
borderRadius: 4, shadowRadius: 10,
marginRight: Spacing.two,
elevation: 4,
}, },
text: {
fontSize: 14, iconContainer: {
width: 42,
height: 42,
borderRadius: 21,
justifyContent: "center",
alignItems: "center",
marginRight: 14,
},
content: {
flex: 1, flex: 1,
}, },
header: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 6,
},
title: {
fontSize: 16,
fontWeight: "700",
},
time: {
fontSize: 12,
color: "#9CA3AF",
},
description: {
fontSize: 14,
lineHeight: 20,
},
}); });

View File

@@ -0,0 +1,447 @@
/**
* CollectionCalendar.tsx
*
* Calendario mensual de recolección de la ruta ASIGNADA al usuario.
* Respeta el principio de "visión de túnel": el ciudadano NO puede ver
* rutas que no sean la suya.
*
* Reglas de color por día (solo para RUTA-40, RUTA-65 y RUTA-80):
* L, M, V, S → verde (RESIDUOS ORGÁNICOS)
* Mi, J → gris (RESIDUOS SÓLIDOS URBANOS VALORABLES)
* Domingo → vacío
*
* El patrón se aplica TODAS las semanas sin excepción. Las celdas del mes
* anterior/siguiente que aparecen al inicio/final también se colorean
* (en tono más claro) para que no queden huecos entre semana.
*
* Otras rutas: días neutros sin etiqueta.
*
* Si el usuario no ha validado su domicilio, se muestra un CTA.
*/
import { useMemo, useState } from "react";
import { View, Text, StyleSheet, Pressable } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useApp } from "../context/AppContext";
import { routes as routeCatalog } from "../data/mocks/routes.mock";
const MONTHS_ES = [
"Enero",
"Febrero",
"Marzo",
"Abril",
"Mayo",
"Junio",
"Julio",
"Agosto",
"Septiembre",
"Octubre",
"Noviembre",
"Diciembre",
];
const DAYS_ES = ["Lu", "Ma", "Mi", "Ju", "Vi", "Sa", "Do"];
const SPECIAL_ROUTES = new Set(["RUTA-40", "RUTA-65", "RUTA-80"]);
const COLOR_ORGANIC = "#10b981";
const COLOR_SOLID = "#9ca3af";
const COLOR_NEUTRAL = "#E5E7EB";
const COLOR_EMPTY = "transparent";
type DayInfo = { day: number; weekIndex: number; inMonth: boolean };
/** Convierte JS getDay (0=Sun..6=Sat) al índice Mon-first (0=Lun..6=Dom). */
const toMondayFirst = (jsDay: number): number => (jsDay === 0 ? 6 : jsDay - 1);
/**
* Devuelve 42 celdas (6 semanas × 7 días). Las celdas del inicio/final
* que caen fuera del mes actual también traen su día real del mes vecino
* (con inMonth=false) para que el calendario no tenga huecos entre semana.
*/
function buildMonthGrid(year: number, month: number): DayInfo[] {
const firstDay = new Date(year, month, 1);
const firstWeekIndex = toMondayFirst(firstDay.getDay());
const daysInMonth = new Date(year, month + 1, 0).getDate();
const daysInPrevMonth = new Date(year, month, 0).getDate();
const cells: DayInfo[] = [];
// Días del mes anterior que aparecen al inicio
for (let i = firstWeekIndex - 1; i >= 0; i--) {
const date = new Date(year, month - 1, daysInPrevMonth - i);
cells.push({
day: daysInPrevMonth - i,
weekIndex: toMondayFirst(date.getDay()),
inMonth: false,
});
}
// Días del mes actual
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
cells.push({
day: d,
weekIndex: toMondayFirst(date.getDay()),
inMonth: true,
});
}
// Días del mes siguiente para completar 42 celdas
let next = 1;
while (cells.length < 42) {
const date = new Date(year, month + 1, next);
cells.push({
day: next,
weekIndex: toMondayFirst(date.getDay()),
inMonth: false,
});
next++;
}
return cells;
}
/**
* Color de fondo según ruta + día de la semana.
* - SPECIAL_ROUTES (40/65/80): patrón verde/gris/vacío todas las semanas
* - Otras rutas: neutro (salvo domingo que va vacío)
*/
function colorForDay(routeId: string, weekIndex: number): string {
if (!SPECIAL_ROUTES.has(routeId)) {
return weekIndex === 6 ? COLOR_EMPTY : COLOR_NEUTRAL;
}
if (weekIndex === 0 || weekIndex === 1 || weekIndex === 4 || weekIndex === 5)
return COLOR_ORGANIC; // Lun, Mar, Vie, Sáb
if (weekIndex === 2 || weekIndex === 3) return COLOR_SOLID; // Mié, Jue
return COLOR_EMPTY; // Domingo
}
export default function CollectionCalendar() {
const { route } = useApp();
const router = useRouter();
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
const myRouteId = route?.routeId ?? null;
const myRouteName = useMemo(
() => routeCatalog.find((r) => r.routeId === myRouteId)?.name ?? "",
[myRouteId],
);
const isSpecial = myRouteId ? SPECIAL_ROUTES.has(myRouteId) : false;
const grid = useMemo(() => buildMonthGrid(year, month), [year, month]);
const handlePrev = () => {
if (month === 0) {
setMonth(11);
setYear((y) => y - 1);
} else {
setMonth((m) => m - 1);
}
};
const handleNext = () => {
if (month === 11) {
setMonth(0);
setYear((y) => y + 1);
} else {
setMonth((m) => m + 1);
}
};
// CTA: sin ruta asignada
if (!myRouteId) {
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Ionicons name="calendar-outline" size={22} color="#0E8A61" />
<Text style={styles.title}>Calendario de recolección</Text>
</View>
<View style={styles.ctaBox}>
<Ionicons name="lock-closed-outline" size={28} color="#9CA3AF" />
<Text style={styles.ctaText}>
Necesitas validar tu domicilio para ver el calendario de tu ruta.
</Text>
<Pressable
style={styles.ctaBtn}
onPress={() => router.push("/addresses")}
>
<Text style={styles.ctaBtnText}>Validar domicilio</Text>
</Pressable>
</View>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.headerRow}>
<Ionicons name="calendar-outline" size={22} color="#0E8A61" />
<Text style={styles.title}>Calendario de recolección</Text>
</View>
<View style={styles.routeBadge}>
<Ionicons name="bus-outline" size={14} color="#0E8A61" />
<Text style={styles.routeBadgeText}>
Tu ruta: <Text style={styles.routeBadgeBold}>{myRouteId}</Text>
{myRouteName ? ` · ${myRouteName}` : ""}
</Text>
</View>
{/* Navegador de mes */}
<View style={styles.monthRow}>
<Pressable onPress={handlePrev} style={styles.navBtn} hitSlop={10}>
<Ionicons name="chevron-back" size={20} color="#0E8A61" />
</Pressable>
<Text style={styles.monthLabel}>
{MONTHS_ES[month]} {year}
</Text>
<Pressable onPress={handleNext} style={styles.navBtn} hitSlop={10}>
<Ionicons name="chevron-forward" size={20} color="#0E8A61" />
</Pressable>
</View>
{/* Encabezado días de la semana */}
<View style={styles.weekHeader}>
{DAYS_ES.map((d) => (
<View key={d} style={styles.weekCell}>
<Text style={styles.weekText}>{d}</Text>
</View>
))}
</View>
{/* Grid de días — 6 filas explícitas para evitar el problema de
alineación que ocurre con flexWrap + porcentajes en React Native. */}
<View>
{Array.from({ length: 6 }).map((_, rowIdx) => (
<View key={rowIdx} style={styles.gridRow}>
{grid.slice(rowIdx * 7, (rowIdx + 1) * 7).map((cell, colIdx) => {
const bg = colorForDay(myRouteId, cell.weekIndex);
const isToday =
cell.inMonth &&
cell.day === today.getDate() &&
month === today.getMonth() &&
year === today.getFullYear();
const isColored =
bg === COLOR_ORGANIC || bg === COLOR_SOLID;
const textColor = isColored ? "#FFFFFF" : "#0F172A";
return (
<View key={colIdx} style={styles.dayCell}>
<View
style={[
styles.dayInner,
{ backgroundColor: bg },
!cell.inMonth && { opacity: 0.4 },
isToday && styles.todayBorder,
]}
>
<Text style={[styles.dayText, { color: textColor }]}>
{cell.day}
</Text>
</View>
</View>
);
})}
</View>
))}
</View>
{/* Leyenda */}
{isSpecial ? (
<View style={styles.legend}>
<View style={styles.legendRow}>
<View
style={[styles.legendSwatch, { backgroundColor: COLOR_ORGANIC }]}
/>
<Text style={styles.legendText}>RESIDUOS ORGÁNICOS</Text>
</View>
<View style={styles.legendRow}>
<View
style={[styles.legendSwatch, { backgroundColor: COLOR_SOLID }]}
/>
<Text style={styles.legendText}>
RESIDUOS SÓLIDOS URBANOS VALORABLES
</Text>
</View>
</View>
) : (
<View style={styles.legendNeutral}>
<Ionicons
name="information-circle-outline"
size={16}
color="#6B7280"
/>
<Text style={styles.legendNeutralText}>
Tu ruta aún no tiene un calendario de separación específico. Te
avisaremos cuando se publique.
</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: "#FFFFFF",
borderRadius: 18,
padding: 16,
marginBottom: 18,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 12,
},
title: {
fontSize: 18,
fontWeight: "800",
color: "#0F172A",
marginLeft: 8,
},
routeBadge: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
backgroundColor: "#ECFDF5",
paddingVertical: 6,
paddingHorizontal: 10,
borderRadius: 999,
marginBottom: 8,
},
routeBadgeText: {
fontSize: 12,
color: "#065F46",
marginLeft: 6,
},
routeBadgeBold: {
fontWeight: "800",
color: "#0E8A61",
},
monthRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 8,
marginBottom: 10,
},
navBtn: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "#ECFDF5",
justifyContent: "center",
alignItems: "center",
},
monthLabel: {
fontSize: 16,
fontWeight: "700",
color: "#0F172A",
},
weekHeader: {
flexDirection: "row",
marginBottom: 6,
},
weekCell: {
flex: 1,
alignItems: "center",
paddingVertical: 4,
paddingHorizontal: 2,
},
weekText: {
fontSize: 11,
fontWeight: "700",
color: "#9CA3AF",
},
gridRow: {
flexDirection: "row",
},
dayCell: {
flex: 1,
padding: 2,
aspectRatio: 1,
},
dayInner: {
flex: 1,
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
},
dayText: {
fontSize: 13,
fontWeight: "600",
},
todayBorder: {
borderWidth: 2,
borderColor: "#0E8A61",
},
legend: {
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: "#F3F4F6",
},
legendRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: 6,
},
legendSwatch: {
width: 14,
height: 14,
borderRadius: 4,
marginRight: 8,
},
legendText: {
fontSize: 11,
fontWeight: "700",
color: "#374151",
letterSpacing: 0.3,
},
legendNeutral: {
flexDirection: "row",
alignItems: "center",
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: "#F3F4F6",
},
legendNeutralText: {
fontSize: 11,
color: "#6B7280",
marginLeft: 6,
flex: 1,
},
ctaBox: {
alignItems: "center",
padding: 20,
},
ctaText: {
fontSize: 13,
color: "#6B7280",
textAlign: "center",
marginTop: 10,
marginBottom: 14,
},
ctaBtn: {
backgroundColor: "#0E8A61",
paddingHorizontal: 18,
paddingVertical: 10,
borderRadius: 10,
},
ctaBtnText: {
color: "#FFFFFF",
fontWeight: "700",
fontSize: 13,
},
});

View File

@@ -0,0 +1,121 @@
/**
* NotificationToast.tsx
*
* Banner animado que cae desde arriba cuando llega una notificación
* nueva del backend. Se autodescarta a los 5 segundos.
*
* Limitación: solo se muestra cuando la app está en primer plano. Para
* notificaciones del SO con la app cerrada se necesita un Development
* Build + expo-notifications (Expo Go SDK 53+ no lo soporta).
*/
import { useEffect, useRef } from "react";
import {
Animated,
Pressable,
StyleSheet,
Text,
View,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useApp } from "../context/AppContext";
const AUTO_DISMISS_MS = 5000;
export default function NotificationToast() {
const { toast, dismissToast } = useApp();
const translateY = useRef(new Animated.Value(-180)).current;
useEffect(() => {
if (!toast) return;
// Slide in
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
damping: 18,
}).start();
// Auto dismiss después de N segundos
const timer = setTimeout(() => {
Animated.timing(translateY, {
toValue: -180,
duration: 250,
useNativeDriver: true,
}).start(() => dismissToast());
}, AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [toast, translateY, dismissToast]);
if (!toast) return null;
return (
<Animated.View
style={[
styles.container,
{ transform: [{ translateY }] },
]}
pointerEvents="box-none"
>
<Pressable style={styles.card} onPress={dismissToast}>
<View style={styles.iconWrap}>
<Ionicons name="notifications" size={22} color="#FFFFFF" />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.title} numberOfLines={1}>
{toast.title}
</Text>
<Text style={styles.body} numberOfLines={2}>
{toast.body}
</Text>
</View>
<Ionicons name="close" size={18} color="#FFFFFF" />
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: "absolute",
top: Platform.OS === "ios" ? 50 : 30,
left: 16,
right: 16,
zIndex: 9999,
},
card: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#0E8A61",
borderRadius: 14,
padding: 14,
shadowColor: "#000",
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.2,
shadowRadius: 10,
elevation: 10,
},
iconWrap: {
width: 38,
height: 38,
borderRadius: 19,
backgroundColor: "rgba(255,255,255,0.2)",
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
title: {
fontSize: 14,
fontWeight: "800",
color: "#FFFFFF",
marginBottom: 2,
},
body: {
fontSize: 12,
color: "rgba(255,255,255,0.95)",
lineHeight: 16,
},
});

View File

@@ -3,13 +3,15 @@ import {
Text, Text,
StyleSheet, StyleSheet,
View, View,
} from 'react-native'; } from "react-native";
import { Colors, Spacing } from '../constants/theme'; import { Ionicons } from "@expo/vector-icons";
import { Colors, Spacing } from "../constants/theme";
type QuickActionProps = { type QuickActionProps = {
title: string; title: string;
icon: string; icon: any;
onPress?: () => void; onPress?: () => void;
}; };
@@ -18,7 +20,6 @@ export default function QuickAction({
icon, icon,
onPress, onPress,
}: QuickActionProps) { }: QuickActionProps) {
const theme = Colors.light; const theme = Colors.light;
return ( return (
@@ -32,9 +33,11 @@ export default function QuickAction({
onPress={onPress} onPress={onPress}
> >
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
<Text style={styles.icon}> <Ionicons
{icon} name={icon}
</Text> size={30}
color="#0E8A61"
/>
</View> </View>
<Text <Text
@@ -53,24 +56,24 @@ export default function QuickAction({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
width: 100, width: 150,
padding: Spacing.three,
borderRadius: 16, paddingVertical: 20,
alignItems: 'center', paddingHorizontal: 12,
justifyContent: 'center',
borderRadius: 18,
alignItems: "center",
justifyContent: "center",
}, },
iconContainer: { iconContainer: {
marginBottom: Spacing.two, marginBottom: Spacing.two,
}, },
icon: {
fontSize: 28,
},
title: { title: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: "600",
textAlign: 'center', textAlign: "center",
}, },
}); });

View File

@@ -0,0 +1,3 @@
// android
// export const API_URL = "http://10.0.2.2:8080";
export const API_URL = "http://172.20.10.4:8080";

View File

@@ -0,0 +1,181 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { setAuthToken } from "../lib/api";
import {
login as loginService,
register as registerService,
type AuthUser,
} from "../services/auth.service";
import {
getMyStatus,
type EtaResult,
type InboxNotification,
type UserStatusResponse,
} from "../services/tracking.service";
// expo-notifications NO se importa: en Expo Go SDK 53+ se rompe al cargar
// porque intenta auto-registrar un push token. Para push real necesitamos
// un Development Build. Por ahora usamos un Toast in-app vía el callback
// onNewNotification.
const POLL_INTERVAL_MS = 30_000; // 30s, igual que el simulador del backend
interface AppContextValue {
user: AuthUser | null;
eta: EtaResult | null;
notifications: InboxNotification[];
route: UserStatusResponse["route"] | null;
loading: boolean;
toast: InboxNotification | null;
dismissToast: () => void;
login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<void>;
logout: () => void;
refreshStatus: () => Promise<void>;
}
const AppContext = createContext<AppContextValue | null>(null);
export const useApp = (): AppContextValue => {
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be used within AppProvider");
return ctx;
};
export const AppProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<AuthUser | null>(null);
const [eta, setEta] = useState<EtaResult | null>(null);
const [notifications, setNotifications] = useState<InboxNotification[]>([]);
const [route, setRoute] = useState<UserStatusResponse["route"] | null>(null);
const [loading, setLoading] = useState(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
// IDs de notificaciones que ya disparamos como push local — evita duplicados
const shownNotificationIds = useRef<Set<string>>(new Set());
// Bandera: en el primer refresh tras login no disparamos el historial
// entero como notificaciones; solo lo marcamos como "ya visto".
const initialRefreshDoneRef = useRef(false);
// Última notificación nueva — la consume el Toast in-app
const [toast, setToast] = useState<InboxNotification | null>(null);
const dismissToast = useCallback(() => setToast(null), []);
const refreshStatus = useCallback(async () => {
setLoading(true);
try {
const res = await getMyStatus();
setEta(res.eta);
setRoute(res.route);
setNotifications(res.notifications);
// Detecta notificaciones nuevas (no vistas antes) y muestra la más
// reciente como Toast in-app. En el primer refresh no toasteamos
// para no inundar al usuario con el historial.
const newOnes: InboxNotification[] = [];
for (const n of res.notifications) {
if (!shownNotificationIds.current.has(n.id)) {
shownNotificationIds.current.add(n.id);
newOnes.push(n);
}
}
if (initialRefreshDoneRef.current && newOnes.length > 0) {
setToast(newOnes[0]); // la primera del array es la más reciente
}
initialRefreshDoneRef.current = true;
} catch (err) {
console.error("Status refresh failed:", err);
} finally {
setLoading(false);
}
}, []);
const login = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
const res = await loginService({ email, password });
setUser(res.user);
} finally {
setLoading(false);
}
}, []);
const register = useCallback(
async (name: string, email: string, password: string) => {
setLoading(true);
try {
// 1) crear cuenta
await registerService({ name, email, password });
// 2) hacer login inmediato para guardar el token y el user
const res = await loginService({ email, password });
setUser(res.user);
} finally {
setLoading(false);
}
},
[],
);
const logout = useCallback(() => {
setAuthToken(null);
setUser(null);
setEta(null);
setRoute(null);
setNotifications([]);
setToast(null);
// Reset del tracking de notificaciones para que la próxima sesión
// arranque limpia (sin disparar el historial viejo).
shownNotificationIds.current.clear();
initialRefreshDoneRef.current = false;
}, []);
// Polling controlado: arranca al loguearse como user normal, se detiene
// al deslogearse o si el usuario es admin (los admins no usan /status).
useEffect(() => {
if (!user || user.role === "ADMIN") {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
return;
}
void refreshStatus();
pollingRef.current = setInterval(() => {
void refreshStatus();
}, POLL_INTERVAL_MS);
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [user, refreshStatus]);
return (
<AppContext.Provider
value={{
user,
eta,
notifications,
route,
loading,
toast,
dismissToast,
login,
register,
logout,
refreshStatus,
}}
>
{children}
</AppContext.Provider>
);
};

View File

@@ -0,0 +1,32 @@
/**
* routes.mock.ts (frontend)
* Lista de rutas para el calendario de recolección.
* No incluye coordenadas GPS — eso vive en el backend.
* Solo lo necesario para el selector y el calendario.
*/
export interface MockRoute {
routeId: string;
name: string;
}
export const routes: MockRoute[] = [
{ routeId: "RUTA-01", name: "Zona Centro - Las Arboledas" },
{ routeId: "RUTA-02", name: "Sector Norte - Av. Tecnológico" },
{ routeId: "RUTA-03", name: "Sector Poniente - San Juanico" },
{ routeId: "RUTA-04", name: "Oriente - Los Olivos" },
{ routeId: "RUTA-05", name: "Sector Sur - Rancho Seco" },
{ routeId: "RUTA-06", name: "Norte Extremo - Rumbos de Roque" },
{ routeId: "RUTA-07", name: "Nororiente - Ciudad Industrial" },
{ routeId: "RUTA-08", name: "Suroriente - Universidad Latina" },
{ routeId: "RUTA-09", name: "Poniente - Hospital General" },
{ routeId: "RUTA-10", name: "Eje Juan Pablo II" },
{ routeId: "RUTA-11", name: "Zona de Oro - Torres Landa" },
{ routeId: "RUTA-12", name: "Nororiente - Las Insurgentes" },
{ routeId: "RUTA-13", name: "Sector Norte - Trojes e Irrigación" },
{ routeId: "RUTA-14", name: "Sur Poniente - La Toscana" },
{ routeId: "RUTA-15", name: "Norponiente - San José de Celaya" },
{ routeId: "RUTA-40", name: "Norte Industrial" },
{ routeId: "RUTA-65", name: "Centro Comercial" },
{ routeId: "RUTA-80", name: "Sector Residencial Sur" },
];

30
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,30 @@
import { API_URL } from "../config/api";
let authToken: string | null = null;
export const setAuthToken = (token: string | null) => {
authToken = token;
};
export const apiFetch = async <T>(
path: string,
options: RequestInit = {},
): Promise<T> => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (authToken) {
headers.Authorization = `Bearer ${authToken}`;
}
const res = await fetch(`${API_URL}${path}`, { ...options, headers });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
return body as T;
};

View File

@@ -0,0 +1,14 @@
import type { NotificationType } from "../services/tracking.service";
export const notificationTypeToAlertType = (
type: NotificationType,
): "started" | "near" | "danger" | "completed" => {
switch (type) {
case "ROUTE_START": return "started";
case "TRUCK_PROXIMITY": return "near";
case "TRUCK_ARRIVED": return "near";
case "ROUTE_COMPLETED": return "completed";
case "DELAY": return "danger";
case "MECHANICAL_FAILURE": return "danger";
}
};

View File

@@ -0,0 +1,64 @@
/**
* notifications.ts
*
* Wrapper sobre expo-notifications. Maneja:
* - Configuración del canal Android
* - Solicitud de permisos
* - Disparo de notificaciones locales (aparecen en el centro de
* notificaciones del sistema incluso si la app está en segundo plano)
*
* NOTA: Esto usa notificaciones LOCALES, no push remoto. Funciona cuando
* la app está en foreground o background (no totalmente cerrada). Para
* notificaciones con la app completamente cerrada hace falta un Development
* Build + Expo Push Service, fuera del alcance del MVP.
*/
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
// Cómo se comportan las notificaciones cuando la app está en primer plano
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
/** Crea el canal de Android (requerido en Android 8+). Idempotente. */
export async function setupAndroidChannel(): Promise<void> {
if (Platform.OS !== "android") return;
await Notifications.setNotificationChannelAsync("ecoruta", {
name: "OptiRuta",
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#0E8A61",
sound: "default",
});
}
/** Solicita permiso al usuario (no-op si ya está concedido). */
export async function requestPushPermissions(): Promise<boolean> {
const settings = await Notifications.getPermissionsAsync();
if (settings.granted) return true;
const result = await Notifications.requestPermissionsAsync();
return result.granted;
}
/** Dispara una notificación local inmediata. */
export async function fireLocalNotification(
title: string,
body: string,
data: Record<string, unknown> = {},
): Promise<void> {
await Notifications.scheduleNotificationAsync({
content: {
title,
body,
data,
sound: "default",
},
trigger: null, // inmediata
});
}

View File

@@ -0,0 +1,25 @@
import { apiFetch } from "../lib/api";
export interface Colonia {
colonia: string;
routeId: string;
horarioEstimado: string;
}
export interface MyAddress {
colonia: string;
routeId: string;
horarioEstimado: string | null;
}
export const listColonias = () =>
apiFetch<Colonia[]>("/api/addresses/colonias");
export const getMyAddress = () =>
apiFetch<MyAddress>("/api/addresses/me");
export const setMyAddress = (colonia: string, street?: string) =>
apiFetch<MyAddress>("/api/addresses/me", {
method: "PUT",
body: JSON.stringify({ colonia, street }),
});

View File

@@ -0,0 +1,55 @@
import { apiFetch } from "../lib/api";
export interface AdminRouteItem {
routeId: string;
name: string;
truckId: number;
status: string;
currentPositionId: number;
arrivalResult: "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
cancelled: boolean;
cancelReason?: string;
updatedAt?: string;
}
export type FeedbackType =
| "TRUCK_DID_NOT_PASS"
| "RATING"
| "SUGGESTION"
| "OTHER";
export interface AdminFeedbackItem {
id: string;
userId: number;
routeId: string | null;
userName?: string;
colonia?: string;
type: FeedbackType;
message: string;
rating?: number;
createdAt: string;
}
export const listAllRoutes = () =>
apiFetch<AdminRouteItem[]>("/api/admin/routes");
export const listAllFeedback = () =>
apiFetch<AdminFeedbackItem[]>("/api/admin/feedback");
export const cancelRoute = (routeId: string, reason?: string) =>
apiFetch<{ message: string; routeId: string }>(
`/api/admin/routes/${routeId}/cancel`,
{
method: "POST",
body: JSON.stringify(reason ? { reason } : {}),
},
);
export const resumeRoute = (routeId: string) =>
apiFetch<{ message: string; routeId: string }>(
`/api/admin/routes/${routeId}/resume`,
{
method: "POST",
body: JSON.stringify({}),
},
);

View File

@@ -0,0 +1,26 @@
import { apiFetch, setAuthToken } from "../lib/api";
export interface AuthUser {
id: number;
email: string;
name: string;
role: string;
}
export const register = (data: { name: string; email: string; password: string }) =>
apiFetch<{ id: number; email: string; name: string }>("/api/auth/register", {
method: "POST",
body: JSON.stringify(data),
});
export const login = async (data: { email: string; password: string }) => {
const res = await apiFetch<{ user: AuthUser; token: string }>("/api/auth/login", {
method: "POST",
body: JSON.stringify(data),
});
setAuthToken(res.token);
return res;
};
export const getMe = () =>
apiFetch<AuthUser & { role: string }>("/api/auth/me");

View File

@@ -0,0 +1,31 @@
import { apiFetch } from "../lib/api";
export type FeedbackType =
| "TRUCK_DID_NOT_PASS"
| "RATING"
| "SUGGESTION"
| "OTHER";
export interface FeedbackPayload {
type: FeedbackType;
message: string;
rating?: number;
}
export interface FeedbackItem {
id: string;
userId: number;
type: FeedbackType;
message: string;
rating?: number;
createdAt: string;
}
export const submitFeedback = (payload: FeedbackPayload) =>
apiFetch<FeedbackItem>("/api/feedback", {
method: "POST",
body: JSON.stringify(payload),
});
export const listMyFeedback = () =>
apiFetch<FeedbackItem[]>("/api/feedback/me");

View File

@@ -0,0 +1,78 @@
import { apiFetch } from "../lib/api";
export type NotificationType =
| "ROUTE_START"
| "TRUCK_PROXIMITY"
| "TRUCK_ARRIVED"
| "ROUTE_COMPLETED"
| "DELAY"
| "MECHANICAL_FAILURE";
export interface GpsUpdatePayload {
truckId: string;
routeId: string;
lat: number;
lng: number;
speed: number;
status: "EN_RUTA" | "DETENIDO" | "FINALIZADO" | "FALLA";
positionId?: number;
timestamp?: string;
}
export interface EtaResult {
etaMinutes: number;
arrivalWindow: { from: string; to: string };
message: string;
}
export interface BackendNotification {
userId: number;
type: NotificationType;
title: string;
body: string;
}
export interface GpsUpdateResponse {
message: string;
truck: { truckId: number; routeId: string; status: string };
eta: EtaResult;
notifications: BackendNotification[];
}
export const sendGpsUpdate = (payload: GpsUpdatePayload) =>
apiFetch<GpsUpdateResponse>("/api/tracking/gps-update", {
method: "POST",
body: JSON.stringify(payload),
});
export interface InboxNotification {
id: string;
userId: number;
type: NotificationType;
title: string;
body: string;
createdAt: string;
}
export type ArrivalResult = "PENDING" | "ARRIVED" | "FAILED" | "CANCELLED";
export interface UserStatusResponse {
user: { id: number; name: string; colonia: string };
route: {
routeId: string;
currentPositionId: number;
status: string;
updatedAt: string;
horarioEstimado: string | null;
arrivalResult: ArrivalResult;
cancelled: boolean;
};
eta: EtaResult | null;
notifications: InboxNotification[];
}
export const getMyStatus = () =>
apiFetch<UserStatusResponse>("/api/tracking/status");
export const resetDemo = () =>
apiFetch<{ message: string }>("/api/tracking/reset-demo", { method: "POST" });