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>
173 lines
5.0 KiB
TypeScript
173 lines
5.0 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Platform, StyleSheet } from "react-native";
|
|
import MapView, { Marker, PROVIDER_DEFAULT } from "react-native-maps";
|
|
import MapViewDirections from "react-native-maps-directions";
|
|
|
|
import { icons } from "@/constants";
|
|
import { useFetch } from "@/lib/fetch";
|
|
import {
|
|
calculateDriverTimes,
|
|
calculateRegion,
|
|
generateMarkersFromData,
|
|
} from "@/lib/map";
|
|
import { useDriverStore, useLocationStore, useServiceStore } from "@/store";
|
|
import type { Driver, MarkerData } from "@/types/type";
|
|
|
|
// react-native-maps sizes itself from a real style object, so give it explicit
|
|
// dimensions rather than relying on percentage classNames resolving to 0.
|
|
const styles = StyleSheet.create({
|
|
map: { ...StyleSheet.absoluteFillObject, borderRadius: 16 },
|
|
});
|
|
|
|
// "mutedStandard" is an Apple Maps type. Android's MapManager looks the value
|
|
// up in a fixed table and unboxes the result into an int, so an unrecognised
|
|
// name is a null Integer -> NullPointerException, and the map never draws.
|
|
const MAP_TYPE = Platform.OS === "ios" ? "mutedStandard" : "standard";
|
|
|
|
// showsPointsOfInterest is iOS-only; on Android the same muting is done with a
|
|
// style array, so both platforms get the same clean base map.
|
|
const MUTED_POI_STYLE = [
|
|
{
|
|
featureType: "poi",
|
|
elementType: "labels",
|
|
stylers: [{ visibility: "off" }],
|
|
},
|
|
{
|
|
featureType: "transit",
|
|
elementType: "labels.icon",
|
|
stylers: [{ visibility: "off" }],
|
|
},
|
|
];
|
|
|
|
export const Map = () => {
|
|
const {
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
} = useLocationStore();
|
|
const { service } = useServiceStore();
|
|
const { selectedDriver, setDrivers } = useDriverStore();
|
|
|
|
// Online drivers of the selected service near the rider. Falls back to a
|
|
// Beirut center when the rider's position isn't resolved yet so the map
|
|
// still populates instead of sitting empty.
|
|
const lat = userLatitude ?? 33.8938;
|
|
const lng = userLongitude ?? 35.5018;
|
|
const { data: drivers, error } = useFetch<Driver[]>(
|
|
`/(api)/driver/nearby?service=${service}&lat=${lat}&lng=${lng}`,
|
|
);
|
|
|
|
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
|
|
|
const region = calculateRegion({
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (Array.isArray(drivers)) {
|
|
if (!userLatitude || !userLongitude) return;
|
|
|
|
const newMarkers = generateMarkersFromData({
|
|
data: drivers,
|
|
userLatitude,
|
|
userLongitude,
|
|
});
|
|
|
|
setMarkers(newMarkers);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [drivers, userLatitude, userLongitude]);
|
|
|
|
useEffect(() => {
|
|
if (markers.length > 0 && destinationLatitude && destinationLongitude) {
|
|
calculateDriverTimes({
|
|
markers,
|
|
userLatitude,
|
|
userLongitude,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
service,
|
|
}).then((driversWithTimes) => {
|
|
setDrivers((driversWithTimes as MarkerData[]) ?? []);
|
|
});
|
|
}
|
|
}, [
|
|
markers,
|
|
destinationLatitude,
|
|
destinationLongitude,
|
|
userLatitude,
|
|
userLongitude,
|
|
setDrivers,
|
|
service,
|
|
]);
|
|
|
|
// The map itself never waits on the driver list or the location fix: drivers
|
|
// are an overlay, and calculateRegion falls back to Beirut without coords.
|
|
// Previously either one failing replaced the whole map with a spinner or an
|
|
// error line, which read as "the map didn't load".
|
|
if (error) console.log("[MAP_DRIVERS]: ", error);
|
|
|
|
return (
|
|
<MapView
|
|
provider={PROVIDER_DEFAULT}
|
|
style={styles.map}
|
|
tintColor="black"
|
|
mapType={MAP_TYPE}
|
|
customMapStyle={MUTED_POI_STYLE}
|
|
showsPointsOfInterest={false}
|
|
initialRegion={region}
|
|
showsUserLocation
|
|
userInterfaceStyle="light"
|
|
>
|
|
{markers.map((marker) => (
|
|
<Marker
|
|
key={marker.id}
|
|
coordinate={{
|
|
latitude: marker.latitude,
|
|
longitude: marker.longitude,
|
|
}}
|
|
title={marker.title}
|
|
image={
|
|
selectedDriver === marker.id ? icons.selectedMarker : icons.marker
|
|
}
|
|
/>
|
|
))}
|
|
|
|
{userLatitude &&
|
|
userLongitude &&
|
|
destinationLatitude &&
|
|
destinationLongitude && (
|
|
<>
|
|
<Marker
|
|
key="destination"
|
|
coordinate={{
|
|
latitude: destinationLatitude,
|
|
longitude: destinationLongitude,
|
|
}}
|
|
title="Destination"
|
|
image={icons.pin}
|
|
/>
|
|
|
|
<MapViewDirections
|
|
origin={{
|
|
latitude: userLatitude,
|
|
longitude: userLongitude,
|
|
}}
|
|
destination={{
|
|
latitude: destinationLatitude,
|
|
longitude: destinationLongitude,
|
|
}}
|
|
apikey={process.env.EXPO_PUBLIC_GOOGLE_API_KEY!}
|
|
strokeColor="#0286FF"
|
|
strokeWidth={3}
|
|
/>
|
|
</>
|
|
)}
|
|
</MapView>
|
|
);
|
|
};
|