349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
import { router, useLocalSearchParams } from "expo-router";
|
|
import { useEffect, useState } from "react";
|
|
import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from "react-native";
|
|
|
|
import { CustomButton } from "@/components/custom-button";
|
|
import { RideLayout } from "@/components/ride-layout";
|
|
import { SERVICES } from "@/constants/services";
|
|
import { ApiError, fetchAPI } from "@/lib/fetch";
|
|
import { useT } from "@/lib/i18n";
|
|
import { calculateTripFare } from "@/lib/map";
|
|
import { formatLBP } from "@/lib/pricing";
|
|
import { requestRide } from "@/lib/request-ride";
|
|
import { useSession } from "@/lib/session";
|
|
import { formatTime, haversine } from "@/lib/utils";
|
|
import { useLocationStore, useServiceStore } from "@/store";
|
|
|
|
type PaymentMethod = "cash" | "card";
|
|
|
|
type NearbyDriver = {
|
|
id: number;
|
|
first_name: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
};
|
|
|
|
// Confirm-ride is now the request screen: the rider no longer browses and
|
|
// picks a driver. They see a single fare estimate + nearest-driver ETA, pick a
|
|
// payment method, and tap Request — auto-match assigns the driver and they're
|
|
// routed to the live status screen.
|
|
const ConfirmRide = () => {
|
|
const params = useLocalSearchParams<{ service?: string }>();
|
|
const {
|
|
userAddress,
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationAddress,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
} = useLocationStore();
|
|
const { service: storeService, setService } = useServiceStore();
|
|
const { user } = useSession();
|
|
const t = useT();
|
|
|
|
const service = params.service ?? storeService;
|
|
const selected = SERVICES.find((s) => s.id === service) ?? SERVICES[0];
|
|
|
|
const [method, setMethod] = useState<PaymentMethod>("cash");
|
|
const [estimate, setEstimate] = useState<{
|
|
fare: string;
|
|
durationSeconds: number;
|
|
} | null>(null);
|
|
const [nearestEta, setNearestEta] = useState<number | null>(null);
|
|
const [driversOnline, setDriversOnline] = useState<number | null>(null);
|
|
const [estimating, setEstimating] = useState(true);
|
|
const [processing, setProcessing] = useState(false);
|
|
|
|
// Trip fare estimate — one Directions call for the trip leg, recomputed when
|
|
// the route or service changes. Independent of driver availability.
|
|
useEffect(() => {
|
|
if (
|
|
!userLatitude ||
|
|
!userLongitude ||
|
|
!destinationLatitude ||
|
|
!destinationLongitude
|
|
)
|
|
return;
|
|
|
|
let cancelled = false;
|
|
setEstimating(true);
|
|
|
|
const run = async () => {
|
|
const trip = await calculateTripFare({
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
service: selected.id,
|
|
});
|
|
if (cancelled) return;
|
|
setEstimate(
|
|
trip
|
|
? { fare: trip.fare, durationSeconds: trip.durationSeconds }
|
|
: null,
|
|
);
|
|
};
|
|
|
|
void run().finally(() => {
|
|
if (!cancelled) setEstimating(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
selected.id,
|
|
]);
|
|
|
|
// Online-driver availability for the selected service, polled so the "no
|
|
// drivers" state self-heals the moment a driver of this service comes
|
|
// online. The nearest driver's pickup ETA is resolved alongside the count.
|
|
useEffect(() => {
|
|
if (!userLatitude || !userLongitude) return;
|
|
|
|
let cancelled = false;
|
|
|
|
const check = async () => {
|
|
try {
|
|
const res = await fetchAPI(
|
|
`/(api)/driver/nearby?service=${selected.id}&lat=${userLatitude}&lng=${userLongitude}`,
|
|
);
|
|
const drivers = (res.data ?? []) as NearbyDriver[];
|
|
if (cancelled) return;
|
|
setDriversOnline(drivers.length);
|
|
if (drivers.length === 0) {
|
|
setNearestEta(null);
|
|
return;
|
|
}
|
|
const nearest = drivers
|
|
.map((d) => ({
|
|
d,
|
|
dist: haversine(
|
|
userLatitude,
|
|
userLongitude,
|
|
d.latitude,
|
|
d.longitude,
|
|
),
|
|
}))
|
|
.sort((a, b) => a.dist - b.dist)[0].d;
|
|
|
|
const directionsRes = await fetch(
|
|
`https://maps.googleapis.com/maps/api/directions/json?origin=${nearest.latitude},${nearest.longitude}&destination=${userLatitude},${userLongitude}&key=${process.env.EXPO_PUBLIC_GOOGLE_API_KEY}`,
|
|
);
|
|
const data = await directionsRes.json();
|
|
const leg = data.routes?.[0]?.legs?.[0];
|
|
if (!cancelled)
|
|
setNearestEta(leg ? Math.round(leg.duration.value / 60) : null);
|
|
} catch {
|
|
if (!cancelled) {
|
|
setDriversOnline(null);
|
|
setNearestEta(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
void check();
|
|
const timer = setInterval(() => void check(), 10000);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(timer);
|
|
};
|
|
}, [userLatitude, userLongitude, selected.id]);
|
|
|
|
const request = async () => {
|
|
if (!userLatitude || !userLongitude || !destinationLatitude || !destinationLongitude) {
|
|
Alert.alert(
|
|
t("confirmRide.alertMissingRouteTitle"),
|
|
t("confirmRide.alertMissingRouteBody"),
|
|
);
|
|
return;
|
|
}
|
|
if (!estimate) {
|
|
Alert.alert(
|
|
t("confirmRide.alertNoEstimateTitle"),
|
|
t("confirmRide.alertNoEstimateBody"),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Nested so the guards above narrow userLatitude/estimate to non-null for
|
|
// the card-confirm callback as well as the direct cash path.
|
|
const doRequest = async () => {
|
|
setProcessing(true);
|
|
try {
|
|
// Keep the store in sync with whatever service we resolved for this ride.
|
|
setService(selected.id);
|
|
|
|
const { ride } = await requestRide({
|
|
method,
|
|
service: selected.id,
|
|
user: { name: user?.name ?? "", email: user?.email ?? "" },
|
|
origin: {
|
|
address: userAddress ?? "",
|
|
latitude: userLatitude,
|
|
longitude: userLongitude,
|
|
},
|
|
destination: {
|
|
address: destinationAddress ?? "",
|
|
latitude: destinationLatitude,
|
|
longitude: destinationLongitude,
|
|
},
|
|
rideTimeSeconds: estimate.durationSeconds,
|
|
fareCents: Math.round(parseFloat(estimate.fare) * 100),
|
|
});
|
|
|
|
router.replace(`/(root)/book-ride?id=${ride.ride_id}`);
|
|
} catch (err) {
|
|
console.log("[REQUEST_RIDE]: ", err);
|
|
const msg =
|
|
err instanceof ApiError
|
|
? err.message
|
|
: t("confirmRide.alertErrorFallback");
|
|
Alert.alert(t("confirmRide.alertErrorTitle"), msg);
|
|
} finally {
|
|
setProcessing(false);
|
|
}
|
|
};
|
|
|
|
if (method === "card") {
|
|
Alert.alert(
|
|
t("confirmRide.alertPayCardTitle"),
|
|
t("confirmRide.alertPayCardBody", { fare: estimate.fare }),
|
|
[
|
|
{ text: t("common.cancel"), style: "cancel" },
|
|
{ text: t("common.continue"), onPress: () => void doRequest() },
|
|
],
|
|
);
|
|
} else {
|
|
void doRequest();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<RideLayout title={t("confirmRide.title")} snapPoints={["60%", "88%"]}>
|
|
<Text className="text-xl font-JakartaSemiBold mb-1 text-black dark:text-white">
|
|
{t("confirmRide.yourTrip")}
|
|
</Text>
|
|
|
|
<View className="flex-row items-center gap-x-2 mb-1">
|
|
<Text className="text-general-200 dark:text-neutral-400 text-xs">
|
|
{t("confirmRide.pickup")}
|
|
</Text>
|
|
</View>
|
|
<Text className="font-JakartaMedium mb-3 text-black dark:text-white" numberOfLines={1}>
|
|
{userAddress}
|
|
</Text>
|
|
|
|
<View className="flex-row items-center gap-x-2 mb-1">
|
|
<Text className="text-general-200 dark:text-neutral-400 text-xs">
|
|
{t("confirmRide.destination")}
|
|
</Text>
|
|
</View>
|
|
<Text className="font-JakartaMedium mb-4 text-black dark:text-white" numberOfLines={1}>
|
|
{destinationAddress}
|
|
</Text>
|
|
|
|
<View className="flex-row items-center justify-between bg-general-500 dark:bg-neutral-950 rounded-2xl p-4 mb-4">
|
|
<View>
|
|
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
|
|
{t(selected.labelKey)} · {t(selected.taglineKey)}
|
|
</Text>
|
|
<Text className="text-general-200 dark:text-neutral-400 text-xs mt-1">
|
|
{t("confirmRide.tripTime", {
|
|
time: estimate ? formatTime(estimate.durationSeconds / 60) : "…",
|
|
})}
|
|
</Text>
|
|
</View>
|
|
<View className="items-end">
|
|
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
|
|
{estimating
|
|
? "…"
|
|
: estimate
|
|
? t("confirmRide.fareDisplay", { fare: estimate.fare })
|
|
: "—"}
|
|
</Text>
|
|
{estimate ? (
|
|
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
|
{t("confirmRide.lbpEstimate", {
|
|
lbp: formatLBP(parseFloat(estimate.fare)),
|
|
})}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
|
|
<Text
|
|
className={`text-base font-JakartaMedium mb-2 ${
|
|
driversOnline === 0
|
|
? "text-rose-500"
|
|
: "text-general-200 dark:text-neutral-400"
|
|
}`}
|
|
>
|
|
{driversOnline === 0
|
|
? t("confirmRide.noDrivers", { service: t(selected.labelKey) })
|
|
: nearestEta == null
|
|
? t("confirmRide.findingDrivers")
|
|
: t("confirmRide.nearestDriver", { eta: nearestEta })}
|
|
</Text>
|
|
|
|
<Text className="text-lg font-JakartaSemiBold mt-2 mb-2 text-black dark:text-white">
|
|
{t("confirmRide.paymentMethod")}
|
|
</Text>
|
|
<View className="flex-row gap-x-3 mb-2">
|
|
<TouchableOpacity
|
|
onPress={() => setMethod("cash")}
|
|
className={`flex-1 items-center py-3 rounded-xl border ${
|
|
method === "cash"
|
|
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
|
|
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`font-JakartaMedium ${
|
|
method === "cash" ? "text-white" : "text-black dark:text-white"
|
|
}`}
|
|
>
|
|
{t("confirmRide.cash")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={() => setMethod("card")}
|
|
className={`flex-1 items-center py-3 rounded-xl border ${
|
|
method === "card"
|
|
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
|
|
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`font-JakartaMedium ${
|
|
method === "card" ? "text-white" : "text-black dark:text-white"
|
|
}`}
|
|
>
|
|
{t("confirmRide.card")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<CustomButton
|
|
title={
|
|
processing
|
|
? t("confirmRide.requesting")
|
|
: driversOnline === 0
|
|
? t("confirmRide.noDriversOnline")
|
|
: method === "cash"
|
|
? t("confirmRide.requestCash")
|
|
: t("confirmRide.requestCard")
|
|
}
|
|
className="mt-4"
|
|
onPress={request}
|
|
disabled={processing || estimating || !estimate || driversOnline === 0}
|
|
/>
|
|
</RideLayout>
|
|
);
|
|
};
|
|
|
|
export default ConfirmRide; |