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"
|
name="login"
|
||||||
options={{ href: null }}
|
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>
|
</Tabs>
|
||||||
</AppProvider>
|
</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."}
|
: "Aún no hay alertas. Vuelve al inicio y desliza para actualizar."}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{notifications.map((n, i) => (
|
{notifications.map((n) => (
|
||||||
<AlertItem
|
<AlertItem
|
||||||
key={`${n.userId}-${n.type}-${i}`}
|
key={n.id}
|
||||||
title={n.title}
|
title={n.title}
|
||||||
description={n.body}
|
description={n.body}
|
||||||
time="ahora"
|
time={new Date(n.createdAt).toLocaleTimeString("es-MX", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
type={notificationTypeToAlertType(n.type)}
|
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() {
|
export default function GuideScreen() {
|
||||||
return (
|
return (
|
||||||
<View
|
<SafeAreaView style={{ flex: 1, backgroundColor: COLORS.background }}>
|
||||||
style={{
|
<ScrollView
|
||||||
flex: 1,
|
showsVerticalScrollIndicator={false}
|
||||||
justifyContent: "center",
|
contentContainerStyle={{ padding: 20, paddingBottom: 120 }}
|
||||||
alignItems: "center",
|
>
|
||||||
}}
|
<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>
|
</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 { ScrollView, View, Text, RefreshControl } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { Redirect, useRouter } from "expo-router";
|
import { Redirect, useRouter } from "expo-router";
|
||||||
@@ -12,15 +11,9 @@ import QuickAction from "../components/QuickAction";
|
|||||||
import { useApp } from "../context/AppContext";
|
import { useApp } from "../context/AppContext";
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { user, eta, loading, advanceTruck } = useApp();
|
const { user, eta, route, loading, refreshStatus } = useApp();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user && !eta) {
|
|
||||||
advanceTruck();
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Redirect href="/login" />;
|
return <Redirect href="/login" />;
|
||||||
}
|
}
|
||||||
@@ -28,7 +21,10 @@ export default function HomeScreen() {
|
|||||||
const minutes = eta ? Math.max(0, eta.etaMinutes) : 0;
|
const minutes = eta ? Math.max(0, eta.etaMinutes) : 0;
|
||||||
const windowText = eta?.arrivalWindow
|
const windowText = eta?.arrivalWindow
|
||||||
? `Ventana: ${eta.arrivalWindow.from} - ${eta.arrivalWindow.to}`
|
? `Ventana: ${eta.arrivalWindow.from} - ${eta.arrivalWindow.to}`
|
||||||
: "Tira para actualizar";
|
: "Esperando datos del camión...";
|
||||||
|
|
||||||
|
const subtitle =
|
||||||
|
eta?.message ?? `Monitoreando ruta ${route?.routeId ?? "..."}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
@@ -44,7 +40,7 @@ export default function HomeScreen() {
|
|||||||
paddingBottom: 120,
|
paddingBottom: 120,
|
||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl refreshing={loading} onRefresh={advanceTruck} />
|
<RefreshControl refreshing={loading} onRefresh={refreshStatus} />
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SectionTitle title="EcoRuta" />
|
<SectionTitle title="EcoRuta" />
|
||||||
@@ -57,11 +53,53 @@ export default function HomeScreen() {
|
|||||||
marginLeft: 12,
|
marginLeft: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{eta?.message ?? "Monitoreo inteligente de recolección"}
|
{subtitle}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<EtaCard minutes={minutes} status={windowText} />
|
<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" />
|
<SectionTitle title="Acciones rápidas" />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
@@ -78,9 +116,9 @@ export default function HomeScreen() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<QuickAction
|
<QuickAction
|
||||||
title="Actualizar"
|
title="Reportar"
|
||||||
icon="refresh-outline"
|
icon="chatbubble-outline"
|
||||||
onPress={advanceTruck}
|
onPress={() => router.push("/feedback")}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useState } from "react";
|
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 { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
import { COLORS } from "../constants/colors";
|
||||||
import InputField from "../components/InputField";
|
import InputField from "../components/InputField";
|
||||||
import PrimaryButton from "../components/PrimaryButton";
|
import PrimaryButton from "../components/PrimaryButton";
|
||||||
|
|
||||||
import { useApp } from "../context/AppContext";
|
import { useApp } from "../context/AppContext";
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
@@ -25,16 +29,61 @@ export default function LoginScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<Text style={styles.title}>Iniciar sesión</Text>
|
<View style={styles.content}>
|
||||||
<InputField placeholder="Email" value={email} onChangeText={setEmail} />
|
<Text style={styles.title}>Iniciar sesión</Text>
|
||||||
<InputField placeholder="Password" value={password} onChangeText={setPassword} secureTextEntry />
|
<Text style={styles.subtitle}>
|
||||||
<PrimaryButton title={submitting ? "Cargando..." : "Entrar"} onPress={handleLogin} />
|
Accede para ver el estado del camión en tu zona.
|
||||||
</View>
|
</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({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, padding: 24, justifyContent: "center" },
|
container: { flex: 1, backgroundColor: COLORS.background },
|
||||||
title: { fontSize: 24, fontWeight: "bold", marginBottom: 20, textAlign: "center" },
|
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 { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
@@ -6,11 +6,25 @@ import { COLORS } from "../constants/colors";
|
|||||||
import PrimaryButton from "../components/PrimaryButton";
|
import PrimaryButton from "../components/PrimaryButton";
|
||||||
|
|
||||||
import { useApp } from "../context/AppContext";
|
import { useApp } from "../context/AppContext";
|
||||||
|
import { resetDemo } from "../services/tracking.service";
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const { user, logout } = useApp();
|
const { user, logout, refreshStatus } = useApp();
|
||||||
const router = useRouter();
|
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) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
@@ -35,7 +49,30 @@ export default function ProfileScreen() {
|
|||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<Text style={styles.title}>Hola, {user.name}</Text>
|
<Text style={styles.title}>Hola, {user.name}</Text>
|
||||||
<Text style={styles.email}>{user.email}</Text>
|
<Text style={styles.email}>{user.email}</Text>
|
||||||
|
|
||||||
<View style={{ height: 24 }} />
|
<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} />
|
<PrimaryButton title="Cerrar sesión" onPress={handleLogout} />
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</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,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { setAuthToken } from "../lib/api";
|
import { setAuthToken } from "../lib/api";
|
||||||
import { login as loginService, type AuthUser } from "../services/auth.service";
|
|
||||||
import {
|
import {
|
||||||
sendGpsUpdate,
|
login as loginService,
|
||||||
type BackendNotification,
|
register as registerService,
|
||||||
|
type AuthUser,
|
||||||
|
} from "../services/auth.service";
|
||||||
|
import {
|
||||||
|
getMyStatus,
|
||||||
type EtaResult,
|
type EtaResult,
|
||||||
|
type InboxNotification,
|
||||||
|
type UserStatusResponse,
|
||||||
} from "../services/tracking.service";
|
} from "../services/tracking.service";
|
||||||
|
|
||||||
const SIMULATION_STEPS = [
|
const POLL_INTERVAL_MS = 30_000; // 30s, igual que el simulador del backend
|
||||||
{ 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface AppContextValue {
|
interface AppContextValue {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
eta: EtaResult | null;
|
eta: EtaResult | null;
|
||||||
notifications: BackendNotification[];
|
notifications: InboxNotification[];
|
||||||
|
route: UserStatusResponse["route"] | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (name: string, email: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
advanceTruck: () => Promise<void>;
|
refreshStatus: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppContext = createContext<AppContextValue | null>(null);
|
const AppContext = createContext<AppContextValue | null>(null);
|
||||||
@@ -44,10 +46,26 @@ export const useApp = (): AppContextValue => {
|
|||||||
export const AppProvider = ({ children }: { children: ReactNode }) => {
|
export const AppProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [eta, setEta] = useState<EtaResult | null>(null);
|
const [eta, setEta] = useState<EtaResult | null>(null);
|
||||||
const [notifications, setNotifications] = useState<BackendNotification[]>([]);
|
const [notifications, setNotifications] = useState<InboxNotification[]>([]);
|
||||||
const [stepIndex, setStepIndex] = useState(0);
|
const [route, setRoute] = useState<UserStatusResponse["route"] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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) => {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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(() => {
|
const logout = useCallback(() => {
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setEta(null);
|
setEta(null);
|
||||||
|
setRoute(null);
|
||||||
setNotifications([]);
|
setNotifications([]);
|
||||||
setStepIndex(0);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const advanceTruck = useCallback(async () => {
|
// Polling controlado: arranca al loguearse, se detiene al deslogearse.
|
||||||
const step = SIMULATION_STEPS[stepIndex % SIMULATION_STEPS.length];
|
useEffect(() => {
|
||||||
setLoading(true);
|
if (!user) {
|
||||||
try {
|
if (pollingRef.current) {
|
||||||
const res = await sendGpsUpdate({
|
clearInterval(pollingRef.current);
|
||||||
truckId: "101",
|
pollingRef.current = null;
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
setStepIndex((i) => i + 1);
|
return;
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [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 (
|
return (
|
||||||
<AppContext.Provider
|
<AppContext.Provider
|
||||||
value={{ user, eta, notifications, loading, login, logout, advanceTruck }}
|
value={{
|
||||||
|
user,
|
||||||
|
eta,
|
||||||
|
notifications,
|
||||||
|
route,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
refreshStatus,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</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");
|
||||||
@@ -44,3 +44,31 @@ export const sendGpsUpdate = (payload: GpsUpdatePayload) =>
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
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