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
This commit is contained in:
@@ -115,6 +115,21 @@ export default function Layout() {
|
||||
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>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
196
frontend/src/app/addresses.tsx
Normal file
196
frontend/src/app/addresses.tsx
Normal 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 },
|
||||
});
|
||||
@@ -46,12 +46,15 @@ export default function AlertsScreen() {
|
||||
: "Aún no hay alertas. Vuelve al inicio y desliza para actualizar."}
|
||||
</Text>
|
||||
|
||||
{notifications.map((n, i) => (
|
||||
{notifications.map((n) => (
|
||||
<AlertItem
|
||||
key={`${n.userId}-${n.type}-${i}`}
|
||||
key={n.id}
|
||||
title={n.title}
|
||||
description={n.body}
|
||||
time="ahora"
|
||||
time={new Date(n.createdAt).toLocaleTimeString("es-MX", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
type={notificationTypeToAlertType(n.type)}
|
||||
/>
|
||||
))}
|
||||
|
||||
221
frontend/src/app/feedback.tsx
Normal file
221
frontend/src/app/feedback.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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 (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
|
||||
>
|
||||
<SectionTitle title="Guía de separación" />
|
||||
|
||||
<Text
|
||||
style={{
|
||||
color: "#6B7280",
|
||||
fontSize: 14,
|
||||
marginBottom: 20,
|
||||
marginLeft: 12,
|
||||
}}
|
||||
>
|
||||
Separar correctamente reduce contaminación y ayuda a la ruta de
|
||||
recolección. Funciona sin conexión.
|
||||
</Text>
|
||||
|
||||
{CATEGORIES.map((cat) => (
|
||||
<View key={cat.key} style={styles.card}>
|
||||
<View
|
||||
style={[
|
||||
styles.iconWrap,
|
||||
{ backgroundColor: `${cat.color}20` },
|
||||
]}
|
||||
>
|
||||
<Text>Guía</Text>
|
||||
<Ionicons name={cat.icon} size={28} color={cat.color} />
|
||||
</View>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={[styles.cardTitle, { color: cat.color }]}>
|
||||
{cat.title}
|
||||
</Text>
|
||||
{cat.examples.map((ex, i) => (
|
||||
<Text key={i} style={styles.example}>
|
||||
• {ex}
|
||||
</Text>
|
||||
))}
|
||||
<View
|
||||
style={[
|
||||
styles.tipBox,
|
||||
{ backgroundColor: `${cat.color}15` },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tipText, { color: cat.color }]}>
|
||||
💡 {cat.tip}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.preventiveBox}>
|
||||
<Text style={styles.preventiveTitle}>Recuerda</Text>
|
||||
<Text style={styles.preventiveBody}>
|
||||
• 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.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -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 <Redirect href="/login" />;
|
||||
}
|
||||
@@ -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 (
|
||||
<SafeAreaView
|
||||
@@ -44,7 +40,7 @@ export default function HomeScreen() {
|
||||
paddingBottom: 120,
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={advanceTruck} />
|
||||
<RefreshControl refreshing={loading} onRefresh={refreshStatus} />
|
||||
}
|
||||
>
|
||||
<SectionTitle title="EcoRuta" />
|
||||
@@ -57,11 +53,53 @@ export default function HomeScreen() {
|
||||
marginLeft: 12,
|
||||
}}
|
||||
>
|
||||
{eta?.message ?? "Monitoreo inteligente de recolección"}
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
<EtaCard minutes={minutes} status={windowText} />
|
||||
|
||||
{route?.horarioEstimado && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#ECFDF5",
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
marginHorizontal: 15,
|
||||
marginTop: 4,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: "#0E8A61",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#065F46", fontSize: 11, fontWeight: "700" }}>
|
||||
PRÓXIMA RECOLECCIÓN
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
color: "#065F46",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{route.horarioEstimado}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#FEF3C7",
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
marginHorizontal: 15,
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#92400E", fontSize: 12, fontWeight: "600" }}>
|
||||
⚠️ No saques tus residuos fuera del horario y no persigas al camión.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SectionTitle title="Acciones rápidas" />
|
||||
|
||||
<View
|
||||
@@ -78,9 +116,9 @@ export default function HomeScreen() {
|
||||
/>
|
||||
|
||||
<QuickAction
|
||||
title="Actualizar"
|
||||
icon="refresh-outline"
|
||||
onPress={advanceTruck}
|
||||
title="Reportar"
|
||||
icon="chatbubble-outline"
|
||||
onPress={() => router.push("/feedback")}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -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 (
|
||||
<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={submitting ? "Cargando..." : "Entrar"} onPress={handleLogin} />
|
||||
</View>
|
||||
<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, 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" },
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<SafeAreaView style={styles.container}>
|
||||
@@ -35,7 +49,30 @@ export default function ProfileScreen() {
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Hola, {user.name}</Text>
|
||||
<Text style={styles.email}>{user.email}</Text>
|
||||
|
||||
<View style={{ height: 24 }} />
|
||||
|
||||
<PrimaryButton
|
||||
title="Mi domicilio"
|
||||
onPress={() => router.push("/addresses")}
|
||||
/>
|
||||
|
||||
<View style={{ height: 12 }} />
|
||||
|
||||
<PrimaryButton
|
||||
title="Buzón de retroalimentación"
|
||||
onPress={() => router.push("/feedback")}
|
||||
/>
|
||||
|
||||
<View style={{ height: 12 }} />
|
||||
|
||||
<PrimaryButton
|
||||
title="Reiniciar demo"
|
||||
onPress={handleResetDemo}
|
||||
/>
|
||||
|
||||
<View style={{ height: 12 }} />
|
||||
|
||||
<PrimaryButton title="Cerrar sesión" onPress={handleLogout} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
103
frontend/src/app/register.tsx
Normal file
103
frontend/src/app/register.tsx
Normal 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" },
|
||||
});
|
||||
@@ -1 +1,3 @@
|
||||
export const API_URL = "http://10.0.2.2:8080";
|
||||
// android
|
||||
// export const API_URL = "http://10.0.2.2:8080";
|
||||
export const API_URL = "http://192.168.93.148:8080";
|
||||
@@ -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<void>;
|
||||
register: (name: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
advanceTruck: () => Promise<void>;
|
||||
refreshStatus: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextValue | null>(null);
|
||||
@@ -44,10 +46,26 @@ export const useApp = (): AppContextValue => {
|
||||
export const AppProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [eta, setEta] = useState<EtaResult | null>(null);
|
||||
const [notifications, setNotifications] = useState<BackendNotification[]>([]);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
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);
|
||||
|
||||
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 (
|
||||
<AppContext.Provider
|
||||
value={{ user, eta, notifications, loading, login, logout, advanceTruck }}
|
||||
value={{
|
||||
user,
|
||||
eta,
|
||||
notifications,
|
||||
route,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshStatus,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
|
||||
25
frontend/src/services/addresses.service.ts
Normal file
25
frontend/src/services/addresses.service.ts
Normal 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 }),
|
||||
});
|
||||
31
frontend/src/services/feedback.service.ts
Normal file
31
frontend/src/services/feedback.service.ts
Normal 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");
|
||||
@@ -43,4 +43,32 @@ 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 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<UserStatusResponse>("/api/tracking/status");
|
||||
|
||||
export const resetDemo = () =>
|
||||
apiFetch<{ message: string }>("/api/tracking/reset-demo", { method: "POST" });
|
||||
Reference in New Issue
Block a user