Build driver app, Uber-style dispatch, POI suggestions; fix map tiles
Driver side (was a stub): - In-app driver onboarding: a driver-role user creates their own linked drivers profile (driver/profile+api GET/POST/PATCH). - Driver dashboard: online/offline toggle, today's earnings, incoming request cards (accept/decline), active ride panel (start/complete trip). Polls /driver/rides every 4s while online. - Location heartbeat (use-driver-location): watchPositionAsync pings /driver/location every ~5s; restarts the watch on app foreground so a backgrounded driver doesn't go permanently stale and miss requests. Dispatch (auto-match nearest, Uber-style): - Ride state machine: requested -> accepted -> en_route -> completed/cancelled with a nullable driver_id until matched (lib/dispatch.matchNextDriver). - matchNextDriver locks the ride (SELECT FOR UPDATE), expires 15s-stale offers, picks the nearest eligible driver of the matching service by haversine, offers one at a time. Called from ride/create, ride/[id] GET (lazy match on the rider's poll), and ride/[id]/respond (on decline). - ride/create is now a request endpoint (driver_id NULL, status=requested, service); drops the pre-match driver_id payment reconciliation. - ride/[id] GET returns status/service/nullable driver; PATCH handles rider cancel + driver en_route/completed. ride/list backs the history tabs. Rider flow (best experience): - confirm-ride is now a request screen: single trip fare + nearest-driver ETA + cash/card + Request Ride -> live status. Periodically polls online drivers of the selected service and disables Request when none are online (prevents the "stuck searching forever" state). - book-ride is the live ride-status screen (searching -> accepted -> en_route -> completed/cancelled + Cancel), polling every 3s. - lib/request-ride unifies the Areeba card flow + cash path. - Map reads /driver/nearby (real positions, service-filtered); lib/map adds calculateTripFare + service-aware fares. POI suggestions: - lib/places (Google Nearby Search) + nearby-suggestions chips for mall/hospital/pharmacy/restaurant on the home screen. Service categories now drive both matching and a per-service fare multiplier (car 1.0 / moto 0.7 / courier 0.85 / chauffeur 1.5). Map tiles: react-native-maps rendered blank on Android because no Google Maps key was set. Switched app.json -> app.config.js so android.config.googleMaps.apiKey is injected from EXPO_PUBLIC_GOOGLE_API_KEY at build time (keeps the key out of git). Requires a native rebuild (expo run:android) to take effect. Also includes the prior payment/auth hardening (server-authoritative payment_orders ledger with double-spend guards, peppered OTP, register TOCTOU fix, stats cents fix) that was left uncommitted. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@ const TabIcon = ({
|
||||
|
||||
const TabsLayout = () => (
|
||||
<Tabs
|
||||
initialRouteName="index"
|
||||
initialRouteName="home"
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: "white",
|
||||
tabBarInactiveTintColor: "white",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { GoogleTextInput } from "@/components/google-text-input";
|
||||
import { LocationNotice } from "@/components/location-notice";
|
||||
import { Map } from "@/components/map";
|
||||
import { NearbySuggestions } from "@/components/nearby-suggestions";
|
||||
import { RideCard } from "@/components/ride-card";
|
||||
import { ServiceSelector } from "@/components/service-selector";
|
||||
import { icons, images } from "@/constants";
|
||||
@@ -26,9 +27,7 @@ const Home = () => {
|
||||
(state) => state.setDestinationLocation,
|
||||
);
|
||||
const { signOut, user } = useSession();
|
||||
const { data: recentRides, loading } = useFetch<Ride[]>(
|
||||
`/(api)/ride/${user?.id}`,
|
||||
);
|
||||
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
|
||||
|
||||
const { status: locationStatus, retry: retryLocation } = useUserLocation();
|
||||
|
||||
@@ -140,6 +139,10 @@ const Home = () => {
|
||||
|
||||
<ServiceSelector />
|
||||
|
||||
<View className="mt-5">
|
||||
<NearbySuggestions />
|
||||
</View>
|
||||
|
||||
<Text className="text-xl font-JakartaBold mt-5 mb-3">
|
||||
Recent Rides
|
||||
</Text>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Image, ScrollView, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
import { InputField } from "@/components/input-field";
|
||||
import { icons } from "@/constants";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const Profile = () => {
|
||||
@@ -17,7 +18,7 @@ const Profile = () => {
|
||||
|
||||
<View className="flex items-center justify-center my-5">
|
||||
<Image
|
||||
source={{ uri: user?.avatarUrl ?? undefined }}
|
||||
source={user?.avatarUrl ? { uri: user.avatarUrl } : icons.profile}
|
||||
alt="Your Avatar"
|
||||
style={{ width: 110, height: 110, borderRadius: 110 / 2 }}
|
||||
className=" rounded-full h-[110px] w-[110px] border-[3px] border-white shadow-sm shadow-neutral-300"
|
||||
@@ -28,7 +29,7 @@ const Profile = () => {
|
||||
<View className="flex flex-col items-start justify-start w-full">
|
||||
<InputField
|
||||
label="First name"
|
||||
placeholder={user?.name.split(" ")[0] ?? "Your First name"}
|
||||
placeholder={user?.name?.split(" ")[0] || "Your First name"}
|
||||
containerStyles="w-full mb-4"
|
||||
inputStyles="p-3.5"
|
||||
editable={false}
|
||||
@@ -36,7 +37,7 @@ const Profile = () => {
|
||||
|
||||
<InputField
|
||||
label="Last name"
|
||||
placeholder={user?.name.split(" ").slice(1).join(" ") ?? "Your Last name"}
|
||||
placeholder={user?.name?.split(" ").slice(1).join(" ") || "Your Last name"}
|
||||
containerStyles="w-full mb-4"
|
||||
inputStyles="p-3.5"
|
||||
editable={false}
|
||||
|
||||
@@ -4,14 +4,10 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { RideCard } from "@/components/ride-card";
|
||||
import { images } from "@/constants";
|
||||
import { useFetch } from "@/lib/fetch";
|
||||
import { useSession } from "@/lib/session";
|
||||
import type { Ride } from "@/types/type";
|
||||
|
||||
const Rides = () => {
|
||||
const { user } = useSession();
|
||||
const { data: recentRides, loading } = useFetch<Ride[]>(
|
||||
`/(api)/ride/${user?.id}`,
|
||||
);
|
||||
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
|
||||
+219
-107
@@ -1,136 +1,248 @@
|
||||
import { router } from "expo-router";
|
||||
import { Image, Text, View } from "react-native";
|
||||
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 { Payment } from "@/components/payment";
|
||||
import { RideLayout } from "@/components/ride-layout";
|
||||
import { icons } from "@/constants";
|
||||
import { formatLBP } from "@/lib/pricing";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { Map } from "@/components/map";
|
||||
import { icons, images } from "@/constants";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { formatTime } from "@/lib/utils";
|
||||
import { useDriverStore, useLocationStore } from "@/store";
|
||||
import { useLocationStore } from "@/store";
|
||||
import type { Ride } from "@/types/type";
|
||||
|
||||
const POLL_MS = 3000;
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
requested: "Finding your driver…",
|
||||
accepted: "Driver assigned — heading to you",
|
||||
en_route: "On your trip",
|
||||
completed: "You've arrived!",
|
||||
cancelled: "Ride 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 { user } = useSession();
|
||||
const { userAddress, destinationAddress } = useLocationStore();
|
||||
const { drivers, selectedDriver } = useDriverStore();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const rideId = Number(id);
|
||||
const setUserLocation = useLocationStore((s) => s.setUserLocation);
|
||||
const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation);
|
||||
|
||||
const driverDetails = drivers?.filter(
|
||||
(driver) => +driver.id === selectedDriver,
|
||||
)[0];
|
||||
const [ride, setRide] = useState<Ride | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!driverDetails) {
|
||||
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("Ride not found.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [rideId, setUserLocation, setDestinationLocation]);
|
||||
|
||||
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("Error", "Could not cancel this ride. Please try again.");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<RideLayout title="Book Ride">
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text className="text-base text-general-200 font-JakartaMedium text-center">
|
||||
No driver selected.{"\n"}Please go back and choose a driver first.
|
||||
</Text>
|
||||
|
||||
<CustomButton
|
||||
title="Choose a Driver"
|
||||
onPress={() => router.replace("/(root)/confirm-ride")}
|
||||
className="mt-6"
|
||||
/>
|
||||
</View>
|
||||
</RideLayout>
|
||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#0286ff" />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !ride) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white items-center justify-center px-7">
|
||||
<Text className="text-base text-general-200 text-center">
|
||||
{error ?? "Could not load this ride."}
|
||||
</Text>
|
||||
<CustomButton
|
||||
title="Back Home"
|
||||
onPress={() => router.replace("/(root)/(tabs)/home")}
|
||||
className="mt-6"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const driver = ride.driver;
|
||||
const terminal = ride.status === "completed" || ride.status === "cancelled";
|
||||
|
||||
return (
|
||||
<RideLayout title="Book Ride">
|
||||
<>
|
||||
<Text className="text-xl font-JakartaSemiBold mb-3">
|
||||
Ride Information
|
||||
<SafeAreaView className="flex-1 bg-general-500">
|
||||
<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">
|
||||
{statusLabel[ride.status] ?? ride.status}
|
||||
</Text>
|
||||
|
||||
<View className="flex flex-col w-full items-center justify-center mt-10">
|
||||
<Image
|
||||
source={{ uri: driverDetails?.profile_image_url }}
|
||||
alt="Driver Avatar"
|
||||
className="w-28 h-28 rounded-full"
|
||||
/>
|
||||
|
||||
<View className="flex flex-row items-center justify-center mt-5 space-x-2">
|
||||
<Text className="text-lg font-JakartaSemiBold">
|
||||
{driverDetails?.title}
|
||||
{/* Searching state */}
|
||||
{ride.status === "requested" ? (
|
||||
<View className="items-center mt-6">
|
||||
<ActivityIndicator size="large" color="#0286ff" />
|
||||
<Text className="text-general-200 mt-3 text-center">
|
||||
We're matching you with the nearest {ride.service} driver.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="flex flex-row items-center space-x-0.5">
|
||||
{/* Driver card — shown once a driver is assigned. */}
|
||||
{driver?.id ? (
|
||||
<View className="bg-white rounded-2xl p-4 mt-2">
|
||||
<View className="flex-row items-center">
|
||||
<Image
|
||||
source={icons.star}
|
||||
alt="Star"
|
||||
className="w-5 h-5"
|
||||
resizeMode="contain"
|
||||
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">
|
||||
{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">
|
||||
{driver.rating?.toFixed(1) ?? "—"}
|
||||
</Text>
|
||||
{driver.car_model ? (
|
||||
<Text className="ml-3 text-general-200">
|
||||
{driver.car_model}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-xs text-general-200 capitalize">
|
||||
{driver.service ?? ride.service}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-lg font-JakartaRegular">
|
||||
{driverDetails?.rating}
|
||||
<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" 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" numberOfLines={1}>
|
||||
{ride.destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between mt-4 pt-3 border-t border-neutral-100">
|
||||
<Text className="text-general-200 text-xs">
|
||||
{ride.payment_status === "cash"
|
||||
? "💵 Cash to driver"
|
||||
: "💳 Paid by card"}
|
||||
</Text>
|
||||
<Text className="font-JakartaBold text-emerald-600">
|
||||
${(ride.fare_price / 100).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="flex flex-col w-full items-start justify-center py-3 px-5 rounded-3xl bg-general-600 mt-5">
|
||||
<View className="flex flex-row items-center justify-between w-full border-b border-white py-3">
|
||||
<Text className="text-lg font-JakartaRegular">Ride Price</Text>
|
||||
{/* Completed summary */}
|
||||
{ride.status === "completed" ? (
|
||||
<View className="bg-white 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">
|
||||
Fare: ${(ride.fare_price / 100).toFixed(2)}
|
||||
</Text>
|
||||
<Text className="text-general-200 text-sm mt-1">
|
||||
Trip time {formatTime(ride.ride_time)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="flex flex-col items-end">
|
||||
<Text className="text-lg font-JakartaRegular text-[#0CC25F]">
|
||||
${driverDetails?.price}
|
||||
{/* Cancelled */}
|
||||
{ride.status === "cancelled" ? (
|
||||
<View className="bg-white rounded-2xl p-4 mt-4 items-center">
|
||||
<Text className="text-general-200">
|
||||
This ride was cancelled.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="mt-auto pt-6">
|
||||
{terminal ? (
|
||||
<CustomButton
|
||||
title="Back Home"
|
||||
onPress={() => router.replace("/(root)/(tabs)/home")}
|
||||
/>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={cancel}
|
||||
disabled={cancelling}
|
||||
className="rounded-full py-3 bg-white items-center border border-rose-300"
|
||||
>
|
||||
<Text className="font-JakartaBold text-rose-500">
|
||||
{cancelling ? "Cancelling…" : "Cancel Ride"}
|
||||
</Text>
|
||||
|
||||
<Text className="text-xs font-JakartaRegular text-general-200">
|
||||
≈ {formatLBP(parseFloat(driverDetails?.price ?? "0"))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between w-full border-b border-white py-3">
|
||||
<Text className="text-lg font-JakartaRegular">Pickup Time</Text>
|
||||
|
||||
<Text className="text-lg font-JakartaRegular">
|
||||
{formatTime(driverDetails?.time!)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between w-full py-3">
|
||||
<Text className="text-lg font-JakartaRegular">Car Seats</Text>
|
||||
|
||||
<Text className="text-lg font-JakartaRegular">
|
||||
{driverDetails?.car_seats}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col w-full items-start justify-center mt-5">
|
||||
<View className="flex flex-row items-center justify-start mt-3 border-t border-b border-general-700 w-full py-3">
|
||||
<Image source={icons.to} alt="To" className="w-6 h-6" />
|
||||
|
||||
<Text className="text-lg font-JakartaRegular ml-2">
|
||||
{userAddress}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-start border-b border-general-700 w-full py-3">
|
||||
<Image source={icons.point} alt="Point" className="w-6 h-6" />
|
||||
|
||||
<Text className="text-lg font-JakartaRegular ml-2">
|
||||
{destinationAddress}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Payment
|
||||
fullName={user?.name ?? ""}
|
||||
email={user?.email ?? ""}
|
||||
amount={driverDetails?.price ?? "0"}
|
||||
driverId={driverDetails?.id}
|
||||
rideTime={driverDetails?.time ?? 0}
|
||||
/>
|
||||
</>
|
||||
</RideLayout>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookRide;
|
||||
export default BookRide;
|
||||
+311
-30
@@ -1,44 +1,325 @@
|
||||
import { router } from "expo-router";
|
||||
import { FlatList, Text, View } from "react-native";
|
||||
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 { DriverCard } from "@/components/driver-card";
|
||||
import { RideLayout } from "@/components/ride-layout";
|
||||
import { useDriverStore } from "@/store";
|
||||
import { SERVICES } from "@/constants/services";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
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 { drivers, selectedDriver, setSelectedDriver } = useDriverStore();
|
||||
const params = useLocalSearchParams<{ service?: string }>();
|
||||
const {
|
||||
userAddress,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
destinationAddress,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
} = useLocationStore();
|
||||
const { service: storeService, setService } = useServiceStore();
|
||||
const { user } = useSession();
|
||||
|
||||
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("Missing route", "Please set a pickup and destination first.");
|
||||
return;
|
||||
}
|
||||
if (!estimate) {
|
||||
Alert.alert("No estimate", "We couldn't estimate this fare. Please try again.");
|
||||
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
|
||||
: "Something went wrong while booking your ride. Please try again.";
|
||||
Alert.alert("Error", msg);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (method === "card") {
|
||||
Alert.alert(
|
||||
"Pay by card",
|
||||
`Your card will be charged $${estimate.fare}.`,
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Continue", onPress: () => void doRequest() },
|
||||
],
|
||||
);
|
||||
} else {
|
||||
void doRequest();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<RideLayout title="Choose a Driver" snapPoints={["65%", "85%"]}>
|
||||
<FlatList
|
||||
data={drivers}
|
||||
renderItem={({ item }) => (
|
||||
<DriverCard
|
||||
item={item}
|
||||
selected={selectedDriver ?? 0}
|
||||
setSelected={() => setSelectedDriver(item.id)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<Text className="text-center text-general-200 font-JakartaMedium mt-10">
|
||||
No drivers available on this route right now.{"\n"}Please try
|
||||
another destination.
|
||||
<RideLayout title="Request Ride" snapPoints={["60%", "88%"]}>
|
||||
<Text className="text-xl font-JakartaSemiBold mb-1">Your trip</Text>
|
||||
|
||||
<View className="flex-row items-center gap-x-2 mb-1">
|
||||
<Text className="text-general-200 text-xs">Pickup</Text>
|
||||
</View>
|
||||
<Text className="font-JakartaMedium mb-3" numberOfLines={1}>
|
||||
{userAddress}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-x-2 mb-1">
|
||||
<Text className="text-general-200 text-xs">Destination</Text>
|
||||
</View>
|
||||
<Text className="font-JakartaMedium mb-4" numberOfLines={1}>
|
||||
{destinationAddress}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center justify-between bg-general-500 rounded-2xl p-4 mb-4">
|
||||
<View>
|
||||
<Text className="text-general-200 text-xs font-JakartaMedium">
|
||||
{selected.label} · {selected.tagline}
|
||||
</Text>
|
||||
<Text className="text-general-200 text-xs mt-1">
|
||||
Trip time {estimate ? formatTime(estimate.durationSeconds / 60) : "…"}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="items-end">
|
||||
<Text className="text-2xl font-JakartaExtraBold">
|
||||
{estimating ? "…" : estimate ? `$${estimate.fare}` : "—"}
|
||||
</Text>
|
||||
{estimate ? (
|
||||
<Text className="text-xs text-general-200">
|
||||
≈ {formatLBP(parseFloat(estimate.fare))}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
className={`text-base font-JakartaMedium mb-2 ${
|
||||
driversOnline === 0 ? "text-rose-500" : "text-general-200"
|
||||
}`}
|
||||
>
|
||||
{driversOnline === 0
|
||||
? `No ${selected.label} drivers online right now`
|
||||
: nearestEta == null
|
||||
? "Finding drivers nearby…"
|
||||
: `Nearest driver ≈ ${nearestEta} min away`}
|
||||
</Text>
|
||||
|
||||
<Text className="text-lg font-JakartaSemiBold mt-2 mb-2">
|
||||
Payment Method
|
||||
</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 border-primary-500"
|
||||
: "bg-white border-general-700"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-JakartaMedium ${
|
||||
method === "cash" ? "text-white" : "text-black"
|
||||
}`}
|
||||
>
|
||||
💵 Cash
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => setMethod("card")}
|
||||
className={`flex-1 items-center py-3 rounded-xl border ${
|
||||
method === "card"
|
||||
? "bg-general-600 border-primary-500"
|
||||
: "bg-white border-general-700"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-JakartaMedium ${
|
||||
method === "card" ? "text-white" : "text-black"
|
||||
}`}
|
||||
>
|
||||
💳 Card
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<CustomButton
|
||||
title={
|
||||
processing
|
||||
? "Requesting…"
|
||||
: driversOnline === 0
|
||||
? "No drivers online"
|
||||
: method === "cash"
|
||||
? "Request Ride · Pay cash to driver"
|
||||
: "Request Ride · Pay by card"
|
||||
}
|
||||
ListFooterComponent={
|
||||
<View className="mx-5 mt-10">
|
||||
<CustomButton
|
||||
title="Select Ride"
|
||||
onPress={() => router.push("/(root)/book-ride")}
|
||||
disabled={selectedDriver === null}
|
||||
className={selectedDriver === null ? "opacity-50" : ""}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
className="mt-4"
|
||||
onPress={request}
|
||||
disabled={processing || estimating || !estimate || driversOnline === 0}
|
||||
/>
|
||||
</RideLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmRide;
|
||||
export default ConfirmRide;
|
||||
+533
-24
@@ -1,40 +1,549 @@
|
||||
import { Image, Text, View } from "react-native";
|
||||
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 { images } from "@/constants";
|
||||
import { icons, images } from "@/constants";
|
||||
import { SERVICES, type ServiceId } from "@/constants/services";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { useSession } from "@/lib/session";
|
||||
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;
|
||||
};
|
||||
|
||||
// Placeholder driver home. The driver experience (going online, accepting
|
||||
// rides) is not built yet — drivers are registered here and managed in the
|
||||
// database for now.
|
||||
const DriverHome = () => {
|
||||
const { signOut, user } = useSession();
|
||||
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("Error", "Could not change your status. Please try again.");
|
||||
} 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(
|
||||
"Error",
|
||||
action === "accept"
|
||||
? "Could not accept this ride. It may have been taken or expired."
|
||||
: "Could not decline this ride. Please try again.",
|
||||
);
|
||||
} 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("Error", "Could not update the ride. Please try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#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-white justify-center items-center px-7">
|
||||
<Image
|
||||
source={images.check}
|
||||
alt="Registered"
|
||||
className="w-[110px] h-[110px] mb-5"
|
||||
/>
|
||||
<SafeAreaView className="flex-1 bg-general-500">
|
||||
<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">
|
||||
Driver mode
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={signOut}
|
||||
className="w-10 h-10 rounded-full bg-white items-center justify-center"
|
||||
>
|
||||
<Image source={icons.out} className="w-4 h-4" alt="Sign out" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text className="text-2xl font-JakartaBold text-center">
|
||||
You're registered as a driver, {user?.name || "there"}!
|
||||
</Text>
|
||||
{/* Online / offline toggle */}
|
||||
<TouchableOpacity
|
||||
onPress={toggleOnline}
|
||||
disabled={busy}
|
||||
className={`rounded-2xl p-5 items-center mb-4 ${
|
||||
online ? "bg-emerald-500" : "bg-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<Text className="text-white text-lg font-JakartaBold">
|
||||
{online ? "● Online — receiving ride requests" : "○ Go online to drive"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-base text-general-200 font-Jakarta text-center mt-3">
|
||||
Driver mode is coming soon. We'll contact you at{" "}
|
||||
{user?.email} once your account is activated.
|
||||
</Text>
|
||||
{/* Earnings summary */}
|
||||
<View className="bg-white rounded-2xl p-4 mb-4 flex-row justify-between">
|
||||
<View>
|
||||
<Text className="text-general-200 text-xs font-JakartaMedium">
|
||||
Today's earnings
|
||||
</Text>
|
||||
<Text className="text-2xl font-JakartaExtraBold">
|
||||
${(earnings / 100).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="items-end">
|
||||
<Text className="text-general-200 text-xs font-JakartaMedium">
|
||||
Completed today
|
||||
</Text>
|
||||
<Text className="text-2xl font-JakartaExtraBold">{rideCount}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<CustomButton
|
||||
title="Sign Out"
|
||||
onPress={() => signOut()}
|
||||
className="mt-10"
|
||||
/>
|
||||
{/* 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">
|
||||
Incoming requests {online ? "" : "(offline)"}
|
||||
</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 rounded-2xl p-6 items-center">
|
||||
<Image source={images.noResult} className="w-24 h-24" resizeMode="contain" />
|
||||
<Text className="text-general-200 mt-2">
|
||||
{online ? "Waiting for ride requests…" : "Go online to start driving."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default DriverHome;
|
||||
// --- Onboarding form ------------------------------------------------------
|
||||
|
||||
const Onboarding = ({
|
||||
onCreated,
|
||||
signOut,
|
||||
userName,
|
||||
}: {
|
||||
onCreated: () => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
userName?: string | null;
|
||||
}) => {
|
||||
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("Invalid seats", "Car seats must be a whole number 1–8.");
|
||||
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("Error", "Could not create your driver profile. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white">
|
||||
<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">
|
||||
Welcome, {userName?.split(" ")[0] || "driver"}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={signOut}
|
||||
className="w-10 h-10 rounded-full bg-neutral-100 items-center justify-center"
|
||||
>
|
||||
<Image source={icons.out} className="w-4 h-4" alt="Sign out" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text className="text-base text-general-200 font-Jakarta mb-4">
|
||||
Set up your driver profile to start receiving ride requests.
|
||||
</Text>
|
||||
|
||||
<Text className="text-lg font-JakartaSemiBold mb-3">
|
||||
What will you drive?
|
||||
</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 bg-neutral-100"
|
||||
}`}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={item.icon}
|
||||
size={24}
|
||||
color={active ? "#0286ff" : "#858585"}
|
||||
/>
|
||||
<Text
|
||||
className={`mt-1.5 text-xs font-JakartaBold ${
|
||||
active ? "text-primary-500" : "text-black"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Text className="text-lg font-JakartaSemiBold mb-3">Car model</Text>
|
||||
<TextInput
|
||||
value={carModel}
|
||||
onChangeText={setCarModel}
|
||||
placeholder="e.g. Toyota Camry"
|
||||
className="bg-neutral-100 rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-4"
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
|
||||
<Text className="text-lg font-JakartaSemiBold mb-3">Car seats</Text>
|
||||
<TextInput
|
||||
value={carSeats}
|
||||
onChangeText={setCarSeats}
|
||||
placeholder="4"
|
||||
keyboardType="number-pad"
|
||||
className="bg-neutral-100 rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-8"
|
||||
/>
|
||||
|
||||
<CustomButton
|
||||
title={submitting ? "Saving…" : "Start driving"}
|
||||
onPress={submit}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Offer card -----------------------------------------------------------
|
||||
|
||||
const OfferCard = ({
|
||||
offer,
|
||||
busy,
|
||||
onAccept,
|
||||
onDecline,
|
||||
}: {
|
||||
offer: Offer;
|
||||
busy: boolean;
|
||||
onAccept: () => void;
|
||||
onDecline: () => void;
|
||||
}) => (
|
||||
<View className="bg-white 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">
|
||||
New request · {offer.service}
|
||||
</Text>
|
||||
<Text className="text-xs text-general-200">
|
||||
{offer.payment_status === "cash" ? "💵 Cash" : "💳 Card"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-x-2 mb-1">
|
||||
<Image source={icons.to} alt="From" className="w-4 h-4" />
|
||||
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||
{offer.origin_address}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-x-2 mb-3">
|
||||
<Image source={icons.point} alt="To" className="w-4 h-4" />
|
||||
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||
{offer.destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between mb-3">
|
||||
<Text className="text-general-200 text-xs">Trip time</Text>
|
||||
<Text className="font-JakartaMedium text-xs">
|
||||
{formatTime(offer.ride_time)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row justify-between mb-3">
|
||||
<Text className="text-general-200 text-xs">Fare</Text>
|
||||
<Text className="font-JakartaMedium text-xs text-emerald-600">
|
||||
${(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 items-center"
|
||||
>
|
||||
<Text className="font-JakartaBold text-neutral-700">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 ? "…" : "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 statusLabel =
|
||||
ride.status === "accepted"
|
||||
? "Head to pickup"
|
||||
: ride.status === "en_route"
|
||||
? "Trip in progress"
|
||||
: 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">{ride.service}</Text>
|
||||
</View>
|
||||
|
||||
{ride.rider_name ? (
|
||||
<Text className="font-JakartaBold mb-2">{ride.rider_name}</Text>
|
||||
) : null}
|
||||
|
||||
<View className="flex-row items-center gap-x-2 mb-1">
|
||||
<Image source={icons.to} alt="From" className="w-4 h-4" />
|
||||
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||
{ride.origin_address}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-x-2 mb-3">
|
||||
<Image source={icons.point} alt="To" className="w-4 h-4" />
|
||||
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||
{ride.destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between mb-4">
|
||||
<Text className="text-general-200 text-xs">Fare</Text>
|
||||
<Text className="font-JakartaMedium text-xs text-emerald-600">
|
||||
${(ride.fare_price / 100).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{ride.status === "accepted" ? (
|
||||
<CustomButton
|
||||
title={busy ? "…" : "Start trip"}
|
||||
bgVariant="success"
|
||||
onPress={() => onAdvance(ride.ride_id, "en_route")}
|
||||
className="mb-2"
|
||||
/>
|
||||
) : null}
|
||||
{ride.status === "en_route" ? (
|
||||
<CustomButton
|
||||
title={busy ? "…" : "Complete trip"}
|
||||
bgVariant="success"
|
||||
onPress={() => onAdvance(ride.ride_id, "completed")}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default DriverHome;
|
||||
Reference in New Issue
Block a user