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:
+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;
|
||||
Reference in New Issue
Block a user