From 131eeacbd2bf43236f2350ec1874599cc73f1007 Mon Sep 17 00:00:00 2001 From: Diego Mireles Date: Sat, 23 May 2026 02:34:13 -0600 Subject: [PATCH] feat(frontend): register, guide, feedback, addresses & status polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/app/_layout.tsx | 15 ++ frontend/src/app/addresses.tsx | 196 ++++++++++++++++++ frontend/src/app/alerts.tsx | 9 +- frontend/src/app/feedback.tsx | 221 +++++++++++++++++++++ frontend/src/app/guide.tsx | 186 ++++++++++++++++- frontend/src/app/index.tsx | 66 ++++-- frontend/src/app/login.tsx | 67 ++++++- frontend/src/app/profile.tsx | 41 +++- frontend/src/app/register.tsx | 103 ++++++++++ frontend/src/config/api.ts | 4 +- frontend/src/context/AppContext.tsx | 121 +++++++---- frontend/src/services/addresses.service.ts | 25 +++ frontend/src/services/feedback.service.ts | 31 +++ frontend/src/services/tracking.service.ts | 30 ++- 14 files changed, 1034 insertions(+), 81 deletions(-) create mode 100644 frontend/src/app/addresses.tsx create mode 100644 frontend/src/app/feedback.tsx create mode 100644 frontend/src/app/register.tsx create mode 100644 frontend/src/services/addresses.service.ts create mode 100644 frontend/src/services/feedback.service.ts 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