diff --git a/frontend/README.md b/frontend/README.md index 4d67aec..eda1de7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 - npm install - ``` +## Tabla de contenidos -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/) -- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) -- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) -- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo +- **Expo SDK 56** (Sept 2025) +- **React Native 0.85** + **React 19** +- **TypeScript** estricto +- **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: - -```bash -npm run reset-project +``` +frontend/ +├── app.json ← config de Expo (name="OptiRuta", slug, etc.) +├── 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/) -- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/) -- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/) +```powershell +cd frontend +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). -- [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. +## Configurar la URL del backend -## 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. -- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. +// Para celular físico (mismo Wi-Fi o hotspot): +// 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://:8080` | +| Celular físico (hotspot) | `http://: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; + register: (name, email, password) => Promise; + logout: () => void; + refreshStatus: () => Promise; +} +``` + +### 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 (path: string, options: RequestInit = {}): Promise => { + 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." diff --git a/frontend/src/app/admin.tsx b/frontend/src/app/admin.tsx index 95c636e..d91068b 100644 --- a/frontend/src/app/admin.tsx +++ b/frontend/src/app/admin.tsx @@ -24,9 +24,12 @@ 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 = { @@ -39,28 +42,36 @@ const arrivalLabel: Record = { export default function AdminScreen() { const { user, logout } = useApp(); const [routes, setRoutes] = useState([]); + const [feedback, setFeedback] = useState([]); const [loading, setLoading] = useState(true); - const fetchRoutes = useCallback(async () => { + const fetchAll = useCallback(async () => { try { - const data = await listAllRoutes(); - setRoutes(data); + 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 las rutas", + 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 fetchRoutes(); - const interval = setInterval(() => void fetchRoutes(), 15_000); + void fetchAll(); + const interval = setInterval(() => void fetchAll(), 15_000); return () => clearInterval(interval); - }, [user, fetchRoutes]); + }, [user, fetchAll]); if (!user) return ; if (user.role !== "ADMIN") return ; @@ -259,12 +270,127 @@ export default function AdminScreen() { ); })} + + {/* === Reportes de ciudadanos === */} + + REPORTES DE CIUDADANOS ({feedback.length}) + + + {feedback.length === 0 ? ( + + + + Aún no hay reportes ciudadanos. Aparecerán aquí cuando los + usuarios usen el buzón. + + + ) : ( + 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 ( + + + + + + + {meta.label} + + {f.userName ?? `Usuario #${f.userId}`} + {f.colonia ? ` · ${f.colonia}` : ""} · {when} + + + {f.routeId ? ( + + + {f.routeId} + + ) : ( + + + Sin ruta + + + )} + + + {f.rating ? ( + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + ) : null} + + {f.message} + + ); + }) + )} )} ); } +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", @@ -372,4 +498,75 @@ const styles = StyleSheet.create({ 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, + }, }); diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts index f9ea032..a1b7021 100644 --- a/frontend/src/services/admin.service.ts +++ b/frontend/src/services/admin.service.ts @@ -12,9 +12,30 @@ export interface AdminRouteItem { 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("/api/admin/routes"); +export const listAllFeedback = () => + apiFetch("/api/admin/feedback"); + export const cancelRoute = (routeId: string, reason?: string) => apiFetch<{ message: string; routeId: string }>( `/api/admin/routes/${routeId}/cancel`,