Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.
Camera permission on Android:
- Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
own plugin never declares CAMERA, and Android denies a request for an
undeclared permission instantly and silently — no dialog is ever shown,
which is indistinguishable from the app not asking at all.
- Handle canAskAgain: once Android stops showing the dialog, repeating why
we need it is a dead end, so offer Open Settings instead (lib/capture-
permission.ts), matching what the location flow already did.
Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.
Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
559 lines
21 KiB
TypeScript
559 lines
21 KiB
TypeScript
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
|
import { router, useLocalSearchParams } from "expo-router";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Image,
|
|
ScrollView,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
} from "react-native";
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
|
|
import { CancelSheet } from "@/components/cancel-sheet";
|
|
import { CustomButton } from "@/components/custom-button";
|
|
import { Map } from "@/components/map";
|
|
import { OfferList } from "@/components/offer-list";
|
|
import { PaymentChoiceSheet } from "@/components/payment-choice-sheet";
|
|
import { RatingSheet } from "@/components/rating-sheet";
|
|
import { icons, images } from "@/constants";
|
|
import { driverPhotoUri } from "@/lib/driver-photo";
|
|
import { ApiError, fetchAPI } from "@/lib/fetch";
|
|
import { useT } from "@/lib/i18n";
|
|
import { payByCard, selectDriver } from "@/lib/request-ride";
|
|
import { useSession } from "@/lib/session";
|
|
import { formatTime } from "@/lib/utils";
|
|
import { useLocationStore } from "@/store";
|
|
import type { Ride, RideOffer } from "@/types/type";
|
|
|
|
const POLL_MS = 3000;
|
|
|
|
// While the request is open, offers arrive one driver at a time and the rider
|
|
// is staring at the list waiting for them. A three-second gap between a driver
|
|
// tapping Offer and their face appearing reads as nothing happening.
|
|
const OPEN_POLL_MS = 1500;
|
|
|
|
const STATUS_KEY: Record<string, string> = {
|
|
requested: "bookRide.status.requested",
|
|
accepted: "bookRide.status.accepted",
|
|
arrived: "bookRide.status.arrived",
|
|
en_route: "bookRide.status.enRoute",
|
|
completed: "bookRide.status.completed",
|
|
cancelled: "bookRide.status.cancelled",
|
|
expired: "bookRide.status.expired",
|
|
};
|
|
|
|
const TERMINAL = ["completed", "cancelled", "expired"];
|
|
|
|
// 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 { user } = useSession();
|
|
const setUserLocation = useLocationStore((s) => s.setUserLocation);
|
|
const setDestinationLocation = useLocationStore(
|
|
(s) => s.setDestinationLocation,
|
|
);
|
|
const clearDestination = useLocationStore((s) => s.clearDestination);
|
|
|
|
const [ride, setRide] = useState<Ride | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [cancelling, setCancelling] = useState(false);
|
|
// The offer the rider tapped, held while they choose how to pay.
|
|
const [picked, setPicked] = useState<RideOffer | null>(null);
|
|
const [paying, setPaying] = useState(false);
|
|
// A card order that was paid but whose selection then failed. Kept so the
|
|
// rider can pick a different driver without paying a second time — the
|
|
// server only consumes an order when a driver is actually assigned.
|
|
const paidOrder = useRef<string | null>(null);
|
|
// Server clock minus device clock, so the elapsed counter is measured on the
|
|
// clock the request window is actually enforced against.
|
|
const clockOffset = useRef(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [cancelOpen, setCancelOpen] = useState(false);
|
|
// Set once, when the ride first lands on 'completed' during this session,
|
|
// so dismissing the sheet doesn't immediately re-open it on the next poll.
|
|
const [ratingOpen, setRatingOpen] = useState(false);
|
|
const [ratingHandled, setRatingHandled] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const res = await fetchAPI(`/(api)/ride/${rideId}`);
|
|
const r = res.data as Ride;
|
|
if (r.now) clockOffset.current = Date.parse(r.now) - Date.now();
|
|
setRide(r);
|
|
|
|
// Ask for the rating the moment the driver ends the trip — the rider is
|
|
// still in the car and still remembers. `my_rating` covers the case
|
|
// where they already rated from the home banner.
|
|
if (r.status === "completed" && r.my_rating == null && !ratingHandled) {
|
|
setRatingOpen(true);
|
|
}
|
|
|
|
// 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, ratingHandled, t]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
// Drop the route when the rider leaves this screen.
|
|
//
|
|
// Nothing used to clear it, so a destination survived for the life of the
|
|
// process — and since backgrounding an app doesn't end that process, the
|
|
// next launch drew a line to a trip that had already finished. Cleared on
|
|
// unmount rather than on completion because `load` re-sets it on every poll:
|
|
// clearing while still on screen would just fight the next poll, and the
|
|
// tracking map would lose the route the rider is watching.
|
|
useEffect(() => () => clearDestination(), [clearDestination]);
|
|
|
|
// Poll while the ride is still in a non-terminal state, quickly while
|
|
// offers are still coming in.
|
|
useEffect(() => {
|
|
const status = ride?.status;
|
|
if (!status || TERMINAL.includes(status)) return;
|
|
const every = status === "requested" ? OPEN_POLL_MS : POLL_MS;
|
|
const timer = setInterval(() => void load(), every);
|
|
return () => clearInterval(timer);
|
|
}, [ride?.status, load]);
|
|
|
|
// Take one of the offers. This is the call that assigns the ride: it pays
|
|
// (or commits to cash), locks in that driver and releases the others.
|
|
//
|
|
// A 409 means the driver was taken while the rider was deciding — a normal
|
|
// outcome of several riders competing for the same cars, not an error. The
|
|
// list simply reloads without them, and any card payment already made stays
|
|
// unspent and is reused for the next pick.
|
|
const pay = async (method: "cash" | "card") => {
|
|
const offer = picked;
|
|
if (!offer || !ride) return;
|
|
|
|
setPaying(true);
|
|
try {
|
|
let orderId = paidOrder.current ?? undefined;
|
|
|
|
if (method === "card" && !orderId) {
|
|
orderId = await payByCard({
|
|
ride,
|
|
user: { name: user?.name ?? "", email: user?.email ?? "" },
|
|
});
|
|
paidOrder.current = orderId;
|
|
}
|
|
|
|
await selectDriver({
|
|
rideId,
|
|
offerId: offer.offer_id,
|
|
method,
|
|
orderId: method === "card" ? orderId : undefined,
|
|
});
|
|
|
|
// Assigned: the money is spent and the ride has a driver.
|
|
paidOrder.current = null;
|
|
setPicked(null);
|
|
await load();
|
|
} catch (err) {
|
|
console.log("[BOOK_RIDE_SELECT]: ", err);
|
|
setPicked(null);
|
|
|
|
if (err instanceof ApiError && err.status === 409) {
|
|
Alert.alert(
|
|
t("bookRide.offers.goneTitle"),
|
|
paidOrder.current
|
|
? t("bookRide.offers.goneBodyPaid")
|
|
: t("bookRide.offers.goneBody"),
|
|
);
|
|
} else {
|
|
Alert.alert(
|
|
t("bookRide.alertErrorTitle"),
|
|
err instanceof ApiError ? err.message : t("bookRide.match.alertBody"),
|
|
);
|
|
}
|
|
await load();
|
|
} finally {
|
|
setPaying(false);
|
|
}
|
|
};
|
|
|
|
const cancel = async (reason: string) => {
|
|
setCancelling(true);
|
|
try {
|
|
await fetchAPI(`/(api)/ride/${rideId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: "cancelled", reason }),
|
|
});
|
|
setCancelOpen(false);
|
|
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 driverId = driver.id;
|
|
const terminal = TERMINAL.includes(ride.status);
|
|
const driverName = [driver.first_name, driver.last_name]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
const cashDue = ride.payment_status === "cash";
|
|
const offers = (ride.offers ?? []) as RideOffer[];
|
|
// Whole seconds the search has been running, measured on the server's clock.
|
|
const searchSeconds = Math.max(
|
|
0,
|
|
Math.round(
|
|
(Date.now() + clockOffset.current - Date.parse(ride.created_at)) / 1000,
|
|
),
|
|
);
|
|
|
|
return (
|
|
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
|
|
<View className="h-[45%] bg-blue-500">
|
|
<Map trackedDriver={driverId ? { ...driver, id: driverId } : null} />
|
|
</View>
|
|
|
|
{/* Scrollable, because the number of things below the map isn't fixed:
|
|
four drivers offering on a request push the fare, the cancel button
|
|
— and the fourth driver — off the bottom of the screen, and a rider
|
|
who can't reach an offer can't take it. */}
|
|
<ScrollView
|
|
className="flex-1 px-5 pt-4"
|
|
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }}
|
|
>
|
|
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
|
|
{/* Once drivers have volunteered the screen stops being a search and
|
|
becomes a decision, and the heading has to say which one it is —
|
|
a rider reading "finding your driver" over a list of drivers
|
|
doesn't know it's waiting on them. */}
|
|
{ride.status === "requested" && offers.length > 0
|
|
? t("bookRide.status.choosing")
|
|
: STATUS_KEY[ride.status]
|
|
? t(STATUS_KEY[ride.status])
|
|
: ride.status}
|
|
</Text>
|
|
|
|
{/* Waiting on the first driver to volunteer. The elapsed counter is
|
|
there because a spinner with no number on it reads as broken after
|
|
about ten seconds — and the request legitimately sits open for a
|
|
couple of minutes. A rider who can see it counting knows their
|
|
request is still live. */}
|
|
{ride.status === "requested" && offers.length === 0 ? (
|
|
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-5 mt-2 items-center">
|
|
<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>
|
|
<Text className="text-xs text-general-200 dark:text-neutral-400 mt-2">
|
|
{t("bookRide.searchingFor", { seconds: searchSeconds })}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{/* Drivers who want the job. The rider picks; everyone else is let go
|
|
the moment they do. */}
|
|
{ride.status === "requested" && offers.length > 0 ? (
|
|
<OfferList
|
|
offers={offers}
|
|
pendingOfferId={paying ? (picked?.offer_id ?? null) : null}
|
|
busy={paying}
|
|
onPick={setPicked}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Pickup code — the rider's half of the handshake. Shown from the
|
|
moment a driver is assigned until the trip starts; the driver
|
|
can't start without hearing it, which is what stops a rider from
|
|
getting into the wrong car (and the wrong car from taking them). */}
|
|
{ride.pickup_code ? (
|
|
<View
|
|
className={`rounded-2xl p-4 mt-2 items-center ${
|
|
ride.status === "arrived"
|
|
? "bg-emerald-500"
|
|
: "bg-white dark:bg-neutral-900"
|
|
}`}
|
|
>
|
|
<Text
|
|
className={`text-xs font-JakartaMedium ${
|
|
ride.status === "arrived"
|
|
? "text-white/90"
|
|
: "text-general-200 dark:text-neutral-400"
|
|
}`}
|
|
>
|
|
{ride.status === "arrived"
|
|
? t("bookRide.driverHere")
|
|
: t("bookRide.pickupCodeLabel")}
|
|
</Text>
|
|
<Text
|
|
className={`text-4xl font-JakartaExtraBold tracking-[8px] mt-1 ${
|
|
ride.status === "arrived"
|
|
? "text-white"
|
|
: "text-black dark:text-white"
|
|
}`}
|
|
>
|
|
{ride.pickup_code}
|
|
</Text>
|
|
<Text
|
|
className={`text-xs text-center mt-1 ${
|
|
ride.status === "arrived"
|
|
? "text-white/90"
|
|
: "text-general-200 dark:text-neutral-400"
|
|
}`}
|
|
>
|
|
{t("bookRide.pickupCodeHint")}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{/* Driver card — shown once the pairing is confirmed. While the ride
|
|
is still 'matched' the confirmation card above is showing the same
|
|
driver, and two cards for one driver reads as two drivers. */}
|
|
{driver?.id && ride.status !== "matched" ? (
|
|
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-2">
|
|
<View className="flex-row items-center">
|
|
<Image
|
|
source={{ uri: driverPhotoUri(driver.profile_image_url) }}
|
|
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>
|
|
<View className="flex-row items-center">
|
|
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize mr-3">
|
|
{driver.service ?? ride.service}
|
|
</Text>
|
|
{/* Call the driver — only while the ride is active. */}
|
|
{!terminal ? (
|
|
<TouchableOpacity
|
|
onPress={() =>
|
|
router.push({
|
|
pathname: "/(root)/call",
|
|
params: { rideId: String(ride.ride_id), mode: "start" },
|
|
})
|
|
}
|
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
accessibilityLabel={t("chat.call")}
|
|
className="w-9 h-9 rounded-full bg-general-400 items-center justify-center"
|
|
>
|
|
<MaterialCommunityIcons
|
|
name="phone"
|
|
size={18}
|
|
color="white"
|
|
/>
|
|
</TouchableOpacity>
|
|
) : null}
|
|
</View>
|
|
</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>
|
|
{/* A cash ride the driver hasn't marked collected is money still
|
|
owed — say so rather than showing a clean "all done". */}
|
|
{cashDue ? (
|
|
<Text className="text-amber-600 dark:text-amber-400 text-sm mt-2 text-center">
|
|
{t("bookRide.cashDue", {
|
|
amount: (ride.fare_price / 100).toFixed(2),
|
|
})}
|
|
</Text>
|
|
) : null}
|
|
{ride.my_rating ? (
|
|
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-2">
|
|
{t("bookRide.youRated", { n: ride.my_rating })}
|
|
</Text>
|
|
) : (
|
|
<TouchableOpacity
|
|
onPress={() => setRatingOpen(true)}
|
|
className="mt-3"
|
|
>
|
|
<Text className="font-JakartaBold text-primary-500">
|
|
{t("bookRide.rateDriver")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
) : null}
|
|
|
|
{/* Cancelled / expired */}
|
|
{ride.status === "cancelled" || ride.status === "expired" ? (
|
|
<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 text-center">
|
|
{ride.status === "expired"
|
|
? t("bookRide.noDriversFound")
|
|
: ride.cancelled_by === "driver"
|
|
? t("bookRide.cancelledByDriver")
|
|
: t("bookRide.rideCancelled")}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<View className="mt-auto pt-6">
|
|
{terminal ? (
|
|
<CustomButton
|
|
title={t("bookRide.backHome")}
|
|
onPress={() => router.replace("/(root)/(tabs)/home")}
|
|
/>
|
|
) : ride.status === "en_route" ? (
|
|
// Once the trip is under way there is nothing to cancel — the
|
|
// rider is in the car. Ending it early is the driver's action.
|
|
<Text className="text-center text-general-200 dark:text-neutral-400 text-sm pb-3">
|
|
{t("bookRide.enRouteNotice")}
|
|
</Text>
|
|
) : (
|
|
<TouchableOpacity
|
|
onPress={() => setCancelOpen(true)}
|
|
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>
|
|
</ScrollView>
|
|
|
|
<PaymentChoiceSheet
|
|
visible={picked !== null}
|
|
driverName={
|
|
picked
|
|
? [picked.first_name, picked.last_name].filter(Boolean).join(" ")
|
|
: null
|
|
}
|
|
fareCents={ride.fare_price}
|
|
submitting={paying}
|
|
onPay={(method) => void pay(method)}
|
|
onCancel={() => setPicked(null)}
|
|
/>
|
|
|
|
<CancelSheet
|
|
visible={cancelOpen}
|
|
audience="rider"
|
|
submitting={cancelling}
|
|
onCancel={() => setCancelOpen(false)}
|
|
onConfirm={(reason) => void cancel(reason)}
|
|
/>
|
|
|
|
<RatingSheet
|
|
visible={ratingOpen}
|
|
rideId={rideId}
|
|
audience="rider"
|
|
subjectName={driverName || null}
|
|
subjectAvatar={driver.profile_image_url}
|
|
onDone={() => {
|
|
setRatingOpen(false);
|
|
setRatingHandled(true);
|
|
void load();
|
|
}}
|
|
onSkip={() => {
|
|
setRatingOpen(false);
|
|
setRatingHandled(true);
|
|
}}
|
|
/>
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
export default BookRide;
|