Files
waseel/app/(root)/book-ride.tsx
T

250 lines
9.1 KiB
TypeScript

import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Image,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { CustomButton } from "@/components/custom-button";
import { Map } from "@/components/map";
import { icons, images } from "@/constants";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { formatTime } from "@/lib/utils";
import { useLocationStore } from "@/store";
import type { Ride } from "@/types/type";
const POLL_MS = 3000;
const STATUS_KEY: Record<string, string> = {
requested: "bookRide.status.requested",
accepted: "bookRide.status.accepted",
en_route: "bookRide.status.enRoute",
completed: "bookRide.status.completed",
cancelled: "bookRide.status.cancelled",
};
// book-ride is now the live ride-status screen. The rider lands here after
// requesting a ride and polls its status until it completes (or they cancel).
const BookRide = () => {
const { id } = useLocalSearchParams<{ id: string }>();
const rideId = Number(id);
const t = useT();
const setUserLocation = useLocationStore((s) => s.setUserLocation);
const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation);
const [ride, setRide] = useState<Ride | null>(null);
const [loading, setLoading] = useState(true);
const [cancelling, setCancelling] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const res = await fetchAPI(`/(api)/ride/${rideId}`);
const r = res.data as Ride;
setRide(r);
// Keep the map's origin/destination in sync with the ride so the route
// line renders even if the rider reached this screen via history.
setUserLocation({
latitude: r.origin_latitude,
longitude: r.origin_longitude,
address: r.origin_address,
});
setDestinationLocation({
latitude: r.destination_latitude,
longitude: r.destination_longitude,
address: r.destination_address,
});
} catch (err) {
console.log("[BOOK_RIDE_LOAD]: ", err);
if (err instanceof ApiError && err.status === 404) {
setError(t("bookRide.rideNotFound"));
}
} finally {
setLoading(false);
}
}, [rideId, setUserLocation, setDestinationLocation, t]);
useEffect(() => {
void load();
}, [load]);
// Poll while the ride is still in a non-terminal state.
useEffect(() => {
const status = ride?.status;
if (!status || status === "completed" || status === "cancelled") return;
const timer = setInterval(() => void load(), POLL_MS);
return () => clearInterval(timer);
}, [ride?.status, load]);
const cancel = async () => {
setCancelling(true);
try {
await fetchAPI(`/(api)/ride/${rideId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "cancelled" }),
});
await load();
} catch (err) {
console.log("[BOOK_RIDE_CANCEL]: ", err);
Alert.alert(t("bookRide.alertErrorTitle"), t("bookRide.alertErrorBody"));
} finally {
setCancelling(false);
}
};
if (loading) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
<ActivityIndicator size="large" color="#0286ff" />
</SafeAreaView>
);
}
if (error || !ride) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center px-7">
<Text className="text-base text-general-200 dark:text-neutral-400 text-center">
{error ?? t("bookRide.couldNotLoad")}
</Text>
<CustomButton
title={t("bookRide.backHome")}
onPress={() => router.replace("/(root)/(tabs)/home")}
className="mt-6"
/>
</SafeAreaView>
);
}
const driver = ride.driver;
const terminal = ride.status === "completed" || ride.status === "cancelled";
return (
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
<View className="h-[45%] bg-blue-500">
<Map />
</View>
<View className="flex-1 px-5 pt-4">
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
{STATUS_KEY[ride.status] ? t(STATUS_KEY[ride.status]) : ride.status}
</Text>
{/* Searching state */}
{ride.status === "requested" ? (
<View className="items-center mt-6">
<ActivityIndicator size="large" color="#0286ff" />
<Text className="text-general-200 dark:text-neutral-400 mt-3 text-center">
{t("bookRide.matchingDriver", { service: ride.service })}
</Text>
</View>
) : null}
{/* Driver card — shown once a driver is assigned. */}
{driver?.id ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-2">
<View className="flex-row items-center">
<Image
source={{ uri: driver.profile_image_url ?? undefined }}
className="w-16 h-16 rounded-full"
/>
<View className="ml-4 flex-1">
<Text className="text-lg font-JakartaSemiBold text-black dark:text-white">
{driver.first_name} {driver.last_name}
</Text>
<View className="flex-row items-center mt-1">
<Image source={icons.star} className="w-4 h-4" />
<Text className="ml-1 text-general-200 dark:text-neutral-400">
{driver.rating?.toFixed(1) ?? t("bookRide.ratingFallback")}
</Text>
{driver.car_model ? (
<Text className="ml-3 text-general-200 dark:text-neutral-400">
{driver.car_model}
</Text>
) : null}
</View>
</View>
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize">
{driver.service ?? ride.service}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mt-4">
<Image source={icons.to} className="w-4 h-4" />
<Text className="font-JakartaMedium text-sm text-black dark:text-white" numberOfLines={1}>
{ride.origin_address}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mt-2">
<Image source={icons.point} className="w-4 h-4" />
<Text className="font-JakartaMedium text-sm text-black dark:text-white" numberOfLines={1}>
{ride.destination_address}
</Text>
</View>
<View className="flex-row justify-between mt-4 pt-3 border-t border-neutral-100 dark:border-neutral-800">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{ride.payment_status === "cash"
? t("bookRide.paymentCash")
: t("bookRide.paymentCard")}
</Text>
<Text className="font-JakartaBold text-emerald-600 dark:text-emerald-400">
${(ride.fare_price / 100).toFixed(2)}
</Text>
</View>
</View>
) : null}
{/* Completed summary */}
{ride.status === "completed" ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4 items-center">
<Image source={images.check} className="w-12 h-12" />
<Text className="text-lg font-JakartaBold mt-3 text-black dark:text-white">
{t("bookRide.fare", { fare: (ride.fare_price / 100).toFixed(2) })}
</Text>
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-1">
{t("bookRide.tripTime", { time: formatTime(ride.ride_time) })}
</Text>
</View>
) : null}
{/* Cancelled */}
{ride.status === "cancelled" ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4 items-center">
<Text className="text-general-200 dark:text-neutral-400">
{t("bookRide.rideCancelled")}
</Text>
</View>
) : null}
<View className="mt-auto pt-6">
{terminal ? (
<CustomButton
title={t("bookRide.backHome")}
onPress={() => router.replace("/(root)/(tabs)/home")}
/>
) : (
<TouchableOpacity
onPress={cancel}
disabled={cancelling}
className="rounded-full py-3 bg-white dark:bg-neutral-900 items-center border border-rose-300 dark:border-rose-900"
>
<Text className="font-JakartaBold text-rose-500">
{cancelling ? t("bookRide.cancelling") : t("bookRide.cancelRide")}
</Text>
</TouchableOpacity>
)}
</View>
</View>
</SafeAreaView>
);
};
export default BookRide;