Files
2026-05-23 00:07:00 -06:00

12 KiB

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:

// 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:

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

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

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:

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)

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)

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)

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:

    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