581 lines
19 KiB
TypeScript
581 lines
19 KiB
TypeScript
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
|
import { router } from "expo-router";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Image,
|
|
ScrollView,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View,
|
|
} from "react-native";
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
|
|
import { CustomButton } from "@/components/custom-button";
|
|
import { icons, images } from "@/constants";
|
|
import { SERVICES, type ServiceId } from "@/constants/services";
|
|
import { ApiError, fetchAPI } from "@/lib/fetch";
|
|
import { useT } from "@/lib/i18n";
|
|
import { useSession } from "@/lib/session";
|
|
import { useTheme } from "@/lib/theme";
|
|
import { useDriverLocation } from "@/lib/use-driver-location";
|
|
import { formatTime } from "@/lib/utils";
|
|
|
|
// Poll cadence for the driver dashboard (offers / active ride / earnings).
|
|
const POLL_MS = 4000;
|
|
|
|
type Profile = {
|
|
id: number;
|
|
first_name: string;
|
|
last_name: string;
|
|
profile_image_url: string | null;
|
|
car_image_url: string | null;
|
|
car_seats: number;
|
|
rating: number;
|
|
service: ServiceId;
|
|
online: boolean;
|
|
car_model: string | null;
|
|
};
|
|
|
|
type Offer = {
|
|
offer_id: number;
|
|
offered_at: string;
|
|
ride_id: number;
|
|
origin_address: string;
|
|
destination_address: string;
|
|
ride_time: number;
|
|
fare_price: number;
|
|
payment_status: string;
|
|
service: string;
|
|
};
|
|
|
|
type ActiveRide = {
|
|
ride_id: number;
|
|
status: string;
|
|
service: string;
|
|
payment_status: string;
|
|
origin_address: string;
|
|
destination_address: string;
|
|
ride_time: number;
|
|
fare_price: number;
|
|
rider_name: string | null;
|
|
rider_phone: string | null;
|
|
};
|
|
|
|
type Dashboard = {
|
|
offers: Offer[];
|
|
active: ActiveRide | null;
|
|
recent: { ride_id: number; fare_price: number; service: string }[];
|
|
earnings: number;
|
|
};
|
|
|
|
const DriverHome = () => {
|
|
const { signOut, user } = useSession();
|
|
const { isDark } = useTheme();
|
|
const t = useT();
|
|
const [loading, setLoading] = useState(true);
|
|
const [profile, setProfile] = useState<Profile | null>(null);
|
|
const [online, setOnline] = useState(false);
|
|
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const loadProfile = useCallback(async () => {
|
|
try {
|
|
const res = await fetchAPI("/(api)/driver/profile");
|
|
const p = res.data as Profile;
|
|
setProfile(p);
|
|
setOnline(p.online);
|
|
} catch (err) {
|
|
// 403 with code ONBOARD means no profile yet — show the onboarding form.
|
|
if (err instanceof ApiError && err.status === 403) {
|
|
setProfile(null);
|
|
} else {
|
|
console.log("[DRIVER_PROFILE_LOAD]: ", err);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadProfile();
|
|
}, [loadProfile]);
|
|
|
|
// Keep the location heartbeat running only while the driver is online and
|
|
// has completed onboarding.
|
|
useDriverLocation(online && profile !== null);
|
|
|
|
// Poll the dashboard while online. useCallback keeps the fetcher stable so the
|
|
// interval effect doesn't re-subscribe on every render.
|
|
const fetchDashboard = useCallback(async () => {
|
|
try {
|
|
const res = await fetchAPI("/(api)/driver/rides");
|
|
setDashboard(res.data as Dashboard);
|
|
} catch (err) {
|
|
console.log("[DRIVER_DASHBOARD_POLL]: ", err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!online || !profile) return;
|
|
void fetchDashboard();
|
|
const timer = setInterval(() => void fetchDashboard(), POLL_MS);
|
|
return () => clearInterval(timer);
|
|
}, [online, profile, fetchDashboard]);
|
|
|
|
const toggleOnline = async () => {
|
|
if (!profile) return;
|
|
const next = !online;
|
|
setBusy(true);
|
|
try {
|
|
await fetchAPI("/(api)/driver/profile", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ online: next }),
|
|
});
|
|
setOnline(next);
|
|
setProfile({ ...profile, online: next });
|
|
if (!next) setDashboard(null);
|
|
} catch (err) {
|
|
console.log("[DRIVER_TOGGLE]: ", err);
|
|
Alert.alert(t("driver.home.alertErrorTitle"), t("driver.home.alertToggleBody"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const respond = async (offer: Offer, action: "accept" | "decline") => {
|
|
setBusy(true);
|
|
try {
|
|
await fetchAPI(`/(api)/ride/${offer.ride_id}/respond`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ action }),
|
|
});
|
|
await fetchDashboard();
|
|
} catch (err) {
|
|
console.log("[DRIVER_RESPOND]: ", err);
|
|
Alert.alert(
|
|
t("driver.activeRide.alertErrorTitle"),
|
|
action === "accept"
|
|
? t("driver.activeRide.alertAcceptBody")
|
|
: t("driver.activeRide.alertDeclineBody"),
|
|
);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const advance = async (rideId: number, status: "en_route" | "completed") => {
|
|
setBusy(true);
|
|
try {
|
|
await fetchAPI(`/(api)/ride/${rideId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
await fetchDashboard();
|
|
} catch (err) {
|
|
console.log("[DRIVER_ADVANCE]: ", err);
|
|
Alert.alert(t("driver.activeRide.alertErrorTitle"), t("driver.activeRide.alertUpdateBody"));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
|
|
<ActivityIndicator size="large" color={isDark ? "#0286ff" : "#0286ff"} />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (!profile) {
|
|
return (
|
|
<Onboarding onCreated={loadProfile} signOut={signOut} userName={user?.name} />
|
|
);
|
|
}
|
|
|
|
const earnings = dashboard?.earnings ?? 0;
|
|
const rideCount = dashboard?.recent.length ?? 0;
|
|
|
|
return (
|
|
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
|
|
<ScrollView
|
|
className="flex-1 px-5"
|
|
contentContainerStyle={{ paddingBottom: 40 }}
|
|
>
|
|
<View className="flex-row items-center justify-between my-5">
|
|
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
|
|
{t("driver.home.driverMode")}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={signOut}
|
|
className="w-10 h-10 rounded-full bg-white dark:bg-neutral-900 items-center justify-center"
|
|
>
|
|
<Image source={icons.out} className="w-4 h-4" alt={t("driver.home.signOutAlt")} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Online / offline toggle */}
|
|
<TouchableOpacity
|
|
onPress={toggleOnline}
|
|
disabled={busy}
|
|
className={`rounded-2xl p-5 items-center mb-4 ${
|
|
online ? "bg-emerald-500" : "bg-neutral-700 dark:bg-neutral-800"
|
|
}`}
|
|
>
|
|
<Text className="text-white text-lg font-JakartaBold">
|
|
{online ? t("driver.home.online") : t("driver.home.goOnline")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* Earnings summary */}
|
|
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mb-4 flex-row justify-between">
|
|
<View>
|
|
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
|
|
{t("driver.home.todaysEarnings")}
|
|
</Text>
|
|
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
|
|
${(earnings / 100).toFixed(2)}
|
|
</Text>
|
|
</View>
|
|
<View className="items-end">
|
|
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
|
|
{t("driver.home.completedToday")}
|
|
</Text>
|
|
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">{rideCount}</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Active ride */}
|
|
{dashboard?.active ? (
|
|
<ActiveRideCard
|
|
ride={dashboard.active}
|
|
busy={busy}
|
|
onAdvance={advance}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Incoming offers */}
|
|
<Text className="text-xl font-JakartaBold mt-4 mb-3 text-black dark:text-white">
|
|
{online
|
|
? t("driver.home.incomingRequests")
|
|
: t("driver.home.incomingRequestsOffline")}
|
|
</Text>
|
|
|
|
{!online ? null : dashboard?.offers.length ? (
|
|
dashboard.offers.map((offer) => (
|
|
<OfferCard
|
|
key={offer.offer_id}
|
|
offer={offer}
|
|
busy={busy}
|
|
onAccept={() => respond(offer, "accept")}
|
|
onDecline={() => respond(offer, "decline")}
|
|
/>
|
|
))
|
|
) : (
|
|
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-6 items-center">
|
|
<Image source={images.noResult} className="w-24 h-24" resizeMode="contain" />
|
|
<Text className="text-general-200 dark:text-neutral-400 mt-2">
|
|
{online ? t("driver.home.waitingRequests") : t("driver.home.goOnlineStart")}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
// --- Onboarding form ------------------------------------------------------
|
|
|
|
const Onboarding = ({
|
|
onCreated,
|
|
signOut,
|
|
userName,
|
|
}: {
|
|
onCreated: () => Promise<void>;
|
|
signOut: () => Promise<void>;
|
|
userName?: string | null;
|
|
}) => {
|
|
const t = useT();
|
|
const { isDark } = useTheme();
|
|
const [service, setService] = useState<ServiceId>("car");
|
|
const [carModel, setCarModel] = useState("");
|
|
const [carSeats, setCarSeats] = useState("4");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const submit = async () => {
|
|
const seats = Number(carSeats);
|
|
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
|
|
Alert.alert(t("driver.home.alertSeatsTitle"), t("driver.home.alertSeatsBody"));
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
await fetchAPI("/(api)/driver/profile", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
service,
|
|
car_model: carModel.trim() || null,
|
|
car_seats: seats,
|
|
}),
|
|
});
|
|
await onCreated();
|
|
} catch (err) {
|
|
console.log("[DRIVER_ONBOARD]: ", err);
|
|
Alert.alert(t("driver.home.alertErrorTitle"), t("driver.home.alertCreateBody"));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950">
|
|
<ScrollView className="flex-1 px-5" contentContainerStyle={{ paddingBottom: 40 }}>
|
|
<View className="flex-row items-center justify-between my-5">
|
|
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
|
|
{t("driver.home.welcome", {
|
|
name: userName?.split(" ")[0] || t("driver.home.welcomeFallback"),
|
|
})}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={signOut}
|
|
className="w-10 h-10 rounded-full bg-neutral-100 dark:bg-neutral-900 items-center justify-center"
|
|
>
|
|
<Image source={icons.out} className="w-4 h-4" alt={t("driver.home.signOutAlt")} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<Text className="text-base text-general-200 dark:text-neutral-400 font-Jakarta mb-4">
|
|
{t("driver.home.setupIntro")}
|
|
</Text>
|
|
|
|
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
|
|
{t("driver.home.whatDrive")}
|
|
</Text>
|
|
<View className="flex-row gap-2 mb-5">
|
|
{SERVICES.map((item) => {
|
|
const active = item.id === service;
|
|
return (
|
|
<TouchableOpacity
|
|
key={item.id}
|
|
onPress={() => setService(item.id)}
|
|
className={`flex-1 items-center rounded-2xl border py-3 ${
|
|
active
|
|
? "border-primary-500 bg-primary-500/10"
|
|
: "border-neutral-100 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900"
|
|
}`}
|
|
>
|
|
<MaterialCommunityIcons
|
|
name={item.icon}
|
|
size={24}
|
|
color={active ? "#0286ff" : isDark ? "#9ca3af" : "#858585"}
|
|
/>
|
|
<Text
|
|
className={`mt-1.5 text-xs font-JakartaBold ${
|
|
active ? "text-primary-500" : "text-black dark:text-white"
|
|
}`}
|
|
>
|
|
{t(item.labelKey)}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
|
|
{t("driver.home.carModel")}
|
|
</Text>
|
|
<TextInput
|
|
value={carModel}
|
|
onChangeText={setCarModel}
|
|
placeholder={t("driver.home.carModelPlaceholder")}
|
|
placeholderTextColor={isDark ? "#737373" : "#858585"}
|
|
className="bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-4"
|
|
autoCapitalize="words"
|
|
/>
|
|
|
|
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
|
|
{t("driver.home.carSeats")}
|
|
</Text>
|
|
<TextInput
|
|
value={carSeats}
|
|
onChangeText={setCarSeats}
|
|
placeholder={t("driver.home.carSeatsPlaceholder")}
|
|
placeholderTextColor={isDark ? "#737373" : "#858585"}
|
|
keyboardType="number-pad"
|
|
className="bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-8"
|
|
/>
|
|
|
|
<CustomButton
|
|
title={submitting ? t("common.saving") : t("driver.home.startDriving")}
|
|
onPress={submit}
|
|
disabled={submitting}
|
|
/>
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
// --- Offer card -----------------------------------------------------------
|
|
|
|
const OfferCard = ({
|
|
offer,
|
|
busy,
|
|
onAccept,
|
|
onDecline,
|
|
}: {
|
|
offer: Offer;
|
|
busy: boolean;
|
|
onAccept: () => void;
|
|
onDecline: () => void;
|
|
}) => {
|
|
const t = useT();
|
|
return (
|
|
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mb-3">
|
|
<View className="flex-row items-center justify-between mb-2">
|
|
<Text className="text-sm font-JakartaBold text-primary-500">
|
|
{t("driver.offerCard.newRequest", { service: offer.service })}
|
|
</Text>
|
|
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
|
{offer.payment_status === "cash"
|
|
? t("driver.offerCard.cash")
|
|
: t("driver.offerCard.card")}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="flex-row items-center gap-x-2 mb-1">
|
|
<Image source={icons.to} alt={t("driver.offerCard.fromAlt")} className="w-4 h-4" />
|
|
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
|
|
{offer.origin_address}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row items-center gap-x-2 mb-3">
|
|
<Image source={icons.point} alt={t("driver.offerCard.toAlt")} className="w-4 h-4" />
|
|
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
|
|
{offer.destination_address}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="flex-row justify-between mb-3">
|
|
<Text className="text-general-200 dark:text-neutral-400 text-xs">
|
|
{t("driver.offerCard.tripTime")}
|
|
</Text>
|
|
<Text className="font-JakartaMedium text-xs text-black dark:text-white">
|
|
{formatTime(offer.ride_time)}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row justify-between mb-3">
|
|
<Text className="text-general-200 dark:text-neutral-400 text-xs">
|
|
{t("driver.offerCard.fare")}
|
|
</Text>
|
|
<Text className="font-JakartaMedium text-xs text-emerald-600 dark:text-emerald-400">
|
|
${(offer.fare_price / 100).toFixed(2)}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="flex-row gap-3">
|
|
<TouchableOpacity
|
|
onPress={onDecline}
|
|
disabled={busy}
|
|
className="flex-1 rounded-full py-3 bg-neutral-200 dark:bg-neutral-800 items-center"
|
|
>
|
|
<Text className="font-JakartaBold text-neutral-700 dark:text-neutral-200">
|
|
{t("driver.offerCard.decline")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={onAccept}
|
|
disabled={busy}
|
|
className="flex-1 rounded-full py-3 bg-emerald-500 items-center"
|
|
>
|
|
<Text className="font-JakartaBold text-white">
|
|
{busy ? "…" : t("driver.offerCard.accept")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// --- Active ride card -----------------------------------------------------
|
|
|
|
const ActiveRideCard = ({
|
|
ride,
|
|
busy,
|
|
onAdvance,
|
|
}: {
|
|
ride: ActiveRide;
|
|
busy: boolean;
|
|
onAdvance: (rideId: number, status: "en_route" | "completed") => void;
|
|
}) => {
|
|
const t = useT();
|
|
const statusLabel =
|
|
ride.status === "accepted"
|
|
? t("driver.activeRide.headToPickup")
|
|
: ride.status === "en_route"
|
|
? t("driver.activeRide.tripInProgress")
|
|
: ride.status;
|
|
|
|
return (
|
|
<View className="bg-primary-500/10 border border-primary-500 rounded-2xl p-4 mb-4">
|
|
<View className="flex-row items-center justify-between mb-2">
|
|
<Text className="text-sm font-JakartaBold text-primary-500">
|
|
● {statusLabel}
|
|
</Text>
|
|
<Text className="text-xs text-general-200 dark:text-neutral-400">{ride.service}</Text>
|
|
</View>
|
|
|
|
{ride.rider_name ? (
|
|
<Text className="font-JakartaBold mb-2 text-black dark:text-white">
|
|
{t("driver.activeRide.rider", { name: ride.rider_name })}
|
|
</Text>
|
|
) : null}
|
|
|
|
<View className="flex-row items-center gap-x-2 mb-1">
|
|
<Image source={icons.to} alt={t("driver.activeRide.fromAlt")} className="w-4 h-4" />
|
|
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
|
|
{ride.origin_address}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row items-center gap-x-2 mb-3">
|
|
<Image source={icons.point} alt={t("driver.activeRide.toAlt")} className="w-4 h-4" />
|
|
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
|
|
{ride.destination_address}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="flex-row justify-between mb-4">
|
|
<Text className="text-general-200 dark:text-neutral-400 text-xs">
|
|
{t("driver.activeRide.fare")}
|
|
</Text>
|
|
<Text className="font-JakartaMedium text-xs text-emerald-600 dark:text-emerald-400">
|
|
${(ride.fare_price / 100).toFixed(2)}
|
|
</Text>
|
|
</View>
|
|
|
|
{ride.status === "accepted" ? (
|
|
<CustomButton
|
|
title={busy ? "…" : t("driver.activeRide.startTrip")}
|
|
bgVariant="success"
|
|
onPress={() => onAdvance(ride.ride_id, "en_route")}
|
|
className="mb-2"
|
|
/>
|
|
) : null}
|
|
{ride.status === "en_route" ? (
|
|
<CustomButton
|
|
title={busy ? "…" : t("driver.activeRide.completeTrip")}
|
|
bgVariant="success"
|
|
onPress={() => onAdvance(ride.ride_id, "completed")}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default DriverHome; |