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:
+88
-15
@@ -1,8 +1,12 @@
|
||||
import { calculateFare } from "@/lib/pricing";
|
||||
import { DEFAULT_SERVICE, type ServiceId } from "@/constants/services";
|
||||
import type { Driver, MarkerData } from "@/types/type";
|
||||
|
||||
const directionsAPI = process.env.EXPO_PUBLIC_GOOGLE_API_KEY;
|
||||
|
||||
// Build map markers from driver rows. Drivers with a real GPS position use it
|
||||
// directly; only legacy seed rows (no position) fall back to a small random
|
||||
// scatter around the rider so the map isn't empty during local dev.
|
||||
export const generateMarkersFromData = ({
|
||||
data,
|
||||
userLatitude,
|
||||
@@ -12,18 +16,25 @@ export const generateMarkersFromData = ({
|
||||
userLatitude: number;
|
||||
userLongitude: number;
|
||||
}): MarkerData[] => {
|
||||
return data.map((driver, i) => {
|
||||
const latOffset = (Math.random() - 0.5) * 0.01; // Random offset between -0.005 and 0.005
|
||||
const lngOffset = (Math.random() - 0.5) * 0.01; // Random offset between -0.005 and 0.005
|
||||
return data
|
||||
.filter((driver) => driver.latitude != null && driver.longitude != null)
|
||||
.map((driver) => {
|
||||
const lat =
|
||||
driver.latitude != null
|
||||
? driver.latitude
|
||||
: userLatitude + (Math.random() - 0.5) * 0.01;
|
||||
const lng =
|
||||
driver.longitude != null
|
||||
? driver.longitude
|
||||
: userLongitude + (Math.random() - 0.5) * 0.01;
|
||||
|
||||
return {
|
||||
id: i,
|
||||
latitude: userLatitude + latOffset,
|
||||
longitude: userLongitude + lngOffset,
|
||||
title: `${driver.first_name} ${driver.last_name}`,
|
||||
...driver,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...driver,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
title: `${driver.first_name} ${driver.last_name}`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const calculateRegion = ({
|
||||
@@ -75,18 +86,23 @@ export const calculateRegion = ({
|
||||
};
|
||||
};
|
||||
|
||||
// Per-driver ETA + fare. The rider pays for the trip leg only (distance +
|
||||
// duration) — never the driver's approach leg. `service` drives the fare
|
||||
// multiplier.
|
||||
export const calculateDriverTimes = async ({
|
||||
markers,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
service = DEFAULT_SERVICE,
|
||||
}: {
|
||||
markers: MarkerData[];
|
||||
userLatitude: number | null;
|
||||
userLongitude: number | null;
|
||||
destinationLatitude: number | null;
|
||||
destinationLongitude: number | null;
|
||||
service?: ServiceId;
|
||||
}) => {
|
||||
if (
|
||||
!userLatitude ||
|
||||
@@ -120,10 +136,13 @@ export const calculateDriverTimes = async ({
|
||||
|
||||
// The rider pays for the trip leg only (distance + duration) —
|
||||
// never for the driver's approach.
|
||||
const price = calculateFare({
|
||||
distanceMeters: legToDestination.distance.value,
|
||||
durationSeconds: timeToDestination,
|
||||
});
|
||||
const price = calculateFare(
|
||||
{
|
||||
distanceMeters: legToDestination.distance.value,
|
||||
durationSeconds: timeToDestination,
|
||||
},
|
||||
service,
|
||||
);
|
||||
|
||||
const totalTripTime = (timeToUser + timeToDestination) / 60; // Minutes until drop-off
|
||||
|
||||
@@ -135,3 +154,57 @@ export const calculateDriverTimes = async ({
|
||||
console.error("Error calculating driver times:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// A single trip-leg fare estimate for the confirm-ride screen. One Directions
|
||||
// call instead of one per driver, since the trip leg is the same regardless of
|
||||
// which driver arrives. Returns { fare, durationSeconds, distanceMeters } or
|
||||
// null when the route is unreachable.
|
||||
export const calculateTripFare = async ({
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
service = DEFAULT_SERVICE,
|
||||
}: {
|
||||
userLatitude: number | null;
|
||||
userLongitude: number | null;
|
||||
destinationLatitude: number | null;
|
||||
destinationLongitude: number | null;
|
||||
service?: ServiceId;
|
||||
}): Promise<{
|
||||
fare: string;
|
||||
durationSeconds: number;
|
||||
distanceMeters: number;
|
||||
} | null> => {
|
||||
if (
|
||||
!userLatitude ||
|
||||
!userLongitude ||
|
||||
!destinationLatitude ||
|
||||
!destinationLongitude
|
||||
)
|
||||
return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://maps.googleapis.com/maps/api/directions/json?origin=${userLatitude},${userLongitude}&destination=${destinationLatitude},${destinationLongitude}&key=${directionsAPI}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
const leg = data.routes?.[0]?.legs?.[0];
|
||||
if (!leg) return null;
|
||||
|
||||
return {
|
||||
fare: calculateFare(
|
||||
{
|
||||
distanceMeters: leg.distance.value,
|
||||
durationSeconds: leg.duration.value,
|
||||
},
|
||||
service,
|
||||
),
|
||||
durationSeconds: leg.duration.value,
|
||||
distanceMeters: leg.distance.value,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error calculating trip fare:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user