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:
Krikorios
2026-08-24 13:57:21 +03:00
co-authored by Claude
parent 4e0a7cca51
commit f50ff27e11
48 changed files with 3342 additions and 602 deletions
+533 -24
View File
@@ -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&apos;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&apos;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&apos;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 18.");
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;