diff --git a/frontend/src/app/_layout.tsx b/frontend/src/app/_layout.tsx
index 85558d1..4920f2c 100644
--- a/frontend/src/app/_layout.tsx
+++ b/frontend/src/app/_layout.tsx
@@ -115,6 +115,21 @@ export default function Layout() {
name="login"
options={{ href: null }}
/>
+
+
+
+
+
+
);
diff --git a/frontend/src/app/addresses.tsx b/frontend/src/app/addresses.tsx
new file mode 100644
index 0000000..24239a1
--- /dev/null
+++ b/frontend/src/app/addresses.tsx
@@ -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([]);
+ const [myAddress, setMy] = useState(null);
+ const [selected, setSelected] = useState(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 ;
+
+ 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 (
+
+
+
+
+
+ Selecciona la colonia donde quieres recibir avisos de recolección.
+ Solo verás la ruta que cubre tu zona.
+
+
+ {myAddress && (
+
+ Domicilio actual
+ {myAddress.colonia}
+
+ Ruta {myAddress.routeId}
+ {myAddress.horarioEstimado
+ ? ` • ${myAddress.horarioEstimado}`
+ : ""}
+
+
+ )}
+
+ Colonias disponibles
+
+ {loading ? (
+
+ ) : (
+ colonias.map((c) => {
+ const active = selected === c.colonia;
+ return (
+ setSelected(c.colonia)}
+ style={[
+ styles.optionRow,
+ active && {
+ borderColor: "#0E8A61",
+ borderWidth: 2,
+ },
+ ]}
+ >
+
+
+ {c.colonia}
+
+ Ruta {c.routeId} • {c.horarioEstimado}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+ );
+}
+
+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 },
+});
diff --git a/frontend/src/app/alerts.tsx b/frontend/src/app/alerts.tsx
index d5262a6..5228c8f 100644
--- a/frontend/src/app/alerts.tsx
+++ b/frontend/src/app/alerts.tsx
@@ -46,12 +46,15 @@ export default function AlertsScreen() {
: "Aún no hay alertas. Vuelve al inicio y desliza para actualizar."}
- {notifications.map((n, i) => (
+ {notifications.map((n) => (
))}
diff --git a/frontend/src/app/feedback.tsx b/frontend/src/app/feedback.tsx
new file mode 100644
index 0000000..d0299e6
--- /dev/null
+++ b/frontend/src/app/feedback.tsx
@@ -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(
+ "TRUCK_DID_NOT_PASS",
+ );
+ const [message, setMessage] = useState("");
+ const [rating, setRating] = useState(undefined);
+ const [submitting, setSubmitting] = useState(false);
+
+ if (!user) return ;
+
+ 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 (
+
+
+
+
+
+ Reporta incidencias o califica el servicio. Tu mensaje llega
+ directamente al equipo operativo.
+
+
+ Tipo de mensaje
+
+ {OPTIONS.map((opt) => {
+ const active = selectedType === opt.type;
+ return (
+ setSelectedType(opt.type)}
+ style={[
+ styles.optionCard,
+ active && { borderColor: opt.color, borderWidth: 2 },
+ ]}
+ >
+
+ {opt.label}
+
+ );
+ })}
+
+
+ {selectedType === "RATING" && (
+ <>
+ Calificación
+
+ {[1, 2, 3, 4, 5].map((n) => (
+ setRating(n)}>
+
+
+ ))}
+
+ >
+ )}
+
+ Mensaje
+
+
+
+
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/frontend/src/app/guide.tsx b/frontend/src/app/guide.tsx
index 3561942..265fc2b 100644
--- a/frontend/src/app/guide.tsx
+++ b/frontend/src/app/guide.tsx
@@ -1,15 +1,179 @@
-import { View, Text } from "react-native";
+import { ScrollView, View, Text, StyleSheet } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { Ionicons } from "@expo/vector-icons";
+
+import { COLORS } from "../constants/colors";
+import SectionTitle from "../components/SectionTitle";
+
+type Category = {
+ key: string;
+ title: string;
+ color: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ examples: string[];
+ tip: string;
+};
+
+const CATEGORIES: Category[] = [
+ {
+ key: "organico",
+ title: "Orgánicos",
+ color: "#22C55E",
+ icon: "leaf",
+ 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",
+ icon: "refresh-circle",
+ 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: "#9CA3AF",
+ icon: "medkit",
+ 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",
+ icon: "warning",
+ 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() {
- return (
-
+
+
+
+
+ Separar correctamente reduce contaminación y ayuda a la ruta de
+ recolección. Funciona sin conexión.
+
+
+ {CATEGORIES.map((cat) => (
+
+
- Guía
+
+
+
+
+ {cat.title}
+
+ {cat.examples.map((ex, i) => (
+
+ • {ex}
+
+ ))}
+
+
+ 💡 {cat.tip}
+
+
+
+
+ ))}
+
+
+ Recuerda
+
+ • Saca tu basura sólo dentro del horario de recolección.{"\n"}
+ • No persigas al camión, es peligroso.{"\n"}
+ • Si tu camión no pasó, usa el buzón de retroalimentación.
+
- );
-}
\ No newline at end of file
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ card: {
+ flexDirection: "row",
+ backgroundColor: "#FFFFFF",
+ borderRadius: 18,
+ padding: 16,
+ 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: 14,
+ },
+ cardContent: { flex: 1 },
+ cardTitle: { fontSize: 17, fontWeight: "800", marginBottom: 6 },
+ example: { fontSize: 13, color: "#4B5563", marginBottom: 3 },
+ tipBox: {
+ marginTop: 10,
+ padding: 10,
+ borderRadius: 10,
+ },
+ tipText: { fontSize: 12, fontWeight: "600" },
+ preventiveBox: {
+ marginTop: 8,
+ padding: 16,
+ backgroundColor: "#FEF3C7",
+ borderRadius: 14,
+ },
+ preventiveTitle: {
+ fontSize: 14,
+ fontWeight: "800",
+ color: "#92400E",
+ marginBottom: 6,
+ },
+ preventiveBody: { fontSize: 13, color: "#92400E", lineHeight: 20 },
+});
diff --git a/frontend/src/app/index.tsx b/frontend/src/app/index.tsx
index c7469e5..0bc880f 100644
--- a/frontend/src/app/index.tsx
+++ b/frontend/src/app/index.tsx
@@ -1,4 +1,3 @@
-import { useEffect } from "react";
import { ScrollView, View, Text, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Redirect, useRouter } from "expo-router";
@@ -12,15 +11,9 @@ import QuickAction from "../components/QuickAction";
import { useApp } from "../context/AppContext";
export default function HomeScreen() {
- const { user, eta, loading, advanceTruck } = useApp();
+ const { user, eta, route, loading, refreshStatus } = useApp();
const router = useRouter();
- useEffect(() => {
- if (user && !eta) {
- advanceTruck();
- }
- }, [user]);
-
if (!user) {
return ;
}
@@ -28,7 +21,10 @@ export default function HomeScreen() {
const minutes = eta ? Math.max(0, eta.etaMinutes) : 0;
const windowText = eta?.arrivalWindow
? `Ventana: ${eta.arrivalWindow.from} - ${eta.arrivalWindow.to}`
- : "Tira para actualizar";
+ : "Esperando datos del camión...";
+
+ const subtitle =
+ eta?.message ?? `Monitoreando ruta ${route?.routeId ?? "..."}`;
return (
+
}
>
@@ -57,11 +53,53 @@ export default function HomeScreen() {
marginLeft: 12,
}}
>
- {eta?.message ?? "Monitoreo inteligente de recolección"}
+ {subtitle}
+ {route?.horarioEstimado && (
+
+
+ PRÓXIMA RECOLECCIÓN
+
+
+ {route.horarioEstimado}
+
+
+ )}
+
+
+
+ ⚠️ No saques tus residuos fuera del horario y no persigas al camión.
+
+
+
router.push("/feedback")}
/>
diff --git a/frontend/src/app/login.tsx b/frontend/src/app/login.tsx
index bfd7b93..796fb74 100644
--- a/frontend/src/app/login.tsx
+++ b/frontend/src/app/login.tsx
@@ -1,8 +1,12 @@
import { useState } from "react";
-import { View, Text, StyleSheet, Alert } from "react-native";
+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() {
@@ -25,16 +29,61 @@ export default function LoginScreen() {
};
return (
-
- Iniciar sesión
-
-
-
-
+
+
+ Iniciar sesión
+
+ Accede para ver el estado del camión en tu zona.
+
+
+
+
+
+
+
+
+ router.replace("/register")}
+ style={styles.linkWrap}
+ >
+
+ ¿No tienes cuenta?{" "}
+ Regístrate
+
+
+
+
);
}
const styles = StyleSheet.create({
- container: { flex: 1, padding: 24, justifyContent: "center" },
- title: { fontSize: 24, fontWeight: "bold", marginBottom: 20, textAlign: "center" },
+ 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" },
});
diff --git a/frontend/src/app/profile.tsx b/frontend/src/app/profile.tsx
index 2c1940f..0d9191f 100644
--- a/frontend/src/app/profile.tsx
+++ b/frontend/src/app/profile.tsx
@@ -1,4 +1,4 @@
-import { View, Text, StyleSheet } from "react-native";
+import { View, Text, StyleSheet, Alert } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
@@ -6,11 +6,25 @@ import { COLORS } from "../constants/colors";
import PrimaryButton from "../components/PrimaryButton";
import { useApp } from "../context/AppContext";
+import { resetDemo } from "../services/tracking.service";
export default function ProfileScreen() {
- const { user, logout } = useApp();
+ const { user, logout, refreshStatus } = useApp();
const router = useRouter();
+ const handleResetDemo = async () => {
+ try {
+ await resetDemo();
+ await refreshStatus();
+ Alert.alert("Demo reiniciado", "Estado y notificaciones borradas.");
+ } catch (err) {
+ Alert.alert(
+ "Error",
+ err instanceof Error ? err.message : "No se pudo reiniciar",
+ );
+ }
+ };
+
if (!user) {
return (
@@ -35,7 +49,30 @@ export default function ProfileScreen() {
Hola, {user.name}
{user.email}
+
+
+ router.push("/addresses")}
+ />
+
+
+
+ router.push("/feedback")}
+ />
+
+
+
+
+
+
+
diff --git a/frontend/src/app/register.tsx b/frontend/src/app/register.tsx
new file mode 100644
index 0000000..b9d1bfd
--- /dev/null
+++ b/frontend/src/app/register.tsx
@@ -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 (
+
+
+ Crear cuenta
+
+ Registra tu correo para recibir alertas de recolección.
+
+
+
+
+
+
+
+
+
+ router.replace("/login")}
+ style={styles.linkWrap}
+ >
+
+ ¿Ya tienes cuenta? Inicia sesión
+
+
+
+
+ );
+}
+
+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" },
+});
diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts
index f334310..567bd76 100644
--- a/frontend/src/config/api.ts
+++ b/frontend/src/config/api.ts
@@ -1 +1,3 @@
-export const API_URL = "http://10.0.2.2:8080";
\ No newline at end of file
+// android
+// export const API_URL = "http://10.0.2.2:8080";
+export const API_URL = "http://192.168.93.148:8080";
\ No newline at end of file
diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx
index aa8bcac..ed9a552 100644
--- a/frontend/src/context/AppContext.tsx
+++ b/frontend/src/context/AppContext.tsx
@@ -2,35 +2,37 @@ import React, {
createContext,
useCallback,
useContext,
+ useEffect,
+ useRef,
useState,
type ReactNode,
} from "react";
import { setAuthToken } from "../lib/api";
-import { login as loginService, type AuthUser } from "../services/auth.service";
import {
- sendGpsUpdate,
- type BackendNotification,
+ login as loginService,
+ register as registerService,
+ type AuthUser,
+} from "../services/auth.service";
+import {
+ getMyStatus,
type EtaResult,
+ type InboxNotification,
+ type UserStatusResponse,
} from "../services/tracking.service";
-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: 5, lat: 20.5210, lng: -100.8210, speed: 0, timestamp: "2026-05-22T06:50:00Z" },
- { positionId: 8, lat: 20.5111, lng: -100.9037, speed: 40, timestamp: "2026-05-22T07:40:00Z" },
-];
+const POLL_INTERVAL_MS = 30_000; // 30s, igual que el simulador del backend
interface AppContextValue {
user: AuthUser | null;
eta: EtaResult | null;
- notifications: BackendNotification[];
+ notifications: InboxNotification[];
+ route: UserStatusResponse["route"] | null;
loading: boolean;
login: (email: string, password: string) => Promise;
+ register: (name: string, email: string, password: string) => Promise;
logout: () => void;
- advanceTruck: () => Promise;
+ refreshStatus: () => Promise;
}
const AppContext = createContext(null);
@@ -44,10 +46,26 @@ export const useApp = (): AppContextValue => {
export const AppProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState(null);
const [eta, setEta] = useState(null);
- const [notifications, setNotifications] = useState([]);
- const [stepIndex, setStepIndex] = useState(0);
+ const [notifications, setNotifications] = useState([]);
+ const [route, setRoute] = useState(null);
const [loading, setLoading] = useState(false);
+ const pollingRef = useRef | null>(null);
+
+ const refreshStatus = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await getMyStatus();
+ setEta(res.eta);
+ setRoute(res.route);
+ setNotifications(res.notifications);
+ } catch (err) {
+ console.error("Status refresh failed:", err);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
const login = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
@@ -58,43 +76,66 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
}
}, []);
+ 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([]);
- setStepIndex(0);
}, []);
- const advanceTruck = useCallback(async () => {
- const step = SIMULATION_STEPS[stepIndex % SIMULATION_STEPS.length];
- setLoading(true);
- try {
- const res = await sendGpsUpdate({
- truckId: "101",
- routeId: "RUTA-01",
- status: "EN_RUTA",
- ...step,
- });
- setEta(res.eta);
-
- // Sólo mostramos las notificaciones que pertenecen al usuario logueado.
- const myNotifications = user
- ? res.notifications.filter((n) => n.userId === user.id)
- : [];
-
- if (myNotifications.length > 0) {
- setNotifications((prev) => [...myNotifications, ...prev]);
+ // Polling controlado: arranca al loguearse, se detiene al deslogearse.
+ useEffect(() => {
+ if (!user) {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
}
- setStepIndex((i) => i + 1);
- } finally {
- setLoading(false);
+ return;
}
- }, [stepIndex, user]);
+
+ void refreshStatus();
+ pollingRef.current = setInterval(() => {
+ void refreshStatus();
+ }, POLL_INTERVAL_MS);
+
+ return () => {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ };
+ }, [user, refreshStatus]);
return (
{children}
diff --git a/frontend/src/services/addresses.service.ts b/frontend/src/services/addresses.service.ts
new file mode 100644
index 0000000..8d27014
--- /dev/null
+++ b/frontend/src/services/addresses.service.ts
@@ -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("/api/addresses/colonias");
+
+export const getMyAddress = () =>
+ apiFetch("/api/addresses/me");
+
+export const setMyAddress = (colonia: string, street?: string) =>
+ apiFetch("/api/addresses/me", {
+ method: "PUT",
+ body: JSON.stringify({ colonia, street }),
+ });
diff --git a/frontend/src/services/feedback.service.ts b/frontend/src/services/feedback.service.ts
new file mode 100644
index 0000000..db13c66
--- /dev/null
+++ b/frontend/src/services/feedback.service.ts
@@ -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("/api/feedback", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+
+export const listMyFeedback = () =>
+ apiFetch("/api/feedback/me");
diff --git a/frontend/src/services/tracking.service.ts b/frontend/src/services/tracking.service.ts
index b312f11..3fee547 100644
--- a/frontend/src/services/tracking.service.ts
+++ b/frontend/src/services/tracking.service.ts
@@ -43,4 +43,32 @@ export const sendGpsUpdate = (payload: GpsUpdatePayload) =>
apiFetch("/api/tracking/gps-update", {
method: "POST",
body: JSON.stringify(payload),
- });
\ No newline at end of file
+ });
+
+export interface InboxNotification {
+ id: string;
+ userId: number;
+ type: NotificationType;
+ title: string;
+ body: string;
+ createdAt: string;
+}
+
+export interface UserStatusResponse {
+ user: { id: number; name: string; colonia: string };
+ route: {
+ routeId: string;
+ currentPositionId: number;
+ status: string;
+ updatedAt: string;
+ horarioEstimado: string | null;
+ };
+ eta: EtaResult | null;
+ notifications: InboxNotification[];
+}
+
+export const getMyStatus = () =>
+ apiFetch("/api/tracking/status");
+
+export const resetDemo = () =>
+ apiFetch<{ message: string }>("/api/tracking/reset-demo", { method: "POST" });
\ No newline at end of file