Waseel: driver capture, chat/calls, dispatch, and session fixes

Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+32 -2
View File
@@ -30,11 +30,41 @@ EXPO_PUBLIC_GEOAPIFY_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# ETA/fare estimates. Note: this key is embedded in the client bundle.
EXPO_PUBLIC_GOOGLE_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# google cloud vision — reads a new driver's licence, ID card and vehicle
# registration at onboarding so the credential fields prefill themselves.
# SERVER-SIDE ONLY: no EXPO_PUBLIC_ prefix, so it is never bundled into the
# app. Enable the Cloud Vision API on the project and restrict the key to it.
# Leaving this unset does not break onboarding — scans are still stored for the
# reviewer, the driver just types the details in by hand.
GOOGLE_VISION_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# where driver uploads are written. Defaults to ./.uploads next to the app,
# with two subdirectories: driver-documents/ (licence, ID and vehicle
# registration scans) and driver-photos/ (the profile photo riders see).
# Point this at a persistent volume in production — a redeploy that wipes it
# leaves reviewers with no documents to check against and every driver without
# a face. Never serve this directory statically: scans are read back only
# through the authenticated /(api)/driver/documents route, and photos only
# through /(api)/driver/photo, which serves a name no driver row references.
UPLOAD_DIR=
# areeba payment gateway (credentials issued after merchant onboarding)
AREEBA_API_BASE_URL="https://your-gateway-host.areeba.com"
AREEBA_MERCHANT_ID=XXXXXXXXXXXX
AREEBA_API_PASSWORD=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
AREEBA_API_VERSION=100
# admin dashboard origin for CORS (lib/admin.ts); defaults to * when unset
ADMIN_CORS_ORIGIN=*
# admin dashboard origin(s) for CORS (lib/admin.ts). Comma-separated, so dev
# and production can both be listed. FAILS CLOSED: when unset, no
# Access-Control-Allow-Origin header is sent at all and browsers block
# cross-origin calls to the owner API — set it explicitly. "*" still works if
# you deliberately want a wildcard, but it is no longer what you get by
# forgetting to configure this.
ADMIN_CORS_ORIGIN=http://localhost:5173
# in-app WebRTC audio calls (STUN for dev; TURN mandatory for production NAT).
# Leave TURN_* blank for development — STUN-only works on the same LAN.
EXPO_PUBLIC_STUN_URL="stun:stun.l.google.com:19302"
EXPO_PUBLIC_TURN_URL=""
EXPO_PUBLIC_TURN_USERNAME=""
EXPO_PUBLIC_TURN_CREDENTIAL=""
+3
View File
@@ -28,3 +28,6 @@ expo-env.d.ts
# admin dashboard build output
dashboard/dist/
# Driver uploads (licence/ID/vehicle scans, profile photos) — personal data.
.uploads/
+127
View File
@@ -63,6 +63,62 @@ const withOverlayPermission = (config) =>
return cfg;
});
// In-app WebRTC audio calls need the microphone. react-native-webrtc ships no
// Expo config plugin, so both platforms' mic permissions are declared here:
// the iOS Info.plist usage string lives in `ios.infoPlist` below, and the
// Android RECORD_AUDIO / MODIFY_AUDIO_SETTINGS permissions are added to the
// manifest at prebuild time — the grant itself is requested at runtime from
// the call screen.
const withMicPermission = (config) =>
withAndroidManifest(config, (cfg) => {
const manifest = cfg.modResults.manifest;
manifest["uses-permission"] = manifest["uses-permission"] || [];
const needed = [
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
];
for (const name of needed) {
const exists = manifest["uses-permission"].some(
(entry) => entry.$ && entry.$["android:name"] === name,
);
if (!exists) {
manifest["uses-permission"].push({ $: { "android:name": name } });
}
}
return cfg;
});
// expo-image-picker's own plugin never declares CAMERA on Android — it only
// blocks permissions when you ask it to. Without the declaration,
// requestCameraPermissionsAsync() is auto-denied by the system and the driver
// hits "allow camera access" with no way to allow it. READ_MEDIA_IMAGES is the
// Android 13+ replacement for READ_EXTERNAL_STORAGE, needed for the gallery
// option on the document scanners.
const withCapturePermissions = (config) =>
withAndroidManifest(config, (cfg) => {
const manifest = cfg.modResults.manifest;
manifest["uses-permission"] = manifest["uses-permission"] || [];
const needed = [
"android.permission.CAMERA",
"android.permission.READ_MEDIA_IMAGES",
];
for (const name of needed) {
const exists = manifest["uses-permission"].some(
(entry) => entry.$ && entry.$["android:name"] === name,
);
if (!exists) {
manifest["uses-permission"].push({ $: { "android:name": name } });
}
}
return cfg;
});
module.exports = ({ config }) => ({
...config,
name: "Waseel",
@@ -82,6 +138,10 @@ module.exports = ({ config }) => ({
ios: {
supportsTablet: true,
bundleIdentifier: "com.waseel.app",
infoPlist: {
NSMicrophoneUsageDescription:
"Waseel uses the microphone for in-app calls with your driver.",
},
},
android: {
adaptiveIcon: {
@@ -89,6 +149,12 @@ module.exports = ({ config }) => ({
backgroundColor: "#ffffff",
},
package: "com.waseel.app",
// NOTE: minSdkVersion is NOT set here. `android.minSdkVersion` is not a
// field Expo's config schema recognises, so prebuild silently ignored it
// and generated a project defaulting to 23 — which the manifest merger
// then rejected against react-native-webrtc's minSdk 24. It lives in the
// expo-build-properties plugin below, which is the supported way to set
// it and the only way it survives `prebuild --clean`.
config: {
googleMaps: {
apiKey: googleMapsApiKey ?? "",
@@ -101,13 +167,74 @@ module.exports = ({ config }) => ({
favicon: "./assets/images/favicon.png",
},
plugins: [
// react-native-webrtc declares minSdk 24, and the Android manifest merger
// refuses to build an app that declares less than a library it links.
// Expo's generated project defaults to 23, so this has to be raised
// explicitly — and it has to be raised *here*, because a value written
// into android/build.gradle or gradle.properties by hand is destroyed by
// the next `prebuild --clean`.
[
"expo-build-properties",
{
android: {
minSdkVersion: 24,
},
},
],
[
"expo-router",
{
origin: serverOrigin,
},
],
// Drivers are tracked while they're online, and that has to survive the
// screen going off — dispatch drops anyone whose last ping is over 60s
// old. The foreground service is what keeps the updates flowing on
// Android, and it declares the FOREGROUND_SERVICE_LOCATION permission and
// the `location` service type that Android 14 requires. It also puts a
// persistent notification in the shade, which is the honest way to run
// background GPS: the driver can always see that it's on.
[
"expo-location",
{
locationAlwaysAndWhenInUsePermission:
"Waseel uses your location while you're online to match you with nearby riders and show them your car on the map.",
isAndroidBackgroundLocationEnabled: true,
isAndroidForegroundServiceEnabled: true,
},
],
// Ride-offer alerts. The tint colour matches the app's primary so the
// small status-bar icon isn't rendered in Android's default grey.
[
"expo-notifications",
{
color: "#0286FF",
},
],
// Driver onboarding photographs the licence, ID card and vehicle
// registration so the details can be read off them and a reviewer can see
// the document itself. The gallery is offered alongside the camera for
// documents because drivers often already have a photo of their papers;
// the profile selfie is camera-only and enforced in the component.
//
// Do NOT add `microphonePermission: false` here. It reads as "this picker
// doesn't need the mic", but the plugin implements it as
// withBlockedPermissions — which stamps tools:node="remove" on
// RECORD_AUDIO and strips it from the *merged* manifest, taking
// react-native-webrtc's in-app calls down with it. Leaving it unset lets
// the picker declare RECORD_AUDIO harmlessly alongside the calls flow.
[
"expo-image-picker",
{
cameraPermission:
"Waseel uses the camera to take your driver photo and scan your licence and vehicle papers.",
photosPermission:
"Waseel needs your photo library so you can upload a picture of your driving licence and vehicle papers.",
},
],
withOverlayPermission,
withMicPermission,
withCapturePermissions,
withCleartextTraffic,
],
experiments: {
+9 -9
View File
@@ -1,13 +1,13 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { sql } from "@/lib/db";
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const rows = await sql`
@@ -22,10 +22,10 @@ export async function GET(request: Request) {
ORDER BY d.id
`;
return withCors(Response.json({ data: rows }));
return withCors(request, Response.json({ data: rows }));
} catch (error) {
console.error("[ADMIN_DRIVERS]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
@@ -42,13 +42,13 @@ type DriverBody = {
export async function POST(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const body = (await request.json()) as DriverBody;
if (!body.first_name?.trim() || !body.last_name?.trim()) {
return withCors(
return withCors(request,
Response.json(
{ error: "first_name and last_name are required." },
{ status: 400 },
@@ -69,10 +69,10 @@ export async function POST(request: Request) {
RETURNING *
`;
return withCors(Response.json({ data: driver }, { status: 201 }));
return withCors(request, Response.json({ data: driver }, { status: 201 }));
} catch (error) {
console.error("[ADMIN_DRIVER_CREATE]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+69 -12
View File
@@ -1,8 +1,9 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { sql } from "@/lib/db";
import { isApprovalStatus } from "@/lib/driver";
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
type DriverBody = {
@@ -12,15 +13,53 @@ type DriverBody = {
car_image_url?: string;
car_seats?: number;
rating?: number;
/** Vetting decision: 'approved' | 'rejected' | 'suspended' | 'pending'. */
approval_status?: string;
/** Shown to the driver when the decision is 'rejected'. */
rejection_reason?: string;
};
export async function PATCH(request: Request, { id }: { id: string }) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const body = (await request.json()) as DriverBody;
// Vetting decision. Anything other than 'approved' also forces the driver
// offline in the same statement: a driver who is suspended mid-shift must
// stop receiving offers immediately, not at their next toggle.
let approval: string | null = null;
if (body.approval_status !== undefined) {
if (!isApprovalStatus(body.approval_status)) {
return withCors(
request,
Response.json(
{
error:
"approval_status must be pending, approved, rejected or suspended.",
},
{ status: 400 },
),
);
}
if (body.approval_status === "rejected" && !body.rejection_reason?.trim()) {
return withCors(
request,
Response.json(
{ error: "A rejection needs a reason the driver can act on." },
{ status: 400 },
),
);
}
approval = body.approval_status;
}
const rejectionReason =
approval === "approved" ? null : (body.rejection_reason?.trim() ?? null);
const rows = await sql`
UPDATE drivers SET
first_name = COALESCE(${body.first_name ?? null}, first_name),
@@ -28,21 +67,39 @@ export async function PATCH(request: Request, { id }: { id: string }) {
profile_image_url = COALESCE(${body.profile_image_url ?? null}, profile_image_url),
car_image_url = COALESCE(${body.car_image_url ?? null}, car_image_url),
car_seats = COALESCE(${body.car_seats ?? null}, car_seats),
rating = COALESCE(${body.rating ?? null}, rating)
rating = COALESCE(${body.rating ?? null}, rating),
approval_status = COALESCE(${approval}, approval_status),
rejection_reason = CASE
WHEN ${approval}::text IS NULL THEN rejection_reason
ELSE ${rejectionReason}
END,
reviewed_at = CASE
WHEN ${approval}::text IS NULL THEN reviewed_at
ELSE CURRENT_TIMESTAMP
END,
reviewed_by = CASE
WHEN ${approval}::text IS NULL THEN reviewed_by
ELSE ${auth.userId}::uuid
END,
online = CASE
WHEN ${approval}::text IS NOT NULL AND ${approval}::text <> 'approved'
THEN FALSE
ELSE online
END
WHERE id = ${id}
RETURNING *
`;
if (!rows[0]) {
return withCors(
return withCors(request,
Response.json({ error: "Driver not found." }, { status: 404 }),
);
}
return withCors(Response.json({ data: rows[0] }));
return withCors(request, Response.json({ data: rows[0] }));
} catch (error) {
console.error("[ADMIN_DRIVER_PATCH]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
@@ -50,7 +107,7 @@ export async function PATCH(request: Request, { id }: { id: string }) {
export async function DELETE(request: Request, { id }: { id: string }) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const used = await sql<{ n: number }>`
@@ -58,7 +115,7 @@ export async function DELETE(request: Request, { id }: { id: string }) {
`;
if (used[0].n > 0) {
return withCors(
return withCors(request,
Response.json(
{ error: "Driver has recorded rides and cannot be deleted." },
{ status: 409 },
@@ -71,15 +128,15 @@ export async function DELETE(request: Request, { id }: { id: string }) {
`;
if (!rows[0]) {
return withCors(
return withCors(request,
Response.json({ error: "Driver not found." }, { status: 404 }),
);
}
return withCors(Response.json({ data: rows[0] }));
return withCors(request, Response.json({ data: rows[0] }));
} catch (error) {
console.error("[ADMIN_DRIVER_DELETE]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+40 -10
View File
@@ -1,8 +1,19 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { query, type SqlValue } from "@/lib/db";
import { RIDE_STATUSES as LIFECYCLE_STATUSES } from "@/lib/ride-lifecycle";
const PAGE_SIZE = 25;
// Lowercased for comparison against the `status` query param.
const RIDE_STATUSES: readonly string[] = LIFECYCLE_STATUSES;
// LEFT JOIN on drivers, deliberately.
//
// This was an INNER JOIN, which meant every ride without a driver was missing
// from the admin list entirely — a rider cancelling before a match, or a
// request that expired with nobody available, simply never appeared. Those are
// exactly the rides an operator needs to see: they're the ones that went
// wrong.
const SELECT_RIDES = `
SELECT
r.ride_id,
@@ -11,26 +22,36 @@ const SELECT_RIDES = `
r.ride_time,
r.fare_price,
r.payment_status,
r.status,
r.cancelled_by,
r.cancellation_reason,
r.platform_fee_cents,
r.driver_payout_cents,
r.commission_rate,
r.platform_fee_settled_at,
r.driver_payout_settled_at,
r.settlement_note,
r.created_at,
r.completed_at,
u.id AS user_id,
u.email AS user_email,
json_build_object(
CASE WHEN d.id IS NULL THEN NULL ELSE json_build_object(
'driver_id', d.id,
'name', d.first_name || ' ' || d.last_name,
'rating', d.rating
) AS driver
) END AS driver
FROM rides r
INNER JOIN drivers d ON d.id = r.driver_id
LEFT JOIN drivers d ON d.id = r.driver_id
INNER JOIN users u ON u.id = r.user_id
`;
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const url = new URL(request.url);
@@ -41,9 +62,18 @@ export async function GET(request: Request) {
const conds: string[] = [];
const params: SqlValue[] = [];
// `status` filters the ride's own lifecycle state when it names one, and
// falls back to the payment status otherwise — so the existing "paid" /
// "cash" filters keep working while "cancelled" and "completed" become
// filterable too, which is what an operator actually reaches for.
if (status) {
params.push(status);
conds.push(`LOWER(r.payment_status) = $${params.length}`);
const n = params.length;
conds.push(
RIDE_STATUSES.includes(status)
? `LOWER(r.status) = $${n}`
: `LOWER(r.payment_status) = $${n}`,
);
}
if (q) {
@@ -60,7 +90,7 @@ export async function GET(request: Request) {
const [{ count }] = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count
FROM rides r
INNER JOIN drivers d ON d.id = r.driver_id
LEFT JOIN drivers d ON d.id = r.driver_id
INNER JOIN users u ON u.id = r.user_id${where}`,
params,
);
@@ -72,7 +102,7 @@ export async function GET(request: Request) {
[...params, PAGE_SIZE, (page - 1) * PAGE_SIZE],
);
return withCors(
return withCors(request,
Response.json({
data: rows,
total: count,
@@ -83,7 +113,7 @@ export async function GET(request: Request) {
);
} catch (error) {
console.error("[ADMIN_RIDES]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+272
View File
@@ -0,0 +1,272 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { query, sql, type SqlValue } from "@/lib/db";
import { isSettlementSide } from "@/lib/settlement";
// Recording that money actually changed hands.
//
// Two different real-world events, one endpoint:
//
// side='platform_fee' — a driver handed the company its cut of the cash
// fares they collected. Clears what THEY owe US.
// side='driver_payout' — the company paid a driver for the card rides they
// drove. Clears what WE owe THEM.
//
// Settling is deliberately idempotent and one-way: a row already stamped is
// skipped rather than re-stamped, so a double-tap on "mark paid" can't rewrite
// when the money moved. Reversing a mistake is a separate, explicit action
// (`undo: true`) so it can't happen by accident.
export async function OPTIONS(request: Request) {
return preflight(request);
}
type Body = {
side?: string;
/** Settle everything outstanding for this driver. */
driver_id?: number;
/** Or settle these specific rides. */
ride_ids?: number[];
/** Free-text reference: a transfer id, a receipt number, "cash in office". */
note?: string;
/** Reverse a settlement recorded in error. */
undo?: boolean;
};
export async function POST(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(request, auth.error);
let body: Body;
try {
body = (await request.json()) as Body;
} catch {
return withCors(
request,
Response.json({ error: "Invalid JSON body." }, { status: 400 }),
);
}
if (!isSettlementSide(body.side)) {
return withCors(
request,
Response.json(
{ error: "side must be 'platform_fee' or 'driver_payout'." },
{ status: 400 },
),
);
}
const rideIds = Array.isArray(body.ride_ids)
? body.ride_ids.map(Number).filter(Number.isInteger)
: [];
const driverId = Number(body.driver_id);
const hasDriver = Number.isInteger(driverId);
if (!hasDriver && rideIds.length === 0) {
return withCors(
request,
Response.json(
{ error: "Provide either driver_id or a non-empty ride_ids array." },
{ status: 400 },
),
);
}
// Column names come from the validated `side`, never from raw input.
const column =
body.side === "platform_fee"
? "platform_fee_settled_at"
: "driver_payout_settled_at";
const amountColumn =
body.side === "platform_fee" ? "platform_fee_cents" : "driver_payout_cents";
// Only one payment type produces a transfer that a human has to make, and
// it's the opposite one for each side:
//
// platform_fee — owed only on CASH rides. On a card ride the company
// already holds its fee; there is nothing to collect.
// driver_payout — owed only on CARD rides. On a cash ride the driver
// already has their share in hand.
//
// Scoping to that payment type is what keeps an undo honest. Without it,
// reversing one collected cash commission also cleared the automatically
// settled fees on that driver's card rides, and the ledger then told the
// operator to go and collect money the company had never been without.
const payableStatus =
body.side === "platform_fee" ? "cash_collected" : "paid";
const undo = body.undo === true;
const params: SqlValue[] = [payableStatus];
const conds: string[] = [
"status = 'completed'",
// Only money that actually materialised can be settled: an uncollected
// cash fare owes nobody anything and must never appear as settled.
"payment_status = $1",
// Idempotent in both directions — already-settled rows are skipped when
// settling, already-clear rows when undoing.
undo ? `${column} IS NOT NULL` : `${column} IS NULL`,
];
if (hasDriver) {
params.push(driverId);
conds.push(`driver_id = $${params.length}`);
}
if (rideIds.length > 0) {
params.push(`{${rideIds.join(",")}}`);
conds.push(`ride_id = ANY($${params.length}::int[])`);
}
try {
params.push(body.note?.trim() ? body.note.trim().slice(0, 500) : null);
const noteParam = params.length;
const rows = await query<{ ride_id: number; amount: number }>(
`UPDATE rides
SET ${column} = ${undo ? "NULL" : "CURRENT_TIMESTAMP"},
settlement_note = COALESCE($${noteParam}, settlement_note)
WHERE ${conds.join(" AND ")}
RETURNING ride_id, COALESCE(${amountColumn}, 0) AS amount`,
params,
);
const totalCents = rows.reduce((sum, r) => sum + Number(r.amount), 0);
return withCors(
request,
Response.json({
data: {
side: body.side,
undone: undo,
rides: rows.length,
ride_ids: rows.map((r) => r.ride_id),
total_cents: totalCents,
},
}),
);
} catch (error) {
console.error("[ADMIN_SETTLE]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}
// GET — the outstanding ledger.
//
// Without arguments: one row per driver, answering the two questions an
// operator has at the end of a shift — which drivers owe us cash commission,
// and which drivers are we behind on paying.
//
// With ?driver_id=N&side=platform_fee: the individual rides making up that
// balance, so a part-payment can be recorded against the exact trips it
// covers. A driver handing over three of yesterday's five fares is a normal
// thing to happen, and settling all five because the UI only offered
// all-or-nothing would put the ledger out of step with the cash.
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(request, auth.error);
try {
const url = new URL(request.url);
const driverParam = Number(url.searchParams.get("driver_id"));
const sideParam = url.searchParams.get("side");
if (Number.isInteger(driverParam) && sideParam !== null) {
if (!isSettlementSide(sideParam)) {
return withCors(
request,
Response.json(
{ error: "side must be 'platform_fee' or 'driver_payout'." },
{ status: 400 },
),
);
}
// Mirrors the POST handler's rules exactly: only the payment type that
// actually leaves a transfer outstanding for this side is listed, so the
// picker can never show a ride that settling would refuse to touch.
const settledColumn =
sideParam === "platform_fee"
? "platform_fee_settled_at"
: "driver_payout_settled_at";
const amountColumn =
sideParam === "platform_fee"
? "platform_fee_cents"
: "driver_payout_cents";
const payableStatus =
sideParam === "platform_fee" ? "cash_collected" : "paid";
const rides = await query<{
ride_id: number;
amount_cents: number;
fare_price: number;
origin_address: string;
destination_address: string;
completed_at: string;
}>(
`SELECT ride_id,
COALESCE(${amountColumn}, 0) AS amount_cents,
fare_price, origin_address, destination_address, completed_at
FROM rides
WHERE driver_id = $1
AND status = 'completed'
AND payment_status = $2
AND ${settledColumn} IS NULL
ORDER BY completed_at DESC`,
[driverParam, payableStatus],
);
return withCors(
request,
Response.json({
data: {
side: sideParam,
driver_id: driverParam,
rides,
total_cents: rides.reduce(
(sum, r) => sum + Number(r.amount_cents),
0,
),
},
}),
);
}
const rows = await sql<{
driver_id: number;
name: string;
owes_company_cents: number;
owed_to_driver_cents: number;
unsettled_rides: number;
}>`
SELECT
d.id AS driver_id,
TRIM(COALESCE(d.first_name,'') || ' ' || COALESCE(d.last_name,'')) AS name,
COALESCE(SUM(r.platform_fee_cents)
FILTER (WHERE r.platform_fee_settled_at IS NULL), 0)::int
AS owes_company_cents,
COALESCE(SUM(r.driver_payout_cents)
FILTER (WHERE r.driver_payout_settled_at IS NULL), 0)::int
AS owed_to_driver_cents,
COUNT(*)::int AS unsettled_rides
FROM drivers d
JOIN rides r ON r.driver_id = d.id
WHERE r.status = 'completed'
AND r.payment_status IN ('paid','cash_collected')
AND (r.platform_fee_settled_at IS NULL
OR r.driver_payout_settled_at IS NULL)
GROUP BY d.id, d.first_name, d.last_name
ORDER BY owes_company_cents DESC, owed_to_driver_cents DESC
`;
return withCors(request, Response.json({ data: rows }));
} catch (error) {
console.error("[ADMIN_SETTLE_GET]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}
+100 -16
View File
@@ -1,20 +1,35 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { sql } from "@/lib/db";
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
// Money only ever comes from rides that actually happened.
//
// "Pending payment" used to be `payment_status <> 'paid'`, which swept in
// every cancelled and expired ride — a rider who changed their mind before
// a driver was even assigned showed up as outstanding revenue the company
// was owed. Settled/outstanding are now scoped to completed rides, and the
// top line is split three ways: what riders paid, what drivers keep, and
// what the company actually earns.
const [totals] = await sql<{
users: number;
drivers: number;
rides: number;
revenue: number;
completed_rides: number;
cancelled_rides: number;
gross_fares: number;
driver_payouts: number;
company_revenue: number;
company_collected: number;
company_outstanding: number;
driver_outstanding: number;
rides_today: number;
avg_fare: number;
pending_count: number;
@@ -25,19 +40,82 @@ export async function GET(request: Request) {
(SELECT COUNT(*)::int FROM users) AS users,
(SELECT COUNT(*)::int FROM drivers) AS drivers,
(SELECT COUNT(*)::int FROM rides) AS rides,
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE payment_status = 'paid') AS revenue,
(SELECT COUNT(*)::int FROM rides WHERE status = 'completed') AS completed_rides,
(SELECT COUNT(*)::int FROM rides WHERE status IN ('cancelled','expired')) AS cancelled_rides,
-- What riders were charged, across every completed ride.
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8
FROM rides WHERE status = 'completed') AS gross_fares,
-- Revenue and payouts count rides whose money actually materialised.
--
-- Scoping these to paid rides is what makes the books reconcile:
-- gross_fares = paid fares + pending_revenue
-- paid fares = company_revenue + driver_payouts
-- company_revenue = company_collected + company_outstanding
-- Counting fees on a fare nobody ever paid would show revenue that can
-- never be collected and never be chased — it belongs in the
-- uncollected line below, not the top line.
(SELECT COALESCE(SUM(COALESCE(driver_payout_cents, 0)) / 100.0, 0)::float8
FROM rides WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')) AS driver_payouts,
(SELECT COALESCE(SUM(COALESCE(platform_fee_cents, 0)) / 100.0, 0)::float8
FROM rides WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')) AS company_revenue,
-- Earned and actually in hand: card fees, plus cash commission a
-- driver has since remitted.
(SELECT COALESCE(SUM(platform_fee_cents) / 100.0, 0)::float8
FROM rides
WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')
AND platform_fee_settled_at IS NOT NULL) AS company_collected,
-- Earned but still sitting in a driver's pocket. This is the number an
-- operator chases at the end of a shift.
(SELECT COALESCE(SUM(platform_fee_cents) / 100.0, 0)::float8
FROM rides
WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')
AND platform_fee_settled_at IS NULL) AS company_outstanding,
-- The mirror: payouts the company still owes its drivers.
(SELECT COALESCE(SUM(driver_payout_cents) / 100.0, 0)::float8
FROM rides
WHERE status = 'completed'
AND payment_status IN ('paid','cash_collected')
AND driver_payout_settled_at IS NULL) AS driver_outstanding,
(SELECT COUNT(*)::int FROM rides WHERE created_at >= CURRENT_DATE) AS rides_today,
(SELECT COALESCE(ROUND(AVG(fare_price) / 100.0, 2), 0)::float8 FROM rides WHERE payment_status = 'paid') AS avg_fare,
(SELECT COUNT(*)::int FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_count,
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_revenue,
(SELECT COALESCE(ROUND(AVG(fare_price) / 100.0, 2), 0)::float8
FROM rides WHERE status = 'completed') AS avg_fare,
-- Completed rides whose money never actually landed: a cash fare the
-- driver didn't collect, or a card ride that never settled.
(SELECT COUNT(*)::int FROM rides
WHERE status = 'completed'
AND payment_status NOT IN ('paid','cash_collected')) AS pending_count,
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides
WHERE status = 'completed'
AND payment_status NOT IN ('paid','cash_collected')) AS pending_revenue,
(SELECT COUNT(*)::int FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days') AS new_users_7d
`;
const trend = await sql<{ day: string; rides: number; revenue: number }>`
const trend = await sql<{
day: string;
rides: number;
revenue: number;
payouts: number;
}>`
SELECT
TO_CHAR(DAY, 'YYYY-MM-DD') AS day,
COUNT(r.ride_id)::int AS rides,
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue
COALESCE(SUM(COALESCE(r.platform_fee_cents, 0))
FILTER (WHERE r.status = 'completed') / 100.0, 0)::float8 AS revenue,
COALESCE(SUM(COALESCE(r.driver_payout_cents, 0))
FILTER (WHERE r.status = 'completed') / 100.0, 0)::float8 AS payouts
FROM generate_series(
CURRENT_DATE - INTERVAL '13 days',
CURRENT_DATE,
@@ -48,28 +126,34 @@ export async function GET(request: Request) {
ORDER BY DAY
`;
// Ranked by what each driver actually earned, not by what their riders
// were charged — and counting only rides that happened.
const topDrivers = await sql<{
driver_id: number;
name: string;
rides: number;
revenue: number;
earnings: number;
company_revenue: number;
}>`
SELECT
d.id AS driver_id,
d.first_name || ' ' || d.last_name AS name,
COUNT(r.ride_id)::int AS rides,
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue
COUNT(r.ride_id) FILTER (WHERE r.status = 'completed')::int AS rides,
COALESCE(SUM(COALESCE(r.driver_payout_cents, 0))
FILTER (WHERE r.status = 'completed') / 100.0, 0)::float8 AS earnings,
COALESCE(SUM(COALESCE(r.platform_fee_cents, 0))
FILTER (WHERE r.status = 'completed') / 100.0, 0)::float8 AS company_revenue
FROM drivers d
LEFT JOIN rides r ON r.driver_id = d.id
GROUP BY d.id, d.first_name, d.last_name
ORDER BY revenue DESC, rides DESC
ORDER BY earnings DESC, rides DESC
LIMIT 5
`;
return withCors(Response.json({ data: { totals, trend, topDrivers } }));
return withCors(request, Response.json({ data: { totals, trend, topDrivers } }));
} catch (error) {
console.error("[ADMIN_STATS]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { requireOwner, withCors, preflight } from "@/lib/admin";
import { sql } from "@/lib/db";
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const url = new URL(request.url);
@@ -58,10 +58,10 @@ export async function GET(request: Request) {
LIMIT 500
`;
return withCors(Response.json({ data: rows }));
return withCors(request, Response.json({ data: rows }));
} catch (error) {
console.error("[ADMIN_USERS]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+13 -13
View File
@@ -6,13 +6,13 @@ type Body = {
email_verified?: boolean;
};
export async function OPTIONS() {
return preflight();
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function PATCH(request: Request, { id }: { id: string }) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
try {
const body = (await request.json()) as Body;
@@ -20,7 +20,7 @@ export async function PATCH(request: Request, { id }: { id: string }) {
if (body.role !== undefined) {
const allowed = ["rider", "driver", "owner", null];
if (!allowed.includes(body.role)) {
return withCors(
return withCors(request,
Response.json(
{ error: "Role must be rider, driver, owner or null." },
{ status: 400 },
@@ -29,7 +29,7 @@ export async function PATCH(request: Request, { id }: { id: string }) {
}
if (id === auth.userId && body.role !== "owner") {
return withCors(
return withCors(request,
Response.json(
{ error: "You cannot remove your own owner role." },
{ status: 400 },
@@ -47,13 +47,13 @@ export async function PATCH(request: Request, { id }: { id: string }) {
`;
if (!rows[0]) {
return withCors(Response.json({ error: "User not found." }, { status: 404 }));
return withCors(request, Response.json({ error: "User not found." }, { status: 404 }));
}
return withCors(Response.json({ data: rows[0] }));
return withCors(request, Response.json({ data: rows[0] }));
} catch (error) {
console.error("[ADMIN_USER_PATCH]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
@@ -61,10 +61,10 @@ export async function PATCH(request: Request, { id }: { id: string }) {
export async function DELETE(request: Request, { id }: { id: string }) {
const auth = await requireOwner(request);
if ("error" in auth) return withCors(auth.error);
if ("error" in auth) return withCors(request, auth.error);
if (id === auth.userId) {
return withCors(
return withCors(request,
Response.json(
{ error: "You cannot delete your own account." },
{ status: 400 },
@@ -79,15 +79,15 @@ export async function DELETE(request: Request, { id }: { id: string }) {
`;
if (!rows[0]) {
return withCors(
return withCors(request,
Response.json({ error: "User not found." }, { status: 404 }),
);
}
return withCors(Response.json({ data: rows[0] }));
return withCors(request, Response.json({ data: rows[0] }));
} catch (error) {
console.error("[ADMIN_USER_DELETE]: ", error);
return withCors(
return withCors(request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
+100
View File
@@ -0,0 +1,100 @@
import { requireAuth } from "@/lib/jwt";
import { requireDriverProfile } from "@/lib/driver";
import { sql } from "@/lib/db";
import { CONNECTED_STATUS_ARRAY } from "@/lib/ride-lifecycle";
// GET — the Chat tab's default view. Returns the caller's currently-active
// ride that has the other party assigned (so a conversation can open), or
// null when there's nothing to chat about. The caller is auto-detected: a
// rider by default, or a driver when ?role=driver is passed (the driver app
// hits this with role=driver since the same account could in principle be a
// rider elsewhere).
//
// We try the rider path first. If the signed-in user owns an active ride
// with a driver assigned, that's their conversation. Otherwise, if they have
// a driver profile, we look for a ride they're assigned to. Either way the
// response carries the caller's `role` and a `peer` summary for the header.
type ActiveRideRow = {
ride_id: number;
status: string;
role: "rider" | "driver";
peer_name: string;
peer_avatar: string | null;
peer_service: string | null;
peer_car_model: string | null;
};
// The client (chat.tsx, call.tsx) expects `peer` nested per the ChatActiveRide
// type, not the flat peer_* columns the query returns.
const toActiveRide = (row: ActiveRideRow) => ({
ride_id: row.ride_id,
status: row.status,
role: row.role,
peer: {
name: row.peer_name,
avatar: row.peer_avatar,
service: row.peer_service,
car_model: row.peer_car_model,
},
});
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
const wantsDriver = new URL(req.url).searchParams.get("role") === "driver";
try {
// Rider path: a ride this user owns that's active and has a driver.
if (!wantsDriver) {
const riderRides = await sql<ActiveRideRow>`
SELECT
r.ride_id,
r.status,
'rider' AS role,
CONCAT_WS(' ', d.first_name, d.last_name) AS peer_name,
d.profile_image_url AS peer_avatar,
d.service AS peer_service,
d.car_model AS peer_car_model
FROM rides r
JOIN drivers d ON d.id = r.driver_id
WHERE r.user_id = ${auth.userId}
AND r.status = ANY(${CONNECTED_STATUS_ARRAY}::text[])
AND r.driver_id IS NOT NULL
ORDER BY r.created_at DESC
LIMIT 1
`;
if (riderRides[0])
return Response.json({ data: toActiveRide(riderRides[0]) });
}
// Driver path: a ride this user (as a driver) is assigned to and is active.
const driver = await requireDriverProfile(req);
if (!("error" in driver)) {
const driverRides = await sql<ActiveRideRow>`
SELECT
r.ride_id,
r.status,
'driver' AS role,
u.name AS peer_name,
NULL::text AS peer_avatar,
r.service AS peer_service,
NULL::text AS peer_car_model
FROM rides r
JOIN users u ON u.id = r.user_id
WHERE r.driver_id = ${driver.driverId}
AND r.status = ANY(${CONNECTED_STATUS_ARRAY}::text[])
ORDER BY r.created_at DESC
LIMIT 1
`;
if (driverRides[0])
return Response.json({ data: toActiveRide(driverRides[0]) });
}
return Response.json({ data: null });
} catch (error) {
console.error("[GET_ACTIVE_CHAT]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
-20
View File
@@ -1,20 +0,0 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const response = await sql`
SELECT id, first_name, last_name, profile_image_url, car_image_url, car_seats, rating
FROM drivers
`;
return Response.json({ data: response });
} catch (error) {
console.log("[GET_DRIVERS]: ", error);
return Response.json({ error }, { status: 500 });
}
}
+80
View File
@@ -0,0 +1,80 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
import { SERVICES } from "@/constants/services";
import { boundingBox, haversine } from "@/lib/utils";
import { DRIVER_STALE_SECONDS } from "@/constants/dispatch";
// GET — how many drivers of each service are within reach of a point.
//
// The rider map filters by the selected service, so an empty map is ambiguous:
// it means "nobody at all" and "nobody driving a moto, though three cars are a
// street away" identically. That's the state riders were getting stuck in —
// staring at an empty map with no way to know that switching service would
// fill it. This answers the question the map can't.
//
// Query: ?lat=33.89&lng=35.50&radius=20000
//
// Returns every known service, zeros included, so the client can render the
// full picker without inventing missing keys.
const DEFAULT_RADIUS_M = 20000;
const MAX_RADIUS_M = 20000;
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const url = new URL(req.url);
const lat = Number(url.searchParams.get("lat"));
const lng = Number(url.searchParams.get("lng"));
if (Number.isNaN(lat) || Number.isNaN(lng)) {
return Response.json(
{ error: "lat and lng query params are required numbers." },
{ status: 400 },
);
}
const requested = Number(url.searchParams.get("radius"));
const radius =
Number.isFinite(requested) && requested > 0
? Math.min(requested, MAX_RADIUS_M)
: DEFAULT_RADIUS_M;
const box = boundingBox(lat, lng, radius);
// Same visibility rules as /driver/nearby — vetted, online, fresh, real
// account, positioned. A driver riders can't be matched to must not be
// counted here either, or the hint sends them to an empty service.
const rows = await sql<{
service: string;
latitude: number;
longitude: number;
}>`
SELECT service, latitude, longitude
FROM drivers
WHERE online = TRUE
AND approval_status = 'approved'
AND user_id IS NOT NULL
AND last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND longitude BETWEEN ${box.minLng} AND ${box.maxLng}
`;
const counts: Record<string, number> = {};
for (const service of SERVICES) counts[service.id] = 0;
for (const row of rows) {
if (haversine(lat, lng, row.latitude, row.longitude) > radius) continue;
if (counts[row.service] === undefined) continue;
counts[row.service] += 1;
}
return Response.json({ data: { radius, counts } });
} catch (error) {
console.error("[DRIVER_AVAILABILITY]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+81
View File
@@ -0,0 +1,81 @@
import { preflight, withCors } from "@/lib/admin";
import { sql } from "@/lib/db";
import { requireAuth } from "@/lib/jwt";
import { isStoredUploadName, readUpload, uploadMimeType } from "@/lib/uploads";
// GET /(api)/driver/documents?name=… — serve one stored document scan.
//
// These are identity documents, so they are not static files: every read is
// authenticated and authorised here. Exactly two principals may fetch a scan —
// the driver it belongs to, and an owner reviewing that driver. Knowing the
// (unguessable) file name is not itself permission.
//
// The name travels as a query parameter rather than a path segment because it
// ends in .jpg/.png/.webp, and a dotted final segment is exactly what static
// asset middleware tends to claim before the router ever sees it. A query
// parameter cannot be mistaken for a file on disk.
//
// CORS is applied because the admin dashboard is a separate origin; it fetches
// the bytes with its bearer token and renders them from a blob URL, since an
// <img src> cannot carry an Authorization header.
export async function OPTIONS(request: Request) {
return preflight(request);
}
const notFound = (request: Request) =>
withCors(request, Response.json({ error: "Not found." }, { status: 404 }));
export async function GET(request: Request) {
const auth = requireAuth(request);
if ("error" in auth) return withCors(request, auth.error);
const name = new URL(request.url).searchParams.get("name");
// Rejecting the name before it reaches the filesystem is what keeps a
// crafted "../../.env" from ever being joined onto the upload directory.
if (!isStoredUploadName(name)) return notFound(request);
try {
const rows = await sql<{ role: string | null; owns: boolean }>`
SELECT
(SELECT role FROM users WHERE id = ${auth.userId}) AS role,
EXISTS (
SELECT 1 FROM drivers
WHERE user_id = ${auth.userId}
AND ${name} IN (
license_image_url, id_image_url, vehicle_reg_image_url
)
) AS owns
`;
const allowed = rows[0]?.role === "owner" || rows[0]?.owns === true;
// A 404 rather than a 403: a caller who is not entitled to the document
// shouldn't learn whether it exists.
if (!allowed) return notFound(request);
const bytes = await readUpload(name, "document");
if (!bytes) return notFound(request);
return withCors(
request,
new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": uploadMimeType(name),
"Content-Length": String(bytes.length),
// Never let a shared cache hold somebody's ID card.
"Cache-Control": "private, no-store",
"Content-Disposition": `inline; filename="${name}"`,
"X-Content-Type-Options": "nosniff",
},
}),
);
} catch (error) {
console.error("[DRIVER_DOCUMENT_GET]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}
+100 -5
View File
@@ -1,5 +1,8 @@
import { requireDriverProfile } from "@/lib/driver";
import { sql } from "@/lib/db";
import { DRIVER_BUSY_ARRAY } from "@/lib/ride-lifecycle";
import { boundingBox, haversine } from "@/lib/utils";
import { BROADCAST_RADIUS_M, REQUEST_TTL_SECONDS } from "@/constants/dispatch";
// POST — driver location heartbeat. Each ping updates lat/lng/last_seen and
// keeps the driver marked online. The client (use-driver-location) fires this
@@ -11,7 +14,7 @@ export async function POST(req: Request) {
try {
const body = await req.json();
const { latitude, longitude } = body;
const { latitude, longitude, heading, speed_kph } = body;
if (
typeof latitude !== "number" ||
@@ -25,18 +28,110 @@ export async function POST(req: Request) {
);
}
// Heading and speed are optional and frequently unavailable — a phone
// sitting still reports heading -1, and a cached fix may carry neither.
// Anything unusable is stored as NULL rather than as a wrong direction,
// because a confidently wrong arrow on a rider's map is worse than none.
const bearing =
typeof heading === "number" && heading >= 0 && heading <= 360
? Math.round(heading) % 360
: null;
const speed =
typeof speed_kph === "number" && speed_kph >= 0 && speed_kph < 300
? Math.round(speed_kph)
: null;
// A ping refreshes position and liveness only. It deliberately does NOT
// set online = TRUE: a ping already in flight when the driver toggles off
// would land afterwards and put them back in the match pool, so they'd
// keep getting requests they thought they'd opted out of. Going online is
// an explicit PATCH to /driver/profile and nothing else.
const { driverId } = result;
const rows = await sql`
UPDATE drivers
SET latitude = ${latitude},
longitude = ${longitude},
last_seen = CURRENT_TIMESTAMP,
online = TRUE
-- COALESCE, not overwrite: a fix without a usable heading (typical
-- at a standstill) shouldn't erase the direction the car was last
-- known to be facing, which is still the best guess for how it's
-- parked. Speed does overwrite, because "not moving" is real
-- information and must be able to reach zero.
heading = COALESCE(${bearing}, heading),
speed_kph = ${speed},
last_seen = CURRENT_TIMESTAMP
WHERE id = ${driverId}
RETURNING id, latitude, longitude, last_seen, online
RETURNING id, latitude, longitude, heading, speed_kph, last_seen, online
`;
return Response.json({ data: rows[0] });
// The nearest open request this driver could take, returned with the
// heartbeat.
//
// While a driver is online this endpoint is hit every few seconds by a
// foreground-service location task that keeps running with the screen
// off — so it is the one request we know is still happening when the
// dashboard poll has stopped. Piggybacking the nearest job here lets the
// app raise a local notification for it without a second round trip, and
// without needing remote push credentials.
//
// Filtered to requests this driver hasn't already offered on, so a driver
// who volunteered and is waiting on the rider isn't buzzed about the same
// job every five seconds.
const box = boundingBox(latitude, longitude, BROADCAST_RADIUS_M);
const driver = rows[0] as { online?: boolean } | undefined;
const nearby = driver?.online
? await sql<{
ride_id: number;
origin_address: string;
fare_price: number;
origin_latitude: number;
origin_longitude: number;
}>`
SELECT r.ride_id, r.origin_address, r.fare_price,
r.origin_latitude, r.origin_longitude
FROM rides r
WHERE r.status = 'requested'
AND r.service = (SELECT service FROM drivers WHERE id = ${driverId})
AND r.created_at > CURRENT_TIMESTAMP - make_interval(secs => ${REQUEST_TTL_SECONDS})
AND r.origin_latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND r.origin_longitude BETWEEN ${box.minLng} AND ${box.maxLng}
AND NOT EXISTS (
SELECT 1 FROM ride_offers ro
WHERE ro.ride_id = r.ride_id
AND ro.driver_id = ${driverId}
AND ro.status = 'offered'
)
AND NOT EXISTS (
SELECT 1 FROM rides busy
WHERE busy.driver_id = ${driverId}
AND busy.status = ANY(${DRIVER_BUSY_ARRAY}::text[])
)
ORDER BY r.created_at DESC
LIMIT 5
`
: [];
// Same great-circle trim the dashboard applies, so the notification and
// the list the driver opens agree on what counts as nearby.
const pending = nearby
.map((r) => ({
ride_id: r.ride_id,
origin_address: r.origin_address,
fare_price: Number(r.fare_price),
distance: haversine(
latitude,
longitude,
Number(r.origin_latitude),
Number(r.origin_longitude),
),
}))
.filter((r) => r.distance <= BROADCAST_RADIUS_M)
.sort((a, b) => a.distance - b.distance)[0];
return Response.json({
data: { ...rows[0], pending_request: pending ?? null },
});
} catch (error) {
console.error("[DRIVER_LOCATION]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
+50 -7
View File
@@ -1,12 +1,30 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
import { boundingBox, haversine } from "@/lib/utils";
import { DRIVER_STALE_SECONDS } from "@/constants/dispatch";
// GET — online drivers of `service` near (lat,lng), for the rider map and the
// "nearest driver ETA" estimate on confirm-ride. Only real, logged-in drivers
// (user_id IS NOT NULL) with a fresh location ping are returned; legacy seed
// rows have no position and are never shown to riders.
// "drivers near you" count on the request screen. Only vetted, logged-in
// drivers (approved + user_id IS NOT NULL) with a fresh location ping are
// returned; legacy seed rows have no position and are never shown to riders.
//
// Query: ?service=car&lat=33.89&lng=35.50&radius=8000
//
// The radius is enforced, not decorative. Returning every online driver in the
// country to any signed-in account turns this endpoint into a live tracker for
// the whole fleet; bounding it means a caller only ever learns about cars they
// could plausibly hail. A coarse bounding box does the work in the index, then
// a great-circle pass trims the corners.
const DEFAULT_RADIUS_M = 8000;
const MAX_RADIUS_M = 20000;
// Drivers are returned at ~11m precision (4 decimal places). That is well
// inside "which street is the car on" for a map pin, and stops the endpoint
// from being a metre-accurate trace of someone's working day.
const COORD_PRECISION = 1e4;
const snap = (value: number): number =>
Math.round(value * COORD_PRECISION) / COORD_PRECISION;
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
@@ -24,20 +42,45 @@ export async function GET(req: Request) {
);
}
const rows = await sql`
const requested = Number(url.searchParams.get("radius"));
const radius =
Number.isFinite(requested) && requested > 0
? Math.min(requested, MAX_RADIUS_M)
: DEFAULT_RADIUS_M;
const box = boundingBox(lat, lng, radius);
const rows = await sql<{
id: number;
latitude: number;
longitude: number;
heading: number | null;
speed_kph: number | null;
}>`
SELECT id, first_name, last_name, profile_image_url, car_image_url,
car_seats, rating, service, car_model, latitude, longitude,
last_seen
heading, speed_kph, last_seen
FROM drivers
WHERE service = ${service}
AND online = TRUE
AND approval_status = 'approved'
AND user_id IS NOT NULL
AND last_seen > CURRENT_TIMESTAMP - INTERVAL '60 seconds'
AND last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
AND latitude IS NOT NULL
AND longitude IS NOT NULL
AND latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND longitude BETWEEN ${box.minLng} AND ${box.maxLng}
`;
return Response.json({ data: rows });
const nearby = rows
.filter((d) => haversine(lat, lng, d.latitude, d.longitude) <= radius)
.map((d) => ({
...d,
latitude: snap(d.latitude),
longitude: snap(d.longitude),
}));
return Response.json({ data: nearby });
} catch (error) {
console.error("[DRIVER_NEARBY]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
+257
View File
@@ -0,0 +1,257 @@
import { preflight, withCors } from "@/lib/admin";
import { sql } from "@/lib/db";
import { requireAuth } from "@/lib/jwt";
import {
deleteUpload,
isStoredUploadName,
MAX_UPLOAD_BYTES,
pruneOrphanUploads,
readUpload,
sniffImageType,
storeUpload,
uploadMimeType,
} from "@/lib/uploads";
// The driver's profile photo — the face a rider sees beside a driver's name
// when picking between offers, and what they check the arriving car's driver
// against.
//
// POST uploads it (authenticated, driver-role only). GET serves it, and unlike
// the document route it does NOT require a token: this image is rendered by
// plain <Image>/<img> tags across the rider app, the driver map and the admin
// dashboard, none of which can attach an Authorization header without turning
// every avatar into a bespoke fetch-and-blob dance. What protects it instead
// is that the name is 128 bits of randomness and the route refuses any name no
// driver row actually points at — so it cannot be enumerated, and it cannot be
// used as a general-purpose anonymous image host for whatever somebody
// uploaded and abandoned.
//
// This is the opposite trade to /(api)/driver/documents, which is why the two
// live in separate directories on disk: a name that addresses a licence scan
// resolves to nothing here.
export async function OPTIONS(request: Request) {
return preflight(request);
}
export async function GET(request: Request) {
const name = new URL(request.url).searchParams.get("name");
const notFound = () =>
withCors(request, Response.json({ error: "Not found." }, { status: 404 }));
// Rejecting the name before it reaches the filesystem is what keeps a
// crafted "../../.env" from ever being joined onto the upload directory.
if (!isStoredUploadName(name)) return notFound();
try {
// Only photos a driver profile actually points at are served. Without
// this, any signed-in driver could upload an arbitrary image and walk away
// with a permanent public URL for it.
const rows = await sql<{ used: boolean }>`
SELECT EXISTS (
SELECT 1 FROM drivers WHERE profile_image_url = ${name}
) AS used
`;
if (!rows[0]?.used) return notFound();
const bytes = await readUpload(name, "photo");
if (!bytes) return notFound();
return withCors(
request,
new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": uploadMimeType(name),
"Content-Length": String(bytes.length),
// The name changes whenever the photo does, so the bytes behind a
// given URL are immutable and can be cached hard. That matters: the
// rider's nearby-drivers view re-renders these constantly.
"Cache-Control": "public, max-age=604800, immutable",
"X-Content-Type-Options": "nosniff",
},
}),
);
} catch (error) {
console.error("[DRIVER_PHOTO_GET]: ", error);
return withCors(
request,
Response.json({ error: "Internal Server Error" }, { status: 500 }),
);
}
}
/**
* Photos are cheap compared with a scan (no Vision call), but still a disk
* write, so keep a lid on how fast one account can retake theirs.
*/
const PHOTO_LIMIT = 15;
const PHOTO_WINDOW_MS = 60 * 60 * 1000;
const recentUploads = new Map<string, number[]>();
const overPhotoLimit = (userId: string): boolean => {
const now = Date.now();
const cutoff = now - PHOTO_WINDOW_MS;
const history = (recentUploads.get(userId) ?? []).filter((at) => at > cutoff);
if (history.length >= PHOTO_LIMIT) {
recentUploads.set(userId, history);
return true;
}
history.push(now);
recentUploads.set(userId, history);
if (recentUploads.size > 500) {
for (const [key, times] of recentUploads) {
if (times.every((at) => at <= cutoff)) recentUploads.delete(key);
}
}
return false;
};
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
let lastPruneAt = 0;
/**
* A driver who takes a photo and then abandons onboarding leaves a file
* nothing points at. Same sweep as the scan route, over the photo directory.
*/
const pruneOrphansOccasionally = async (): Promise<void> => {
if (Date.now() - lastPruneAt < PRUNE_INTERVAL_MS) return;
lastPruneAt = Date.now();
try {
const rows = await sql<{ profile_image_url: string | null }>`
SELECT profile_image_url FROM drivers
WHERE profile_image_url IS NOT NULL
`;
const referenced = new Set(
rows.map((row) => row.profile_image_url).filter(Boolean) as string[],
);
await pruneOrphanUploads(referenced, "photo");
} catch (error) {
console.error("[DRIVER_PHOTO_PRUNE]: ", error);
}
};
// POST — upload or replace the driver's profile photo.
//
// A driver who already has a profile row gets it attached straight away, so
// retaking a bad photo is one step. During onboarding there is no row yet, so
// the name is just returned and travels up with the profile submission.
export async function POST(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const users = await sql<{ role: string | null }>`
SELECT role FROM users WHERE id = ${auth.userId}
`;
if (users[0]?.role !== "driver") {
return Response.json(
{ error: "Only driver accounts can upload a driver photo." },
{ status: 403 },
);
}
if (overPhotoLimit(auth.userId)) {
return Response.json(
{
error: "Too many uploads. Wait a few minutes and try again.",
code: "PHOTO_RATE_LIMIT",
},
{ status: 429 },
);
}
const body = await req.json();
const raw = body.image_base64;
if (typeof raw !== "string" || raw.length === 0) {
return Response.json(
{ error: "image_base64 is required." },
{ status: 400 },
);
}
const encoded = raw.includes(",") ? raw.slice(raw.indexOf(",") + 1) : raw;
// Base64 inflates by 4/3, so reject on the encoded length before
// allocating — otherwise an oversized upload is buffered just to be
// refused.
if (encoded.length > MAX_UPLOAD_BYTES * 1.4) {
return Response.json(
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
{ status: 413 },
);
}
const image = Buffer.from(encoded, "base64");
if (image.length > MAX_UPLOAD_BYTES) {
return Response.json(
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
{ status: 413 },
);
}
const mimeType = sniffImageType(image);
if (!mimeType) {
return Response.json(
{
error: "Upload a JPEG, PNG or WebP photo.",
code: "UNSUPPORTED_IMAGE",
},
{ status: 400 },
);
}
const photo = await storeUpload(image, mimeType, "photo");
// Attach it now if the driver already has a profile, so retaking a bad
// photo is a single step. Mid-onboarding there is no row yet and the name
// simply travels up with the profile submission instead.
//
// This deliberately does not touch approval_status: a driver swapping a
// blurry photo for a clear one shouldn't be knocked out of service, and
// the reviewer sees whatever the current photo is when they next open the
// profile.
const existing = await sql<{ profile_image_url: string | null }>`
SELECT profile_image_url FROM drivers WHERE user_id = ${auth.userId}
`;
const attached = existing.length > 0;
if (attached) {
await sql`
UPDATE drivers SET profile_image_url = ${photo}
WHERE user_id = ${auth.userId}
`;
// Only a name we stored is safe to unlink — an owner may have set an
// external URL from the dashboard, and that is not ours to delete.
const previous = existing[0].profile_image_url;
if (previous && previous !== photo && isStoredUploadName(previous)) {
await deleteUpload(previous, "photo");
}
}
void pruneOrphansOccasionally();
return Response.json({
data: {
/** Opaque stored name; send it with the profile if onboarding. */
photo,
attached,
},
});
} catch (error) {
console.error("[DRIVER_PHOTO_POST]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+246 -15
View File
@@ -1,6 +1,8 @@
import { requireAuth } from "@/lib/jwt";
import { sql, query } from "@/lib/db";
import { isServiceId, requireDriverProfile } from "@/lib/driver";
import { DRIVER_BUSY_ARRAY } from "@/lib/ride-lifecycle";
import { deleteUpload, isStoredUploadName } from "@/lib/uploads";
import { SERVICES, type ServiceId } from "@/constants/services";
// GET — the signed-in user's own driver profile, or 403 (code: ONBOARD) when
@@ -12,23 +14,54 @@ export async function GET(req: Request) {
const { auth, driverId } = result;
const rows = await sql`
SELECT id, first_name, last_name, profile_image_url, car_image_url,
car_seats, rating, service, online, car_model, user_id
car_seats, rating, rating_count, service, online, car_model, user_id,
approval_status, rejection_reason, submitted_at, reviewed_at,
license_number, license_expiry, plate_number,
license_image_url, id_image_url, vehicle_reg_image_url
FROM drivers WHERE id = ${driverId}
`;
return Response.json({ data: rows[0], userId: auth.userId });
}
// Credentials collected at onboarding. The numbers are typed by the driver —
// usually prefilled from a scan by /(api)/driver/scan, but a scan is only ever
// a suggestion, so they are validated here exactly as if they had been typed
// from scratch. The scans themselves are stored alongside so the reviewer
// checks the numbers against the document rather than taking them on trust.
const trimmed = (v: unknown, max: number): string | null => {
if (typeof v !== "string") return null;
const value = v.trim();
return value.length > 0 && value.length <= max ? value : null;
};
// Expiry is a plain YYYY-MM-DD date and has to still be in the future — an
// expired licence is exactly what vetting exists to catch.
const futureDate = (v: unknown): string | null => {
if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return null;
const date = new Date(`${v}T00:00:00Z`);
if (Number.isNaN(date.getTime()) || date.getTime() <= Date.now()) return null;
return v;
};
// Scans and profile photos are both referenced by the opaque name their
// upload route handed back, and only names in that shape are accepted. A client
// cannot invent one, so it cannot point its profile row at a file it never
// uploaded — and since the name is all that is stored, there is no path here
// for the filesystem to interpret.
const storedName = (v: unknown): string | null =>
isStoredUploadName(v) ? v : null;
// POST — onboarding. A driver-role user creates their one linked drivers row.
// The user must carry role='driver' (set on sign-up / role.tsx) so a rider
// can't silently become a driver by hitting this endpoint.
// The user must carry role='driver' (set on sign-up / role.tsx), and the row is
// created 'pending': it is not matched, not shown to riders, and cannot go
// online until an owner approves it. Role alone has never been a credential.
export async function POST(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const body = await req.json();
const { car_model, car_seats, service, profile_image_url, car_image_url } =
body;
const { car_model, car_seats, service, car_image_url } = body;
// The user must be flagged a driver to onboard a driver profile.
const users = await sql<{ role: string | null; name: string | null }>`
@@ -43,7 +76,9 @@ export async function POST(req: Request) {
if (!isServiceId(service)) {
return Response.json(
{ error: `service must be one of: ${SERVICES.map((s) => s.id).join(", ")}.` },
{
error: `service must be one of: ${SERVICES.map((s) => s.id).join(", ")}.`,
},
{ status: 400 },
);
}
@@ -56,6 +91,66 @@ export async function POST(req: Request) {
);
}
const licenseNumber = trimmed(body.license_number, 60);
const nationalId = trimmed(body.national_id, 60);
const plateNumber = trimmed(body.plate_number, 20);
const licenseExpiry = futureDate(body.license_expiry);
if (!licenseNumber || !nationalId || !plateNumber) {
return Response.json(
{
error:
"Driving licence number, national ID and plate number are required.",
code: "CREDENTIALS_REQUIRED",
},
{ status: 400 },
);
}
if (!licenseExpiry) {
return Response.json(
{
error: "Licence expiry must be a future date (YYYY-MM-DD).",
code: "LICENSE_EXPIRED",
},
{ status: 400 },
);
}
const licenseDocument = storedName(body.license_document);
const idDocument = storedName(body.id_document);
const vehicleRegDocument = storedName(body.vehicle_reg_document);
const profilePhoto = storedName(body.profile_photo);
// The licence scan is the one document review cannot do without: it is
// what the reviewer checks the typed licence number and expiry against.
// The ID card and vehicle registration help but are not required, so a
// driver whose registration is with the car's owner can still onboard.
if (!licenseDocument) {
return Response.json(
{
error: "Scan your driving licence before submitting.",
code: "LICENSE_SCAN_REQUIRED",
},
{ status: 400 },
);
}
// The profile photo is what a rider sees next to a driver's name when
// choosing between offers, and it is how they check that the person who
// pulls up is the person the app sent. A driver with no photo would be an
// anonymous row in that list, so it is collected up front rather than left
// as a profile nicety somebody gets round to.
if (!profilePhoto) {
return Response.json(
{
error: "Add a profile photo before submitting.",
code: "PHOTO_REQUIRED",
},
{ status: 400 },
);
}
const [firstName, ...rest] = (users[0].name ?? "").split(" ");
// One profile per driver user. The partial unique index on user_id
@@ -64,20 +159,32 @@ export async function POST(req: Request) {
const rows = await sql`
INSERT INTO drivers (
user_id, first_name, last_name, profile_image_url, car_image_url,
car_seats, rating, service, car_model, online
car_seats, rating, service, car_model, online,
approval_status, license_number, license_expiry, national_id,
plate_number, submitted_at,
license_image_url, id_image_url, vehicle_reg_image_url
) VALUES (
${auth.userId},
${firstName || "Driver"},
${rest.join(" ") || ""},
${profile_image_url ?? null},
${profilePhoto},
${car_image_url ?? null},
${seats},
5.0,
${service as ServiceId},
${car_model ?? null},
FALSE
FALSE,
'pending',
${licenseNumber},
${licenseExpiry},
${nationalId},
${plateNumber},
CURRENT_TIMESTAMP,
${licenseDocument},
${idDocument},
${vehicleRegDocument}
)
RETURNING id, service, online
RETURNING id, service, online, approval_status
`;
return Response.json({ data: rows[0] }, { status: 201 });
} catch (error) {
@@ -112,8 +219,130 @@ export async function PATCH(req: Request) {
values.push(value);
};
// A profile that hasn't been cleared cannot go online, and therefore can
// never be matched. This is the gate the whole vetting flow rests on —
// everything else (dispatch filters, the rider map) is defence in depth.
if (online === true && result.approvalStatus !== "approved") {
return Response.json(
{
error: "Your driver account is not approved yet.",
code: "NOT_APPROVED",
approval_status: result.approvalStatus,
},
{ status: 403 },
);
}
// A rejected driver may fix their details and resubmit, which puts them
// back in the review queue rather than silently leaving them stuck. A
// rejection is often about the scan rather than the numbers ("the photo is
// unreadable"), so a fresh scan on its own counts as a resubmission.
const resubmitted =
result.approvalStatus === "rejected" &&
(body.license_number !== undefined ||
body.national_id !== undefined ||
body.plate_number !== undefined ||
body.license_expiry !== undefined ||
body.license_document !== undefined ||
body.id_document !== undefined ||
body.vehicle_reg_document !== undefined);
// Scans replaced by this resubmission, deleted once the row actually
// points at the new ones — an orphaned file is tidier than a row pointing
// at a document that is no longer on disk.
const superseded: string[] = [];
if (resubmitted) {
const licenseNumber = trimmed(body.license_number, 60);
const nationalId = trimmed(body.national_id, 60);
const plateNumber = trimmed(body.plate_number, 20);
const licenseExpiry = futureDate(body.license_expiry);
if (!licenseNumber || !nationalId || !plateNumber || !licenseExpiry) {
return Response.json(
{
error:
"Licence number, expiry (future date), national ID and plate number are all required to resubmit.",
code: "CREDENTIALS_REQUIRED",
},
{ status: 400 },
);
}
// Only documents the driver re-scanned are sent; anything omitted keeps
// the scan already on file.
const replacements: Record<string, string | null> = {
license_image_url: storedName(body.license_document),
id_image_url: storedName(body.id_document),
vehicle_reg_image_url: storedName(body.vehicle_reg_document),
};
const existing = await sql<{
license_image_url: string | null;
id_image_url: string | null;
vehicle_reg_image_url: string | null;
}>`
SELECT license_image_url, id_image_url, vehicle_reg_image_url
FROM drivers WHERE id = ${result.driverId}
`;
// Same rule as onboarding, applied to the state the row will be left in:
// a driver may resubmit without re-scanning, but not end up with no
// licence scan at all.
if (!(replacements.license_image_url ?? existing[0]?.license_image_url)) {
return Response.json(
{
error: "Scan your driving licence before resubmitting.",
code: "LICENSE_SCAN_REQUIRED",
},
{ status: 400 },
);
}
for (const [column, name] of Object.entries(replacements)) {
if (!name) continue;
const previous = existing[0]?.[column as keyof (typeof existing)[0]];
if (previous && previous !== name) superseded.push(previous);
push(column, name);
}
push("license_number", licenseNumber);
push("license_expiry", licenseExpiry);
push("national_id", nationalId);
push("plate_number", plateNumber);
push("approval_status", "pending");
push("rejection_reason", null);
updates.push(`submitted_at = CURRENT_TIMESTAMP`);
}
// Going offline mid-ride would strand the rider: dispatch stops seeing the
// driver, the location heartbeat stops, and the rider's map freezes on a
// car that never arrives — with no way to re-dispatch, since the ride is
// already assigned. Finish or cancel the ride first.
if (online === false) {
const active = await sql<{ ride_id: number }>`
SELECT ride_id FROM rides
WHERE driver_id = ${result.driverId}
AND status = ANY(${DRIVER_BUSY_ARRAY}::text[])
LIMIT 1
`;
if (active[0]) {
return Response.json(
{
error: "Finish or cancel your current ride before going offline.",
code: "RIDE_IN_PROGRESS",
ride_id: active[0].ride_id,
},
{ status: 409 },
);
}
}
if (typeof online === "boolean") push("online", online);
if (typeof car_model === "string" || car_model === null) push("car_model", car_model);
if (typeof car_model === "string" || car_model === null)
push("car_model", car_model);
if (car_seats !== undefined) {
const seats = Number(car_seats);
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
@@ -126,10 +355,7 @@ export async function PATCH(req: Request) {
}
if (service !== undefined) {
if (!isServiceId(service)) {
return Response.json(
{ error: "Invalid service." },
{ status: 400 },
);
return Response.json({ error: "Invalid service." }, { status: 400 });
}
push("service", service as string);
}
@@ -143,6 +369,11 @@ export async function PATCH(req: Request) {
`UPDATE drivers SET ${updates.join(", ")} WHERE id = $${idx} RETURNING *`,
values,
);
// Nothing references the old scans now, and they are identity documents —
// don't keep them around a moment longer than the row does.
await Promise.all(superseded.map((name) => deleteUpload(name, "document")));
return Response.json({ data: rows[0] });
} catch (error) {
console.error("[DRIVER_PROFILE_PATCH]: ", error);
+217 -31
View File
@@ -1,11 +1,21 @@
import { requireDriverProfile } from "@/lib/driver";
import { sql } from "@/lib/db";
import { DRIVER_BUSY_ARRAY, expireStaleRequests } from "@/lib/ride-lifecycle";
import { boundingBox, haversine } from "@/lib/utils";
import { BROADCAST_RADIUS_M, REQUEST_TTL_SECONDS } from "@/constants/dispatch";
import { splitFare } from "@/lib/pricing";
// GET — the driver's world in one poll:
// offers : incoming ride_offers awaiting this driver's accept/decline,
// each joined to its ride so the card can show pickup/dest/fare.
// active : the ride this driver is currently on (accepted or en_route).
// requests: open ride requests broadcast near this driver, each carrying
// how far the pickup is, what the driver would earn, and whether
// they have already offered on it.
// active : the ride this driver is currently on (accepted -> en_route).
// recent : rides completed today, for the earnings summary.
//
// Requests are found by distance from the driver's own last position, using
// the same radius lib/dispatch broadcasts over — the two questions ("who
// should be told about this request?" and "what is open near me?") have to
// agree, or a driver gets pushed a job their dashboard then hides.
export async function GET(req: Request) {
const result = await requireDriverProfile(req);
if ("error" in result) return result.error;
@@ -13,53 +23,211 @@ export async function GET(req: Request) {
try {
const { driverId } = result;
const offers = await sql`
SELECT
ro.id AS offer_id, ro.offered_at,
r.ride_id, r.origin_address, r.destination_address,
r.origin_latitude, r.origin_longitude,
r.destination_latitude, r.destination_longitude,
r.ride_time, r.fare_price, r.payment_status, r.service, r.user_id
FROM ride_offers ro
JOIN rides r ON r.ride_id = ro.ride_id
WHERE ro.driver_id = ${driverId} AND ro.status = 'offered'
ORDER BY ro.offered_at DESC
// This poll is one of the lazy paths that stands in for a background
// worker, so it also buries requests nobody was picked for. Awaited: the
// list read below should not include a request that just died.
await expireStaleRequests();
// The driver's own position and state. A driver with no fix yet can't be
// told what's near them, and one who is offline shouldn't be shown work.
const [me] = await sql<{
latitude: number | null;
longitude: number | null;
service: string;
online: boolean;
}>`
SELECT latitude, longitude, service, online
FROM drivers WHERE id = ${driverId}
`;
const canSeeRequests =
me?.online === true && me.latitude !== null && me.longitude !== null;
// Coarse box in the index, great-circle pass afterwards — the same
// two-step every other proximity query in this codebase uses.
const box = canSeeRequests
? boundingBox(me.latitude!, me.longitude!, BROADCAST_RADIUS_M)
: null;
const openRequests = box
? await sql<OpenRequestRow>`
SELECT
r.ride_id, r.origin_address, r.destination_address,
r.origin_latitude, r.origin_longitude,
r.destination_latitude, r.destination_longitude,
r.ride_time, r.fare_price, r.service, r.created_at,
u.name AS rider_name, u.rating AS rider_rating,
mine.id AS my_offer_id,
(SELECT COUNT(*)::int FROM ride_offers ro
WHERE ro.ride_id = r.ride_id AND ro.status = 'offered')
AS offer_count
FROM rides r
LEFT JOIN users u ON u.id = r.user_id
LEFT JOIN ride_offers mine
ON mine.ride_id = r.ride_id
AND mine.driver_id = ${driverId}
AND mine.status = 'offered'
WHERE r.status = 'requested'
AND r.service = ${me.service}
AND r.created_at > CURRENT_TIMESTAMP - make_interval(secs => ${REQUEST_TTL_SECONDS})
AND r.origin_latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND r.origin_longitude BETWEEN ${box.minLng} AND ${box.maxLng}
ORDER BY r.created_at DESC
`
: [];
// Distance is computed here rather than in SQL so the filter and the
// number the driver reads on the card are the same calculation.
const requests = (openRequests as unknown as OpenRequestRow[])
.map((row) => ({
...row,
pickup_distance_m: Math.round(
haversine(
me.latitude!,
me.longitude!,
Number(row.origin_latitude),
Number(row.origin_longitude),
),
),
}))
.filter((row) => row.pickup_distance_m <= BROADCAST_RADIUS_M)
.sort((a, b) => a.pickup_distance_m - b.pickup_distance_m);
// Note: pickup_code is deliberately NOT selected here. The whole point of
// the code is that the driver has to get it from the rider at the car.
//
// The rider's phone number isn't selected either. It used to be shipped to
// the driver client and never rendered — personal data in transit for
// nothing. Driver↔rider contact goes through the in-app chat and WebRTC
// call, which is this app's equivalent of a masked number.
const active = await sql`
SELECT
r.ride_id, r.status, r.service, r.payment_status,
r.origin_address, r.destination_address,
r.origin_latitude, r.origin_longitude,
r.destination_latitude, r.destination_longitude,
r.ride_time, r.fare_price, r.created_at,
u.name AS rider_name, u.phone AS rider_phone
r.ride_time, r.fare_price, r.created_at, r.arrived_at,
u.name AS rider_name, u.rating AS rider_rating
FROM rides r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.driver_id = ${driverId} AND r.status IN ('accepted', 'en_route')
WHERE r.driver_id = ${driverId}
AND r.status = ANY(${DRIVER_BUSY_ARRAY}::text[])
ORDER BY r.created_at DESC
LIMIT 1
`;
const recent = await sql`
SELECT ride_id, fare_price, service, completed_at
// driver_payout_cents is what the driver actually keeps; fare_price is
// what the rider paid. Everything the driver sees is the payout — COALESCE
// covers rides completed before the split existed.
const recent = await sql<RecentRow>`
SELECT ride_id, fare_price, service, payment_status, completed_at,
COALESCE(driver_payout_cents, fare_price) AS payout_cents,
COALESCE(platform_fee_cents, 0) AS fee_cents
FROM rides
WHERE driver_id = ${driverId} AND status = 'completed'
AND completed_at >= CURRENT_DATE
ORDER BY completed_at DESC
`;
const earnings = recent.reduce(
(sum, r) => sum + Number(r.fare_price),
0,
// The driver's running balance with the company, across all time rather
// than just today — an unremitted commission doesn't stop mattering at
// midnight. Two directions: cash commission they're holding for us, and
// card payouts we still owe them.
const [balance] = await sql<{
owes_company_cents: number;
owed_to_driver_cents: number;
}>`
SELECT
COALESCE(SUM(platform_fee_cents)
FILTER (WHERE platform_fee_settled_at IS NULL), 0)::int
AS owes_company_cents,
COALESCE(SUM(driver_payout_cents)
FILTER (WHERE driver_payout_settled_at IS NULL), 0)::int
AS owed_to_driver_cents
FROM rides
WHERE driver_id = ${driverId}
AND status = 'completed'
AND payment_status IN ('paid','cash_collected')
`;
// A ride the driver finished recently and hasn't rated. Surfaced as a
// prompt on the dashboard so the rating survives the driver immediately
// accepting their next trip.
const pendingRating = await sql`
SELECT r.ride_id, u.name AS rider_name
FROM rides r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.driver_id = ${driverId}
AND r.status = 'completed'
AND r.completed_at > CURRENT_TIMESTAMP - INTERVAL '1 day'
AND NOT EXISTS (
SELECT 1 FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'driver'
)
ORDER BY r.completed_at DESC
LIMIT 1
`;
const settled = (r: RecentRow) =>
r.payment_status === "paid" || r.payment_status === "cash_collected";
const sumPayout = (rows: typeof recent) =>
rows.reduce((sum, r) => sum + Number(r.payout_cents), 0);
const sumFares = (rows: typeof recent) =>
rows.reduce((sum, r) => sum + Number(r.fare_price), 0);
// Earnings count settled money only, and count the driver's share of it.
// A cash ride the driver marked "not collected" is still an unpaid trip
// and used to land in this headline anyway, so the figure a driver saw and
// the figure they'd be paid against disagreed from day one.
const earnings = sumPayout(recent.filter(settled));
// The platform's cut of the same rides, so the number above is explainable
// rather than mysteriously smaller than the fares they remember charging.
const platformFees = recent
.filter(settled)
.reduce((sum, r) => sum + Number(r.fee_cents), 0);
// Cash the driver has taken in hand today — the full fare, because that's
// the physical money in their pocket, not their share of it. This is the
// figure they'll be reconciled against, and the platform's cut of it is
// owed back.
const cashCollected = sumFares(
recent.filter((r) => r.payment_status === "cash_collected"),
);
// Fares that were never collected. Surfaced rather than hidden so an
// unpaid trip is visible to the driver on the day it happened.
const cashOwed = sumFares(recent.filter((r) => r.payment_status === "cash"));
// A driver deciding whether to take a ride cares what they'll be paid, not
// what the rider is charged. The split isn't stored until completion, so
// it's computed here from the same helper that stamps it later — the two
// can't disagree, and the driver is never shown a number they won't get.
const withPayout = <T extends { fare_price: number }>(row: T) => ({
...row,
payout_cents: splitFare(Number(row.fare_price)).driverPayoutCents,
});
return Response.json({
data: {
offers: offers as unknown as OfferRow[],
active: (active[0] as unknown as ActiveRide | undefined) ?? null,
recent: recent as unknown as RecentRow[],
// The server's clock, so the client can draw a request countdown that
// matches the TTL dispatch actually enforces. Without it a phone whose
// clock is a few seconds out shows a timer that expires early or late.
now: new Date().toISOString(),
requests: requests.map(withPayout),
active: active[0]
? withPayout(active[0] as unknown as ActiveRide)
: null,
recent,
earnings,
platform_fees: platformFees,
cash_collected: cashCollected,
cash_owed: cashOwed,
owes_company: Number(balance?.owes_company_cents ?? 0),
owed_to_driver: Number(balance?.owed_to_driver_cents ?? 0),
pending_rating:
(pendingRating[0] as unknown as PendingRatingRow | undefined) ?? null,
},
});
} catch (error) {
@@ -68,9 +236,7 @@ export async function GET(req: Request) {
}
}
type OfferRow = {
offer_id: number;
offered_at: string;
type OpenRequestRow = {
ride_id: number;
origin_address: string;
destination_address: string;
@@ -80,12 +246,23 @@ type OfferRow = {
destination_longitude: number;
ride_time: number;
fare_price: number;
payment_status: string;
service: string;
user_id: string;
created_at: string;
rider_name: string | null;
rider_rating: number | null;
/** The id of this driver's live offer on the request, or null. */
my_offer_id: number | null;
/** How many drivers are competing for it, this one included. */
offer_count: number;
/** Metres from the driver's last position to the pickup. */
pickup_distance_m?: number;
/** The driver's share of the fare, computed per request. */
payout_cents?: number;
};
type ActiveRide = {
/** The driver's share of the fare, computed per request. */
payout_cents?: number;
ride_id: number;
status: string;
service: string;
@@ -99,13 +276,22 @@ type ActiveRide = {
ride_time: number;
fare_price: number;
created_at: string;
arrived_at: string | null;
rider_name: string | null;
rider_phone: string | null;
rider_rating: number | null;
};
type RecentRow = {
ride_id: number;
fare_price: number;
payout_cents: number;
fee_cents: number;
service: string;
payment_status: string;
completed_at: string;
};
type PendingRatingRow = {
ride_id: number;
rider_name: string | null;
};
+209
View File
@@ -0,0 +1,209 @@
import { sql } from "@/lib/db";
import {
isDocumentType,
OcrUnavailableError,
parseDocumentText,
recogniseDocument,
} from "@/lib/document-ocr";
import { requireAuth } from "@/lib/jwt";
import {
MAX_UPLOAD_BYTES,
pruneOrphanUploads,
sniffImageType,
storeUpload,
} from "@/lib/uploads";
// POST — a driver photographs one of their documents; we keep the scan and
// read what we can off it to prefill the onboarding form.
//
// The scan is stored whether or not OCR succeeds: the reviewer wants to see the
// actual licence next to the numbers the driver submitted, and that value does
// not depend on Vision having had a good day. When OCR fails the route still
// answers 200 with an empty field set and a code the client uses to say "type
// these in yourself" — an unreadable photo is a normal outcome, not an error.
/**
* Scans are the most expensive call in the app (a paid Vision request plus a
* disk write), so cap how fast one account can make them. In-process and
* therefore per-server — enough to stop a stuck retry loop or a bored driver
* burning the Vision quota, not a defence against a distributed attacker.
*/
const SCAN_LIMIT = 20;
const SCAN_WINDOW_MS = 60 * 60 * 1000;
const recentScans = new Map<string, number[]>();
const overScanLimit = (userId: string): boolean => {
const now = Date.now();
const cutoff = now - SCAN_WINDOW_MS;
const history = (recentScans.get(userId) ?? []).filter((at) => at > cutoff);
if (history.length >= SCAN_LIMIT) {
recentScans.set(userId, history);
return true;
}
history.push(now);
recentScans.set(userId, history);
// Without this the map grows one entry per driver forever. Anything whose
// whole history has aged out is a driver who isn't scanning any more.
if (recentScans.size > 500) {
for (const [key, times] of recentScans) {
if (times.every((at) => at <= cutoff)) recentScans.delete(key);
}
}
return false;
};
/**
* Abandoned onboarding leaves identity documents on disk that nothing points
* at. Sweeping them here rather than on a cron keeps the deployment to one
* process; once an hour is often enough for files that get a day's grace.
*/
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
let lastPruneAt = 0;
const pruneOrphansOccasionally = async (): Promise<void> => {
if (Date.now() - lastPruneAt < PRUNE_INTERVAL_MS) return;
lastPruneAt = Date.now();
try {
const rows = await sql<{
license_image_url: string | null;
id_image_url: string | null;
vehicle_reg_image_url: string | null;
}>`
SELECT license_image_url, id_image_url, vehicle_reg_image_url
FROM drivers
WHERE license_image_url IS NOT NULL
OR id_image_url IS NOT NULL
OR vehicle_reg_image_url IS NOT NULL
`;
const referenced = new Set<string>();
for (const row of rows) {
for (const name of Object.values(row)) {
if (name) referenced.add(name);
}
}
await pruneOrphanUploads(referenced, "document");
} catch (error) {
// A failed sweep must never fail the driver's scan.
console.error("[DRIVER_SCAN_PRUNE]: ", error);
}
};
export async function POST(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const body = await req.json();
const { doc_type: docType } = body;
if (!isDocumentType(docType)) {
return Response.json(
{ error: "doc_type must be license, id or vehicle_reg." },
{ status: 400 },
);
}
// Same gate as onboarding itself: only a driver-role account has any
// business uploading driver documents.
const users = await sql<{ role: string | null }>`
SELECT role FROM users WHERE id = ${auth.userId}
`;
if (users[0]?.role !== "driver") {
return Response.json(
{ error: "Only driver accounts can scan documents." },
{ status: 403 },
);
}
if (overScanLimit(auth.userId)) {
return Response.json(
{
error: "Too many scans. Wait a few minutes and try again.",
code: "SCAN_RATE_LIMIT",
},
{ status: 429 },
);
}
const raw = body.image_base64;
if (typeof raw !== "string" || raw.length === 0) {
return Response.json(
{ error: "image_base64 is required." },
{ status: 400 },
);
}
// Some clients send a full data URI. Take the payload either way.
const encoded = raw.includes(",") ? raw.slice(raw.indexOf(",") + 1) : raw;
// Base64 inflates by 4/3, so reject on the encoded length before
// allocating — otherwise an oversized upload is buffered just to be
// refused.
if (encoded.length > MAX_UPLOAD_BYTES * 1.4) {
return Response.json(
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
{ status: 413 },
);
}
const image = Buffer.from(encoded, "base64");
if (image.length > MAX_UPLOAD_BYTES) {
return Response.json(
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
{ status: 413 },
);
}
// The magic bytes decide the type, not whatever the client claimed, so a
// non-image can't be parked on the disk under a .jpg name.
const mimeType = sniffImageType(image);
if (!mimeType) {
return Response.json(
{
error: "Upload a JPEG, PNG or WebP photo.",
code: "UNSUPPORTED_IMAGE",
},
{ status: 400 },
);
}
const document = await storeUpload(image, mimeType, "document");
void pruneOrphansOccasionally();
let fields = {};
let ocrFailed = false;
try {
const text = await recogniseDocument(image);
fields = parseDocumentText(text, docType);
} catch (error) {
if (!(error instanceof OcrUnavailableError)) throw error;
// Logged, not surfaced: the message can name the API key's failure mode
// and the driver can do nothing with it but type the fields manually.
console.error("[DRIVER_SCAN_OCR]: ", error.message);
ocrFailed = true;
}
return Response.json({
data: {
doc_type: docType,
/** Opaque stored name; submit it with the profile to attach the scan. */
document,
fields,
...(ocrFailed ? { code: "OCR_UNAVAILABLE" } : {}),
},
});
} catch (error) {
console.error("[DRIVER_SCAN_POST]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+80
View File
@@ -0,0 +1,80 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
// Device registration for push notifications.
//
// POST — claim this device for the signed-in user. Upsert on the token, so
// signing in as a different account on the same phone MOVES the
// device rather than leaving the previous account subscribed to
// notifications that are now someone else's.
// DELETE — release the device, called on sign-out.
//
// Not driver-only: riders need it too (a driver accepting, arriving, or the
// search timing out are all things worth waking a phone for), so it lives
// under /push rather than /driver.
const isExpoToken = (v: unknown): v is string =>
typeof v === "string" &&
v.length <= 256 &&
/^Expo(nent)?PushToken\[[^\]]+\]$/.test(v);
export async function POST(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const body = await req.json();
const { token, platform } = body;
if (!isExpoToken(token)) {
return Response.json(
{ error: "A valid Expo push token is required." },
{ status: 400 },
);
}
const rows = await sql<{ token: string }>`
INSERT INTO push_tokens (token, user_id, platform)
VALUES (${token}, ${auth.userId}, ${platform ?? null})
ON CONFLICT (token) DO UPDATE
SET user_id = EXCLUDED.user_id,
platform = EXCLUDED.platform,
updated_at = CURRENT_TIMESTAMP
RETURNING token
`;
return Response.json({ data: { registered: Boolean(rows[0]) } });
} catch (error) {
console.error("[PUSH_TOKEN_POST]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function DELETE(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const body = await req.json().catch(() => ({}));
const { token } = body as { token?: unknown };
if (!isExpoToken(token)) {
return Response.json(
{ error: "A valid Expo push token is required." },
{ status: 400 },
);
}
// Scoped to the caller: a token can only be released by the account that
// currently holds it.
await sql`
DELETE FROM push_tokens
WHERE token = ${token} AND user_id = ${auth.userId}
`;
return Response.json({ data: { released: true } });
} catch (error) {
console.error("[PUSH_TOKEN_DELETE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+271 -37
View File
@@ -1,11 +1,22 @@
import { requireAuth } from "@/lib/jwt";
import { sql, query } from "@/lib/db";
import { matchNextDriver } from "@/lib/dispatch";
import { sql } from "@/lib/db";
import { broadcastRequest } from "@/lib/dispatch";
import { requireDriverProfile } from "@/lib/driver";
import {
isCancellationReason,
DRIVER_CANCELLABLE_ARRAY,
RIDER_CANCELLABLE_ARRAY,
} from "@/lib/ride-lifecycle";
import { REQUEST_TTL_SECONDS } from "@/constants/dispatch";
import { COMMISSION_RATE } from "@/lib/pricing";
// GET — single ride by id, the rider's status-poll endpoint. If the ride is
// still 'requested' with no offer in flight, kick auto-match before reading
// so the rider's poll itself drives matching forward (no background worker).
// GET — single ride by id, the rider's status-poll endpoint.
//
// While the ride is still open this also returns the drivers who have offered
// on it, which is what the rider chooses from. The poll re-drives the
// broadcast too (a no-op once announced), so a request whose announcement lost
// its race with the push service still reaches drivers on the next tick —
// there is no background worker to do it.
export async function GET(request: Request, { id }: { id: string }) {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
@@ -23,9 +34,13 @@ export async function GET(request: Request, { id }: { id: string }) {
return Response.json({ error: "Ride not found." }, { status: 404 });
}
// Lazy match: try to offer the ride to a driver if it's still requested.
// Lazy dispatch: announce the request if that hasn't happened yet, and
// give up on it if it has run past its window. Awaited, because the row
// this request is about to read is the one the sweep may rewrite — a
// rider whose request just expired should be told, not shown a list of
// drivers they can no longer pick.
if (ride[0].status === "requested") {
void matchNextDriver(rideId);
await broadcastRequest(rideId);
}
const rows = await sql`
@@ -43,8 +58,22 @@ export async function GET(request: Request, { id }: { id: string }) {
r.status,
r.service,
r.created_at,
r.accepted_at,
r.arrived_at,
r.started_at,
r.completed_at,
r.cancelled_at,
r.cancelled_by,
r.cancellation_reason,
r.cash_collected_at,
-- The rider's copy of the pickup code. Only ever sent to the ride's
-- own rider (this route is rider-scoped), and only while it still
-- matters: once the trip has started the code is spent.
CASE WHEN r.status IN ('accepted', 'arrived') THEN r.pickup_code END
AS pickup_code,
-- Has this rider already rated the ride? Drives the rating card.
(SELECT rr.rating FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'rider') AS my_rating,
json_build_object(
'id', d.id,
'first_name', d.first_name,
@@ -53,6 +82,7 @@ export async function GET(request: Request, { id }: { id: string }) {
'profile_image_url', d.profile_image_url,
'car_image_url', d.car_image_url,
'rating', d.rating,
'rating_count', d.rating_count,
'service', d.service,
'car_model', d.car_model,
'latitude', d.latitude,
@@ -63,7 +93,38 @@ export async function GET(request: Request, { id }: { id: string }) {
WHERE r.ride_id = ${rideId}
`;
return Response.json({ data: rows[0] });
// The drivers who have volunteered, newest first. Only while the request
// is open: once it is assigned, the losing offers are nobody's business
// and the winning one is just "your driver". Coordinates are deliberately
// not included — a rider comparing offers needs how far away each driver
// is, not where they are, and only the chosen driver's position is theirs
// to watch.
const offers =
rows[0]?.status === "requested"
? await sql`
SELECT
ro.id AS offer_id, ro.offered_at, ro.pickup_distance_m,
d.id AS driver_id, d.first_name, d.last_name,
d.profile_image_url, d.car_image_url, d.car_model, d.car_seats,
d.rating, d.rating_count, d.service
FROM ride_offers ro
JOIN drivers d ON d.id = ro.driver_id
WHERE ro.ride_id = ${rideId} AND ro.status = 'offered'
ORDER BY ro.pickup_distance_m NULLS LAST, ro.offered_at
`
: [];
return Response.json({
data: {
...rows[0],
offers,
// The server's clock and the request window, so the "still looking"
// countdown the rider watches is the one the server actually enforces
// rather than whatever their phone thinks the time is.
now: new Date().toISOString(),
request_ttl_seconds: REQUEST_TTL_SECONDS,
},
});
} catch (error) {
console.error("[GET_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
@@ -71,17 +132,27 @@ export async function GET(request: Request, { id }: { id: string }) {
}
// PATCH — ride lifecycle transitions.
// Rider: { status: 'cancelled' } — only from 'requested' or 'accepted', and
// only on their own ride.
// Driver: { status: 'en_route' | 'completed' } — only on the ride they own
// (driver_id = their profile), from the right prior state.
// Rider: { status: 'cancelled', reason? } — before the trip starts, on
// their own ride.
// Driver: { status: 'arrived' } accepted -> arrived
// { status: 'en_route', pickup_code } arrived -> en_route
// { status: 'completed', cash_collected? } en_route -> completed
// { status: 'cancelled', reason? } before the trip starts
// Every transition is a single guarded UPDATE: the prior state is part of the
// WHERE clause, so a double-tap or a stale client can't skip a step or
// resurrect a finished ride, and two racing writers can't both win.
export async function PATCH(request: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
let body: { status?: string };
let body: {
status?: string;
reason?: string;
pickup_code?: string;
cash_collected?: boolean;
};
try {
body = await request.json();
} catch {
@@ -89,58 +160,221 @@ export async function PATCH(request: Request, { id }: { id: string }) {
}
const next = body.status;
// A reason is optional, but if one is sent it has to be a known code — the
// admin portal counts these, and free text would make them uncountable.
const reason = body.reason;
if (reason !== undefined && !isCancellationReason(reason)) {
return Response.json(
{ error: "Unknown cancellation reason." },
{ status: 400 },
);
}
try {
// Rider cancel — authenticate by ownership of the ride.
// Cancel — either the rider (any time before the trip starts) or the
// assigned driver (same window). Rider path is tried first: a user who is
// also a driver should cancel their own ride as a rider, not be misrouted
// to the driver branch.
if (next === "cancelled") {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
const rows = await sql<{ status: string }>`
const riderCancel = await sql<{ status: string }>`
UPDATE rides
SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP
SET status = 'cancelled',
cancelled_at = CURRENT_TIMESTAMP,
cancelled_by = 'rider',
cancellation_reason = ${reason ?? null}
WHERE ride_id = ${rideId}
AND user_id = ${auth.userId}
AND status IN ('requested', 'accepted')
AND status = ANY(${RIDER_CANCELLABLE_ARRAY}::text[])
RETURNING status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot be cancelled." },
{ status: 409 },
);
if (riderCancel[0]) {
// Free the driver's offer so dispatch doesn't keep a phantom offer in
// flight for a ride that no longer exists.
await sql`
UPDATE ride_offers
SET status = 'cancelled', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND status = 'offered'
`;
return Response.json({ data: { status: riderCancel[0].status } });
}
return Response.json({ data: { status: rows[0].status } });
const driver = await requireDriverProfile(request);
if (!("error" in driver)) {
const driverCancel = await sql<{ status: string }>`
UPDATE rides
SET status = 'cancelled',
cancelled_at = CURRENT_TIMESTAMP,
cancelled_by = 'driver',
cancellation_reason = ${reason ?? null}
WHERE ride_id = ${rideId}
AND driver_id = ${driver.driverId}
AND status = ANY(${DRIVER_CANCELLABLE_ARRAY}::text[])
RETURNING status
`;
if (driverCancel[0]) {
return Response.json({ data: { status: driverCancel[0].status } });
}
}
return Response.json(
{ error: "Ride cannot be cancelled." },
{ status: 409 },
);
}
// Driver transitions — must be the driver assigned to the ride.
if (next === "en_route" || next === "completed") {
if (next === "arrived" || next === "en_route" || next === "completed") {
const result = await requireDriverProfile(request);
if ("error" in result) return result.error;
const { driverId } = result;
const priorStatus = next === "en_route" ? "accepted" : "en_route";
const setClause =
next === "completed"
? "status = $1, completed_at = CURRENT_TIMESTAMP, driver_id = $2"
: "status = $1, driver_id = $2";
const rows = await query<{ status: string }>(
`UPDATE rides SET ${setClause}
WHERE ride_id = $3 AND driver_id = $2 AND status = $4
RETURNING status`,
[next, driverId, rideId, priorStatus],
);
// Driver is at the pickup point. Purely informational for the rider,
// but it's the signal that turns "on the way" into "your car is here".
if (next === "arrived") {
const rows = await sql<{ status: string }>`
UPDATE rides
SET status = 'arrived', arrived_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'accepted'
RETURNING status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
}
// Start the trip. The pickup code is the handshake that proves the
// person in the car is the rider who ordered it — checked inside the
// UPDATE so a wrong code can't start the trip even under a race.
if (next === "en_route") {
const code = String(body.pickup_code ?? "").trim();
if (!code) {
return Response.json(
{ error: "Pickup code required.", code: "PICKUP_CODE_REQUIRED" },
{ status: 400 },
);
}
const rows = await sql<{ status: string }>`
UPDATE rides
SET status = 'en_route', started_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status IN ('accepted', 'arrived')
AND pickup_code = ${code}
RETURNING status
`;
if (!rows[0]) {
// Distinguish "wrong code" from "wrong state" — the driver needs to
// know whether to re-ask the rider or reload the screen.
const current = await sql<{
status: string;
pickup_code: string | null;
}>`
SELECT status, pickup_code FROM rides
WHERE ride_id = ${rideId} AND driver_id = ${driverId}
`;
if (
current[0] &&
["accepted", "arrived"].includes(current[0].status) &&
current[0].pickup_code !== code
) {
return Response.json(
{
error: "That code doesn't match.",
code: "PICKUP_CODE_INVALID",
},
{ status: 403 },
);
}
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
}
// Complete. For a cash ride the driver also confirms they collected the
// fare, which is what moves the money from "owed" to "settled" — a cash
// ride left at payment_status='cash' is an unreconciled debt, and the
// admin portal reports on exactly that gap.
const settleCash = body.cash_collected === true;
// Stamp the fare split at completion. Computed from the row's own
// fare_price inside the UPDATE so it can't disagree with what was
// charged, and recorded with the rate used so a later rate change never
// rewrites what this driver was owed today.
//
// The ::numeric casts are load-bearing. Parameters are sent untyped, so
// Postgres infers each one from context — and next to an integer column
// it infers `fare_price * $n` as integer multiplication, then refuses to
// parse "0.2" as an integer. Every completion failed on that, which is
// what left drivers unable to end a trip at all.
const rows = await sql<{ status: string; payment_status: string }>`
UPDATE rides
SET status = 'completed',
completed_at = CURRENT_TIMESTAMP,
commission_rate = ${COMMISSION_RATE}::numeric,
platform_fee_cents = ROUND(fare_price * ${COMMISSION_RATE}::numeric),
driver_payout_cents =
fare_price - ROUND(fare_price * ${COMMISSION_RATE}::numeric),
payment_status = CASE
WHEN payment_status = 'cash' AND ${settleCash}::boolean
THEN 'cash_collected'
ELSE payment_status
END,
cash_collected_at = CASE
WHEN payment_status = 'cash' AND ${settleCash}::boolean
THEN CURRENT_TIMESTAMP
ELSE cash_collected_at
END,
-- Whoever physically holds their own share is settled immediately;
-- only the other side is left owed. A card ride means the company
-- has its fee and owes the driver; a collected cash fare means the
-- driver has their payout and owes the company. See
-- lib/settlement.ts, which is where this rule is defined.
platform_fee_settled_at = CASE
WHEN payment_status = 'paid' THEN CURRENT_TIMESTAMP
ELSE platform_fee_settled_at
END,
driver_payout_settled_at = CASE
WHEN payment_status = 'cash' AND ${settleCash}::boolean
THEN CURRENT_TIMESTAMP
ELSE driver_payout_settled_at
END
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'en_route'
RETURNING status, payment_status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
return Response.json({
data: {
status: rows[0].status,
payment_status: rows[0].payment_status,
},
});
}
return Response.json({ error: "Unknown status transition." }, { status: 400 });
return Response.json(
{ error: "Unknown status transition." },
{ status: 400 },
);
} catch (error) {
console.error("[PATCH_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
+243
View File
@@ -0,0 +1,243 @@
import { sql } from "@/lib/db";
import { requireRideParticipant, rideIsActive } from "@/lib/ride-participants";
// In-app WebRTC audio call signaling, carried over the same DB-backed polling
// pattern as chat (no WebSocket). Non-trickle ICE: each side gathers all
// candidates locally and bundles them into a single SDP offer/answer stored as
// text, so the whole handshake is a few polled round-trips.
//
// POST { sdp_offer } -> caller starts a call (status=ringing)
// GET -> poll: callee reads the offer, both read
// the answer + status; lazily sweeps stale
// ringing calls to 'missed'.
// PATCH { action, sdp_answer? } -> answer / decline / end
// A ringing call older than this with no answer is treated as missed. Swept
// lazily inside GET, the way the broadcast advances on the ride-status poll.
const RINGING_TTL_SECONDS = 30;
type CallRow = {
id: number;
ride_id: number;
caller_type: "rider" | "driver";
status: "ringing" | "answered" | "ended" | "declined" | "missed";
sdp_offer: string | null;
sdp_answer: string | null;
started_at: string | null;
ended_at: string | null;
created_at: string;
};
// POST — initiate a call. Rejects if the ride isn't active or a call is already
// in flight for it, so two calls can't stack on one ride.
export async function POST(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const participant = await requireRideParticipant(req, rideId);
if ("error" in participant) return participant.error;
let body: { sdp_offer?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const sdpOffer = body.sdp_offer;
if (!sdpOffer || typeof sdpOffer !== "string") {
return Response.json({ error: "Missing sdp_offer." }, { status: 400 });
}
try {
if (!(await rideIsActive(rideId))) {
return Response.json(
{ error: "This ride is no longer active." },
{ status: 409 },
);
}
// Snapshot both parties onto the call row so authorization is one
// equality check on poll and the call survives a driver reassignment.
const ride = await sql<{ user_id: string; driver_id: number }>`
SELECT user_id, driver_id FROM rides
WHERE ride_id = ${rideId} AND driver_id IS NOT NULL
`;
if (!ride[0]) {
return Response.json(
{ error: "This ride has no driver assigned." },
{ status: 409 },
);
}
// Only one non-terminal call per ride at a time.
const inFlight = await sql<{ n: number }>`
SELECT COUNT(*)::int AS n FROM calls
WHERE ride_id = ${rideId} AND status IN ('ringing','answered')
`;
if ((inFlight[0]?.n ?? 0) > 0) {
return Response.json(
{ error: "A call is already in progress for this ride." },
{ status: 409 },
);
}
const inserted = await sql<{ id: number }>`
INSERT INTO calls (ride_id, user_id, driver_id, caller_type, status, sdp_offer)
VALUES (
${rideId},
${ride[0].user_id},
${ride[0].driver_id},
${participant.role},
'ringing',
${sdpOffer}
)
RETURNING id
`;
return Response.json({ data: { callId: inserted[0].id } }, { status: 201 });
} catch (error) {
console.error("[POST_CALL]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
// GET — poll the call for this ride. Returns the latest non-terminal call (or
// the most recent terminal one so the caller sees ended/declined/missed), with
// `is_caller` so each side knows whether it placed the call.
export async function GET(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const participant = await requireRideParticipant(req, rideId);
if ("error" in participant) return participant.error;
try {
// Lazy missed-call sweep: a ringing call nobody answered in time is
// marked missed so the caller's screen can stop ringing.
await sql`
UPDATE calls
SET status = 'missed', ended_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND status = 'ringing'
AND created_at < CURRENT_TIMESTAMP - make_interval(secs => ${RINGING_TTL_SECONDS})
`;
const rows = await sql<CallRow>`
SELECT id, ride_id, caller_type, status, sdp_offer, sdp_answer,
started_at, ended_at, created_at
FROM calls
WHERE ride_id = ${rideId}
ORDER BY created_at DESC
LIMIT 1
`;
const call = rows[0] ?? null;
return Response.json({
data: call
? { ...call, is_caller: call.caller_type === participant.role }
: null,
});
} catch (error) {
console.error("[GET_CALL]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
// PATCH — answer (callee only), decline (callee only), or end (either).
export async function PATCH(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const participant = await requireRideParticipant(req, rideId);
if ("error" in participant) return participant.error;
let body: { action?: string; sdp_answer?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const action = body.action;
if (action !== "answer" && action !== "decline" && action !== "end") {
return Response.json(
{ error: "action must be 'answer', 'decline', or 'end'." },
{ status: 400 },
);
}
try {
// Answer/decline are the callee's moves; end is either party's.
const isCaller = (callerType: string) => callerType === participant.role;
const rows = await sql<{ caller_type: string; status: string }>`
SELECT caller_type, status FROM calls
WHERE ride_id = ${rideId} AND status IN ('ringing','answered')
ORDER BY created_at DESC LIMIT 1
`;
const call = rows[0];
if (!call) {
return Response.json(
{ error: "No active call for this ride." },
{ status: 409 },
);
}
if (action === "answer") {
if (isCaller(call.caller_type)) {
return Response.json(
{ error: "Caller cannot answer their own call." },
{ status: 403 },
);
}
if (call.status !== "ringing") {
return Response.json(
{ error: "Call is no longer ringing." },
{ status: 409 },
);
}
const sdpAnswer = body.sdp_answer;
if (!sdpAnswer || typeof sdpAnswer !== "string") {
return Response.json({ error: "Missing sdp_answer." }, { status: 400 });
}
await sql`
UPDATE calls
SET status = 'answered', sdp_answer = ${sdpAnswer}, started_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND status = 'ringing'
`;
return Response.json({ data: { action: "answered" } });
}
if (action === "decline") {
if (isCaller(call.caller_type)) {
return Response.json(
{ error: "Caller cannot decline their own call." },
{ status: 403 },
);
}
await sql`
UPDATE calls
SET status = 'declined', ended_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND status = 'ringing'
`;
return Response.json({ data: { action: "declined" } });
}
// end — either party, while ringing or answered.
await sql`
UPDATE calls
SET status = 'ended', ended_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND status IN ('ringing','answered')
`;
return Response.json({ data: { action: "ended" } });
} catch (error) {
console.error("[PATCH_CALL]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
+154
View File
@@ -0,0 +1,154 @@
import { sql } from "@/lib/db";
import { requireRideParticipant, rideIsActive } from "@/lib/ride-participants";
// In-app chat for a ride. Both the rider and the assigned driver can read and
// post, but only while the ride is active (accepted / en_route); a terminal
// ride is read-only so the conversation is frozen once the trip ends.
type MessageRow = {
id: number;
ride_id: number;
sender_type: "rider" | "driver";
sender_id: string;
body: string;
created_at: string;
sender_name: string;
sender_avatar: string | null;
};
// GET — messages for the ride. `?since=<id>` returns only rows with id > since
// (the polling cursor), oldest-first so the client can append directly. With
// no cursor the full history is returned for the initial load.
export async function GET(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const participant = await requireRideParticipant(req, rideId);
if ("error" in participant) return participant.error;
const sinceParam = new URL(req.url).searchParams.get("since");
const since = Number(sinceParam);
const hasCursor = Number.isInteger(since) && since > 0;
try {
// The optional `since` cursor can't be a nested sql fragment (sql executes
// immediately), so branch into two queries that each take no extra params.
const rows = hasCursor
? await sql<MessageRow>`
SELECT
m.id,
m.ride_id,
m.sender_type,
COALESCE(m.sender_user_id::text, m.sender_driver_id::text) AS sender_id,
m.body,
m.created_at,
COALESCE(u.name, CONCAT_WS(' ', d.first_name, d.last_name)) AS sender_name,
d.profile_image_url AS sender_avatar
FROM messages m
LEFT JOIN users u ON u.id = m.sender_user_id
LEFT JOIN drivers d ON d.id = m.sender_driver_id
WHERE m.ride_id = ${rideId} AND m.id > ${since}
ORDER BY m.id ASC
`
: await sql<MessageRow>`
SELECT
m.id,
m.ride_id,
m.sender_type,
COALESCE(m.sender_user_id::text, m.sender_driver_id::text) AS sender_id,
m.body,
m.created_at,
COALESCE(u.name, CONCAT_WS(' ', d.first_name, d.last_name)) AS sender_name,
d.profile_image_url AS sender_avatar
FROM messages m
LEFT JOIN users u ON u.id = m.sender_user_id
LEFT JOIN drivers d ON d.id = m.sender_driver_id
WHERE m.ride_id = ${rideId}
ORDER BY m.id ASC
`;
return Response.json({ data: rows });
} catch (error) {
console.error("[GET_MESSAGES]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
// POST — send a message. Rejected (409) if the ride is no longer active, so a
// completed/cancelled trip can't receive new messages.
export async function POST(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const participant = await requireRideParticipant(req, rideId);
if ("error" in participant) return participant.error;
let body: { body?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const text = (body.body ?? "").trim();
if (!text) {
return Response.json({ error: "Message body is empty." }, { status: 400 });
}
if (text.length > 4000) {
return Response.json({ error: "Message is too long." }, { status: 400 });
}
try {
if (!(await rideIsActive(rideId))) {
return Response.json(
{ error: "This ride is no longer active." },
{ status: 409 },
);
}
const inserted = await sql<MessageRow>`
INSERT INTO messages (ride_id, sender_type, sender_user_id, sender_driver_id, body)
VALUES (
${rideId},
${participant.role},
${participant.role === "rider" ? participant.userId : null},
${participant.role === "driver" ? participant.driverId : null},
${text}
)
RETURNING
id,
ride_id,
sender_type,
COALESCE(sender_user_id::text, sender_driver_id::text) AS sender_id,
body,
created_at
`;
// Join the sender's name/avatar for the returned row so the client can
// render the optimistic bubble identically to polled ones.
const message = inserted[0];
if (participant.role === "driver") {
const driver = await sql<{ name: string; avatar: string | null }>`
SELECT CONCAT_WS(' ', first_name, last_name) AS name, profile_image_url AS avatar
FROM drivers WHERE id = ${participant.driverId}
`;
message.sender_name = driver[0]?.name ?? "";
message.sender_avatar = driver[0]?.avatar ?? null;
} else {
const rider = await sql<{ name: string }>`
SELECT name FROM users WHERE id = ${participant.userId}
`;
message.sender_name = rider[0]?.name ?? "";
message.sender_avatar = null;
}
return Response.json({ data: message }, { status: 201 });
} catch (error) {
console.error("[POST_MESSAGE]: ", error);
return Response.json({ error: "Internal Server Error." }, { status: 500 });
}
}
+171
View File
@@ -0,0 +1,171 @@
import { requireApprovedDriver } from "@/lib/driver";
import { sql, transaction } from "@/lib/db";
import { sendPushToUser } from "@/lib/push";
import { DRIVER_BUSY_ARRAY } from "@/lib/ride-lifecycle";
import { haversine } from "@/lib/utils";
// POST — a driver's answer to a broadcast request.
//
// { action: 'offer' } — volunteer for it. The rider sees this driver
// appear in their list of offers and may pick them.
// { action: 'withdraw' } — take the offer back, before the rider picks.
//
// Offering is not an assignment: several drivers can be offered on the same
// request at once and none of them is committed until the rider chooses. That
// is why offering doesn't take a driver off the board, and why withdrawing is
// free — the cost of a driver changing their mind lands here rather than on a
// rider whose ride was already promised away.
export async function POST(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
// Approval is re-checked here, not just at broadcast time: a driver
// suspended between seeing a request and tapping Offer must not be able to
// put themselves in front of a rider. (Rides already under way stay under
// requireDriverProfile — a suspension must never strand a rider who is
// sitting in the car.)
const result = await requireApprovedDriver(req);
if ("error" in result) return result.error;
const { driverId } = result;
let body: { action?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const action = body.action;
if (action !== "offer" && action !== "withdraw") {
return Response.json(
{ error: "action must be 'offer' or 'withdraw'." },
{ status: 400 },
);
}
try {
if (action === "withdraw") {
const withdrawn = await sql<{ id: number }>`
UPDATE ride_offers
SET status = 'withdrawn', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'offered'
RETURNING id
`;
if (!withdrawn[0]) {
return Response.json(
{ error: "There is no live offer to withdraw." },
{ status: 409 },
);
}
return Response.json({ data: { status: "withdrawn" } });
}
const offered = await transaction<{
userId: string;
alreadyOffered: boolean;
} | null>(async (tx) => {
// Lock the request so a rider picking someone else at this exact moment
// and this driver offering can't both believe they won.
const rides = await tx<{
status: string;
user_id: string;
service: string;
lat: number;
lng: number;
}>`
SELECT status, user_id, service,
origin_latitude AS lat, origin_longitude AS lng
FROM rides WHERE ride_id = ${rideId} FOR UPDATE
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return null;
// The driver's own state has to be re-read here rather than trusted from
// the dashboard that drew the button: service, liveness and — above all
// — whether they picked up another ride in the meantime.
const drivers = await tx<{
service: string;
online: boolean;
latitude: number | null;
longitude: number | null;
}>`
SELECT service, online, latitude, longitude
FROM drivers WHERE id = ${driverId}
`;
const driver = drivers[0];
if (!driver || !driver.online || driver.service !== ride.service) {
return null;
}
const busy = await tx<{ n: number }>`
SELECT COUNT(*)::int AS n FROM rides
WHERE driver_id = ${driverId}
AND status = ANY(${DRIVER_BUSY_ARRAY}::text[])
`;
if ((busy[0]?.n ?? 0) > 0) return null;
const distance =
driver.latitude === null || driver.longitude === null
? null
: Math.round(
haversine(ride.lat, ride.lng, driver.latitude, driver.longitude),
);
// ON CONFLICT rather than an existence check: the unique index is the
// real guard, and a driver who taps Offer twice (or re-offers after
// withdrawing) should end up with one live offer either way.
const rows = await tx<{ inserted: boolean }>`
INSERT INTO ride_offers (ride_id, driver_id, status, pickup_distance_m)
VALUES (${rideId}, ${driverId}, 'offered', ${distance})
ON CONFLICT (ride_id, driver_id) DO UPDATE
SET status = 'offered',
offered_at = CURRENT_TIMESTAMP,
responded_at = NULL,
pickup_distance_m = EXCLUDED.pickup_distance_m
WHERE ride_offers.status IN ('withdrawn', 'offered')
RETURNING (xmax = 0) AS inserted
`;
// No row means the conflict target existed in a state we refuse to
// revive — the rider already picked someone, or this offer was closed
// with the request.
if (!rows[0]) return null;
return { userId: ride.user_id, alreadyOffered: !rows[0].inserted };
});
if (!offered) {
return Response.json(
{ error: "This request is no longer open." },
{ status: 409 },
);
}
// Nudge the rider — they are sitting on a screen watching for exactly
// this. Only for the first offer on the request: the rest arrive on the
// list they are already looking at, and a buzz per driver would turn a
// busy street into a nuisance.
if (!offered.alreadyOffered) {
const [count] = await sql<{ n: number }>`
SELECT COUNT(*)::int AS n FROM ride_offers
WHERE ride_id = ${rideId} AND status = 'offered'
`;
if ((count?.n ?? 0) === 1) {
void sendPushToUser(offered.userId, {
title: "A driver is available",
body: "Open your ride to see who can pick you up.",
data: { type: "ride_offer_received", rideId },
});
}
}
return Response.json({ data: { status: "offered" } });
} catch (error) {
console.error("[RIDE_OFFER]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+117
View File
@@ -0,0 +1,117 @@
import { sql } from "@/lib/db";
import { requireRideParticipant } from "@/lib/ride-participants";
import { refreshDriverRating, refreshRiderRating } from "@/lib/ride-lifecycle";
// Two-way rating on a finished ride: the rider rates the driver, the driver
// rates the rider. Either party may only rate once (the UNIQUE (ride_id,
// rater_type) constraint makes the write an idempotent upsert, so a re-submit
// corrects a mis-tap instead of double-counting), and only after the ride is
// completed — a cancelled ride has nothing to rate.
// GET — both sides' ratings for this ride, so a client can show "you rated
// this ride 5" and (once the other party has rated) what they said.
export async function GET(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const participant = await requireRideParticipant(req, rideId);
if ("error" in participant) return participant.error;
try {
const rows = await sql<{
rater_type: "rider" | "driver";
rating: number;
comment: string | null;
created_at: string;
}>`
SELECT rater_type, rating, comment, created_at
FROM ride_ratings WHERE ride_id = ${rideId}
`;
const mine = rows.find((r) => r.rater_type === participant.role) ?? null;
const theirs = rows.find((r) => r.rater_type !== participant.role) ?? null;
return Response.json({ data: { mine, theirs } });
} catch (error) {
console.error("[GET_RIDE_RATING]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// POST — submit (or correct) this party's rating. Body: { rating: 1..5,
// comment?: string }.
export async function POST(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const participant = await requireRideParticipant(req, rideId);
if ("error" in participant) return participant.error;
let body: { rating?: unknown; comment?: unknown };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const rating = Number(body.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return Response.json(
{ error: "rating must be a whole number from 1 to 5." },
{ status: 400 },
);
}
// Comments are optional and capped — they're shown verbatim in the admin
// portal's ride detail, so an unbounded field is a liability.
const rawComment =
typeof body.comment === "string" ? body.comment.trim() : "";
const comment = rawComment ? rawComment.slice(0, 500) : null;
try {
const rides = await sql<{
status: string;
driver_id: number | null;
user_id: string;
}>`
SELECT status, driver_id, user_id FROM rides WHERE ride_id = ${rideId}
`;
const ride = rides[0];
if (!ride) {
return Response.json({ error: "Ride not found." }, { status: 404 });
}
if (ride.status !== "completed") {
return Response.json(
{ error: "Only a completed ride can be rated." },
{ status: 409 },
);
}
const rows = await sql<{ rating: number; comment: string | null }>`
INSERT INTO ride_ratings (ride_id, rater_type, rating, comment)
VALUES (${rideId}, ${participant.role}, ${rating}, ${comment})
ON CONFLICT (ride_id, rater_type) DO UPDATE
SET rating = EXCLUDED.rating,
comment = EXCLUDED.comment,
updated_at = CURRENT_TIMESTAMP
RETURNING rating, comment
`;
// Fold the new score into the rated party's headline average. Awaited
// rather than fire-and-forget so the client's next read sees it.
if (participant.role === "rider" && ride.driver_id !== null) {
await refreshDriverRating(ride.driver_id);
} else if (participant.role === "driver") {
await refreshRiderRating(ride.user_id);
}
return Response.json({ data: rows[0] }, { status: 201 });
} catch (error) {
console.error("[RATE_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
-102
View File
@@ -1,102 +0,0 @@
import { requireDriverProfile } from "@/lib/driver";
import { transaction } from "@/lib/db";
import { matchNextDriver } from "@/lib/dispatch";
// POST — a driver responds to a ride offer.
// { action: 'accept' } — claim the ride: offer -> accepted, ride -> accepted,
// ride.driver_id set to this driver. Guarded so only
// the offered driver can accept, and only while the
// offer is still 'offered' (not expired/timed out).
// { action: 'decline' } — release the ride: offer -> declined, then offer
// it to the next-nearest driver via matchNextDriver.
export async function POST(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const result = await requireDriverProfile(req);
if ("error" in result) return result.error;
const { driverId } = result;
let body: { action?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const action = body.action;
if (action !== "accept" && action !== "decline") {
return Response.json(
{ error: "action must be 'accept' or 'decline'." },
{ status: 400 },
);
}
try {
if (action === "accept") {
const claimed = await transaction(async (tx) => {
// Atomically flip the offer to accepted only if it's still offered to
// this driver. This is the race guard: two drivers can't both accept,
// and an expired offer can't be revived.
const offer = await tx<{ id: number }>`
UPDATE ride_offers
SET status = 'accepted', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'offered'
RETURNING id
`;
if (!offer[0]) return null;
// Assign the ride to this driver. The status='requested' guard means
// we never overwrite a ride another driver already accepted.
const ride = await tx`
UPDATE rides
SET status = 'accepted', driver_id = ${driverId}
WHERE ride_id = ${rideId} AND status = 'requested'
RETURNING ride_id
`;
if (!ride[0]) return null;
return offer[0].id;
});
if (claimed === null) {
return Response.json(
{ error: "This offer is no longer available." },
{ status: 409 },
);
}
return Response.json({ data: { action: "accepted" } });
}
// Decline: mark the offer declined and offer the ride to the next driver.
const declined = await transaction(async (tx) => {
const offer = await tx`
UPDATE ride_offers
SET status = 'declined', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'offered'
RETURNING id
`;
return offer[0]?.id ?? null;
});
if (declined === null) {
return Response.json(
{ error: "This offer is no longer available." },
{ status: 409 },
);
}
void matchNextDriver(rideId);
return Response.json({ data: { action: "declined" } });
} catch (error) {
console.error("[RIDE_RESPOND]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+202
View File
@@ -0,0 +1,202 @@
import { requireAuth } from "@/lib/jwt";
import { transaction } from "@/lib/db";
import { getOrder, consumeOrderForRide } from "@/lib/payment-orders";
import { sendPushToDriver } from "@/lib/push";
import { DRIVER_BUSY_ARRAY, generatePickupCode } from "@/lib/ride-lifecycle";
// POST — the rider picks one of the drivers who offered, and pays.
//
// { offer_id, payment_method: 'cash' }
// { offer_id, payment_method: 'card', payment_order_id }
//
// This is the single moment a ride is assigned. Everything that has to be true
// at once — the request is still open, this offer is still live, the driver is
// still free, and (for card) a paid order of the right amount exists and has
// not been spent — is checked inside one transaction, so a rider and a
// disappearing driver can't half-complete it.
//
// The card order is consumed here rather than earlier for the same reason: if
// the pick fails because the driver just took another job, the transaction
// rolls back with the order still 'paid', and the rider can pick a different
// driver with the money they already put down instead of paying twice.
export async function POST(req: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
let body: {
offer_id?: number;
payment_method?: string;
payment_order_id?: string;
};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const offerId = Number(body.offer_id);
if (!Number.isInteger(offerId)) {
return Response.json({ error: "offer_id is required." }, { status: 400 });
}
const method = body.payment_method;
if (method !== "cash" && method !== "card") {
return Response.json({ error: "Invalid payment method." }, { status: 400 });
}
try {
// Card: everything about the order is verified before the transaction
// opens, so the only thing left to do inside it is spend it.
if (method === "card") {
if (!body.payment_order_id) {
return Response.json(
{ error: "Missing payment order id." },
{ status: 400 },
);
}
const order = await getOrder(body.payment_order_id);
if (!order)
return Response.json(
{ error: "Payment order not found." },
{ status: 404 },
);
if (order.user_id !== auth.userId)
return Response.json({ error: "Unauthorized." }, { status: 403 });
if (order.status !== "paid")
return Response.json(
{ error: "Payment not verified." },
{ status: 400 },
);
}
const picked = await transaction<
| { driverId: number; fare: number }
| "gone"
| "amount_mismatch"
| "order_spent"
>(async (tx) => {
// Lock the request. A second tap on a second driver serialises behind
// this and finds the ride already assigned.
const rides = await tx<{
status: string;
fare_price: number;
origin_address: string;
}>`
SELECT status, fare_price, origin_address
FROM rides
WHERE ride_id = ${rideId} AND user_id = ${auth.userId}
FOR UPDATE
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return "gone";
const offers = await tx<{ driver_id: number }>`
SELECT driver_id FROM ride_offers
WHERE id = ${offerId} AND ride_id = ${rideId} AND status = 'offered'
`;
const offer = offers[0];
if (!offer) return "gone";
// The driver may have been picked by somebody else in the seconds the
// rider spent deciding. Their other ride is the authority, not the offer.
const busy = await tx<{ n: number }>`
SELECT COUNT(*)::int AS n FROM rides
WHERE driver_id = ${offer.driver_id}
AND status = ANY(${DRIVER_BUSY_ARRAY}::text[])
`;
if ((busy[0]?.n ?? 0) > 0) return "gone";
let paymentStatus = "cash";
let orderId: string | null = null;
if (method === "card") {
const order = await getOrder(body.payment_order_id!);
if (!order) return "gone";
// Re-checked against the row we just locked: the fare is authoritative
// here, not the number the client did its arithmetic with.
if (order.amount_cents !== Number(ride.fare_price))
return "amount_mismatch";
const consumed = await consumeOrderForRide(
body.payment_order_id!,
auth.userId,
tx,
);
if (!consumed) return "order_spent";
paymentStatus = "paid";
orderId = body.payment_order_id!;
}
// Assign. The status='requested' guard is what stops a double-submit
// from reassigning a ride that already has a driver.
const assigned = await tx<{ ride_id: number }>`
UPDATE rides
SET status = 'accepted',
driver_id = ${offer.driver_id},
accepted_at = CURRENT_TIMESTAMP,
payment_status = ${paymentStatus},
payment_order_id = COALESCE(${orderId}, payment_order_id),
pickup_code = COALESCE(pickup_code, ${generatePickupCode()})
WHERE ride_id = ${rideId} AND status = 'requested'
RETURNING ride_id
`;
if (!assigned[0]) return "gone";
await tx`
UPDATE ride_offers
SET status = 'accepted', responded_at = CURRENT_TIMESTAMP
WHERE id = ${offerId}
`;
// Everyone else who volunteered is released in the same breath, so no
// driver is left with a card for a job that is already someone else's.
await tx`
UPDATE ride_offers
SET status = 'passed', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND id <> ${offerId} AND status = 'offered'
`;
return { driverId: offer.driver_id, fare: Number(ride.fare_price) };
});
if (picked === "amount_mismatch") {
return Response.json(
{ error: "Payment does not match this ride." },
{ status: 400 },
);
}
if (picked === "order_spent") {
return Response.json(
{ error: "That payment has already been used." },
{ status: 409 },
);
}
if (picked === "gone") {
return Response.json(
{
error: "That driver is no longer available.",
code: "OFFER_UNAVAILABLE",
},
{ status: 409 },
);
}
void sendPushToDriver(picked.driverId, {
title: "You got the ride",
body: "The rider picked you. Head to the pickup point.",
data: { type: "ride_assigned", rideId },
});
return Response.json({ data: { status: "accepted" } });
} catch (error) {
console.error("[RIDE_SELECT]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+82
View File
@@ -0,0 +1,82 @@
import { sql } from "@/lib/db";
import { requireAuth } from "@/lib/jwt";
import { ACTIVE_STATUS_ARRAY, expireStaleRequests } from "@/lib/ride-lifecycle";
// GET — "does this rider have unfinished business?", answered in one call.
//
// active : a ride still in flight (requested/accepted/arrived/en_route).
// Killing the app used to strand a rider away from their
// tracking screen with no way back; the home banner reads
// this to put them back on it.
// pending_rating : a ride that finished recently and hasn't been rated yet,
// so the prompt survives the app being backgrounded at
// drop-off — the moment ratings are most often lost.
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
// Sweep searches that have run past the TTL (unscoped — this is one of the
// lazy paths that stands in for a background worker), so the banner never
// advertises a ride that is really long dead.
await expireStaleRequests();
const active = await sql<{
ride_id: number;
status: string;
service: string;
origin_address: string;
destination_address: string;
fare_price: number;
driver_name: string | null;
}>`
SELECT
r.ride_id, r.status, r.service,
r.origin_address, r.destination_address, r.fare_price,
NULLIF(TRIM(COALESCE(d.first_name, '') || ' ' || COALESCE(d.last_name, '')), '')
AS driver_name
FROM rides r
LEFT JOIN drivers d ON d.id = r.driver_id
WHERE r.user_id = ${auth.userId}
AND r.status = ANY(${ACTIVE_STATUS_ARRAY}::text[])
ORDER BY r.created_at DESC
LIMIT 1
`;
// Only prompt for rides that ended in the last day — a week-old ride is a
// nag, not a reminder.
const pending = await sql<{
ride_id: number;
destination_address: string;
driver_name: string | null;
driver_avatar: string | null;
}>`
SELECT
r.ride_id, r.destination_address,
NULLIF(TRIM(COALESCE(d.first_name, '') || ' ' || COALESCE(d.last_name, '')), '')
AS driver_name,
d.profile_image_url AS driver_avatar
FROM rides r
LEFT JOIN drivers d ON d.id = r.driver_id
WHERE r.user_id = ${auth.userId}
AND r.status = 'completed'
AND r.completed_at > CURRENT_TIMESTAMP - INTERVAL '1 day'
AND NOT EXISTS (
SELECT 1 FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'rider'
)
ORDER BY r.completed_at DESC
LIMIT 1
`;
return Response.json({
data: {
active: active[0] ?? null,
pending_rating: pending[0] ?? null,
},
});
} catch (error) {
console.error("[RIDE_ACTIVE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+43 -117
View File
@@ -1,18 +1,25 @@
import { requireAuth } from "@/lib/jwt";
import { sql, transaction } from "@/lib/db";
import { getOrder, consumeOrderForRide } from "@/lib/payment-orders";
import { matchNextDriver } from "@/lib/dispatch";
import { sql } from "@/lib/db";
import { broadcastRequest } from "@/lib/dispatch";
import { isServiceId } from "@/lib/driver";
import { ACTIVE_STATUS_ARRAY } from "@/lib/ride-lifecycle";
import { DEFAULT_SERVICE } from "@/constants/services";
// Explicit missing check — a truthy check would reject legitimate 0 values
// like latitude 0.0 (the equator) or a zero fare.
const isMissing = (v: unknown): boolean => v === undefined || v === null;
// POST — request a ride. The rider no longer picks a driver; the ride is
// created with status='requested' and driver_id=NULL, then auto-match offers
// it to the nearest eligible driver of the requested service. `driver_id` in
// the body is accepted for backward compatibility but ignored.
// POST — open a ride request.
//
// This fires the moment the rider taps "Find now", before any payment
// decision: the ride is created with status='requested', driver_id=NULL and
// payment_status='pending', then broadcast to every eligible driver near the
// pickup. Drivers volunteer, the rider picks one, and /ride/:id/select is
// where the driver, the payment method and (for card) the paid order all land
// together.
//
// Nothing is charged here, so there is nothing to refund if no driver takes
// it — which is the point of moving payment behind the pick.
export async function POST(request: Request) {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
@@ -28,8 +35,6 @@ export async function POST(request: Request) {
destination_longitude,
ride_time,
fare_price,
payment_method,
payment_order_id,
service,
} = body;
@@ -49,116 +54,33 @@ export async function POST(request: Request) {
);
}
if (payment_method !== "card" && payment_method !== "cash")
return Response.json(
{ error: "Invalid payment method." },
{ status: 400 },
);
const rideService = isServiceId(service) ? service : DEFAULT_SERVICE;
const fareCents = Math.round(Number(fare_price));
if (payment_method === "card") {
// Card: the ride is only recorded once a paid, server-authoritative
// payment order is consumed. The client can no longer self-declare
// payment_status='paid'.
if (isMissing(payment_order_id))
return Response.json(
{ error: "Missing payment order id." },
{ status: 400 },
);
const order = await getOrder(payment_order_id);
if (!order)
return Response.json(
{ error: "Payment order not found." },
{ status: 404 },
);
if (order.user_id !== auth.userId)
return Response.json({ error: "Unauthorized." }, { status: 403 });
if (order.status !== "paid")
return Response.json(
{ error: "Payment not verified." },
{ status: 400 },
);
if (order.amount_cents !== fareCents)
return Response.json(
{ error: "Payment amount mismatch." },
{ status: 400 },
);
// Reconcile route intent (driver isn't known yet, so driver_id is no
// longer part of the intent check). Null intent fields are skipped.
const intentsMatch =
(order.origin_address === null ||
order.origin_address === origin_address) &&
(order.destination_address === null ||
order.destination_address === destination_address) &&
(order.ride_time === null || order.ride_time === Number(ride_time));
if (!intentsMatch)
return Response.json(
{ error: "Payment does not match this ride." },
{ status: 400 },
);
// Consume the order and insert the ride on one connection, so a failure
// rolls back both and no paid order is wasted without a ride.
const inserted = await transaction(async (tx) => {
const consumed = await consumeOrderForRide(
payment_order_id,
auth.userId,
tx,
);
if (!consumed) throw new Error("PAYMENT_ORDER_NOT_CONSUMABLE");
const rows = await tx`
INSERT INTO rides (
origin_address,
destination_address,
origin_latitude,
origin_longitude,
destination_latitude,
destination_longitude,
ride_time,
fare_price,
payment_status,
driver_id,
user_id,
payment_order_id,
status,
service
) VALUES (
${origin_address},
${destination_address},
${origin_latitude},
${origin_longitude},
${destination_latitude},
${destination_longitude},
${ride_time},
${fareCents},
'paid',
NULL,
${auth.userId},
${payment_order_id},
'requested',
${rideService}
)
RETURNING *
`;
return rows[0];
});
// Kick off auto-match asynchronously — don't block the response on it.
void matchNextDriver(inserted.ride_id);
return Response.json({ data: inserted }, { status: 201 });
if (!Number.isFinite(fareCents) || fareCents <= 0) {
return Response.json({ error: "Invalid fare." }, { status: 400 });
}
// One ride in flight per rider. Without this a rider who backs out of the
// tracking screen and re-books ends up with two live requests broadcast to
// the same drivers, who then see the same job twice from one person.
const inFlight = await sql<{ ride_id: number; status: string }>`
SELECT ride_id, status FROM rides
WHERE user_id = ${auth.userId}
AND status = ANY(${ACTIVE_STATUS_ARRAY}::text[])
ORDER BY created_at DESC
LIMIT 1
`;
if (inFlight[0]) {
return Response.json(
{
error: "You already have a ride in progress.",
code: "RIDE_IN_PROGRESS",
ride_id: inFlight[0].ride_id,
},
{ status: 409 },
);
}
// Cash: settled directly with the driver at drop-off. No order involved.
const response = await sql`
INSERT INTO rides (
origin_address,
@@ -183,7 +105,7 @@ export async function POST(request: Request) {
${destination_longitude},
${ride_time},
${fareCents},
'cash',
'pending',
NULL,
${auth.userId},
'requested',
@@ -192,7 +114,11 @@ export async function POST(request: Request) {
RETURNING *
`;
void matchNextDriver(response[0].ride_id);
// Announce it to nearby drivers. Not awaited: the rider's screen should
// open on "looking for drivers" immediately, and the rider's own status
// poll re-drives the broadcast if this one loses its race with the push
// service.
void broadcastRequest(response[0].ride_id);
return Response.json({ data: response[0] }, { status: 201 });
} catch (error) {
+6 -1
View File
@@ -1,5 +1,6 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
import { TERMINAL_STATUS_ARRAY } from "@/lib/ride-lifecycle";
// GET — the signed-in rider's ride history (completed + cancelled rides),
// newest first, with the assigned driver (nullable via LEFT JOIN). This feeds
@@ -27,6 +28,10 @@ export async function GET(req: Request) {
r.created_at,
r.completed_at,
r.cancelled_at,
r.cancelled_by,
r.cancellation_reason,
(SELECT rr.rating FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'rider') AS my_rating,
json_build_object(
'id', d.id,
'first_name', d.first_name,
@@ -41,7 +46,7 @@ export async function GET(req: Request) {
FROM rides r
LEFT JOIN drivers d ON d.id = r.driver_id
WHERE r.user_id = ${auth.userId}
AND r.status IN ('completed', 'cancelled')
AND r.status = ANY(${TERMINAL_STATUS_ARRAY}::text[])
ORDER BY r.created_at DESC
`;
+26 -1
View File
@@ -18,6 +18,15 @@ export async function GET(req: Request) {
}
}
// PATCH — one-time role selection, straight after sign-up.
//
// The role is write-once. It used to be freely re-assignable, which meant any
// account could flip itself to 'driver' on demand; combined with self-service
// onboarding that was a rider account away from receiving live pickups. Role
// is no longer a credential on its own (driver profiles are vetted), but it
// still shouldn't be a toggle: a user who genuinely needs to switch goes
// through support, which leaves a record. Re-sending the same role is a no-op
// so a retried request from the role screen still succeeds.
export async function PATCH(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
@@ -32,11 +41,27 @@ export async function PATCH(req: Request) {
const response = await sql`
UPDATE users SET role = ${role}
WHERE id = ${auth.userId}
AND (role IS NULL OR role = ${role})
RETURNING id, role
`;
if (response.length === 0) {
return Response.json({ error: "User not found." }, { status: 404 });
const existing = await sql<{ role: string | null }>`
SELECT role FROM users WHERE id = ${auth.userId}
`;
if (!existing[0]) {
return Response.json({ error: "User not found." }, { status: 404 });
}
return Response.json(
{
error: "Your account role has already been set.",
code: "ROLE_ALREADY_SET",
role: existing[0].role,
},
{ status: 409 },
);
}
return Response.json({ data: response[0] });
+7
View File
@@ -63,6 +63,13 @@ const TabsLayout = () => {
tabBarActiveTintColor: "white",
tabBarInactiveTintColor: "white",
tabBarShowLabel: false,
// Get out of the way while someone is typing. The bar floats
// (position: absolute) and Android resizes the window around the
// keyboard, so it doesn't stay at the bottom of the screen — it rides up
// and parks on top of the address suggestions the rider is trying to
// tap, which is the worst possible place for it during a pickup or
// destination search.
tabBarHideOnKeyboard: true,
tabBarStyle: {
backgroundColor: isDark ? "#0a0a0a" : "#333",
borderRadius: 50,
+7 -34
View File
@@ -1,38 +1,11 @@
import { Image, ScrollView, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ChatThread } from "@/components/chat-thread";
import { images } from "@/constants";
import { useT } from "@/lib/i18n";
// Tab-bar footprint: 78px tall + 20px bottom margin (see (tabs)/_layout.tsx).
// It's position:"absolute" so it reserves no layout space of its own — the
// composer below needs this much extra clearance or the floating pill bar
// sits on top of it.
const TAB_BAR_CLEARANCE = 98;
const Chat = () => {
const t = useT();
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 p-5">
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
{t("chat.title")}
</Text>
<View className="flex-1 h-fit flex justify-center items-center">
<Image
source={images.message}
alt={t("chat.messageAlt")}
className="w-full h-40"
resizeMode="contain"
/>
<Text className="text-3xl font-JakartaBold mt-3 text-black dark:text-white">
{t("chat.noMessages")}
</Text>
<Text className="text-base mt-2 text-center px-7 text-general-200 dark:text-neutral-400">
{t("chat.startConversation")}
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
};
const Chat = () => <ChatThread tabBarClearance={TAB_BAR_CLEARANCE} />;
export default Chat;
+10 -1
View File
@@ -9,6 +9,7 @@ import {
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ActiveRideBanner } from "@/components/active-ride-banner";
import { GoogleTextInput } from "@/components/google-text-input";
import { LocationNotice } from "@/components/location-notice";
import { Map } from "@/components/map";
@@ -28,6 +29,7 @@ const Home = () => {
const setDestinationLocation = useLocationStore(
(state) => state.setDestinationLocation,
);
const clearDestination = useLocationStore((state) => state.clearDestination);
const { signOut, user } = useSession();
const { isDark } = useTheme();
const t = useT();
@@ -36,6 +38,9 @@ const Home = () => {
const { status: locationStatus, retry: retryLocation } = useUserLocation();
const handleSignOut = () => {
// A different person signing in on this phone must not inherit the last
// rider's destination — the store lives in the JS process, not the session.
clearDestination();
signOut();
router.replace("/(auth)/sign-in");
@@ -103,6 +108,10 @@ const Home = () => {
</View>
</View>
{/* Unfinished ride or unrated trip the way back into a ride the
rider navigated away from. */}
<ActiveRideBanner />
<GoogleTextInput
icon={icons.search}
containerStyles="bg-white dark:bg-neutral-900 shadow-md shadow-neutral-300 dark:shadow-neutral-950/40"
@@ -118,7 +127,7 @@ const Home = () => {
<>
{/* The map draws straight away on the Beirut fallback so the
slot never sits empty while the fix is still coming. */}
<Map />
<Map routeless />
{locationStatus === "pending" ? (
<View className="absolute bottom-3 self-center flex-row items-center rounded-full bg-white/95 dark:bg-neutral-900/95 px-4 py-2 shadow-md shadow-neutral-400/40 dark:shadow-neutral-950/40">
+113 -138
View File
@@ -1,8 +1,15 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useFocusEffect } from "expo-router";
import { Alert, Linking, Platform, ScrollView, Text, View } from "react-native";
import {
Alert,
Linking,
Platform,
ScrollView,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useCallback, useState } from "react";
import { Children, Fragment, useCallback, useState } from "react";
import { SettingsRow } from "@/components/settings-row";
import {
@@ -12,7 +19,6 @@ import {
} from "@/lib/settings";
import { useT } from "@/lib/i18n";
import { useLocationPermission } from "@/lib/use-location-permission";
import { useTheme } from "@/lib/theme";
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
@@ -22,15 +28,45 @@ const SectionHeader = ({ title }: { title: string }) => (
</Text>
);
const Card = ({ children }: { children: React.ReactNode }) => (
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
{children}
</View>
);
/**
* A grouped settings card. Renders an optional muted description header, then
* its children with an automatic divider between each row so callers never
* hand-thread `border-t` wrapper Views. Null/conditional children (and arrays
* from `.map`) are flattened by `Children.toArray`, so conditionals like
* `status !== "granted" ? <Row/> : null` and `options.map(...)` both work.
*/
const SettingsCard = ({
description,
children,
}: {
description?: string;
children: React.ReactNode;
}) => {
const rows = Children.toArray(children);
return (
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
{description ? (
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{description}
</Text>
</View>
) : null}
{rows.map((row, index) => (
<Fragment key={index}>
{index > 0 ? (
<View className="border-t border-neutral-100 dark:border-neutral-800" />
) : null}
{row}
</Fragment>
))}
</View>
);
};
const Settings = () => {
const t = useT();
const { isDark } = useTheme();
const mode = useSettingsStore((state) => state.mode);
const setMode = useSettingsStore((state) => state.setMode);
@@ -63,20 +99,6 @@ const Settings = () => {
? t("settings.maps.statusBlocked")
: t("settings.maps.statusUnknown");
const modeLabel =
mode === "light"
? t("settings.appearance.light")
: mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system");
const langLabel =
lang === "en"
? t("settings.language.en")
: lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr");
const callEmergency = useCallback(async () => {
try {
await Linking.openURL("tel:112");
@@ -158,7 +180,7 @@ const Settings = () => {
{/* 1. Maps & Navigation */}
<SectionHeader title={t("settings.maps.title")} />
<Card>
<SettingsCard>
<SettingsRow
icon="map-marker-radius"
title={t("settings.maps.title")}
@@ -167,60 +189,39 @@ const Settings = () => {
value={locationStatusLabel}
/>
{status !== "granted" ? (
<View className="border-t border-neutral-100 dark:border-neutral-800">
<SettingsRow
icon="cog"
title={t("settings.maps.openSettings")}
right="chevron"
onPress={openSettings}
/>
</View>
<SettingsRow
icon="cog"
title={t("settings.maps.openSettings")}
right="chevron"
onPress={openSettings}
/>
) : null}
</Card>
</SettingsCard>
{/* 2. Appearance */}
<SectionHeader title={t("settings.appearance.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.appearance.description")}
</Text>
</View>
{appearanceOptions.map((option, index) => (
<View
<SettingsCard description={t("settings.appearance.description")}>
{appearanceOptions.map((option) => (
<SettingsRow
key={option.mode}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
icon={option.icon}
title={
option.mode === "light"
? t("settings.appearance.light")
: option.mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system")
}
>
<SettingsRow
icon={option.icon}
title={
option.mode === "light"
? t("settings.appearance.light")
: option.mode === "dark"
? t("settings.appearance.dark")
: t("settings.appearance.system")
}
right="value"
value={
mode === option.mode
? isDark
? "✓"
: "✓"
: ""
}
onPress={() => setMode(option.mode)}
/>
</View>
right="check"
selected={mode === option.mode}
onPress={() => setMode(option.mode)}
/>
))}
</Card>
</SettingsCard>
{/* 3. Safety */}
<SectionHeader title={t("settings.safety.title")} />
<Card>
<SettingsCard>
<SettingsRow
icon="phone-in-talk"
title={t("settings.safety.call112")}
@@ -230,64 +231,45 @@ const Settings = () => {
onPress={callEmergency}
/>
{safetyTiles.map((tile) => (
<View
<SettingsRow
key={tile.key}
className="border-t border-neutral-100 dark:border-neutral-800"
>
<SettingsRow
icon={tile.icon}
title={tile.title}
subtitle={
expandedSafety === tile.key ? undefined : tile.body
}
right="chevron"
onPress={() =>
setExpandedSafety((current) =>
current === tile.key ? null : tile.key,
)
}
/>
</View>
icon={tile.icon}
title={tile.title}
subtitle={expandedSafety === tile.key ? undefined : tile.body}
right="chevron"
onPress={() =>
setExpandedSafety((current) =>
current === tile.key ? null : tile.key,
)
}
/>
))}
</Card>
</SettingsCard>
{/* 4. Language */}
<SectionHeader title={t("settings.language.title")} />
<Card>
<View className="px-4 py-2.5">
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
{t("settings.language.description")}
</Text>
</View>
{languageOptions.map((option, index) => (
<View
<SettingsCard description={t("settings.language.description")}>
{languageOptions.map((option) => (
<SettingsRow
key={option.lang}
className={
index > 0
? "border-t border-neutral-100 dark:border-neutral-800"
: ""
icon={option.icon}
title={
option.lang === "en"
? t("settings.language.en")
: option.lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr")
}
>
<SettingsRow
icon={option.icon}
title={
option.lang === "en"
? t("settings.language.en")
: option.lang === "ar"
? t("settings.language.ar")
: t("settings.language.fr")
}
right="value"
value={lang === option.lang ? "✓" : ""}
onPress={() => chooseLanguage(option.lang)}
/>
</View>
right="check"
selected={lang === option.lang}
onPress={() => chooseLanguage(option.lang)}
/>
))}
</Card>
</SettingsCard>
{/* 5. Keep awake */}
<SectionHeader title={t("settings.keepAwake.title")} />
<Card>
{/* 5. General — keep-awake toggle + (Android) display-over-other-apps */}
<SectionHeader title={t("settings.general.title")} />
<SettingsCard>
<SettingsRow
icon="monitor"
title={t("settings.keepAwake.title")}
@@ -296,27 +278,20 @@ const Settings = () => {
switchValue={keepAwake}
onSwitchChange={setKeepAwake}
/>
</Card>
{/* 6. Display over other apps (Android only) */}
{Platform.OS === "android" ? (
<>
<SectionHeader title={t("settings.overlay.title")} />
<Card>
<SettingsRow
icon="application-brackets"
title={t("settings.overlay.allow")}
subtitle={
overlayRequested
? t("settings.overlay.openedHint")
: t("settings.overlay.description")
}
right="chevron"
onPress={openOverlaySettings}
/>
</Card>
</>
) : null}
{Platform.OS === "android" ? (
<SettingsRow
icon="application-brackets"
title={t("settings.overlay.allow")}
subtitle={
overlayRequested
? t("settings.overlay.openedHint")
: t("settings.overlay.description")
}
right="chevron"
onPress={openOverlaySettings}
/>
) : null}
</SettingsCard>
</ScrollView>
</SafeAreaView>
);
+42 -12
View File
@@ -1,18 +1,48 @@
import { Stack } from "expo-router";
import { Redirect, Stack } from "expo-router";
import CallWatcher from "@/components/call-watcher";
import { useSession } from "@/lib/session";
const RootLayout = () => {
const { isLoaded, isSignedIn } = useSession();
// Everything under (root) is behind the session, so the check belongs here
// rather than in each screen. app/index.tsx only guards the way in, which
// left a session that ended *while* a screen was open with nowhere to go:
// the screen stayed mounted and kept polling with a token the server had
// already rejected.
//
// Sign-in, not welcome: someone who reaches this point had an account a
// moment ago, and the onboarding carousel is not what they need.
if (!isLoaded) return null;
if (!isSignedIn) return <Redirect href="/(auth)/sign-in" />;
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="find-ride" options={{ headerShown: false }} />
<Stack.Screen name="confirm-ride" options={{ headerShown: false }} />
<Stack.Screen name="book-ride" options={{ headerShown: false }} />
<Stack.Screen name="role" options={{ headerShown: false }} />
<Stack.Screen
name="driver-home"
options={{ headerShown: false, gestureEnabled: false }}
/>
</Stack>
<>
{/* Watches for incoming WebRTC calls on the active ride and routes the
user to the call screen regardless of which tab is open. No UI. */}
<CallWatcher />
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="find-ride" options={{ headerShown: false }} />
<Stack.Screen name="adjust-pin" options={{ headerShown: false }} />
<Stack.Screen name="book-ride" options={{ headerShown: false }} />
<Stack.Screen name="role" options={{ headerShown: false }} />
<Stack.Screen
name="driver-home"
options={{ headerShown: false, gestureEnabled: false }}
/>
<Stack.Screen name="driver-chat" options={{ headerShown: false }} />
<Stack.Screen
name="call"
options={{
headerShown: false,
presentation: "fullScreenModal",
gestureEnabled: false,
}}
/>
</Stack>
</>
);
};
+199
View File
@@ -0,0 +1,199 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import * as Location from "expo-location";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { CustomButton } from "@/components/custom-button";
import { PinAdjuster } from "@/components/pin-adjuster";
import { useT } from "@/lib/i18n";
import { addressForCoords } from "@/lib/reverse-geocode";
import { useLocationStore } from "@/store";
// "Move the pin to where you actually are."
//
// An address from autocomplete lands on whatever the geocoder considers the
// centre of that place — which can be the wrong side of a building, the wrong
// end of a long street, or the middle of a junction the driver can't stop in.
// The rider knows the doorway; this screen lets them say so, for the pickup
// and the drop-off alike.
//
// Reverse geocoding is debounced rather than run on every frame of the pan:
// the label only has to be right once the map stops.
const GEOCODE_DEBOUNCE_MS = 450;
// Falls back to Beirut, matching the map's own default, so the screen always
// has somewhere to open even before a fix arrives.
const FALLBACK = { latitude: 33.8938, longitude: 35.5018 };
type Coords = { latitude: number; longitude: number };
const AdjustPin = () => {
const t = useT();
const params = useLocalSearchParams<{ mode?: string }>();
const mode = params.mode === "destination" ? "destination" : "origin";
const {
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
setUserLocation,
setDestinationLocation,
} = useLocationStore();
// Open on the point being edited. A destination that hasn't been chosen yet
// starts at the rider instead of an arbitrary city centre, because the place
// they're going is usually near the place they are.
const initial: Coords =
mode === "origin"
? {
latitude: userLatitude ?? FALLBACK.latitude,
longitude: userLongitude ?? FALLBACK.longitude,
}
: {
latitude: destinationLatitude ?? userLatitude ?? FALLBACK.latitude,
longitude:
destinationLongitude ?? userLongitude ?? FALLBACK.longitude,
};
const [coords, setCoords] = useState<Coords>(initial);
const [address, setAddress] = useState<string | null>(null);
const [resolving, setResolving] = useState(true);
const debounce = useRef<ReturnType<typeof setTimeout>>();
const resolve = useCallback((next: Coords) => {
setCoords(next);
clearTimeout(debounce.current);
debounce.current = setTimeout(async () => {
const label = await addressForCoords(next.latitude, next.longitude);
setAddress(label);
setResolving(false);
}, GEOCODE_DEBOUNCE_MS);
}, []);
// Label the point the screen opened on, so the card isn't blank on arrival.
useEffect(() => {
resolve(initial);
return () => clearTimeout(debounce.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const confirm = () => {
const payload = {
latitude: coords.latitude,
longitude: coords.longitude,
address: address ?? t("common.yourLocation"),
};
if (mode === "origin") setUserLocation(payload);
else setDestinationLocation(payload);
router.back();
};
// Jump back to the rider's own position — the usual reason to open this
// screen is that the suggested pickup drifted away from where they're
// standing.
const recenter = async () => {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") return;
const position = await Location.getLastKnownPositionAsync({
maxAge: 60_000,
});
if (!position) return;
setResolving(true);
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
} catch (error) {
console.log("[ADJUST_PIN_RECENTER]: ", error);
}
};
return (
<View className="flex-1 bg-white dark:bg-neutral-950">
<PinAdjuster
initial={initial}
onMoveStart={() => setResolving(true)}
onSettled={resolve}
/>
<SafeAreaView className="flex-1" pointerEvents="box-none">
<View className="px-5 pt-2" pointerEvents="box-none">
<TouchableOpacity
onPress={() => router.back()}
accessibilityLabel={t("common.back")}
className="w-10 h-10 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
>
<MaterialCommunityIcons name="arrow-left" size={20} color="#0286ff" />
</TouchableOpacity>
</View>
<View className="flex-1" pointerEvents="none" />
<View className="px-5 pb-5" pointerEvents="box-none">
<TouchableOpacity
onPress={recenter}
accessibilityLabel={t("adjustPin.recenter")}
className="self-end mb-3 w-11 h-11 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
>
<MaterialCommunityIcons
name="crosshairs-gps"
size={20}
color="#0286ff"
/>
</TouchableOpacity>
<View className="rounded-2xl bg-white dark:bg-neutral-900 p-5 shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
<Text className="text-xs font-JakartaSemiBold uppercase tracking-wide text-general-200 dark:text-neutral-500 mb-1">
{mode === "origin"
? t("adjustPin.pickupLabel")
: t("adjustPin.destinationLabel")}
</Text>
<View className="flex-row items-center min-h-[26px] mb-1">
{resolving ? (
<>
<ActivityIndicator size="small" color="#0286ff" />
<Text className="ml-2 font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("adjustPin.locating")}
</Text>
</>
) : (
<Text
className="font-JakartaBold text-black dark:text-white text-base flex-1"
numberOfLines={2}
>
{address}
</Text>
)}
</View>
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mb-4">
{t("adjustPin.hint")}
</Text>
<CustomButton
title={
mode === "origin"
? t("adjustPin.confirmPickup")
: t("adjustPin.confirmDestination")
}
onPress={confirm}
disabled={resolving}
/>
</View>
</View>
</SafeAreaView>
</View>
);
};
export default AdjustPin;
+339 -31
View File
@@ -1,54 +1,99 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
Image,
ScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { CancelSheet } from "@/components/cancel-sheet";
import { CustomButton } from "@/components/custom-button";
import { Map } from "@/components/map";
import { OfferList } from "@/components/offer-list";
import { PaymentChoiceSheet } from "@/components/payment-choice-sheet";
import { RatingSheet } from "@/components/rating-sheet";
import { icons, images } from "@/constants";
import { driverPhotoUri } from "@/lib/driver-photo";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { payByCard, selectDriver } from "@/lib/request-ride";
import { useSession } from "@/lib/session";
import { formatTime } from "@/lib/utils";
import { useLocationStore } from "@/store";
import type { Ride } from "@/types/type";
import type { Ride, RideOffer } from "@/types/type";
const POLL_MS = 3000;
// While the request is open, offers arrive one driver at a time and the rider
// is staring at the list waiting for them. A three-second gap between a driver
// tapping Offer and their face appearing reads as nothing happening.
const OPEN_POLL_MS = 1500;
const STATUS_KEY: Record<string, string> = {
requested: "bookRide.status.requested",
accepted: "bookRide.status.accepted",
arrived: "bookRide.status.arrived",
en_route: "bookRide.status.enRoute",
completed: "bookRide.status.completed",
cancelled: "bookRide.status.cancelled",
expired: "bookRide.status.expired",
};
const TERMINAL = ["completed", "cancelled", "expired"];
// 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 { id } = useLocalSearchParams<{ id: string }>();
const rideId = Number(id);
const t = useT();
const { user } = useSession();
const setUserLocation = useLocationStore((s) => s.setUserLocation);
const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation);
const setDestinationLocation = useLocationStore(
(s) => s.setDestinationLocation,
);
const clearDestination = useLocationStore((s) => s.clearDestination);
const [ride, setRide] = useState<Ride | null>(null);
const [loading, setLoading] = useState(true);
const [cancelling, setCancelling] = useState(false);
// The offer the rider tapped, held while they choose how to pay.
const [picked, setPicked] = useState<RideOffer | null>(null);
const [paying, setPaying] = useState(false);
// A card order that was paid but whose selection then failed. Kept so the
// rider can pick a different driver without paying a second time — the
// server only consumes an order when a driver is actually assigned.
const paidOrder = useRef<string | null>(null);
// Server clock minus device clock, so the elapsed counter is measured on the
// clock the request window is actually enforced against.
const clockOffset = useRef(0);
const [error, setError] = useState<string | null>(null);
const [cancelOpen, setCancelOpen] = useState(false);
// Set once, when the ride first lands on 'completed' during this session,
// so dismissing the sheet doesn't immediately re-open it on the next poll.
const [ratingOpen, setRatingOpen] = useState(false);
const [ratingHandled, setRatingHandled] = useState(false);
const load = useCallback(async () => {
try {
const res = await fetchAPI(`/(api)/ride/${rideId}`);
const r = res.data as Ride;
if (r.now) clockOffset.current = Date.parse(r.now) - Date.now();
setRide(r);
// Ask for the rating the moment the driver ends the trip — the rider is
// still in the car and still remembers. `my_rating` covers the case
// where they already rated from the home banner.
if (r.status === "completed" && r.my_rating == null && !ratingHandled) {
setRatingOpen(true);
}
// 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({
@@ -69,28 +114,98 @@ const BookRide = () => {
} finally {
setLoading(false);
}
}, [rideId, setUserLocation, setDestinationLocation, t]);
}, [rideId, setUserLocation, setDestinationLocation, ratingHandled, t]);
useEffect(() => {
void load();
}, [load]);
// Poll while the ride is still in a non-terminal state.
// Drop the route when the rider leaves this screen.
//
// Nothing used to clear it, so a destination survived for the life of the
// process — and since backgrounding an app doesn't end that process, the
// next launch drew a line to a trip that had already finished. Cleared on
// unmount rather than on completion because `load` re-sets it on every poll:
// clearing while still on screen would just fight the next poll, and the
// tracking map would lose the route the rider is watching.
useEffect(() => () => clearDestination(), [clearDestination]);
// Poll while the ride is still in a non-terminal state, quickly while
// offers are still coming in.
useEffect(() => {
const status = ride?.status;
if (!status || status === "completed" || status === "cancelled") return;
const timer = setInterval(() => void load(), POLL_MS);
if (!status || TERMINAL.includes(status)) return;
const every = status === "requested" ? OPEN_POLL_MS : POLL_MS;
const timer = setInterval(() => void load(), every);
return () => clearInterval(timer);
}, [ride?.status, load]);
const cancel = async () => {
// Take one of the offers. This is the call that assigns the ride: it pays
// (or commits to cash), locks in that driver and releases the others.
//
// A 409 means the driver was taken while the rider was deciding — a normal
// outcome of several riders competing for the same cars, not an error. The
// list simply reloads without them, and any card payment already made stays
// unspent and is reused for the next pick.
const pay = async (method: "cash" | "card") => {
const offer = picked;
if (!offer || !ride) return;
setPaying(true);
try {
let orderId = paidOrder.current ?? undefined;
if (method === "card" && !orderId) {
orderId = await payByCard({
ride,
user: { name: user?.name ?? "", email: user?.email ?? "" },
});
paidOrder.current = orderId;
}
await selectDriver({
rideId,
offerId: offer.offer_id,
method,
orderId: method === "card" ? orderId : undefined,
});
// Assigned: the money is spent and the ride has a driver.
paidOrder.current = null;
setPicked(null);
await load();
} catch (err) {
console.log("[BOOK_RIDE_SELECT]: ", err);
setPicked(null);
if (err instanceof ApiError && err.status === 409) {
Alert.alert(
t("bookRide.offers.goneTitle"),
paidOrder.current
? t("bookRide.offers.goneBodyPaid")
: t("bookRide.offers.goneBody"),
);
} else {
Alert.alert(
t("bookRide.alertErrorTitle"),
err instanceof ApiError ? err.message : t("bookRide.match.alertBody"),
);
}
await load();
} finally {
setPaying(false);
}
};
const cancel = async (reason: string) => {
setCancelling(true);
try {
await fetchAPI(`/(api)/ride/${rideId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "cancelled" }),
body: JSON.stringify({ status: "cancelled", reason }),
});
setCancelOpen(false);
await load();
} catch (err) {
console.log("[BOOK_RIDE_CANCEL]: ", err);
@@ -124,35 +239,127 @@ const BookRide = () => {
}
const driver = ride.driver;
const terminal = ride.status === "completed" || ride.status === "cancelled";
const driverId = driver.id;
const terminal = TERMINAL.includes(ride.status);
const driverName = [driver.first_name, driver.last_name]
.filter(Boolean)
.join(" ");
const cashDue = ride.payment_status === "cash";
const offers = (ride.offers ?? []) as RideOffer[];
// Whole seconds the search has been running, measured on the server's clock.
const searchSeconds = Math.max(
0,
Math.round(
(Date.now() + clockOffset.current - Date.parse(ride.created_at)) / 1000,
),
);
return (
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
<View className="h-[45%] bg-blue-500">
<Map />
<Map trackedDriver={driverId ? { ...driver, id: driverId } : null} />
</View>
<View className="flex-1 px-5 pt-4">
{/* Scrollable, because the number of things below the map isn't fixed:
four drivers offering on a request push the fare, the cancel button
and the fourth driver off the bottom of the screen, and a rider
who can't reach an offer can't take it. */}
<ScrollView
className="flex-1 px-5 pt-4"
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }}
>
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
{STATUS_KEY[ride.status] ? t(STATUS_KEY[ride.status]) : ride.status}
{/* Once drivers have volunteered the screen stops being a search and
becomes a decision, and the heading has to say which one it is
a rider reading "finding your driver" over a list of drivers
doesn't know it's waiting on them. */}
{ride.status === "requested" && offers.length > 0
? t("bookRide.status.choosing")
: STATUS_KEY[ride.status]
? t(STATUS_KEY[ride.status])
: ride.status}
</Text>
{/* Searching state */}
{ride.status === "requested" ? (
<View className="items-center mt-6">
{/* Waiting on the first driver to volunteer. The elapsed counter is
there because a spinner with no number on it reads as broken after
about ten seconds and the request legitimately sits open for a
couple of minutes. A rider who can see it counting knows their
request is still live. */}
{ride.status === "requested" && offers.length === 0 ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-5 mt-2 items-center">
<ActivityIndicator size="large" color="#0286ff" />
<Text className="text-general-200 dark:text-neutral-400 mt-3 text-center">
{t("bookRide.matchingDriver", { service: ride.service })}
</Text>
<Text className="text-xs text-general-200 dark:text-neutral-400 mt-2">
{t("bookRide.searchingFor", { seconds: searchSeconds })}
</Text>
</View>
) : null}
{/* Driver card — shown once a driver is assigned. */}
{driver?.id ? (
{/* Drivers who want the job. The rider picks; everyone else is let go
the moment they do. */}
{ride.status === "requested" && offers.length > 0 ? (
<OfferList
offers={offers}
pendingOfferId={paying ? (picked?.offer_id ?? null) : null}
busy={paying}
onPick={setPicked}
/>
) : null}
{/* Pickup code the rider's half of the handshake. Shown from the
moment a driver is assigned until the trip starts; the driver
can't start without hearing it, which is what stops a rider from
getting into the wrong car (and the wrong car from taking them). */}
{ride.pickup_code ? (
<View
className={`rounded-2xl p-4 mt-2 items-center ${
ride.status === "arrived"
? "bg-emerald-500"
: "bg-white dark:bg-neutral-900"
}`}
>
<Text
className={`text-xs font-JakartaMedium ${
ride.status === "arrived"
? "text-white/90"
: "text-general-200 dark:text-neutral-400"
}`}
>
{ride.status === "arrived"
? t("bookRide.driverHere")
: t("bookRide.pickupCodeLabel")}
</Text>
<Text
className={`text-4xl font-JakartaExtraBold tracking-[8px] mt-1 ${
ride.status === "arrived"
? "text-white"
: "text-black dark:text-white"
}`}
>
{ride.pickup_code}
</Text>
<Text
className={`text-xs text-center mt-1 ${
ride.status === "arrived"
? "text-white/90"
: "text-general-200 dark:text-neutral-400"
}`}
>
{t("bookRide.pickupCodeHint")}
</Text>
</View>
) : null}
{/* Driver card shown once the pairing is confirmed. While the ride
is still 'matched' the confirmation card above is showing the same
driver, and two cards for one driver reads as two drivers. */}
{driver?.id && ride.status !== "matched" ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-2">
<View className="flex-row items-center">
<Image
source={{ uri: driver.profile_image_url ?? undefined }}
source={{ uri: driverPhotoUri(driver.profile_image_url) }}
className="w-16 h-16 rounded-full"
/>
<View className="ml-4 flex-1">
@@ -171,20 +378,48 @@ const BookRide = () => {
) : null}
</View>
</View>
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize">
{driver.service ?? ride.service}
</Text>
<View className="flex-row items-center">
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize mr-3">
{driver.service ?? ride.service}
</Text>
{/* Call the driver — only while the ride is active. */}
{!terminal ? (
<TouchableOpacity
onPress={() =>
router.push({
pathname: "/(root)/call",
params: { rideId: String(ride.ride_id), mode: "start" },
})
}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
accessibilityLabel={t("chat.call")}
className="w-9 h-9 rounded-full bg-general-400 items-center justify-center"
>
<MaterialCommunityIcons
name="phone"
size={18}
color="white"
/>
</TouchableOpacity>
) : null}
</View>
</View>
<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 text-black dark:text-white" numberOfLines={1}>
<Text
className="font-JakartaMedium text-sm text-black dark:text-white"
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 text-black dark:text-white" numberOfLines={1}>
<Text
className="font-JakartaMedium text-sm text-black dark:text-white"
numberOfLines={1}
>
{ride.destination_address}
</Text>
</View>
@@ -212,14 +447,41 @@ const BookRide = () => {
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-1">
{t("bookRide.tripTime", { time: formatTime(ride.ride_time) })}
</Text>
{/* A cash ride the driver hasn't marked collected is money still
owed say so rather than showing a clean "all done". */}
{cashDue ? (
<Text className="text-amber-600 dark:text-amber-400 text-sm mt-2 text-center">
{t("bookRide.cashDue", {
amount: (ride.fare_price / 100).toFixed(2),
})}
</Text>
) : null}
{ride.my_rating ? (
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-2">
{t("bookRide.youRated", { n: ride.my_rating })}
</Text>
) : (
<TouchableOpacity
onPress={() => setRatingOpen(true)}
className="mt-3"
>
<Text className="font-JakartaBold text-primary-500">
{t("bookRide.rateDriver")}
</Text>
</TouchableOpacity>
)}
</View>
) : null}
{/* Cancelled */}
{ride.status === "cancelled" ? (
{/* Cancelled / expired */}
{ride.status === "cancelled" || ride.status === "expired" ? (
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4 items-center">
<Text className="text-general-200 dark:text-neutral-400">
{t("bookRide.rideCancelled")}
<Text className="text-general-200 dark:text-neutral-400 text-center">
{ride.status === "expired"
? t("bookRide.noDriversFound")
: ride.cancelled_by === "driver"
? t("bookRide.cancelledByDriver")
: t("bookRide.rideCancelled")}
</Text>
</View>
) : null}
@@ -230,19 +492,65 @@ const BookRide = () => {
title={t("bookRide.backHome")}
onPress={() => router.replace("/(root)/(tabs)/home")}
/>
) : ride.status === "en_route" ? (
// Once the trip is under way there is nothing to cancel — the
// rider is in the car. Ending it early is the driver's action.
<Text className="text-center text-general-200 dark:text-neutral-400 text-sm pb-3">
{t("bookRide.enRouteNotice")}
</Text>
) : (
<TouchableOpacity
onPress={cancel}
onPress={() => setCancelOpen(true)}
disabled={cancelling}
className="rounded-full py-3 bg-white dark:bg-neutral-900 items-center border border-rose-300 dark:border-rose-900"
>
<Text className="font-JakartaBold text-rose-500">
{cancelling ? t("bookRide.cancelling") : t("bookRide.cancelRide")}
{cancelling
? t("bookRide.cancelling")
: t("bookRide.cancelRide")}
</Text>
</TouchableOpacity>
)}
</View>
</View>
</ScrollView>
<PaymentChoiceSheet
visible={picked !== null}
driverName={
picked
? [picked.first_name, picked.last_name].filter(Boolean).join(" ")
: null
}
fareCents={ride.fare_price}
submitting={paying}
onPay={(method) => void pay(method)}
onCancel={() => setPicked(null)}
/>
<CancelSheet
visible={cancelOpen}
audience="rider"
submitting={cancelling}
onCancel={() => setCancelOpen(false)}
onConfirm={(reason) => void cancel(reason)}
/>
<RatingSheet
visible={ratingOpen}
rideId={rideId}
audience="rider"
subjectName={driverName || null}
subjectAvatar={driver.profile_image_url}
onDone={() => {
setRatingOpen(false);
setRatingHandled(true);
void load();
}}
onSkip={() => {
setRatingOpen(false);
setRatingHandled(true);
}}
/>
</SafeAreaView>
);
};
+228
View File
@@ -0,0 +1,228 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { RTCView } from "react-native-webrtc";
import { fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useCall } from "@/lib/use-call";
import type { ChatActiveRide } from "@/types/type";
// In-app WebRTC audio call screen. Two entry modes:
// mode=start — caller opened this from the chat header; we place the call.
// mode=incoming — CallWatcher detected a ringing call; we attach and wait
// for the user to Accept/Decline.
// Either way the authoritative ride/role/peer come from GET /(api)/chat/active
// (so a stale nav param never dials the wrong ride).
const Call = () => {
const t = useT();
const params = useLocalSearchParams<{
rideId?: string;
role?: "rider" | "driver";
mode?: "start" | "incoming";
}>();
const [active, setActive] = useState<ChatActiveRide | null>(null);
const [resolving, setResolving] = useState(true);
const call = useCall();
const startedRef = useRef(false);
// Resolve the active ride + peer once, then kick off the right flow.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetchAPI("/(api)/chat/active");
const a = (res.data ?? null) as ChatActiveRide | null;
if (cancelled) return;
setActive(a);
if (!a) return;
if (startedRef.current) return;
startedRef.current = true;
const peerName = a.peer?.name ?? "";
if (params.mode === "start") {
void call.startCall(a.ride_id, a.role, peerName);
} else {
call.watch(a.ride_id, a.role, peerName);
}
} catch (err) {
console.log("[CALL_SCREEN_RESOLVE]: ", err);
} finally {
if (!cancelled) setResolving(false);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Surface a mic-permission denial and back out.
useEffect(() => {
if (call.micError) {
Alert.alert(t("call.micDeniedTitle"), t("call.micDeniedBody"), [
{ text: "OK", onPress: () => router.back() },
]);
}
}, [call.micError, t]);
// When the call reaches a terminal state, show the label briefly, then
// leave the screen so the user returns to where they came from.
useEffect(() => {
if (call.status !== "ended") return;
const timer = setTimeout(() => router.back(), 1200);
return () => clearTimeout(timer);
}, [call.status]);
const peerName = active?.peer?.name ?? call.peerName ?? "";
const handleEnd = useCallback(() => {
void call.endCall();
}, [call]);
const handleAccept = useCallback(() => {
void call.answerCall();
}, [call]);
const handleDecline = useCallback(() => {
void call.declineCall();
}, [call]);
if (resolving) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
<Text className="text-general-200 dark:text-neutral-400">
{t("call.connecting")}
</Text>
</SafeAreaView>
);
}
if (!active) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center px-7">
<Text className="text-base text-center text-general-200 dark:text-neutral-400">
{t("call.unavailable")}
</Text>
<TouchableOpacity
onPress={() => router.back()}
className="mt-6 px-6 py-3 rounded-full bg-general-400"
>
<Text className="text-white font-JakartaBold">
{t("call.cancel")}
</Text>
</TouchableOpacity>
</SafeAreaView>
);
}
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-between py-10">
{/* Audio sink hidden; keeps the native audio pipeline attached even
though this is an audio-only call (RTCView is the stream sink). */}
{call.remoteStream ? (
<RTCView
streamURL={call.remoteStream.toURL()}
className="w-1 h-1 opacity-0"
/>
) : null}
{/* Peer identity + status */}
<View className="items-center mt-16">
<View className="w-28 h-28 rounded-full bg-general-400 items-center justify-center mb-6">
<Text className="text-4xl font-JakartaBold text-white">
{(peerName.trim()[0] ?? "?").toUpperCase()}
</Text>
</View>
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
{peerName}
</Text>
<Text className="text-base mt-1 text-general-200 dark:text-neutral-400">
{call.status === "incoming"
? t("call.incoming")
: call.status === "outgoing" || call.status === "connecting"
? t("call.connectingWith", { name: peerName })
: call.status === "in-call"
? t("call.inCall")
: call.status === "ended"
? t("call.ended")
: t("call.connecting")}
</Text>
</View>
{/* Controls vary by state */}
<View className="flex-row items-center justify-center mb-10">
{call.status === "incoming" ? (
<>
<CallButton
icon="phone-hangup"
color="#ef4444"
label={t("call.decline")}
onPress={handleDecline}
/>
<CallButton
icon="phone"
color="#22c55e"
label={t("call.accept")}
onPress={handleAccept}
/>
</>
) : (
<>
<CallButton
icon={call.muted ? "microphone-off" : "microphone"}
color={call.muted ? "#ef4444" : "#6b7280"}
label={call.muted ? t("call.unmute") : t("call.mute")}
onPress={call.toggleMute}
/>
<CallButton
icon="phone-hangup"
color="#ef4444"
label={t("call.end")}
onPress={handleEnd}
/>
<CallButton
icon={call.speakerOn ? "volume-high" : "volume-off"}
color={call.speakerOn ? "#0286ff" : "#6b7280"}
label={call.speakerOn ? t("call.speaker") : t("call.speakerOff")}
onPress={call.toggleSpeaker}
/>
</>
)}
</View>
</SafeAreaView>
);
};
const CallButton = ({
icon,
color,
label,
onPress,
}: {
icon: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
color: string;
label: string;
onPress: () => void;
}) => (
<TouchableOpacity
onPress={onPress}
className="items-center mx-6"
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
>
<View
className="w-16 h-16 rounded-full items-center justify-center"
style={{ backgroundColor: color }}
>
<MaterialCommunityIcons name={icon} size={28} color="white" />
</View>
<Text className="text-xs mt-2 text-general-200 dark:text-neutral-400">
{label}
</Text>
</TouchableOpacity>
);
export default Call;
-349
View File
@@ -1,349 +0,0 @@
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 { RideLayout } from "@/components/ride-layout";
import { SERVICES } from "@/constants/services";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
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 params = useLocalSearchParams<{ service?: string }>();
const {
userAddress,
userLatitude,
userLongitude,
destinationAddress,
destinationLatitude,
destinationLongitude,
} = useLocationStore();
const { service: storeService, setService } = useServiceStore();
const { user } = useSession();
const t = useT();
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(
t("confirmRide.alertMissingRouteTitle"),
t("confirmRide.alertMissingRouteBody"),
);
return;
}
if (!estimate) {
Alert.alert(
t("confirmRide.alertNoEstimateTitle"),
t("confirmRide.alertNoEstimateBody"),
);
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
: t("confirmRide.alertErrorFallback");
Alert.alert(t("confirmRide.alertErrorTitle"), msg);
} finally {
setProcessing(false);
}
};
if (method === "card") {
Alert.alert(
t("confirmRide.alertPayCardTitle"),
t("confirmRide.alertPayCardBody", { fare: estimate.fare }),
[
{ text: t("common.cancel"), style: "cancel" },
{ text: t("common.continue"), onPress: () => void doRequest() },
],
);
} else {
void doRequest();
}
};
return (
<RideLayout title={t("confirmRide.title")} snapPoints={["60%", "88%"]}>
<Text className="text-xl font-JakartaSemiBold mb-1 text-black dark:text-white">
{t("confirmRide.yourTrip")}
</Text>
<View className="flex-row items-center gap-x-2 mb-1">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("confirmRide.pickup")}
</Text>
</View>
<Text className="font-JakartaMedium mb-3 text-black dark:text-white" numberOfLines={1}>
{userAddress}
</Text>
<View className="flex-row items-center gap-x-2 mb-1">
<Text className="text-general-200 dark:text-neutral-400 text-xs">
{t("confirmRide.destination")}
</Text>
</View>
<Text className="font-JakartaMedium mb-4 text-black dark:text-white" numberOfLines={1}>
{destinationAddress}
</Text>
<View className="flex-row items-center justify-between bg-general-500 dark:bg-neutral-950 rounded-2xl p-4 mb-4">
<View>
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t(selected.labelKey)} · {t(selected.taglineKey)}
</Text>
<Text className="text-general-200 dark:text-neutral-400 text-xs mt-1">
{t("confirmRide.tripTime", {
time: estimate ? formatTime(estimate.durationSeconds / 60) : "…",
})}
</Text>
</View>
<View className="items-end">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{estimating
? "…"
: estimate
? t("confirmRide.fareDisplay", { fare: estimate.fare })
: "—"}
</Text>
{estimate ? (
<Text className="text-xs text-general-200 dark:text-neutral-400">
{t("confirmRide.lbpEstimate", {
lbp: formatLBP(parseFloat(estimate.fare)),
})}
</Text>
) : null}
</View>
</View>
<Text
className={`text-base font-JakartaMedium mb-2 ${
driversOnline === 0
? "text-rose-500"
: "text-general-200 dark:text-neutral-400"
}`}
>
{driversOnline === 0
? t("confirmRide.noDrivers", { service: t(selected.labelKey) })
: nearestEta == null
? t("confirmRide.findingDrivers")
: t("confirmRide.nearestDriver", { eta: nearestEta })}
</Text>
<Text className="text-lg font-JakartaSemiBold mt-2 mb-2 text-black dark:text-white">
{t("confirmRide.paymentMethod")}
</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 dark:bg-primary-500/20 border-primary-500"
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
}`}
>
<Text
className={`font-JakartaMedium ${
method === "cash" ? "text-white" : "text-black dark:text-white"
}`}
>
{t("confirmRide.cash")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setMethod("card")}
className={`flex-1 items-center py-3 rounded-xl border ${
method === "card"
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
}`}
>
<Text
className={`font-JakartaMedium ${
method === "card" ? "text-white" : "text-black dark:text-white"
}`}
>
{t("confirmRide.card")}
</Text>
</TouchableOpacity>
</View>
<CustomButton
title={
processing
? t("confirmRide.requesting")
: driversOnline === 0
? t("confirmRide.noDriversOnline")
: method === "cash"
? t("confirmRide.requestCash")
: t("confirmRide.requestCard")
}
className="mt-4"
onPress={request}
disabled={processing || estimating || !estimate || driversOnline === 0}
/>
</RideLayout>
);
};
export default ConfirmRide;
+11
View File
@@ -0,0 +1,11 @@
import { ChatThread } from "@/components/chat-thread";
// Standalone chat screen for the driver side. Reuses the same ChatThread as
// the rider's (tabs) Chat screen, but outside the rider's (tabs) navigator —
// routing a driver into "/(root)/(tabs)/chat" would mount the rider's tab bar
// (Home/Rides/Chat/Profile/Settings) around them, exposing rider-only screens
// and clashing visually with the composer at the bottom. No tab bar here, so
// no extra clearance is needed.
const DriverChat = () => <ChatThread />;
export default DriverChat;
+1526 -122
View File
File diff suppressed because it is too large Load Diff
+283 -8
View File
@@ -1,11 +1,128 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
// Every control on this screen lives inside the RideLayout bottom sheet, and
// on Android a react-native touchable in there loses its first press to the
// sheet's gesture handler — which is why "Find now" had to be tapped twice to
// send a request. The sheet's own touchables are the fix the library ships for
// this; on iOS they are react-native's, unchanged.
import { TouchableOpacity } from "@gorhom/bottom-sheet";
import { router } from "expo-router";
import { useEffect, useState } from "react";
import { Alert, Text, View } from "react-native";
import { CustomButton } from "@/components/custom-button";
import { GoogleTextInput } from "@/components/google-text-input";
import { RideLayout } from "@/components/ride-layout";
import { icons } from "@/constants";
import { SERVICES, type ServiceId } from "@/constants/services";
import { ApiError } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useLocationStore } from "@/store";
import { router } from "expo-router";
import { Text, View } from "react-native";
import { calculateTripFare } from "@/lib/map";
import { formatLBP } from "@/lib/pricing";
import { createRideRequest } from "@/lib/request-ride";
import { useServiceAvailability } from "@/lib/use-service-availability";
import { formatTime } from "@/lib/utils";
import { useLocationStore, useServiceStore } from "@/store";
/**
* "Set it on the map" for one of the two points.
*
* An autocomplete result lands on whatever the geocoder calls the centre of a
* place, which is regularly the wrong side of a building or the wrong end of a
* long street and a driver sent to the wrong side of a divided road can't
* simply turn around. This is the escape hatch: the rider drags the map to the
* exact doorway.
*/
const AdjustOnMap = ({ mode }: { mode: "origin" | "destination" }) => {
const t = useT();
return (
<TouchableOpacity
onPress={() =>
router.push({ pathname: "/(root)/adjust-pin", params: { mode } })
}
className="flex-row items-center gap-x-2 mt-2 self-start px-1 py-1.5"
>
<MaterialCommunityIcons
name="map-marker-radius"
size={16}
color="#0286ff"
/>
<Text className="text-sm font-JakartaBold text-primary-500">
{t("findRide.adjustOnMap")}
</Text>
</TouchableOpacity>
);
};
/**
* Which service the request goes out on, with live availability.
*
* It lives on this screen because this is now the last screen before drivers
* are contacted the request is broadcast on tap, so the choice of who to
* broadcast it to has to be made here, next to the button that sends it.
*/
const ServiceRow = ({
service,
counts,
onSelect,
}: {
service: ServiceId;
counts: Record<ServiceId, number>;
onSelect: (id: ServiceId) => void;
}) => {
const t = useT();
return (
<View className="flex-row gap-2">
{SERVICES.map((item) => {
const active = item.id === service;
const available = counts[item.id] ?? 0;
return (
<TouchableOpacity
key={item.id}
onPress={() => onSelect(item.id)}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityState={{ selected: active }}
className={`flex-1 items-center rounded-2xl border py-2.5 ${
active
? "border-primary-500 bg-primary-500/10"
: "border-neutral-100 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800"
}`}
>
<MaterialCommunityIcons
name={item.icon}
size={20}
color={active ? "#0286ff" : "#858585"}
/>
<Text
className={`text-[11px] mt-1 font-JakartaMedium ${
active
? "text-primary-500"
: "text-general-200 dark:text-neutral-400"
}`}
>
{t(item.labelKey)}
</Text>
{/* The count is the honest version of an empty map: it says
whether asking this service is worth doing before the rider
sends a request nobody will answer. */}
<Text
className={`text-[10px] ${
available > 0
? "text-emerald-600 dark:text-emerald-400"
: "text-general-200 dark:text-neutral-500"
}`}
>
{available > 0 ? t("findRide.nAvailable", { n: available }) : "—"}
</Text>
</TouchableOpacity>
);
})}
</View>
);
};
const FindRide = () => {
const t = useT();
@@ -19,13 +136,126 @@ const FindRide = () => {
setDestinationLocation,
setUserLocation,
} = useLocationStore();
const { service, setService } = useServiceStore();
const canFind =
const [estimate, setEstimate] = useState<{
fare: string;
durationSeconds: number;
} | null>(null);
const [estimating, setEstimating] = useState(false);
const [sending, setSending] = useState(false);
const hasRoute =
!!userLatitude &&
!!userLongitude &&
!!destinationLatitude &&
!!destinationLongitude;
const { counts } = useServiceAvailability(userLatitude, userLongitude);
// The fare is quoted before the request goes out, not after: it is what the
// drivers deciding whether to take the job are shown, so it has to exist by
// the time the request does. Recomputed when the route or service changes.
useEffect(() => {
if (!hasRoute) {
setEstimate(null);
return;
}
let cancelled = false;
setEstimating(true);
void calculateTripFare({
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service,
})
.then((trip) => {
if (cancelled) return;
setEstimate(
trip
? { fare: trip.fare, durationSeconds: trip.durationSeconds }
: null,
);
})
.finally(() => {
if (!cancelled) setEstimating(false);
});
return () => {
cancelled = true;
};
}, [
hasRoute,
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
service,
]);
const findNow = async () => {
if (!hasRoute || !estimate) return;
setSending(true);
try {
const ride = await createRideRequest({
service,
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("[FIND_RIDE]: ", err);
// The rider already has a ride in flight. Booking a second one isn't
// what they want — they want the one they lost track of, so take them
// to it instead of showing an error they can't act on.
if (
err instanceof ApiError &&
err.status === 409 &&
err.body?.code === "RIDE_IN_PROGRESS"
) {
const inProgressId = String(err.body.ride_id);
Alert.alert(
t("confirmRide.alertInProgressTitle"),
t("confirmRide.alertInProgressBody"),
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("confirmRide.viewRide"),
onPress: () =>
router.replace(`/(root)/book-ride?id=${inProgressId}`),
},
],
);
return;
}
Alert.alert(
t("confirmRide.alertErrorTitle"),
err instanceof ApiError
? err.message
: t("confirmRide.alertErrorFallback"),
);
} finally {
setSending(false);
}
};
return (
<RideLayout title={t("findRide.title")} snapPoints={["85%"]}>
<View className="my-3">
@@ -39,6 +269,8 @@ const FindRide = () => {
containerStyles="bg-neutral-100 dark:bg-neutral-800"
handlePress={setUserLocation}
/>
<AdjustOnMap mode="origin" />
</View>
<View className="my-3">
@@ -52,13 +284,56 @@ const FindRide = () => {
containerStyles="bg-neutral-100 dark:bg-neutral-800"
handlePress={setDestinationLocation}
/>
<AdjustOnMap mode="destination" />
</View>
<Text className="text-sm font-JakartaSemiBold mb-2 mt-1 text-black dark:text-white">
{t("findRide.service")}
</Text>
<ServiceRow service={service} counts={counts} onSelect={setService} />
{/* The quote, shown before the request goes out rather than on a screen
after it. This is the number the rider agrees to and the number every
driver who sees the request is offered, so it belongs next to the
button that sends it. */}
<View className="flex-row items-center justify-between rounded-2xl bg-general-500 dark:bg-neutral-950 px-4 py-3 mt-4">
<View>
<Text className="text-xs font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("findRide.estimatedFare")}
</Text>
<Text className="text-[11px] text-general-200 dark:text-neutral-400 mt-0.5">
{estimate
? t("confirmRide.tripTime", {
time: formatTime(estimate.durationSeconds / 60),
})
: t("findRide.setBothPoints")}
</Text>
</View>
<View className="items-end">
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
{estimating ? "…" : estimate ? `$${estimate.fare}` : "—"}
</Text>
{estimate ? (
<Text className="text-[11px] text-general-200 dark:text-neutral-400">
{t("confirmRide.lbpEstimate", {
lbp: formatLBP(parseFloat(estimate.fare)),
})}
</Text>
) : null}
</View>
</View>
<Text className="text-[11px] text-center text-general-200 dark:text-neutral-400 mt-3">
{t("findRide.payLaterHint")}
</Text>
<CustomButton
title={t("findRide.findNow")}
onPress={() => router.push("/(root)/confirm-ride")}
disabled={!canFind}
className={`mt-5 ${!canFind ? "opacity-50" : ""}`}
Touchable={TouchableOpacity}
title={sending ? t("findRide.sending") : t("findRide.findNow")}
onPress={() => void findNow()}
disabled={!hasRoute || !estimate || estimating || sending}
className={`mt-3 ${!hasRoute || !estimate || estimating || sending ? "opacity-50" : ""}`}
/>
</RideLayout>
);
+11
View File
@@ -5,13 +5,24 @@ import { useEffect } from "react";
import "react-native-reanimated";
import { I18nProvider } from "@/lib/i18n";
import { configureNotificationHandler } from "@/lib/notifications";
import { SessionProvider } from "@/lib/session";
import { SettingsProvider } from "@/lib/settings-provider";
import { ThemeProvider } from "@/lib/theme";
// Registers the driver background-location task. Imported for the side effect
// alone: Android can restart the app process headlessly to deliver a location
// update, and the task must already be defined when the bundle finishes
// evaluating — which means at module scope, not inside a component.
import "@/lib/location-task";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
// A ride offer that arrives while the app is open still needs to be seen — the
// driver may be on another screen, and they only have 15 seconds to answer.
configureNotificationHandler();
const RootLayout = () => {
const [loaded] = useFonts({
"Jakarta-Bold": require("../assets/fonts/PlusJakartaSans-Bold.ttf"),
+146
View File
@@ -0,0 +1,146 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { RatingSheet } from "@/components/rating-sheet";
import { fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
// Home-screen banner for unfinished business. Two things can be unfinished
// after the rider leaves the tracking screen:
//
// * a ride still in flight — before this, killing the app mid-ride stranded
// the rider with no route back to their driver, since home only lists
// completed history;
// * a finished ride they never rated — the prompt is easy to miss when the
// app is backgrounded the moment the door closes.
//
// Both are recoverable from one poll, so they share one banner.
const POLL_MS = 15000;
type ActiveRide = {
ride_id: number;
status: string;
service: string;
destination_address: string;
driver_name: string | null;
};
type PendingRating = {
ride_id: number;
destination_address: string;
driver_name: string | null;
driver_avatar: string | null;
};
const STATUS_KEY: Record<string, string> = {
requested: "bookRide.status.requested",
accepted: "bookRide.status.accepted",
arrived: "bookRide.status.arrived",
en_route: "bookRide.status.enRoute",
};
export const ActiveRideBanner = () => {
const t = useT();
const [active, setActive] = useState<ActiveRide | null>(null);
const [pending, setPending] = useState<PendingRating | null>(null);
const [ratingOpen, setRatingOpen] = useState(false);
const [dismissed, setDismissed] = useState<number[]>([]);
const load = useCallback(async () => {
try {
const res = await fetchAPI("/(api)/ride/active");
setActive(res.data?.active ?? null);
setPending(res.data?.pending_rating ?? null);
} catch (err) {
// A signed-out or offline home screen simply shows no banner.
console.log("[ACTIVE_RIDE_BANNER]: ", err);
}
}, []);
useEffect(() => {
void load();
const timer = setInterval(() => void load(), POLL_MS);
return () => clearInterval(timer);
}, [load]);
if (active) {
return (
<TouchableOpacity
onPress={() =>
router.push({
pathname: "/(root)/book-ride",
params: { id: String(active.ride_id) },
})
}
className="bg-primary-500 rounded-2xl p-4 mb-4 flex-row items-center"
>
<View className="flex-1">
<Text className="text-white/80 text-xs font-JakartaMedium">
{STATUS_KEY[active.status]
? t(STATUS_KEY[active.status])
: active.status}
</Text>
<Text
className="text-white font-JakartaBold mt-0.5"
numberOfLines={1}
>
{active.driver_name
? t("home.activeRideWithDriver", { name: active.driver_name })
: active.destination_address}
</Text>
</View>
<MaterialCommunityIcons name="chevron-right" size={24} color="white" />
</TouchableOpacity>
);
}
if (pending && !dismissed.includes(pending.ride_id)) {
return (
<>
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mb-4 flex-row items-center">
<View className="flex-1">
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
{t("home.rateLastRide")}
</Text>
<Text
className="text-black dark:text-white font-JakartaBold mt-0.5"
numberOfLines={1}
>
{pending.destination_address}
</Text>
</View>
<TouchableOpacity
onPress={() => setRatingOpen(true)}
className="bg-primary-500 rounded-full px-4 py-2 ml-3"
>
<Text className="text-white font-JakartaBold text-xs">
{t("home.rate")}
</Text>
</TouchableOpacity>
</View>
<RatingSheet
visible={ratingOpen}
rideId={pending.ride_id}
audience="rider"
subjectName={pending.driver_name}
subjectAvatar={pending.driver_avatar}
onDone={() => {
setRatingOpen(false);
setDismissed((prev) => [...prev, pending.ride_id]);
void load();
}}
onSkip={() => {
setRatingOpen(false);
setDismissed((prev) => [...prev, pending.ride_id]);
}}
/>
</>
);
}
return null;
};
+100
View File
@@ -0,0 +1,100 @@
import { router } from "expo-router";
import { useEffect, useRef } from "react";
import { fetchAPI } from "@/lib/fetch";
import type { CallRecord, ChatActiveRide } from "@/types/type";
// Listens for an incoming WebRTC call (a 'ringing' call row this user did not
// place) and routes the user to the call screen — regardless of which tab is
// open. Rendered once at the root layout level; emits no UI.
//
// It only polls while an active ride exists (the only window in which a call
// can happen). To avoid re-navigating on every poll, it remembers the call id
// it already handed off to the call screen and resets once that call goes
// terminal.
const ACTIVE_POLL_MS = 5000;
const CALL_POLL_MS = 3000;
const CallWatcher = () => {
// The ride we're watching for an incoming call on.
const rideIdRef = useRef<number | null>(null);
// The call id we've already navigated to, so we don't re-push the screen.
const handledCallIdRef = useRef<number | null>(null);
useEffect(() => {
let cancelled = false;
// Refresh which ride (if any) is active for this user, then poll its call
// row. Both run on intervals; the call poll no-ops until a rideId is known.
const activeTimer = setInterval(async () => {
try {
const res = await fetchAPI("/(api)/chat/active");
const active = (res.data ?? null) as ChatActiveRide | null;
if (cancelled) return;
rideIdRef.current = active?.ride_id ?? null;
} catch (err) {
console.log("[CALL_WATCHER_ACTIVE]: ", err);
}
}, ACTIVE_POLL_MS);
const callTimer = setInterval(async () => {
const rideId = rideIdRef.current;
if (rideId === null) return;
try {
const res = await fetchAPI(`/(api)/ride/${rideId}/call`);
const call = (res.data ?? null) as CallRecord | null;
if (cancelled || !call) return;
// A terminal call clears the handled marker so the next incoming call
// can navigate again.
if (
call.status === "ended" ||
call.status === "declined" ||
call.status === "missed"
) {
if (handledCallIdRef.current === call.id) {
handledCallIdRef.current = null;
}
return;
}
// An incoming ringing call we didn't place: hand off to the call
// screen, once per call id.
if (call.status === "ringing" && !call.is_caller) {
if (handledCallIdRef.current === call.id) return;
handledCallIdRef.current = call.id;
router.push({
pathname: "/(root)/call",
params: { rideId: String(rideId), mode: "incoming" },
});
}
} catch (err) {
console.log("[CALL_WATCHER_CALL]: ", err);
}
}, CALL_POLL_MS);
// Kick the active poll immediately so an incoming call on a freshly
// matched ride is noticed without waiting for the first interval.
void (async () => {
try {
const res = await fetchAPI("/(api)/chat/active");
if (cancelled) return;
rideIdRef.current =
((res.data ?? null) as ChatActiveRide | null)?.ride_id ?? null;
} catch {
// ignore — the interval will retry
}
})();
return () => {
cancelled = true;
clearInterval(activeTimer);
clearInterval(callTimer);
};
}, []);
return null;
};
export default CallWatcher;
+106
View File
@@ -0,0 +1,106 @@
import { useState } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import ReactNativeModal from "react-native-modal";
import { useT } from "@/lib/i18n";
// Cancelling asks *why* before it asks "are you sure". The reason codes are
// fixed (lib/ride-lifecycle CANCELLATION_REASONS) rather than free text, so
// the admin portal can count them — "driver never showed" and "I changed my
// mind" are the same cancellation in the ledger otherwise, and only one of
// them is a problem worth chasing.
const RIDER_REASONS = [
"wait_too_long",
"driver_no_show",
"unreachable",
"wrong_address",
"changed_mind",
"other",
] as const;
const DRIVER_REASONS = [
"rider_no_show",
"unreachable",
"wrong_address",
"vehicle_issue",
"other",
] as const;
type Props = {
visible: boolean;
audience: "rider" | "driver";
submitting?: boolean;
onCancel: () => void;
onConfirm: (reason: string) => void;
};
export const CancelSheet = ({
visible,
audience,
submitting,
onCancel,
onConfirm,
}: Props) => {
const t = useT();
const [reason, setReason] = useState<string | null>(null);
const reasons = audience === "rider" ? RIDER_REASONS : DRIVER_REASONS;
return (
<ReactNativeModal isVisible={visible} onBackdropPress={onCancel}>
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
<Text className="text-xl font-JakartaBold text-black dark:text-white">
{t("cancelSheet.title")}
</Text>
<Text className="text-sm text-general-200 dark:text-neutral-400 mt-1 mb-4">
{audience === "rider"
? t("cancelSheet.subtitleRider")
: t("cancelSheet.subtitleDriver")}
</Text>
{reasons.map((code) => {
const selected = reason === code;
return (
<TouchableOpacity
key={code}
onPress={() => setReason(code)}
className={`rounded-2xl border px-4 py-3 mb-2 ${
selected
? "border-primary-500 bg-primary-500/10"
: "border-neutral-200 dark:border-neutral-800"
}`}
>
<Text
className={`font-JakartaMedium ${
selected ? "text-primary-500" : "text-black dark:text-white"
}`}
>
{t(`cancelSheet.reasons.${code}`)}
</Text>
</TouchableOpacity>
);
})}
<TouchableOpacity
onPress={() => reason && onConfirm(reason)}
disabled={!reason || submitting}
className={`rounded-full py-3 items-center mt-3 bg-rose-500 ${
!reason || submitting ? "opacity-50" : ""
}`}
>
<Text className="font-JakartaBold text-white">
{submitting
? t("cancelSheet.cancelling")
: t("cancelSheet.confirm")}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={onCancel} className="py-3 mt-1">
<Text className="text-center font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("cancelSheet.keepRide")}
</Text>
</TouchableOpacity>
</View>
</ReactNativeModal>
);
};
+278
View File
@@ -0,0 +1,278 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router, useFocusEffect } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
FlatList,
Image,
Keyboard,
Pressable,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import {
SafeAreaView,
useSafeAreaInsets,
} from "react-native-safe-area-context";
import { images } from "@/constants";
import { driverPhotoUri } from "@/lib/driver-photo";
import { fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { ensureMicPermission } from "@/lib/use-call";
import { useChat } from "@/lib/use-chat";
import { useTheme } from "@/lib/theme";
import type { ChatActiveRide, Message } from "@/types/type";
const initials = (name: string): string => {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
};
type ChatThreadProps = {
/**
* Extra clearance (px) the composer needs below the safe area nonzero
* when this screen sits under the rider's floating tab bar (position:
* "absolute", ~78px tall + 20px margin), which doesn't reserve layout
* space of its own and would otherwise sit on top of the composer. Pass 0
* for a standalone screen (no tab bar underneath, e.g. the driver's).
*/
tabBarClearance?: number;
};
// Ride-scoped chat thread: header with the peer + call button, message list,
// and composer. Shared by the rider's (tabs) Chat screen and the driver's
// standalone chat screen — both resolve the same conversation via
// GET /(api)/chat/active, which returns the correct peer for either role.
export const ChatThread = ({ tabBarClearance = 0 }: ChatThreadProps) => {
const t = useT();
const { isDark } = useTheme();
const insets = useSafeAreaInsets();
const [active, setActive] = useState<ChatActiveRide | null>(null);
const [resolving, setResolving] = useState(true);
// Resolve which conversation (if any) is open for the signed-in user. Re-run
// whenever the screen is focused so a just-matched ride appears immediately.
useFocusEffect(
useCallback(() => {
let cancelled = false;
(async () => {
setResolving(true);
try {
const res = await fetchAPI("/(api)/chat/active");
if (!cancelled) setActive((res.data ?? null) as ChatActiveRide);
} catch (err) {
console.log("[CHAT_ACTIVE]: ", err);
if (!cancelled) setActive(null);
} finally {
if (!cancelled) setResolving(false);
}
})();
return () => {
cancelled = true;
};
}, []),
);
const rideId = active?.ride_id ?? null;
const role = active?.role ?? null;
const { messages, loading, sending, sendMessage } = useChat(rideId, role);
const [draft, setDraft] = useState("");
const peer = active?.peer ?? null;
const peerName = peer?.name ?? "";
// Prime the mic permission as soon as a conversation (and its Call button)
// is on screen, so the OS prompt lands here — not mid-handshake after the
// user has already tapped Call and navigated to the call screen.
const hasPeer = Boolean(peer);
useEffect(() => {
if (hasPeer) void ensureMicPermission();
}, [hasPeer]);
const openCall = useCallback(() => {
if (!active) return;
router.push({
pathname: "/(root)/call",
params: {
rideId: String(active.ride_id),
role: active.role,
mode: "start",
},
});
}, [active]);
const submit = useCallback(() => {
const text = draft.trim();
if (!text || sending) return;
setDraft("");
void sendMessage(text);
Keyboard.dismiss();
}, [draft, sending, sendMessage]);
const renderBubble = useCallback(
({ item }: { item: Message }) => {
const mine = item.sender_type === role;
return (
<View
className={`flex-row ${mine ? "justify-end" : "justify-start"} my-1`}
>
<View
className={`max-w-[78%] rounded-2xl px-4 py-2.5 ${
mine ? "bg-general-400" : "bg-neutral-100 dark:bg-neutral-800"
}`}
>
<Text
className={`text-[15px] ${
mine ? "text-white" : "text-black dark:text-white"
}`}
>
{item.body}
</Text>
</View>
</View>
);
},
[role],
);
const emptyConversation = useMemo(
() => (
<View className="flex-1 h-fit flex justify-center items-center">
<Image
source={images.message}
alt={t("chat.messageAlt")}
className="w-full h-40"
resizeMode="contain"
/>
<Text className="text-3xl font-JakartaBold mt-3 text-black dark:text-white">
{t("chat.noMessages")}
</Text>
<Text className="text-base mt-2 text-center px-7 text-general-200 dark:text-neutral-400">
{t("chat.startConversation")}
</Text>
</View>
),
[t],
);
if (resolving) {
return (
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
<ActivityIndicator size="large" color={isDark ? "#fff" : "#0286ff"} />
</SafeAreaView>
);
}
return (
<SafeAreaView
className="flex-1 bg-white dark:bg-neutral-950"
edges={["top"]}
>
{/* Conversation header — only when a ride is matched */}
{active && peer ? (
<View className="flex-row items-center px-4 py-3 border-b border-neutral-100 dark:border-neutral-800">
<Pressable
onPress={() =>
router.push({
pathname: "/(root)/book-ride",
params: { id: String(active.ride_id) },
})
}
className="flex-row items-center flex-1"
>
{peer.avatar ? (
<Image
source={{ uri: driverPhotoUri(peer.avatar) }}
className="w-10 h-10 rounded-full bg-neutral-200 dark:bg-neutral-700"
resizeMode="cover"
/>
) : (
<View className="w-10 h-10 rounded-full bg-general-400 items-center justify-center">
<Text className="text-white font-JakartaBold">
{initials(peerName)}
</Text>
</View>
)}
<View className="ml-3">
<Text className="text-base font-JakartaBold text-black dark:text-white">
{peerName}
</Text>
{peer.car_model ? (
<Text className="text-xs text-general-200 dark:text-neutral-400">
{peer.car_model}
</Text>
) : null}
</View>
</Pressable>
<TouchableOpacity
onPress={openCall}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
accessibilityLabel={t("chat.call")}
className="w-10 h-10 rounded-full bg-general-300 dark:bg-neutral-800 items-center justify-center"
>
<MaterialCommunityIcons name="phone" size={20} color="white" />
</TouchableOpacity>
</View>
) : null}
{active && peer ? (
<>
{loading && messages.length === 0 ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator
size="small"
color={isDark ? "#fff" : "#0286ff"}
/>
</View>
) : (
<FlatList
data={messages}
keyExtractor={(m) => String(m.id)}
renderItem={renderBubble}
contentContainerStyle={{
flexGrow: 1,
paddingHorizontal: 16,
paddingVertical: 12,
}}
onScrollBeginDrag={Keyboard.dismiss}
keyboardShouldPersistTaps="never"
ListEmptyComponent={emptyConversation}
/>
)}
{/* Composer */}
<View
className="flex-row items-center px-3 py-2 border-t border-neutral-100 dark:border-neutral-800"
style={{ paddingBottom: insets.bottom + 8 + tabBarClearance }}
>
<TextInput
value={draft}
onChangeText={setDraft}
placeholder={t("chat.inputPlaceholder")}
placeholderTextColor={isDark ? "#737373" : "#9ca3af"}
className="flex-1 min-h-[44px] max-h-28 rounded-full bg-neutral-100 dark:bg-neutral-800 px-4 py-2.5 text-[15px] text-black dark:text-white"
multiline
/>
<TouchableOpacity
onPress={submit}
disabled={sending || !draft.trim()}
accessibilityLabel={t("chat.send")}
className="w-11 h-11 ml-2 rounded-full bg-general-400 items-center justify-center disabled:opacity-40"
>
<MaterialCommunityIcons name="send" size={20} color="white" />
</TouchableOpacity>
</View>
</>
) : (
<View className="flex-1 px-5">{emptyConversation}</View>
)}
</SafeAreaView>
);
};
+7 -2
View File
@@ -40,9 +40,14 @@ export const CustomButton = ({
iconLeft: IconLeft,
iconRight: IconRight,
className,
// Which touchable the button is built on. React Native's own works
// everywhere except inside a @gorhom/bottom-sheet on Android, where the
// sheet's gesture handler eats the first press — the button only fires on
// the second tap. Screens hosted in a sheet pass the sheet's touchable.
Touchable = TouchableOpacity,
...props
}: ButtonProps) => (
<TouchableOpacity
<Touchable
onPress={onPress}
className={`w-full rounded-full p-3 flex flex-row justify-center items-center shadow-md shadow-neutral-400/70 dark:shadow-neutral-950/70 ${getBgVariantStyle(bgVariant)} ${className}`}
{...props}
@@ -54,5 +59,5 @@ export const CustomButton = ({
</Text>
{IconRight && <IconRight />}
</TouchableOpacity>
</Touchable>
);
+320
View File
@@ -0,0 +1,320 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import type * as ImagePicker from "expo-image-picker";
import { useState } from "react";
import {
ActivityIndicator,
Alert,
Image,
Text,
TouchableOpacity,
View,
} from "react-native";
import { alertPermissionDenied } from "@/lib/capture-permission";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { loadImagePicker } from "@/lib/image-picker";
import { useT } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
/** The three documents a Lebanese driver is vetted against. */
export type DocumentType = "license" | "id" | "vehicle_reg";
/**
* What a scan can fill in. Every field is optional and independent: a licence
* whose number reads cleanly but whose expiry is smudged yields just the
* number. Mirrors ExtractedFields on the server deliberately redeclared here
* so the client bundle doesn't pull in lib/document-ocr.ts, which is Node-only.
*/
export type ScannedFields = {
license_number?: string;
license_expiry?: string;
national_id?: string;
plate_number?: string;
car_model?: string;
};
type ScanResponse = {
data: {
doc_type: DocumentType;
document: string;
fields: ScannedFields;
code?: string;
};
};
/**
* Photographs one document, sends it for OCR, and reports back both the stored
* scan's name (which goes with the profile submission) and whatever fields
* were read off it.
*
* The component never writes to the form itself it hands the values up, and
* the form decides what to do with them. That separation is what lets a driver
* correct a misread field and not have the next scan silently stamp over it.
* A failed read is not an error state here: the scan is still stored for the
* reviewer, and the driver types the details in by hand as before.
*/
export const DocumentScanner = ({
docType,
label,
hint,
optional = false,
onFile = false,
onScanned,
}: {
docType: DocumentType;
label: string;
hint: string;
optional?: boolean;
/** A scan of this document is already stored — resubmitting may not need a new one. */
onFile?: boolean;
onScanned: (document: string, fields: ScannedFields) => void;
}) => {
const t = useT();
const { isDark } = useTheme();
const [preview, setPreview] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const upload = async (asset: ImagePicker.ImagePickerAsset) => {
if (!asset.base64) {
Alert.alert(t("driver.scan.errorTitle"), t("driver.scan.errorBody"));
return;
}
setBusy(true);
setStatus(null);
setFailed(false);
try {
const { data } = (await fetchAPI("/(api)/driver/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ doc_type: docType, image_base64: asset.base64 }),
})) as ScanResponse;
setPreview(asset.uri);
onScanned(data.document, data.fields);
const filled = Object.values(data.fields).filter(Boolean).length;
// Three outcomes worth telling apart: OCR read something, OCR ran and
// found nothing usable, or OCR never ran. All three keep the scan; only
// the wording changes, because in every case the driver's next move is
// to check the fields below.
setStatus(
filled > 0
? t("driver.scan.filled", undefined, filled)
: data.code === "OCR_UNAVAILABLE"
? t("driver.scan.savedUnreadable")
: t("driver.scan.savedNoFields"),
);
} catch (err) {
console.log("[DOCUMENT_SCAN]: ", err);
const code =
err instanceof ApiError
? (err.body?.code as string | undefined)
: undefined;
Alert.alert(
t("driver.scan.errorTitle"),
code === "IMAGE_TOO_LARGE"
? t("driver.scan.errorTooLarge")
: code === "SCAN_RATE_LIMIT"
? t("driver.scan.errorRateLimit")
: code === "UNSUPPORTED_IMAGE"
? t("driver.scan.errorUnsupported")
: t("driver.scan.errorBody"),
);
setFailed(true);
} finally {
setBusy(false);
}
};
const capture = async (source: "camera" | "library") => {
if (busy) return;
// Loaded on demand: on a binary built before expo-image-picker was added
// the native module is missing, and importing it at the top of this file
// would take the whole app down instead of just this button.
const picker = loadImagePicker();
if (!picker) {
Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable"));
return;
}
// Ask only for the permission the tapped button actually needs — a driver
// who refuses the camera can still pick an existing photo of their papers.
let permission: ImagePicker.PermissionResponse;
try {
permission =
source === "camera"
? await picker.requestCameraPermissionsAsync()
: await picker.requestMediaLibraryPermissionsAsync();
} catch (error) {
console.log("[DOCUMENT_SCAN_PERMISSION]: ", error);
Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable"));
return;
}
if (!permission.granted) {
alertPermissionDenied(permission, {
title: t("driver.scan.permissionTitle"),
message:
source === "camera"
? t("driver.scan.permissionCamera")
: t("driver.scan.permissionLibrary"),
blocked:
source === "camera"
? t("driver.scan.permissionCameraBlocked")
: t("driver.scan.permissionLibraryBlocked"),
openSettings: t("common.openSettings"),
cancel: t("common.cancel"),
});
return;
}
// `quality: 0.6` keeps a phone photo comfortably under the upload cap
// while staying sharp enough to read small print; no cropping step,
// because OCR wants the whole card and an edited crop routinely loses the
// line the expiry date sits on.
const options: ImagePicker.ImagePickerOptions = {
mediaTypes: picker.MediaTypeOptions.Images,
quality: 0.6,
base64: true,
exif: false,
};
let result: ImagePicker.ImagePickerResult;
try {
result =
source === "camera"
? await picker.launchCameraAsync(options)
: await picker.launchImageLibraryAsync(options);
} catch (error) {
console.log("[DOCUMENT_SCAN_CAPTURE]: ", error);
Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable"));
return;
}
if (result.canceled || !result.assets[0]) return;
await upload(result.assets[0]);
};
const scanned = preview !== null;
return (
<View className="bg-neutral-100 dark:bg-neutral-900 rounded-2xl p-4 mb-4">
<View className="flex-row items-start justify-between mb-1">
<Text className="text-sm font-JakartaBold text-black dark:text-white flex-1 pr-2">
{label}
</Text>
{optional ? (
<Text className="text-[11px] font-JakartaSemiBold text-general-200 dark:text-neutral-500 uppercase">
{t("driver.scan.optional")}
</Text>
) : null}
</View>
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mb-3">
{hint}
</Text>
<View className="flex-row items-center">
{scanned ? (
<Image
source={{ uri: preview }}
className="w-16 h-16 rounded-xl mr-3"
resizeMode="cover"
alt={label}
/>
) : null}
<View className="flex-1 flex-row gap-2">
<TouchableOpacity
onPress={() => void capture("camera")}
disabled={busy}
className="flex-1 flex-row items-center justify-center rounded-full bg-primary-500 py-3 px-2"
>
{busy ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<>
<MaterialCommunityIcons
name="camera-outline"
size={16}
color="#ffffff"
/>
<Text className="text-white font-JakartaBold text-xs ml-1.5">
{scanned ? t("driver.scan.retake") : t("driver.scan.take")}
</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => void capture("library")}
disabled={busy}
className="flex-1 flex-row items-center justify-center rounded-full border border-neutral-300 dark:border-neutral-700 py-3 px-2"
>
<MaterialCommunityIcons
name="image-outline"
size={16}
color={isDark ? "#e5e5e5" : "#333333"}
/>
<Text className="text-black dark:text-white font-JakartaBold text-xs ml-1.5">
{t("driver.scan.choose")}
</Text>
</TouchableOpacity>
</View>
</View>
{busy ? (
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mt-3">
{t("driver.scan.reading")}
</Text>
) : status ? (
<View className="flex-row items-center mt-3">
<MaterialCommunityIcons
name="check-circle-outline"
size={14}
color="#10b981"
/>
<Text className="text-xs font-JakartaSemiBold text-emerald-600 dark:text-emerald-400 ml-1.5 flex-1">
{status}
</Text>
</View>
) : failed ? (
<View className="flex-row items-center mt-3">
<MaterialCommunityIcons
name="alert-outline"
size={14}
color="#f43f5e"
/>
<Text className="text-xs font-JakartaSemiBold text-rose-500 ml-1.5 flex-1">
{t("driver.scan.errorRetry")}
</Text>
</View>
) : onFile ? (
// Resubmitting after a rejection: the reviewer already has a scan, so
// say so rather than making the driver wonder whether it was lost.
<View className="flex-row items-center mt-3">
<MaterialCommunityIcons
name="paperclip"
size={14}
color={isDark ? "#9ca3af" : "#858585"}
/>
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 ml-1.5 flex-1">
{t("driver.scan.alreadyOnFile")}
</Text>
</View>
) : null}
</View>
);
};
+15 -4
View File
@@ -1,6 +1,7 @@
import { Image, Text, TouchableOpacity, View } from "react-native";
import { icons } from "@/constants";
import { driverPhotoUri } from "@/lib/driver-photo";
import { tr } from "@/lib/i18n";
import { formatTime } from "@/lib/utils";
import { DriverCardProps } from "@/types/type";
@@ -20,7 +21,7 @@ export const DriverCard = ({
} flex flex-row items-center justify-between py-5 px-3 rounded-xl`}
>
<Image
source={{ uri: item.profile_image_url }}
source={{ uri: driverPhotoUri(item.profile_image_url) }}
alt={tr("components.driverCard.avatarAlt")}
className="w-14 h-14 rounded-full"
/>
@@ -32,14 +33,24 @@ export const DriverCard = ({
</Text>
<View className="flex flex-row items-center space-x-1 ml-2">
<Image source={icons.star} alt={tr("components.driverCard.starAlt")} className="w-3.5 h-3.5" />
<Text className="text-sm font-JakartaRegular text-black dark:text-white">{item.rating}</Text>
<Image
source={icons.star}
alt={tr("components.driverCard.starAlt")}
className="w-3.5 h-3.5"
/>
<Text className="text-sm font-JakartaRegular text-black dark:text-white">
{item.rating}
</Text>
</View>
</View>
<View className="flex flex-row items-center justify-start">
<View className="flex flex-row items-center">
<Image source={icons.dollar} alt={tr("components.driverCard.dollarAlt")} className="w-4 h-4" />
<Image
source={icons.dollar}
alt={tr("components.driverCard.dollarAlt")}
className="w-4 h-4"
/>
<Text className="text-sm font-JakartaRegular ml-1 text-black dark:text-white">
${item.price}
</Text>
+21 -16
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import {
FlatList,
Image,
Keyboard,
Text,
TextInput,
TouchableOpacity,
@@ -104,6 +104,10 @@ export const GoogleTextInput = ({
const onSelect = async (suggestion: Suggestion) => {
setQuery(suggestion.text);
setSuggestions([]);
// The search is over the moment a place is picked. Left open, the keyboard
// covers whatever the next tap was meant to be — and inside a bottom sheet
// it holds the sheet in its extended state on top of it.
Keyboard.dismiss();
try {
const details = await fetchPlaceDetails(suggestion.placeId);
@@ -147,6 +151,11 @@ export const GoogleTextInput = ({
/>
</View>
{/* Rendered as plain rows, not a FlatList. Places never returns more
than a handful of predictions, so there is nothing to virtualise
and a list that scrolls inside the home feed (or inside the ride
sheet) fights its parent for the gesture and swallows taps meant
for a suggestion. */}
{suggestions.length > 0 && (
<View
className="rounded-xl mt-1"
@@ -155,21 +164,17 @@ export const GoogleTextInput = ({
shadowColor: inputShadow,
}}
>
<FlatList
data={suggestions}
keyExtractor={(item) => item.placeId}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => onSelect(item)}
className="p-3 border-b border-general-700 dark:border-neutral-700"
>
<Text className="text-base font-JakartaRegular text-black dark:text-white">
{item.text}
</Text>
</TouchableOpacity>
)}
/>
{suggestions.map((item) => (
<TouchableOpacity
key={item.placeId}
onPress={() => onSelect(item)}
className="p-3 border-b border-general-700 dark:border-neutral-700"
>
<Text className="text-base font-JakartaRegular text-black dark:text-white">
{item.text}
</Text>
</TouchableOpacity>
))}
</View>
)}
</View>
+310 -27
View File
@@ -1,10 +1,16 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useEffect, useRef, useState } from "react";
import { Platform, StyleSheet } from "react-native";
import MapView, { Marker, PROVIDER_DEFAULT } from "react-native-maps";
import { Platform, StyleSheet, View } from "react-native";
import MapView, {
AnimatedRegion,
Marker,
MarkerAnimated,
PROVIDER_DEFAULT,
} from "react-native-maps";
import MapViewDirections from "react-native-maps-directions";
import { icons } from "@/constants";
import { useFetch } from "@/lib/fetch";
import { SERVICES } from "@/constants/services";
import { tr } from "@/lib/i18n";
import {
calculateDriverTimes,
@@ -12,15 +18,186 @@ import {
generateMarkersFromData,
} from "@/lib/map";
import { useTheme } from "@/lib/theme";
import { useNearbyDrivers } from "@/lib/use-nearby-drivers";
import { useDriverStore, useLocationStore, useServiceStore } from "@/store";
import type { Driver, MarkerData } from "@/types/type";
import type { 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 },
markerBubble: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#111827",
borderWidth: 2,
borderColor: "#ffffff",
// A flat dot on a light map is hard to pick out; a soft shadow lifts it.
shadowColor: "#000",
shadowOpacity: 0.3,
shadowRadius: 3,
shadowOffset: { width: 0, height: 1 },
elevation: 4,
},
markerBubbleSelected: {
backgroundColor: "#0286ff",
},
// Wraps bubble + arrow so the arrow can orbit the bubble by rotating the
// whole frame, while the vehicle glyph inside stays upright and readable.
markerFrame: {
width: 54,
height: 54,
alignItems: "center",
justifyContent: "center",
},
headingArrow: {
position: "absolute",
top: 0,
},
});
// How long a marker takes to slide to its new position.
//
// Deliberately the poll interval, not less: each update is the car's position
// as of that moment, so spreading the movement across the whole gap until the
// next one is what makes a series of samples read as continuous travel. A
// shorter duration would animate quickly and then sit frozen, which looks
// worse than not animating at all.
const MARKER_GLIDE_MS = 5000;
// Below this the GPS heading is mostly noise — a stationary phone reports
// wildly varying directions — so the arrow is hidden and the car is simply
// drawn as parked.
const MOVING_KPH = 5;
// A driver pin drawn as the vehicle they actually drive.
//
// Every driver used to get the same car marker, so a moto rider watching a
// motorbike approach saw a car on their map — and the four services were
// indistinguishable at a glance. The glyphs come from the same SERVICES table
// the service picker uses, so a pin and its tile always agree.
const glyphFor = (service?: string | null) =>
(SERVICES.find((s) => s.id === service) ?? SERVICES[0]).icon;
const ServiceMarker = ({
marker,
selected,
}: {
marker: MarkerData;
selected: boolean;
}) => {
// Android renders a custom marker view by snapshotting it, and a snapshot
// taken before layout is blank. Track changes briefly so the first real
// frame is captured, then stop — leaving it on re-snapshots every marker on
// every frame, which makes a map full of drivers crawl.
const [tracksViewChanges, setTracksViewChanges] = useState(true);
const heading = marker.heading ?? null;
const moving = (marker.speed_kph ?? 0) >= MOVING_KPH;
const showArrow = moving && heading !== null;
// The marker's own coordinate, animated rather than assigned.
//
// Positions arrive every few seconds; setting them directly teleports each
// car across the gap it covered since the last update. Holding the
// coordinate in an AnimatedRegion and easing to each new fix turns the same
// samples into visible travel — which is the whole point of showing other
// drivers at all.
const coordinate = useRef(
new AnimatedRegion({
latitude: marker.latitude,
longitude: marker.longitude,
latitudeDelta: 0,
longitudeDelta: 0,
}),
).current;
useEffect(() => {
// `timing` is not on the public typings for AnimatedRegion in this
// version, though it exists at runtime; the cast keeps the call honest
// without loosening the rest of the component.
(
coordinate as unknown as {
timing: (config: Record<string, unknown>) => {
start: () => void;
};
}
)
.timing({
latitude: marker.latitude,
longitude: marker.longitude,
latitudeDelta: 0,
longitudeDelta: 0,
duration: MARKER_GLIDE_MS,
// AnimatedRegion drives a native prop that the native driver can't
// handle, so this animation runs on the JS thread by necessity.
useNativeDriver: false,
})
.start();
}, [coordinate, marker.latitude, marker.longitude]);
useEffect(() => {
setTracksViewChanges(true);
const timer = setTimeout(() => setTracksViewChanges(false), 800);
return () => clearTimeout(timer);
}, [selected, marker.service, showArrow, heading]);
// react-native-maps accepts an AnimatedRegion here at runtime — it is what
// every animated-marker example passes — but types the prop as an animated
// LatLng, so the two don't line up. Cast at the boundary rather than
// loosening the component's own types.
const animatedCoordinate = coordinate as unknown as React.ComponentProps<
typeof MarkerAnimated
>["coordinate"];
return (
<MarkerAnimated
coordinate={animatedCoordinate}
title={marker.title}
anchor={{ x: 0.5, y: 0.5 }}
tracksViewChanges={tracksViewChanges}
>
<View style={styles.markerFrame}>
{/* Rotating the frame swings the arrow around the bubble to point the
way the car is travelling, while the bubble itself and the
vehicle glyph in it stays upright and legible. */}
{showArrow ? (
<View
style={[
StyleSheet.absoluteFill,
{ transform: [{ rotate: `${heading}deg` }] },
styles.markerFrame,
]}
>
<MaterialCommunityIcons
name="navigation"
size={14}
color={selected ? "#0286ff" : "#111827"}
style={styles.headingArrow}
/>
</View>
) : null}
<View
style={[
styles.markerBubble,
selected ? styles.markerBubbleSelected : null,
]}
>
<MaterialCommunityIcons
name={glyphFor(marker.service)}
size={18}
color="#ffffff"
/>
</View>
</View>
</MarkerAnimated>
);
};
// "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.
@@ -41,7 +218,52 @@ const MUTED_POI_STYLE = [
},
];
export const Map = () => {
// The single driver assigned to a ride, as returned by GET /ride/:id. Used to
// show the rider a live marker for the driver who accepted, instead of the
// generic "nearby drivers of this service" search list.
type TrackedDriver = {
id: number;
latitude: number | null;
longitude: number | null;
first_name?: string | null;
last_name?: string | null;
profile_image_url?: string | null;
car_image_url?: string | null;
car_seats?: number | null;
rating?: number | null;
car_model?: string | null;
// Drives the pin glyph, so the rider watching their assigned driver arrive
// sees a motorbike when a motorbike is coming.
service?: string | null;
};
type LatLng = { latitude: number; longitude: number };
export type MapProps = {
trackedDriver?: TrackedDriver | null;
/**
* Show position and nearby drivers only never a route line, and never zoom
* out to fit a destination.
*
* The home map answers "where am I and what's around me". Drawing the
* destination there meant a rider who had merely searched an address, or
* finished a trip earlier, kept seeing a route to it every time they opened
* the app.
*/
routeless?: boolean;
// Driver view: override the store-derived origin/destination so the map
// centers on the driver's own live position and pins the rider's pickup,
// without touching the rider-facing location store.
originOverride?: LatLng | null;
destinationOverride?: (LatLng & { label?: string }) | null;
};
export const Map = ({
trackedDriver,
originOverride,
destinationOverride,
routeless = false,
}: MapProps = {}) => {
const {
userLatitude,
userLongitude,
@@ -52,24 +274,47 @@ export const Map = () => {
const { selectedDriver, setDrivers } = useDriverStore();
const { isDark } = useTheme();
const trackingMode =
Boolean(trackedDriver) ||
originOverride !== undefined ||
destinationOverride !== undefined;
// 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.
//
// The search starts tight around the rider and widens in 5 km steps only
// when it finds nobody, so a rider on a busy street sees the cars actually
// near them rather than every car in the country.
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 { drivers } = useNearbyDrivers(service, lat, lng);
const [markers, setMarkers] = useState<MarkerData[]>([]);
const mapRef = useRef<MapView>(null);
const region = calculateRegion({
userLatitude,
userLongitude,
destinationLatitude,
destinationLongitude,
});
// Region: in tracking mode, center on the driver's own position (or the
// pickup point if that isn't resolved yet) instead of the rider's location
// store, which tracking mode never touches.
const region = trackingMode
? calculateRegion({
userLatitude:
originOverride?.latitude ?? destinationOverride?.latitude ?? null,
userLongitude:
originOverride?.longitude ?? destinationOverride?.longitude ?? null,
destinationLatitude: originOverride
? (destinationOverride?.latitude ?? null)
: null,
destinationLongitude: originOverride
? (destinationOverride?.longitude ?? null)
: null,
})
: calculateRegion({
userLatitude,
userLongitude,
destinationLatitude: routeless ? null : destinationLatitude,
destinationLongitude: routeless ? null : destinationLongitude,
});
// `initialRegion` is read once, at mount. The map mounts before the location
// fix arrives, so it would sit on the Beirut fallback forever and never zoom
@@ -89,6 +334,36 @@ export const Map = () => {
}, [regionKey]);
useEffect(() => {
if (trackedDriver) {
setMarkers(
trackedDriver.latitude != null && trackedDriver.longitude != null
? [
{
id: trackedDriver.id,
latitude: trackedDriver.latitude,
longitude: trackedDriver.longitude,
title:
`${trackedDriver.first_name ?? ""} ${trackedDriver.last_name ?? ""}`.trim(),
profile_image_url: trackedDriver.profile_image_url ?? "",
car_image_url: trackedDriver.car_image_url ?? "",
car_seats: trackedDriver.car_seats ?? 0,
rating: trackedDriver.rating ?? 0,
first_name: trackedDriver.first_name ?? "",
last_name: trackedDriver.last_name ?? "",
car_model: trackedDriver.car_model ?? null,
service: trackedDriver.service ?? undefined,
},
]
: [],
);
return;
}
if (trackingMode) {
setMarkers([]);
return;
}
if (Array.isArray(drivers)) {
if (!userLatitude || !userLongitude) return;
@@ -101,9 +376,10 @@ export const Map = () => {
setMarkers(newMarkers);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [drivers, userLatitude, userLongitude]);
}, [trackedDriver, trackingMode, drivers, userLatitude, userLongitude]);
useEffect(() => {
if (trackingMode) return;
if (markers.length > 0 && destinationLatitude && destinationLongitude) {
calculateDriverTimes({
markers,
@@ -117,6 +393,7 @@ export const Map = () => {
});
}
}, [
trackingMode,
markers,
destinationLatitude,
destinationLongitude,
@@ -130,7 +407,6 @@ export const Map = () => {
// 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
@@ -146,20 +422,26 @@ export const Map = () => {
userInterfaceStyle={isDark ? "dark" : "light"}
>
{markers.map((marker) => (
<Marker
<ServiceMarker
key={marker.id}
coordinate={{
latitude: marker.latitude,
longitude: marker.longitude,
}}
title={marker.title}
image={
selectedDriver === marker.id ? icons.selectedMarker : icons.marker
}
marker={marker}
selected={Boolean(trackedDriver) || selectedDriver === marker.id}
/>
))}
{userLatitude &&
{destinationOverride ? (
<Marker
key="pickup"
coordinate={{
latitude: destinationOverride.latitude,
longitude: destinationOverride.longitude,
}}
title={destinationOverride.label ?? tr("components.map.destination")}
image={icons.pin}
/>
) : (
!routeless &&
userLatitude &&
userLongitude &&
destinationLatitude &&
destinationLongitude && (
@@ -188,7 +470,8 @@ export const Map = () => {
strokeWidth={3}
/>
</>
)}
)
)}
</MapView>
);
};
+2 -1
View File
@@ -1,10 +1,11 @@
import { Text, View } from "react-native";
import { useT } from "@/lib/i18n";
import type { MapProps } from "@/components/map";
// react-native-maps does not support web. This stub keeps the web bundle
// working for local testing; use a native build for real map functionality.
export const Map = () => {
export const Map = (_props: MapProps = {}) => {
const t = useT();
return (
+177
View File
@@ -0,0 +1,177 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import {
ActivityIndicator,
Image,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SERVICES } from "@/constants/services";
import { driverPhotoUri } from "@/lib/driver-photo";
import { useT } from "@/lib/i18n";
import type { RideOffer } from "@/types/type";
// The drivers who have volunteered for a request, and the rider's choice
// between them.
//
// Dispatch broadcasts the job and this is what comes back: several drivers,
// none of them assigned, each waiting to be picked. So every row has to carry
// what a person actually decides on — how far away they are, how they're
// rated, what they drive — and picking one has to be a single deliberate tap,
// because that tap is what commits the rider and releases everyone else.
// Rough road-speed assumption for turning a straight-line distance into
// minutes. A per-offer Directions call would be more accurate and would also
// mean one billed request per driver per poll; this is honest to within a
// couple of minutes in city traffic, which is the precision a rider comparing
// three drivers is actually using.
const URBAN_KMH = 22;
// Streets aren't straight. Multiplying the great-circle distance gets closer
// to the distance a car really drives.
const ROAD_FACTOR = 1.3;
const etaMinutes = (meters: number | null): number | null => {
if (meters === null || !Number.isFinite(meters)) return null;
return Math.max(
1,
Math.round(((meters * ROAD_FACTOR) / 1000 / URBAN_KMH) * 60),
);
};
const distanceLabel = (meters: number | null): string | null => {
if (meters === null || !Number.isFinite(meters)) return null;
return meters < 1000
? `${Math.round(meters / 50) * 50} m`
: `${(meters / 1000).toFixed(1)} km`;
};
type Props = {
offers: RideOffer[];
/** Offer currently being taken, so only that row shows a spinner. */
pendingOfferId: number | null;
busy: boolean;
onPick: (offer: RideOffer) => void;
};
export const OfferList = ({ offers, pendingOfferId, busy, onPick }: Props) => {
const t = useT();
// What the rider is getting into. A driver who never filled in their car
// model would otherwise leave the vehicle line blank on the one screen where
// the rider is choosing between cars, so the service they drive for stands
// in — "Car · 4 seats" is thin, but it isn't nothing.
const vehicle = (offer: RideOffer): string => {
const service = SERVICES.find((s) => s.id === offer.service);
const label = offer.car_model ?? (service ? t(service.labelKey) : null);
const seats = offer.car_seats
? t("bookRide.offers.seats", undefined, offer.car_seats)
: null;
return [label, seats].filter(Boolean).join(" · ");
};
return (
<View className="mt-2">
<View className="flex-row items-center justify-between mb-2">
<Text className="text-base font-JakartaBold text-black dark:text-white">
{t("bookRide.offers.title")}
</Text>
<Text className="text-xs text-general-200 dark:text-neutral-400">
{t("bookRide.offers.count", undefined, offers.length)}
</Text>
</View>
{offers.map((offer) => {
const name = [offer.first_name, offer.last_name]
.filter(Boolean)
.join(" ");
const distance = offer.pickup_distance_m ?? null;
const eta = etaMinutes(distance);
const taking = pendingOfferId === offer.offer_id;
// The face the rider is choosing between. This is the screen the
// driver's photo exists for, so it leads the row.
const photo = driverPhotoUri(offer.profile_image_url);
return (
<View
key={offer.offer_id}
className="bg-white dark:bg-neutral-900 rounded-2xl p-3 mb-2 flex-row items-center"
>
{photo ? (
<Image
source={{ uri: photo }}
className="w-12 h-12 rounded-full"
/>
) : (
<View className="w-12 h-12 rounded-full bg-neutral-200 dark:bg-neutral-800 items-center justify-center">
<MaterialCommunityIcons
name="account"
size={22}
color="#9ca3af"
/>
</View>
)}
<View className="ml-3 flex-1">
<Text
className="font-JakartaSemiBold text-black dark:text-white"
numberOfLines={1}
>
{name || t("bookRide.match.driverFallback")}
</Text>
<View className="flex-row items-center gap-x-2 mt-0.5">
<View className="flex-row items-center gap-x-1">
<MaterialCommunityIcons
name="star"
size={13}
color="#f59e0b"
/>
<Text className="text-xs text-general-200 dark:text-neutral-400">
{offer.rating != null
? Number(offer.rating).toFixed(1)
: t("bookRide.ratingFallback")}
</Text>
</View>
{vehicle(offer) ? (
<Text
className="text-xs text-general-200 dark:text-neutral-400 flex-1"
numberOfLines={1}
>
{vehicle(offer)}
</Text>
) : null}
</View>
{eta !== null ? (
<Text className="text-xs font-JakartaMedium text-primary-500 mt-0.5">
{t("bookRide.offers.away", {
eta,
distance: distanceLabel(distance) ?? "",
})}
</Text>
) : null}
</View>
<TouchableOpacity
onPress={() => onPick(offer)}
disabled={busy}
className={`rounded-full px-5 py-2.5 ml-2 ${
busy && !taking ? "bg-emerald-500/40" : "bg-emerald-500"
}`}
>
{taking ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text className="text-white font-JakartaBold text-xs">
{t("bookRide.offers.pick")}
</Text>
)}
</TouchableOpacity>
</View>
);
})}
</View>
);
};
+108
View File
@@ -0,0 +1,108 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { Text, TouchableOpacity, View } from "react-native";
import ReactNativeModal from "react-native-modal";
import { useT } from "@/lib/i18n";
// How the rider pays, asked at the moment it becomes a real question: after
// they have chosen a driver, not before they know one exists.
//
// The card path opens the gateway's hosted page and can take the better part
// of a minute, during which the driver they picked could be taken by someone
// else — so the sheet says what happens either way rather than dropping the
// rider into a browser with no warning.
type Props = {
visible: boolean;
driverName: string | null;
fareCents: number;
submitting: boolean;
onPay: (method: "cash" | "card") => void;
onCancel: () => void;
};
export const PaymentChoiceSheet = ({
visible,
driverName,
fareCents,
submitting,
onPay,
onCancel,
}: Props) => {
const t = useT();
const fare = (fareCents / 100).toFixed(2);
return (
<ReactNativeModal
isVisible={visible}
onBackdropPress={submitting ? undefined : onCancel}
>
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-5">
<Text className="text-lg font-JakartaBold text-black dark:text-white">
{driverName
? t("bookRide.payment.titleNamed", { name: driverName })
: t("bookRide.payment.title")}
</Text>
<Text className="text-sm text-general-200 dark:text-neutral-400 mt-1">
{t("bookRide.payment.subtitle", { fare })}
</Text>
<TouchableOpacity
onPress={() => onPay("cash")}
disabled={submitting}
className="flex-row items-center gap-x-3 rounded-2xl border border-neutral-200 dark:border-neutral-700 px-4 py-4 mt-4"
>
<MaterialCommunityIcons name="cash" size={22} color="#10b981" />
<View className="flex-1">
<Text className="font-JakartaBold text-black dark:text-white">
{t("bookRide.payment.cash")}
</Text>
<Text className="text-xs text-general-200 dark:text-neutral-400">
{t("bookRide.payment.cashHint")}
</Text>
</View>
<MaterialCommunityIcons
name="chevron-right"
size={20}
color="#9ca3af"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onPay("card")}
disabled={submitting}
className="flex-row items-center gap-x-3 rounded-2xl border border-neutral-200 dark:border-neutral-700 px-4 py-4 mt-2"
>
<MaterialCommunityIcons
name="credit-card-outline"
size={22}
color="#0286ff"
/>
<View className="flex-1">
<Text className="font-JakartaBold text-black dark:text-white">
{t("bookRide.payment.card")}
</Text>
<Text className="text-xs text-general-200 dark:text-neutral-400">
{t("bookRide.payment.cardHint")}
</Text>
</View>
<MaterialCommunityIcons
name="chevron-right"
size={20}
color="#9ca3af"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onCancel}
disabled={submitting}
className="items-center py-3 mt-2"
>
<Text className="font-JakartaBold text-general-200 dark:text-neutral-400">
{submitting ? t("bookRide.payment.working") : t("common.cancel")}
</Text>
</TouchableOpacity>
</View>
</ReactNativeModal>
);
};
+81
View File
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Text, TextInput, TouchableOpacity, View } from "react-native";
import ReactNativeModal from "react-native-modal";
import { CustomButton } from "@/components/custom-button";
import { useT } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
// The driver's half of the pickup handshake: they ask the rider for the code
// on the rider's screen and type it here to start the trip. The code is never
// sent to the driver's device, so a wrong entry is a real mismatch — either
// the wrong passenger got in, or the driver is at the wrong car.
type Props = {
visible: boolean;
submitting?: boolean;
/** Set when the server rejected the last attempt. */
error?: string | null;
onCancel: () => void;
onSubmit: (code: string) => void;
};
export const PickupCodeSheet = ({
visible,
submitting,
error,
onCancel,
onSubmit,
}: Props) => {
const t = useT();
const { isDark } = useTheme();
const [code, setCode] = useState("");
return (
<ReactNativeModal
isVisible={visible}
onBackdropPress={onCancel}
avoidKeyboard
>
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
<Text className="text-xl font-JakartaBold text-center text-black dark:text-white">
{t("pickupCode.title")}
</Text>
<Text className="text-sm text-general-200 dark:text-neutral-400 text-center mt-1">
{t("pickupCode.subtitle")}
</Text>
<TextInput
value={code}
onChangeText={(v) => setCode(v.replace(/\D/g, "").slice(0, 4))}
keyboardType="number-pad"
maxLength={4}
autoFocus
placeholder="0000"
placeholderTextColor={isDark ? "#525252" : "#d4d4d4"}
className="bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white rounded-2xl py-4 my-5 text-center text-3xl font-JakartaExtraBold tracking-[10px]"
/>
{error ? (
<Text className="text-rose-500 text-sm text-center mb-3">
{error}
</Text>
) : null}
<CustomButton
title={submitting ? "…" : t("pickupCode.startTrip")}
bgVariant="success"
onPress={() => onSubmit(code)}
disabled={code.length < 4 || submitting}
className={code.length < 4 ? "opacity-50" : ""}
/>
<TouchableOpacity onPress={onCancel} className="py-3 mt-1">
<Text className="text-center font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("common.cancel")}
</Text>
</TouchableOpacity>
</View>
</ReactNativeModal>
);
};
+99
View File
@@ -0,0 +1,99 @@
import { useRef } from "react";
import { Image, StyleSheet, View } from "react-native";
import MapView, { PROVIDER_DEFAULT, type Region } from "react-native-maps";
import { icons } from "@/constants";
import { useTheme } from "@/lib/theme";
// Fine-tuning a pickup or drop-off point.
//
// The pin does NOT move — the map moves under it. Dragging a marker means
// fighting for a few pixels with the same thumb that pans the map, and on a
// phone the marker spends most of the gesture hidden under the finger holding
// it. Anchoring the pin to the centre of the screen and sliding the map
// underneath makes the target the one thing always visible, which is why every
// ride-hailing app converged on it.
//
// The component is deliberately dumb: it reports the centre when the map
// settles and nothing else. Reverse geocoding, debouncing and confirmation all
// live on the screen, so this stays reusable for the origin and the
// destination alike.
export type PinAdjusterProps = {
initial: { latitude: number; longitude: number };
/** Fired when the map stops moving, with the coordinate under the pin. */
onSettled: (coords: { latitude: number; longitude: number }) => void;
/** Fired as soon as a drag starts, to clear a now-stale address label. */
onMoveStart?: () => void;
};
// Tight enough that the rider is choosing a doorway, not a district.
const ZOOM_DELTA = 0.004;
const styles = StyleSheet.create({
map: StyleSheet.absoluteFillObject,
// Sits above the map and ignores touches, so panning still reaches the map.
pinLayer: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
},
pin: {
width: 36,
height: 36,
// The pin's point is at its bottom edge, but the coordinate we report is
// the centre of the screen — so lift it by its own height to put the tip,
// not the middle of the graphic, on the spot being chosen.
marginBottom: 36,
},
// A small ground marker under the tip: without it, on a busy map, it is
// genuinely hard to tell which pixel the pin is pointing at.
dot: {
position: "absolute",
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: "rgba(2,134,255,0.9)",
borderWidth: 1,
borderColor: "#ffffff",
},
});
export const PinAdjuster = ({
initial,
onSettled,
onMoveStart,
}: PinAdjusterProps) => {
const { isDark } = useTheme();
const mapRef = useRef<MapView>(null);
const region: Region = {
latitude: initial.latitude,
longitude: initial.longitude,
latitudeDelta: ZOOM_DELTA,
longitudeDelta: ZOOM_DELTA,
};
return (
<View style={StyleSheet.absoluteFill}>
<MapView
ref={mapRef}
provider={PROVIDER_DEFAULT}
style={styles.map}
initialRegion={region}
showsUserLocation
showsMyLocationButton={false}
userInterfaceStyle={isDark ? "dark" : "light"}
onPanDrag={onMoveStart}
onRegionChangeComplete={(next) =>
onSettled({ latitude: next.latitude, longitude: next.longitude })
}
/>
<View style={styles.pinLayer} pointerEvents="none">
<Image source={icons.pin} style={styles.pin} resizeMode="contain" />
<View style={styles.dot} />
</View>
</View>
);
};
+20
View File
@@ -0,0 +1,20 @@
import { Text, View } from "react-native";
import { useT } from "@/lib/i18n";
import type { PinAdjusterProps } from "@/components/pin-adjuster";
// react-native-maps does not support web, same as components/map.web.tsx.
// The screen around this still works — the rider just can't drag a pin — so
// the stub reports nothing and leaves whatever coordinate they arrived with
// intact, rather than blocking the flow on a platform used only for testing.
export const PinAdjuster = (_props: PinAdjusterProps) => {
const t = useT();
return (
<View className="flex-1 items-center justify-center bg-general-100 dark:bg-neutral-900">
<Text className="text-general-200 dark:text-neutral-400 text-center font-JakartaMedium px-8">
{t("components.map.webUnavailable")}
</Text>
</View>
);
};
+199
View File
@@ -0,0 +1,199 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import type * as ImagePicker from "expo-image-picker";
import { useState } from "react";
import {
ActivityIndicator,
Alert,
Image,
Text,
TouchableOpacity,
View,
} from "react-native";
import { alertPermissionDenied } from "@/lib/capture-permission";
import { driverPhotoUri } from "@/lib/driver-photo";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { loadImagePicker } from "@/lib/image-picker";
import { useT } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
type PhotoResponse = { data: { photo: string; attached: boolean } };
/**
* The driver's own photo the one a rider sees against their name in the list
* of offers, and checks the arriving driver against.
*
* Deliberately not the document scanner: this photo is never read by OCR, it
* is cropped square because it is rendered in a circle everywhere, and it
* opens the front camera because it is a picture of a person rather than a
* piece of paper.
*
* Uploading attaches it immediately for a driver who already has a profile, so
* replacing a bad photo is one tap. During onboarding there is no profile row
* yet, so the caller keeps the returned name and sends it with the submission.
*/
export const ProfilePhotoPicker = ({
current,
onUploaded,
}: {
/** The photo already on the profile, if any. */
current?: string | null;
onUploaded: (photo: string) => void;
}) => {
const t = useT();
const { isDark } = useTheme();
const [preview, setPreview] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// A just-taken photo wins over what the server has, so the driver sees the
// result of their own tap rather than the picture it replaced.
const shown = preview ?? driverPhotoUri(current) ?? null;
const upload = async (asset: ImagePicker.ImagePickerAsset) => {
if (!asset.base64) {
Alert.alert(t("driver.photo.errorTitle"), t("driver.photo.errorBody"));
return;
}
setBusy(true);
try {
const { data } = (await fetchAPI("/(api)/driver/photo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_base64: asset.base64 }),
})) as PhotoResponse;
setPreview(asset.uri);
onUploaded(data.photo);
} catch (err) {
console.log("[DRIVER_PHOTO]: ", err);
const code =
err instanceof ApiError
? (err.body?.code as string | undefined)
: undefined;
Alert.alert(
t("driver.photo.errorTitle"),
code === "IMAGE_TOO_LARGE"
? t("driver.photo.errorTooLarge")
: code === "PHOTO_RATE_LIMIT"
? t("driver.photo.errorRateLimit")
: code === "UNSUPPORTED_IMAGE"
? t("driver.photo.errorUnsupported")
: t("driver.photo.errorBody"),
);
} finally {
setBusy(false);
}
};
// Camera only — deliberately no gallery option.
//
// This photo is the rider's check that the person who pulled up is the
// person the app sent them, so it has to be a picture of whoever is holding
// the phone right now. Letting it come from the gallery would let a driver
// register with someone else's face, or a photo of a photo, and nothing
// downstream could tell the difference. It is not proof of identity — a
// determined faker can point the camera at a printout — but it removes the
// effortless version of that, and it keeps the photo current.
const capture = async () => {
if (busy) return;
// Loaded on demand — see lib/image-picker. On a binary built before
// expo-image-picker was added this is the difference between one button
// not working and the app not starting.
const picker = loadImagePicker();
if (!picker) {
Alert.alert(t("driver.photo.errorTitle"), t("driver.captureUnavailable"));
return;
}
// Everything that touches the picker is wrapped: the availability check
// above should make a missing native module impossible, but a driver must
// never be shown a raw "Cannot find native module" either way.
let result: ImagePicker.ImagePickerResult;
try {
const permission = await picker.requestCameraPermissionsAsync();
if (!permission.granted) {
alertPermissionDenied(permission, {
title: t("driver.photo.permissionTitle"),
message: t("driver.photo.permissionCamera"),
blocked: t("driver.photo.permissionCameraBlocked"),
openSettings: t("common.openSettings"),
cancel: t("common.cancel"),
});
return;
}
// No crop step: one tap, done. Every surface renders this in a circle
// with a centre crop anyway, and a selfie is already centred on the face.
result = await picker.launchCameraAsync({
mediaTypes: picker.MediaTypeOptions.Images,
quality: 0.7,
base64: true,
exif: false,
cameraType: picker.CameraType.front,
});
} catch (error) {
console.log("[DRIVER_PHOTO_CAMERA]: ", error);
Alert.alert(t("driver.photo.errorTitle"), t("driver.captureUnavailable"));
return;
}
if (result.canceled || !result.assets[0]) return;
await upload(result.assets[0]);
};
return (
<View className="items-center mb-6">
<TouchableOpacity
onPress={() => void capture()}
disabled={busy}
className="w-28 h-28 rounded-full bg-neutral-100 dark:bg-neutral-900 items-center justify-center overflow-hidden border-2 border-primary-500"
>
{busy ? (
<ActivityIndicator color="#0286ff" />
) : shown ? (
<Image
source={{ uri: shown }}
className="w-28 h-28"
resizeMode="cover"
/>
) : (
<MaterialCommunityIcons
name="camera-plus-outline"
size={30}
color={isDark ? "#9ca3af" : "#858585"}
/>
)}
</TouchableOpacity>
<Text className="text-sm font-JakartaBold text-black dark:text-white mt-3">
{t("driver.photo.title")}
</Text>
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 text-center mt-1 px-6">
{t("driver.photo.hint")}
</Text>
<TouchableOpacity
onPress={() => void capture()}
disabled={busy}
className="flex-row items-center rounded-full bg-primary-500 py-2.5 px-5 mt-3"
>
<MaterialCommunityIcons
name="camera-outline"
size={15}
color="#ffffff"
/>
<Text className="text-white font-JakartaBold text-xs ml-1.5">
{shown ? t("driver.photo.retake") : t("driver.photo.take")}
</Text>
</TouchableOpacity>
</View>
);
};
+140
View File
@@ -0,0 +1,140 @@
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useState } from "react";
import { Image, Text, TextInput, TouchableOpacity, View } from "react-native";
import ReactNativeModal from "react-native-modal";
import { CustomButton } from "@/components/custom-button";
import { driverPhotoUri } from "@/lib/driver-photo";
import { fetchAPI } from "@/lib/fetch";
import { useT } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
// The post-trip rating prompt, shared by both apps: a rider rates their driver
// and a driver rates their rider through the same endpoint, which infers who
// is rating from the caller's role on the ride. Both sides get the same sheet
// so the two directions can't drift apart.
type Props = {
visible: boolean;
rideId: number;
/** Who is being rated — only used for the copy. */
subjectName?: string | null;
subjectAvatar?: string | null;
/** Rider-facing copy differs from driver-facing copy. */
audience: "rider" | "driver";
onDone: () => void;
/** Called on "not now"; omit to make the rating unskippable. */
onSkip?: () => void;
};
export const RatingSheet = ({
visible,
rideId,
subjectName,
subjectAvatar,
audience,
onDone,
onSkip,
}: Props) => {
const t = useT();
const { isDark } = useTheme();
const [stars, setStars] = useState(0);
const [comment, setComment] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (stars < 1) return;
setSubmitting(true);
setError(null);
try {
await fetchAPI(`/(api)/ride/${rideId}/rate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rating: stars,
comment: comment.trim() || null,
}),
});
onDone();
} catch (err) {
console.log("[RATE_RIDE]: ", err);
setError(t("rating.error"));
} finally {
setSubmitting(false);
}
};
return (
<ReactNativeModal isVisible={visible} onBackdropPress={onSkip}>
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
{subjectAvatar ? (
<Image
source={{ uri: driverPhotoUri(subjectAvatar) }}
className="w-16 h-16 rounded-full self-center mb-3"
/>
) : null}
<Text className="text-xl font-JakartaBold text-center text-black dark:text-white">
{audience === "rider"
? t("rating.rateDriverTitle", { name: subjectName ?? "" })
: t("rating.rateRiderTitle", { name: subjectName ?? "" })}
</Text>
<Text className="text-sm text-general-200 dark:text-neutral-400 text-center mt-1">
{t("rating.subtitle")}
</Text>
<View className="flex-row justify-center gap-x-2 my-5">
{[1, 2, 3, 4, 5].map((value) => (
<TouchableOpacity
key={value}
onPress={() => setStars(value)}
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
accessibilityLabel={t("rating.starLabel", { n: value })}
>
<MaterialCommunityIcons
name={value <= stars ? "star" : "star-outline"}
size={38}
color={
value <= stars ? "#f5b301" : isDark ? "#525252" : "#d4d4d4"
}
/>
</TouchableOpacity>
))}
</View>
<TextInput
value={comment}
onChangeText={setComment}
placeholder={t("rating.commentPlaceholder")}
placeholderTextColor={isDark ? "#737373" : "#858585"}
multiline
maxLength={500}
className="bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white rounded-2xl px-4 py-3 font-Jakarta text-[15px] min-h-[72px]"
textAlignVertical="top"
/>
{error ? (
<Text className="text-rose-500 text-sm text-center mt-3">
{error}
</Text>
) : null}
<CustomButton
title={submitting ? t("common.saving") : t("rating.submit")}
onPress={submit}
disabled={submitting || stars < 1}
className={`mt-5 ${stars < 1 ? "opacity-50" : ""}`}
/>
{onSkip ? (
<TouchableOpacity onPress={onSkip} className="py-3 mt-1">
<Text className="text-center font-JakartaMedium text-general-200 dark:text-neutral-400">
{t("rating.notNow")}
</Text>
</TouchableOpacity>
) : null}
</View>
</ReactNativeModal>
);
};
+94 -7
View File
@@ -5,6 +5,29 @@ import { tr } from "@/lib/i18n";
import { formatDate, formatTime } from "@/lib/utils";
import type { Ride } from "@/types/type";
// How a finished ride ended. The history list used to render every ride
// identically — a cancelled trip showed the same driver, the same fare and, on
// a card ride, the same green "Paid by card" as one that actually happened, so
// a rider scrolling their history saw cancellations as completed journeys.
// The outcome is now the first thing on the card.
const OUTCOME = {
completed: {
labelKey: "components.rideCard.outcomeCompleted",
text: "text-emerald-600 dark:text-emerald-400",
chip: "bg-emerald-500/10",
},
cancelled: {
labelKey: "components.rideCard.outcomeCancelled",
text: "text-rose-500",
chip: "bg-rose-500/10",
},
expired: {
labelKey: "components.rideCard.outcomeExpired",
text: "text-amber-600 dark:text-amber-400",
chip: "bg-amber-500/10",
},
} as const;
export const RideCard = ({ ride }: { ride: Ride }) => {
const {
destination_latitude,
@@ -15,11 +38,54 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
ride_time,
driver,
payment_status,
status,
cancelled_by,
cancellation_reason,
} = ride;
const outcome = OUTCOME[status as keyof typeof OUTCOME] ?? null;
const didNotHappen = status === "cancelled" || status === "expired";
// A cancelled or expired ride never had a driver assigned in most cases, and
// the LEFT JOIN hands back an object of nulls — which rendered as an empty
// gap where a name should be.
const driverName = [driver?.first_name, driver?.last_name]
.filter(Boolean)
.join(" ");
return (
<View className="flex flex-row items-center justify-center bg-white dark:bg-neutral-900 rounded-lg shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40 mb-3">
<View className="flex flex-col items-center justify-center p-3">
{outcome ? (
<View className="flex flex-row items-center justify-between w-full mb-3">
<View className={`rounded-full px-3 py-1 ${outcome.chip}`}>
<Text className={`text-xs font-JakartaBold ${outcome.text}`}>
{tr(outcome.labelKey)}
</Text>
</View>
{/* Who ended it, and why the two things a rider looking back at
a cancelled trip actually wants to know. */}
{didNotHappen && cancelled_by ? (
<Text
className="text-[11px] font-JakartaMedium text-gray-500 dark:text-neutral-400 flex-1 text-right ml-2"
numberOfLines={1}
>
{cancelled_by === "system"
? tr("components.rideCard.cancelledBySystem")
: tr(
cancelled_by === "driver"
? "components.rideCard.cancelledByDriver"
: "components.rideCard.cancelledByYou",
)}
{cancellation_reason
? ` · ${tr(`cancelSheet.reasons.${cancellation_reason}`)}`
: ""}
</Text>
) : null}
</View>
) : null}
<View className="flex flex-row items-center justify-between">
<Image
source={{
@@ -69,7 +135,7 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
</Text>
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
{driver.first_name} {driver.last_name}
{driverName || tr("components.rideCard.noDriver")}
</Text>
</View>
@@ -98,14 +164,35 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
{tr("components.rideCard.paymentStatus")}
</Text>
{/* A ride that never happened has no payment worth reporting as
successful. A cash ride simply wasn't collected; a card ride
that was charged before cancellation is called out as owed a
refund rather than shown as a cheerful green "Paid". */}
<Text
className={`font-JakartaMedium capitalize text-xs ${payment_status === "paid" ? "text-emerald-500 dark:text-emerald-400" : "text-gray-500 dark:text-neutral-400"}`}
className={`font-JakartaMedium capitalize text-xs ${
didNotHappen
? payment_status === "paid"
? "text-amber-600 dark:text-amber-400"
: "text-gray-500 dark:text-neutral-400"
: payment_status === "paid" ||
payment_status === "cash_collected"
? "text-emerald-500 dark:text-emerald-400"
: "text-gray-500 dark:text-neutral-400"
}`}
>
{payment_status === "cash"
? tr("components.rideCard.paymentCash")
: payment_status === "paid"
? tr("components.rideCard.paymentPaid")
: tr("components.rideCard.paymentOther", { status: payment_status })}
{didNotHappen
? payment_status === "paid"
? tr("components.rideCard.paymentRefundDue")
: tr("components.rideCard.paymentNotCharged")
: payment_status === "cash"
? tr("components.rideCard.paymentCash")
: payment_status === "cash_collected"
? tr("components.rideCard.paymentCashCollected")
: payment_status === "paid"
? tr("components.rideCard.paymentPaid")
: tr("components.rideCard.paymentOther", {
status: payment_status,
})}
</Text>
</View>
</View>
+14 -5
View File
@@ -1,8 +1,9 @@
import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
import BottomSheet, { BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { router } from "expo-router";
import { useRef, type PropsWithChildren } from "react";
import { Image, Text, TouchableOpacity, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { icons } from "@/constants";
import { tr } from "@/lib/i18n";
@@ -22,6 +23,7 @@ export const RideLayout = ({
}: PropsWithChildren<RideLayoutProps>) => {
const bottomSheetRef = useRef<BottomSheet>(null);
const { isDark } = useTheme();
const insets = useSafeAreaInsets();
return (
<GestureHandlerRootView>
@@ -57,14 +59,21 @@ export const RideLayout = ({
backgroundColor: isDark ? "#525252" : "#d4d4d4",
}}
>
<BottomSheetView
style={{
flex: 1,
{/* Scrollable rather than a plain view: the keyboard takes half the
screen while the rider is typing an address, and everything below
the field it covers the fare, "Find now" was simply out of
reach until they dismissed it. The bottom inset keeps the button
clear of the Android gesture bar. */}
<BottomSheetScrollView
style={{ flex: 1 }}
contentContainerStyle={{
padding: 20,
paddingBottom: 20 + insets.bottom,
}}
keyboardShouldPersistTaps="handled"
>
{children}
</BottomSheetView>
</BottomSheetScrollView>
</BottomSheet>
</View>
</GestureHandlerRootView>
+31 -1
View File
@@ -3,7 +3,8 @@ import { Text, TouchableOpacity, View } from "react-native";
import { SERVICES } from "@/constants/services";
import { useT } from "@/lib/i18n";
import { useServiceStore } from "@/store";
import { useServiceAvailability } from "@/lib/use-service-availability";
import { useLocationStore, useServiceStore } from "@/store";
/**
* Service picker: Car / Moto / Courier / My Car.
@@ -12,11 +13,23 @@ import { useServiceStore } from "@/store";
* services, anything off-screen is a service riders won't discover. Selection
* styling matches the role picker on sign-up so the two read as the same
* control.
*
* Each tile also carries live availability. The map only ever draws the
* selected service, so picking one with nobody on it produced an empty map and
* no explanation the rider couldn't tell "no drivers tonight" from "no motos,
* but four cars are around the corner". Showing the count on the tile makes
* that visible before they choose, instead of after they've given up.
*/
export const ServiceSelector = () => {
const { service, setService } = useServiceStore();
const { userLatitude, userLongitude } = useLocationStore();
const t = useT();
const { counts, loading } = useServiceAvailability(
userLatitude,
userLongitude,
);
const selected = SERVICES.find((item) => item.id === service);
return (
@@ -52,6 +65,23 @@ export const ServiceSelector = () => {
>
{t(item.labelKey)}
</Text>
{/* Availability. Hidden until the first count lands so the tiles
don't flash "none nearby" while the request is still out. */}
<Text
numberOfLines={1}
className={`mt-0.5 text-[10px] font-JakartaMedium ${
loading
? "text-transparent"
: counts[item.id] > 0
? "text-emerald-600 dark:text-emerald-400"
: "text-general-200 dark:text-neutral-500"
}`}
>
{counts[item.id] > 0
? t("services.nearbyCount", { n: counts[item.id] })
: t("services.noneNearby")}
</Text>
</TouchableOpacity>
);
})}
+9 -2
View File
@@ -5,7 +5,7 @@ import { useTheme } from "@/lib/theme";
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
type RightKind = "chevron" | "switch" | "value" | "none";
type RightKind = "chevron" | "switch" | "value" | "check" | "none";
type SettingsRowProps = {
icon: IconName;
@@ -14,6 +14,8 @@ type SettingsRowProps = {
right?: RightKind;
/** For `right: "value"` — the string shown on the trailing side. */
value?: string;
/** For `right: "check"` — shows a blue check when true, nothing when false. */
selected?: boolean;
/** For `right: "switch"`. */
switchValue?: boolean;
onSwitchChange?: (value: boolean) => void;
@@ -29,13 +31,14 @@ export const SettingsRow = ({
subtitle,
right = "none",
value,
selected,
switchValue,
onSwitchChange,
onPress,
danger = false,
}: SettingsRowProps) => {
const { isDark } = useTheme();
const interactive = right === "chevron" || right === "value";
const interactive = right === "chevron" || right === "value" || right === "check";
const content = (
<View className="flex-row items-center py-3.5">
@@ -83,6 +86,10 @@ export const SettingsRow = ({
</Text>
) : null}
{right === "check" && selected ? (
<MaterialCommunityIcons name="check" size={22} color="#0286ff" />
) : null}
{right === "chevron" ? (
<MaterialCommunityIcons
name="chevron-right"
+48
View File
@@ -0,0 +1,48 @@
// Dispatch timings and distances shared by the server engine (lib/dispatch.ts)
// and both clients. They live here rather than in lib/dispatch.ts because that
// module imports the database driver and can't be pulled into the bundle.
/**
* How long a request stays open for drivers to offer on before it gives up.
*
* This is the number both sides watch: the rider sees it as "we're still
* looking", the driver as how long they have to decide before the job is off
* the board. Long enough that a driver finishing a drop-off can still take it,
* short enough that a rider standing on a corner at 4am gets an answer instead
* of a spinner.
*/
export const REQUEST_TTL_SECONDS = 150;
/**
* How far a request is broadcast from the pickup point.
*
* A request is shown to every eligible driver inside this radius rather than
* to the nearest one at a time the rider picks from whoever volunteers, so
* dispatch's job is to put the job in front of enough people to give them a
* real choice. Matches the default radius riders see drivers over on the map,
* so a rider who can see a car can be offered by that car.
*/
export const BROADCAST_RADIUS_M = 8000;
/**
* Android notification channel for incoming ride requests. Created on the
* client with max importance, sound and vibration, and named here so the
* server sends to the same channel the client registered a mismatch
* silently downgrades the notification to the default channel and it stops
* making noise.
*/
export const OFFER_CHANNEL_ID = "ride-offers";
/**
* How old a driver's last position ping may be before they're treated as gone,
* whatever their `online` flag says. Every rider-facing query and the dispatch
* broadcast share this, so a driver can never be visible on the map but
* unreachable by a request, or vice versa.
*
* The heartbeat fires every 5s, so this is ~24 missed beats of slack. That
* sounds generous until you watch a real phone: Android throttles JS timers
* hard once the app leaves the foreground, and gaps of 20-30s were measured on
* a device that was awake and on screen. At 60s those gaps flickered drivers
* in and out of every rider's map.
*/
export const DRIVER_STALE_SECONDS = 120;
+7
View File
@@ -130,6 +130,13 @@ tr:last-child td {
.badge.paid { color: var(--success); border-color: var(--success); }
.badge.unpaid { color: var(--danger); border-color: var(--danger); }
/* Driver vetting states. Pending has to catch the eye it is a queue someone
has to work through while approved stays quiet, being the resting state. */
.badge.pending { color: #f5a524; border-color: #f5a524; }
.badge.approved { color: var(--success); border-color: var(--success); }
.badge.rejected { color: var(--danger); border-color: var(--danger); }
.badge.suspended { color: var(--danger); border-color: var(--danger); }
button,
select,
input {
+26
View File
@@ -48,3 +48,29 @@ export const api = async <T>(
return body as T;
};
/**
* Fetch a binary response a driver's document scan as an object URL.
*
* Scans are served from an authenticated route, and an `<img src>` cannot
* carry the bearer token, so the bytes are fetched here and handed to the
* image as a blob URL instead. The caller owns the returned URL and must
* revokeObjectURL it, or the blob is pinned in memory for the tab's life.
*/
export const apiObjectUrl = async (path: string): Promise<string> => {
const headers = new Headers();
if (authToken) headers.set("Authorization", `Bearer ${authToken}`);
const res = await fetch(`${API_URL}${path}`, { headers });
if (res.status === 401) {
clearToken();
throw new ApiError("Session expired. Please sign in again.", 401);
}
if (!res.ok) {
throw new ApiError(`Could not load document (${res.status})`, res.status);
}
return URL.createObjectURL(await res.blob());
};
+573 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { api } from "../lib/api";
import { api, apiObjectUrl } from "../lib/api";
type Driver = {
id: number;
@@ -14,8 +14,33 @@ type Driver = {
car_model: string | null;
total_rides: number;
revenue: number;
approval_status: string;
rejection_reason: string | null;
submitted_at: string | null;
// What the driver typed at onboarding, usually read off the scans below.
license_number: string | null;
license_expiry: string | null;
national_id: string | null;
plate_number: string | null;
// Stored scan names, served through /driver/documents/:name.
license_image_url: string | null;
id_image_url: string | null;
vehicle_reg_image_url: string | null;
};
// What each driver still owes the company, and what the company still owes
// them. Loaded alongside the driver list so an operator can reconcile a shift
// without leaving the page.
type Balance = {
driver_id: number;
name: string;
owes_company_cents: number;
owed_to_driver_cents: number;
unsettled_rides: number;
};
const money = (cents: number) => (cents / 100).toFixed(2);
const EMPTY = {
first_name: "",
last_name: "",
@@ -29,11 +54,31 @@ export default function Drivers() {
const [drivers, setDrivers] = useState<Driver[]>([]);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<Driver | "new" | null>(null);
const [balances, setBalances] = useState<Record<number, Balance>>({});
// Vetting a driver against their scans. Separate from the edit form: this is
// a decision about whether someone may carry passengers, not a field update.
const [reviewing, setReviewing] = useState<Driver | null>(null);
// Opening the picker rather than settling outright. A driver handing over
// part of what they owe is normal, and settling the whole balance because
// the button only offered all-or-nothing would put the ledger out of step
// with the cash actually received.
const [settleFor, setSettleFor] = useState<{
driver: Driver;
side: "platform_fee" | "driver_payout";
} | null>(null);
const load = useCallback(async () => {
try {
const res = await api<{ data: Driver[] }>("/admin/drivers");
const [res, ledger] = await Promise.all([
api<{ data: Driver[] }>("/admin/drivers"),
api<{ data: Balance[] }>("/admin/settle"),
]);
setDrivers(res.data);
setBalances(
Object.fromEntries(ledger.data.map((b) => [b.driver_id, b])),
);
setError(null);
} catch (e) {
setError((e as Error).message);
@@ -69,9 +114,12 @@ export default function Drivers() {
<th>Service</th>
<th>Seats</th>
<th>Rating</th>
<th>Vetting</th>
<th>Online</th>
<th>Rides</th>
<th>Revenue</th>
<th>Owes company</th>
<th>Owed to driver</th>
<th></th>
</tr>
</thead>
@@ -87,11 +135,53 @@ export default function Drivers() {
</td>
<td>{d.car_seats}</td>
<td>{d.rating}</td>
<td>
<span className={`badge ${d.approval_status}`}>
{d.approval_status}
</span>
</td>
<td>{d.online ? "● online" : "○ off"}</td>
<td>{d.total_rides}</td>
<td>{d.revenue.toLocaleString()}</td>
<td>
{balances[d.id]?.owes_company_cents ? (
<strong>{money(balances[d.id].owes_company_cents)}</strong>
) : (
<span className="muted"></span>
)}
</td>
<td>
{balances[d.id]?.owed_to_driver_cents ? (
<strong>{money(balances[d.id].owed_to_driver_cents)}</strong>
) : (
<span className="muted"></span>
)}
</td>
<td>
<div className="row-actions">
{balances[d.id]?.owes_company_cents ? (
<button
className="secondary"
onClick={() =>
setSettleFor({ driver: d, side: "platform_fee" })
}
>
Collect
</button>
) : null}
{balances[d.id]?.owed_to_driver_cents ? (
<button
className="secondary"
onClick={() =>
setSettleFor({ driver: d, side: "driver_payout" })
}
>
Pay out
</button>
) : null}
<button className="secondary" onClick={() => setReviewing(d)}>
Review
</button>
<button className="secondary" onClick={() => setEditing(d)}>
Edit
</button>
@@ -105,6 +195,29 @@ export default function Drivers() {
</tbody>
</table>
{settleFor && (
<SettlePicker
driver={settleFor.driver}
side={settleFor.side}
onClose={() => setSettleFor(null)}
onSettled={() => {
setSettleFor(null);
load();
}}
/>
)}
{reviewing && (
<VettingPanel
driver={reviewing}
onClose={() => setReviewing(null)}
onDecided={() => {
setReviewing(null);
load();
}}
/>
)}
{editing && (
<DriverForm
initial={editing === "new" ? null : editing}
@@ -119,6 +232,261 @@ export default function Drivers() {
);
}
// One document scan, fetched with the operator's token and rendered from a
// blob URL — the route is authenticated, so a bare <img src> would 401.
function DocumentScan({ name, label }: { name: string; label: string }) {
const [src, setSrc] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let url: string | null = null;
let cancelled = false;
apiObjectUrl(`/driver/documents?name=${encodeURIComponent(name)}`)
.then((objectUrl) => {
url = objectUrl;
// The panel may have closed while the fetch was in flight; revoke
// rather than setting state on an unmounted component.
if (cancelled) URL.revokeObjectURL(objectUrl);
else setSrc(objectUrl);
})
.catch((e) => {
if (!cancelled) setError((e as Error).message);
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [name]);
return (
<figure style={{ margin: 0 }}>
<figcaption className="muted" style={{ fontSize: 12, marginBottom: 4 }}>
{label}
</figcaption>
{error ? (
<div className="error">{error}</div>
) : src ? (
// Opens full size in a tab: small print on a licence is unreadable at
// thumbnail size, and reading it is the whole point of this panel.
<a href={src} target="_blank" rel="noreferrer">
<img
src={src}
alt={label}
style={{
width: "100%",
maxHeight: 220,
objectFit: "contain",
background: "#00000010",
borderRadius: 8,
}}
/>
</a>
) : (
<div className="muted">Loading</div>
)}
</figure>
);
}
// The driver's profile photo. Unlike a document scan this route is public, so
// the browser can load it straight from a <src> — a stored name is resolved
// through the API, while an external URL an owner typed in is used as-is.
function DriverPhoto({ name }: { name: string }) {
const src = /^https?:/i.test(name)
? name
: `${import.meta.env.VITE_API_URL ?? ""}/driver/photo?name=${encodeURIComponent(name)}`;
return (
<img
src={src}
alt="Driver profile photo"
style={{
width: 72,
height: 72,
borderRadius: "50%",
objectFit: "cover",
background: "#00000010",
}}
/>
);
}
/**
* Check what a driver typed against the documents they photographed, then
* approve or reject.
*
* The scans exist precisely because the typed numbers arrive from OCR and OCR
* is fallible so the two are shown side by side and the decision rests on
* the document, not on the field. Rejecting requires a reason, which is what
* the driver sees in the app and corrects against.
*/
function VettingPanel({
driver,
onClose,
onDecided,
}: {
driver: Driver;
onClose: () => void;
onDecided: () => void;
}) {
const [reason, setReason] = useState(driver.rejection_reason ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const decide = async (
approval_status: "approved" | "rejected" | "suspended",
) => {
if (approval_status !== "approved" && !reason.trim()) {
setError("Give the driver a reason they can act on.");
return;
}
setBusy(true);
setError(null);
try {
await api(`/admin/drivers/${driver.id}`, {
method: "PATCH",
body: JSON.stringify({
approval_status,
rejection_reason:
approval_status === "approved" ? undefined : reason.trim(),
}),
});
onDecided();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const scans: [string | null, string][] = [
[driver.license_image_url, "Driving licence"],
[driver.id_image_url, "ID card"],
[driver.vehicle_reg_image_url, "Vehicle registration"],
];
const present = scans.filter(([name]) => name);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>
Vetting {driver.first_name} {driver.last_name} (#{driver.id})
</h3>
{/* The photo riders will actually see. It is checked here rather than
left to chance because it is the one part of the profile shown to
every passenger before they get into the car. */}
{driver.profile_image_url && (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<DriverPhoto name={driver.profile_image_url} />
<span className="muted" style={{ fontSize: 12 }}>
Shown to riders choosing a driver
</span>
</div>
)}
<p className="muted" style={{ marginTop: 0 }}>
Status: <strong>{driver.approval_status}</strong>
{driver.submitted_at
? ` · submitted ${new Date(driver.submitted_at).toLocaleString()}`
: ""}
</p>
<table>
<tbody>
<tr>
<td>Licence number</td>
<td>
{driver.license_number ?? <span className="muted"></span>}
</td>
</tr>
<tr>
<td>Licence expiry</td>
<td>
{driver.license_expiry ? (
driver.license_expiry.slice(0, 10)
) : (
<span className="muted"></span>
)}
</td>
</tr>
<tr>
<td>National ID</td>
<td>{driver.national_id ?? <span className="muted"></span>}</td>
</tr>
<tr>
<td>Plate</td>
<td>{driver.plate_number ?? <span className="muted"></span>}</td>
</tr>
<tr>
<td>Car</td>
<td>{driver.car_model ?? <span className="muted"></span>}</td>
</tr>
</tbody>
</table>
{present.length > 0 ? (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: 12,
margin: "12px 0",
}}
>
{present.map(([name, label]) => (
<DocumentScan key={name} name={name as string} label={label} />
))}
</div>
) : (
<div className="muted" style={{ margin: "12px 0" }}>
No scans on file this profile predates document capture.
</div>
)}
<input
placeholder="Reason (required to reject or suspend)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
{error && <div className="error">{error}</div>}
<div className="row-actions">
<button disabled={busy} onClick={() => decide("approved")}>
{busy ? "Saving…" : "Approve"}
</button>
<button
type="button"
className="danger"
disabled={busy}
onClick={() => decide("rejected")}
>
Reject
</button>
{driver.approval_status === "approved" && (
<button
type="button"
className="danger"
disabled={busy}
onClick={() => decide("suspended")}
>
Suspend
</button>
)}
<button type="button" className="secondary" onClick={onClose}>
Close
</button>
</div>
</div>
</div>
);
}
function DriverForm({
initial,
onClose,
@@ -206,3 +574,206 @@ function DriverForm({
</div>
);
}
// Pick exactly which rides a payment covers.
//
// Settling is an assertion about the real world — that cash was handed over,
// or a transfer was made — so the operator has to be able to say precisely
// which trips it accounts for. Everything is selected by default, because
// settling the whole balance is still the common case; unticking is the
// exception, not the workflow.
type UnsettledRide = {
ride_id: number;
amount_cents: number;
fare_price: number;
origin_address: string;
destination_address: string;
completed_at: string;
};
function SettlePicker({
driver,
side,
onClose,
onSettled,
}: {
driver: Driver;
side: "platform_fee" | "driver_payout";
onClose: () => void;
onSettled: () => void;
}) {
const [rides, setRides] = useState<UnsettledRide[]>([]);
const [picked, setPicked] = useState<Set<number>>(new Set());
const [note, setNote] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const collecting = side === "platform_fee";
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await api<{ data: { rides: UnsettledRide[] } }>(
`/admin/settle?driver_id=${driver.id}&side=${side}`,
);
if (cancelled) return;
setRides(res.data.rides);
setPicked(new Set(res.data.rides.map((r) => r.ride_id)));
} catch (e) {
if (!cancelled) setError((e as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [driver.id, side]);
const toggle = (rideId: number) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(rideId)) next.delete(rideId);
else next.add(rideId);
return next;
});
const allPicked = rides.length > 0 && picked.size === rides.length;
const total = rides
.filter((r) => picked.has(r.ride_id))
.reduce((sum, r) => sum + r.amount_cents, 0);
const submit = async () => {
if (picked.size === 0) return;
setBusy(true);
try {
await api("/admin/settle", {
method: "POST",
body: JSON.stringify({
side,
// Ride ids, not driver_id: the server settles exactly these and
// leaves the rest of the balance outstanding.
ride_ids: [...picked],
note: note.trim() || undefined,
}),
});
onSettled();
} catch (e) {
setError((e as Error).message);
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>
{collecting ? "Collect commission from" : "Pay out"}{" "}
{driver.first_name} {driver.last_name}
</h3>
<p className="muted" style={{ marginTop: -6 }}>
{collecting
? "Cash rides where this driver still owes the platform fee."
: "Card rides where the platform still owes this driver."}
</p>
{error ? <p className="error">{error}</p> : null}
{loading ? (
<p className="muted">Loading rides</p>
) : rides.length === 0 ? (
<p className="muted">Nothing outstanding.</p>
) : (
<>
<label style={{ display: "block", margin: "8px 0" }}>
<input
type="checkbox"
checked={allPicked}
onChange={() =>
setPicked(
allPicked
? new Set()
: new Set(rides.map((r) => r.ride_id)),
)
}
/>{" "}
Select all ({rides.length})
</label>
<div style={{ maxHeight: 260, overflowY: "auto" }}>
<table>
<thead>
<tr>
<th></th>
<th>Ride</th>
<th>Route</th>
<th>Fare</th>
<th>{collecting ? "Commission" : "Payout"}</th>
</tr>
</thead>
<tbody>
{rides.map((r) => (
<tr key={r.ride_id}>
<td>
<input
type="checkbox"
checked={picked.has(r.ride_id)}
onChange={() => toggle(r.ride_id)}
/>
</td>
<td>#{r.ride_id}</td>
<td style={{ fontSize: 11 }}>
{r.origin_address} {r.destination_address}
<div className="muted">
{new Date(r.completed_at).toLocaleDateString()}
</div>
</td>
<td>{money(r.fare_price)}</td>
<td>
<strong>{money(r.amount_cents)}</strong>
</td>
</tr>
))}
</tbody>
</table>
</div>
<input
placeholder="Reference (transfer id, receipt no., 'cash in office')"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
<p>
<strong>
{picked.size} of {rides.length} ride
{rides.length === 1 ? "" : "s"} · {money(total)}
</strong>
</p>
</>
)}
<div className="row-actions">
<button className="secondary" onClick={onClose} disabled={busy}>
Cancel
</button>
<button
onClick={submit}
disabled={busy || picked.size === 0}
title={
picked.size === 0 ? "Select at least one ride" : undefined
}
>
{busy
? "Recording…"
: collecting
? `Mark ${money(total)} collected`
: `Mark ${money(total)} paid`}
</button>
</div>
</div>
</div>
);
}
+108 -12
View File
@@ -8,9 +8,19 @@ type Ride = {
ride_time: number;
fare_price: number;
payment_status: string;
status: string;
cancelled_by: string | null;
cancellation_reason: string | null;
platform_fee_cents: number | null;
driver_payout_cents: number | null;
commission_rate: string | number | null;
platform_fee_settled_at: string | null;
driver_payout_settled_at: string | null;
created_at: string;
completed_at: string | null;
user_email: string;
driver: { driver_id: number; name: string; rating: number };
// Null for a ride that was cancelled or expired before a driver was matched.
driver: { driver_id: number; name: string; rating: number } | null;
};
type RidesResponse = {
@@ -23,6 +33,21 @@ type RidesResponse = {
const fmt = (n: number) => n.toLocaleString();
// Money is stored in cents; the table shows currency units.
const money = (cents: number | null | undefined) =>
cents == null ? "—" : (cents / 100).toFixed(2);
// A cancelled or expired ride earns nobody anything, so the split columns show
// a dash rather than a zero — "no money changed hands here" and "the fee
// happened to be zero" are different facts.
const happened = (r: Ride) => r.status === "completed";
const STATUS_CLASS: Record<string, string> = {
completed: "paid",
cancelled: "unpaid",
expired: "unpaid",
};
export default function Rides() {
const [rides, setRides] = useState<Ride[]>([]);
const [meta, setMeta] = useState({ total: 0, page: 1, pages: 1 });
@@ -78,9 +103,18 @@ export default function Rides() {
setPage(1);
}}
>
<option value="">All payments</option>
<option value="paid">Paid</option>
<option value="unpaid">Unpaid</option>
{/* "Unpaid" used to be an option here, but no row ever carries that
value payment_status is paid / cash / cash_collected so the
filter silently returned nothing. These are the real values, plus
the ride's own lifecycle state, which is what an operator
actually wants to filter by. */}
<option value="">All rides</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
<option value="expired">No driver found</option>
<option value="paid">Paid by card</option>
<option value="cash">Cash owed</option>
<option value="cash_collected">Cash collected</option>
</select>
<button className="secondary" onClick={search}>
Search
@@ -105,7 +139,11 @@ export default function Rides() {
<th>Driver</th>
<th>Time (min)</th>
<th>Fare</th>
<th>Driver gets</th>
<th>Company gets</th>
<th>Ride</th>
<th>Payment</th>
<th>Settled</th>
<th>Date</th>
</tr>
</thead>
@@ -117,17 +155,75 @@ export default function Rides() {
{r.origin_address} {r.destination_address}
</td>
<td>{r.user_email}</td>
<td>{r.driver.name}</td>
<td>{r.driver?.name ?? <span className="muted">no driver</span>}</td>
<td>{r.ride_time}</td>
<td>{fmt(r.fare_price)}</td>
<td>{money(r.fare_price)}</td>
<td>{happened(r) ? money(r.driver_payout_cents) : "—"}</td>
<td>{happened(r) ? money(r.platform_fee_cents) : "—"}</td>
<td>
<span
className={`badge ${
r.payment_status.toLowerCase() === "paid" ? "paid" : "unpaid"
}`}
>
{r.payment_status}
<span className={`badge ${STATUS_CLASS[r.status] ?? ""}`}>
{r.status}
</span>
{r.cancellation_reason ? (
<div className="muted" style={{ fontSize: 11 }}>
{r.cancelled_by}: {r.cancellation_reason.replace(/_/g, " ")}
</div>
) : null}
</td>
<td>
{/* A ride that never happened has no payment to report as
pending it owes nobody anything. */}
{happened(r) ? (
<span
className={`badge ${
["paid", "cash_collected"].includes(
r.payment_status.toLowerCase(),
)
? "paid"
: "unpaid"
}`}
>
{r.payment_status}
</span>
) : (
<span className="muted">not charged</span>
)}
</td>
<td>
{/* Two directions: cash rides leave the company waiting on
its fee, card rides leave the driver waiting on their
payout. A ride that produced no money owes nobody. */}
{!happened(r) ||
!["paid", "cash_collected"].includes(
r.payment_status.toLowerCase(),
) ? (
<span className="muted"></span>
) : (
<>
<div
className={
r.platform_fee_settled_at ? "" : "muted"
}
style={{ fontSize: 11 }}
title="Company's commission"
>
{r.platform_fee_settled_at
? "company paid"
: "company awaiting"}
</div>
<div
className={
r.driver_payout_settled_at ? "" : "muted"
}
style={{ fontSize: 11 }}
title="Driver's payout"
>
{r.driver_payout_settled_at
? "driver paid"
: "driver awaiting"}
</div>
</>
)}
</td>
<td>{new Date(r.created_at).toLocaleString()}</td>
</tr>
+58 -15
View File
@@ -6,15 +6,28 @@ type Stats = {
users: number;
drivers: number;
rides: number;
revenue: number;
completed_rides: number;
cancelled_rides: number;
gross_fares: number;
driver_payouts: number;
company_revenue: number;
company_collected: number;
company_outstanding: number;
driver_outstanding: number;
rides_today: number;
avg_fare: number;
pending_count: number;
pending_revenue: number;
new_users_7d: number;
};
trend: { day: string; rides: number; revenue: number }[];
topDrivers: { driver_id: number; name: string; rides: number; revenue: number }[];
trend: { day: string; rides: number; revenue: number; payouts: number }[];
topDrivers: {
driver_id: number;
name: string;
rides: number;
earnings: number;
company_revenue: number;
}[];
};
const fmt = (n: number) => n.toLocaleString();
@@ -22,7 +35,7 @@ const fmt = (n: number) => n.toLocaleString();
export default function Stats() {
const [stats, setStats] = useState<Stats | null>(null);
const [error, setError] = useState<string | null>(null);
const [metric, setMetric] = useState<"rides" | "revenue">("rides");
const [metric, setMetric] = useState<"rides" | "revenue" | "payouts">("rides");
useEffect(() => {
api<{ data: Stats }>("/admin/stats")
@@ -52,25 +65,51 @@ export default function Stats() {
<div className="card">
<div className="label">Rides</div>
<div className="value">{fmt(t.rides)}</div>
<div className="sub">{fmt(t.rides_today)} today</div>
<div className="sub">
{fmt(t.completed_rides)} completed · {fmt(t.cancelled_rides)} cancelled
</div>
</div>
<div className="card">
<div className="label">Revenue (paid)</div>
<div className="value">{fmt(t.revenue)}</div>
<div className="sub">avg fare {fmt(t.avg_fare)}</div>
<div className="label">Gross fares</div>
<div className="value">{fmt(t.gross_fares)}</div>
<div className="sub">what riders paid · avg {fmt(t.avg_fare)}</div>
</div>
<div className="card">
<div className="label">Company revenue</div>
<div className="value">{fmt(t.company_revenue)}</div>
<div className="sub">
{fmt(t.company_collected)} collected
</div>
</div>
{/* Commission drivers took in cash and haven't handed over yet. This
is the number to chase at the end of a shift. */}
<div className={`card ${t.company_outstanding > 0 ? "warn" : ""}`}>
<div className="label">Commission to collect</div>
<div className="value">{fmt(t.company_outstanding)}</div>
<div className="sub">held by drivers from cash rides</div>
</div>
<div className="card">
<div className="label">Payouts owed</div>
<div className="value">{fmt(t.driver_outstanding)}</div>
<div className="sub">of {fmt(t.driver_payouts)} total earned</div>
</div>
{/* Completed rides whose money never landed. Cancelled rides are no
longer counted here they never owed anything. */}
<div className={`card ${t.pending_count > 0 ? "warn" : ""}`}>
<div className="label">Pending payments</div>
<div className="label">Uncollected</div>
<div className="value">{fmt(t.pending_count)}</div>
<div className="sub">{fmt(t.pending_revenue)} outstanding</div>
<div className="sub">{fmt(t.pending_revenue)} on completed rides</div>
</div>
</div>
<h2>Last 14 days</h2>
<div className="toolbar">
<select value={metric} onChange={(e) => setMetric(e.target.value as "rides" | "revenue")}>
<select value={metric} onChange={(e) =>
setMetric(e.target.value as "rides" | "revenue" | "payouts")
}>
<option value="rides">Rides</option>
<option value="revenue">Revenue (paid)</option>
<option value="revenue">Company revenue</option>
<option value="payouts">Driver payouts</option>
</select>
</div>
<div className="chart" style={{ marginBottom: 40 }}>
@@ -79,7 +118,9 @@ export default function Stats() {
key={d.day}
className="bar"
style={{ height: `${Math.max(1, (d[metric] / max) * 100)}%` }}
title={`${d.day}: ${metric === "rides" ? `${d.rides} rides` : fmt(d.revenue)}`}
title={`${d.day}: ${
metric === "rides" ? `${d.rides} rides` : fmt(d[metric])
}`}
>
<span>{d.day.slice(5)}</span>
</div>
@@ -92,7 +133,8 @@ export default function Stats() {
<tr>
<th>Driver</th>
<th>Rides</th>
<th>Revenue</th>
<th>Driver earned</th>
<th>Company earned</th>
</tr>
</thead>
<tbody>
@@ -100,7 +142,8 @@ export default function Stats() {
<tr key={d.driver_id}>
<td>{d.name}</td>
<td>{fmt(d.rides)}</td>
<td>{fmt(d.revenue)}</td>
<td>{fmt(d.earnings)}</td>
<td>{fmt(d.company_revenue)}</td>
</tr>
))}
</tbody>
+6
View File
@@ -37,6 +37,12 @@ declare global {
AREEBA_MERCHANT_ID: string;
AREEBA_API_PASSWORD: string;
AREEBA_API_VERSION: string;
// in-app WebRTC audio calls (STUN for dev; TURN for production NAT)
EXPO_PUBLIC_STUN_URL: string;
EXPO_PUBLIC_TURN_URL: string;
EXPO_PUBLIC_TURN_USERNAME: string;
EXPO_PUBLIC_TURN_CREDENTIAL: string;
}
}
}
+45 -7
View File
@@ -1,20 +1,58 @@
import { sql } from "@/lib/db";
import { requireAuth } from "@/lib/jwt";
export const corsHeaders: Record<string, string> = {
"Access-Control-Allow-Origin": process.env.ADMIN_CORS_ORIGIN ?? "*",
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
// The owner API is cross-origin only for the admin dashboard, so the allowed
// origin has to be named explicitly. An unset ADMIN_CORS_ORIGIN used to fall
// back to "*", which meant a missing env var silently opened every owner
// endpoint to every website the owner happened to have open. Fail closed
// instead: with nothing configured we send no allow-origin header at all and
// the browser blocks the call, which is a loud, obvious failure to fix.
//
// A comma-separated list is accepted so dev (localhost) and production can be
// configured at once; the header echoes back whichever entry matched, since
// "Access-Control-Allow-Origin" only ever takes a single value.
const allowedOrigins = (): string[] =>
(process.env.ADMIN_CORS_ORIGIN ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
export const corsHeaders = (req: Request): Record<string, string> => {
const headers: Record<string, string> = {
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
Vary: "Origin",
};
const allowed = allowedOrigins();
if (allowed.length === 0) return headers;
// A wildcard is still honoured when it is configured deliberately — the
// change is that it is no longer what you get by forgetting to configure it.
if (allowed.includes("*")) {
headers["Access-Control-Allow-Origin"] = "*";
return headers;
}
const origin = req.headers.get("origin");
if (origin && allowed.includes(origin)) {
headers["Access-Control-Allow-Origin"] = origin;
}
return headers;
};
export const withCors = (response: Response): Response => {
for (const [key, value] of Object.entries(corsHeaders)) {
// Takes the request first so the origin it echoes is never accidentally
// omitted — a call site that forgets it won't compile.
export const withCors = (req: Request, response: Response): Response => {
for (const [key, value] of Object.entries(corsHeaders(req))) {
response.headers.set(key, value);
}
return response;
};
export const preflight = (): Response => withCors(new Response(null, { status: 204 }));
export const preflight = (req: Request): Response =>
withCors(req, new Response(null, { status: 204 }));
// Returns the authenticated owner or a ready-to-return error Response.
export const requireOwner = async (
+70
View File
@@ -0,0 +1,70 @@
import type * as ImagePicker from "expo-image-picker";
import { Alert, Linking } from "react-native";
type Copy = {
title: string;
/** Why we need it — shown while Android will still show its own dialog. */
message: string;
/** Shown once Android has stopped showing that dialog. */
blocked: string;
openSettings: string;
cancel: string;
};
/**
* Explains a refused camera or photo-library permission, and offers the only
* way out when there is one.
*
* `granted: false` covers two states that feel completely different to a
* driver. While `canAskAgain` is true the system dialog appeared and they
* declined it, so repeating why we need it and letting them tap the button
* again is the whole fix. Once `canAskAgain` is false Android stops showing
* that dialog altogether: `requestCameraPermissionsAsync()` returns denied
* without anything appearing on screen, so from the driver's side the app has
* simply stopped asking, and no amount of tapping will ever change it. The
* only remaining route is the system settings page for the app, so that case
* gets a button that opens it rather than a message telling them to allow
* something they are never going to be offered.
*
* Android also lands drivers in that second state through no choice of their
* own: requesting a runtime permission the manifest doesn't declare is
* auto-denied and flagged as permanently denied, and the flag survives an
* update install. A driver who ran a build predating the CAMERA declaration in
* app.config.js is stuck there until they either use this button or reinstall.
*/
export const alertPermissionDenied = (
permission: ImagePicker.PermissionResponse,
copy: Copy,
) => {
// Both branches below end in an alert and nothing else, which leaves no
// trace in the logs — the reason a driver reporting "it never asks me" is
// indistinguishable from one who never tapped the button. Logging the two
// fields that decide the branch makes that difference readable.
console.log(
"[CAPTURE_PERMISSION_DENIED]: ",
JSON.stringify({
status: permission.status,
canAskAgain: permission.canAskAgain,
}),
);
if (permission.canAskAgain) {
Alert.alert(copy.title, copy.message);
return;
}
Alert.alert(copy.title, copy.blocked, [
{ text: copy.cancel, style: "cancel" },
{
text: copy.openSettings,
onPress: () => {
// Failure here is not worth a second alert on top of this one: the
// driver is already reading instructions that name the settings
// screen, and reaching it by hand still works.
void Linking.openSettings().catch((error) =>
console.log("[CAPTURE_PERMISSION_SETTINGS]: ", error),
);
},
},
]);
};
+65 -6
View File
@@ -1,12 +1,74 @@
import { lookup, setDefaultResultOrder } from "dns";
import { Pool, type QueryResultRow } from "pg";
// Neon's host resolves to both IPv6 (AAAA) and IPv4 (A). This host has no IPv6
// route, so an IPv6-first connect fails instantly with ENETUNREACH and only
// then falls back to IPv4 — wasting a round-trip on every fresh connection and
// racing Neon's wake. Force IPv4 first so the working path is tried first.
setDefaultResultOrder("ipv4first");
// Neon free-tier scales compute to zero when idle; the first connection after a
// cold wake can take 1030s to establish. A 10s connect timeout 500s every
// request during that wake window, so allow 30s. Once a ride is active the
// driver-location + call polls keep the DB warm, so this only bites the first
// poll after a long idle.
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 10_000,
connectionTimeoutMillis: 30_000,
});
// A pooled client can die out from under us (Neon recycle, network blip).
// Without this handler Node logs an unhandled "idle client error" and the
// pool just drops the client; log it so a flaky connection is visible.
pool.on("error", (err) => {
console.error("[DB_POOL_ERROR]: ", err.message);
});
// Connection errors that are safe to retry on a fresh pool client. Neon's
// direct endpoint intermittently ETIMEDOUTs while the compute wakes; a single
// retry a second later almost always succeeds once the endpoint is warm.
const RETRY_CODES = new Set([
"ETIMEDOUT",
"ECONNRESET",
"ENETUNREACH",
"EHOSTUNREACH",
"EPIPE",
"08000",
"08006",
"08001",
"08004",
"57P03",
]);
const isRetryable = (err: unknown): boolean => {
const e = err as { code?: string };
return Boolean(e && typeof e.code === "string" && RETRY_CODES.has(e.code));
};
// Retry a pool.query a couple of times on transient connection errors. The
// query itself is idempotent from the pool's perspective: a connect failure
// means no statement ran, and pg removes the dead client before the next
// attempt, so we never double-execute a committed statement.
const queryWithRetry = async <R extends QueryResultRow = QueryResultRow>(
text: string,
values: SqlValue[],
): Promise<R[]> => {
let lastErr: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
try {
const result = await pool.query<R>(text, values);
return result.rows;
} catch (err) {
lastErr = err;
if (!isRetryable(err) || attempt === 2) throw err;
// Back off ~1s, ~2s; Neon wake completes within a few seconds.
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
throw lastErr;
};
export type SqlValue = string | number | boolean | null | Date;
export async function sql<R extends QueryResultRow = QueryResultRow>(
@@ -19,17 +81,14 @@ export async function sql<R extends QueryResultRow = QueryResultRow>(
"",
);
const result = await pool.query<R>(text, values);
return result.rows;
return queryWithRetry<R>(text, values);
}
export async function query<R extends QueryResultRow = QueryResultRow>(
text: string,
values: SqlValue[] = [],
): Promise<R[]> {
const result = await pool.query<R>(text, values);
return result.rows;
return queryWithRetry<R>(text, values);
}
export async function transaction<T>(
+121 -89
View File
@@ -1,107 +1,139 @@
// Uber-style auto-match dispatch. A requested ride has no driver; this engine
// offers it to the nearest eligible driver of the matching service. Drivers
// accept/decline; a decline (or a 15s offer expiry) triggers the next-nearest
// match. There is no background worker — matchNextDriver is called lazily from
// the rider status poll and the driver poll, so matching progresses on every
// request cycle.
// Broadcast dispatch. A new request is put in front of every eligible driver
// near the pickup at once; each of them may volunteer for it (a row in
// ride_offers) and the rider picks between whoever did.
//
// The engine's whole job is therefore the announcement. There is no queue to
// advance, no timer to chase a declining driver with, and no background
// worker: drivers discover requests through their dashboard poll and their
// location heartbeat, and this module exists to make sure a phone that is
// face-down in a pocket still buzzes when a job appears nearby.
import { transaction } from "@/lib/db";
import { haversine } from "@/lib/utils";
import { sql } from "@/lib/db";
import { sendPushToDriver } from "@/lib/push";
import { boundingBox, haversine } from "@/lib/utils";
import { expireStaleRequests } from "@/lib/ride-lifecycle";
import {
BROADCAST_RADIUS_M,
DRIVER_STALE_SECONDS,
OFFER_CHANNEL_ID,
} from "@/constants/dispatch";
// A driver has this long to respond to an offer before it expires and the next
// driver is offered. Tuned short so a rider searching for a driver isn't left
// hanging on a phone that's face-down on a seat.
const OFFER_TTL_SECONDS = 15;
// A driver whose last location ping is older than this is treated as offline
// even if their `online` flag is still true (they closed the app without
// toggling off).
const DRIVER_STALE_SECONDS = 60;
// Fares are stored in cents; the notification shows what the rider is paying.
const formatFare = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
type EligibleDriver = {
type NearbyDriverRow = {
id: number;
latitude: number;
longitude: number;
};
// Offer `rideId` to the nearest eligible driver, if no offer is already in
// flight for it. Idempotent: safe to call on every poll. Returns the driver id
// that was offered, or null if no driver was available.
export const matchNextDriver = async (
rideId: number,
): Promise<number | null> => {
/**
* Drivers who should see `rideId` right now: right service, vetted, online,
* fresh position, not already on a ride, and within the broadcast radius of
* the pickup.
*
* Exported because the driver's own poll asks the mirror-image question
* "which open requests are near me?" and the two must agree. If a driver
* could be pushed a request their dashboard then filtered out, they'd get a
* notification for a job that isn't there when they open the app.
*/
export const driversForRequest = async (rideId: number): Promise<number[]> => {
const rides = await sql<{
lat: number;
lng: number;
service: string;
status: string;
}>`
SELECT origin_latitude AS lat, origin_longitude AS lng, service, status
FROM rides WHERE ride_id = ${rideId}
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return [];
// A coarse bounding box does the work in the index, then a great-circle
// pass trims the corners — same two-step the rider's map search uses.
const box = boundingBox(ride.lat, ride.lng, BROADCAST_RADIUS_M);
const candidates = await sql<NearbyDriverRow>`
SELECT d.id, d.latitude, d.longitude
FROM drivers d
WHERE d.service = ${ride.service}
AND d.online = TRUE
AND d.approval_status = 'approved'
AND d.user_id IS NOT NULL
AND d.latitude IS NOT NULL
AND d.longitude IS NOT NULL
AND d.last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
AND d.latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND d.longitude BETWEEN ${box.minLng} AND ${box.maxLng}
AND NOT EXISTS (
SELECT 1 FROM rides r
WHERE r.driver_id = d.id
AND r.status IN ('accepted', 'arrived', 'en_route')
)
`;
return candidates
.filter(
(d) =>
haversine(ride.lat, ride.lng, d.latitude, d.longitude) <=
BROADCAST_RADIUS_M,
)
.map((d) => d.id);
};
/**
* Announce `rideId` to every eligible driver nearby.
*
* Idempotent, and deliberately so: it is called from the rider's status poll
* as well as from ride creation, and a request that buzzed forty phones once
* must not buzz them again every three seconds. `broadcast_at` is the latch
* claimed with a guarded UPDATE so two concurrent callers can't both win it.
*
* Returns how many drivers were notified (0 if the announcement was already
* made, or nobody was in range).
*/
export const broadcastRequest = async (rideId: number): Promise<number> => {
try {
return await transaction(async (tx) => {
// Lock the ride row so concurrent matchers serialize on it.
const rides = await tx<{ status: string; service: string }>`
SELECT status, service FROM rides WHERE ride_id = ${rideId} FOR UPDATE
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return null;
// Give up on requests that have run past their window before announcing
// one — this is one of the lazy paths that stands in for a worker.
await expireStaleRequests(rideId);
// Expire any offers that have been sitting past their TTL.
await tx`
UPDATE ride_offers
SET status = 'expired', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND status = 'offered'
AND offered_at < CURRENT_TIMESTAMP - make_interval(secs => ${OFFER_TTL_SECONDS})
`;
// Claim the announcement. Whoever gets the row does the pushing.
const claimed = await sql<{
origin_address: string;
fare_price: number;
service: string;
}>`
UPDATE rides
SET broadcast_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND status = 'requested'
AND broadcast_at IS NULL
RETURNING origin_address, fare_price, service
`;
if (!claimed[0]) return 0;
// If there is still an active (unexpired) offer in flight, leave it —
// don't stack a second offer on top.
const inFlight = await tx<{ n: number }>`
SELECT COUNT(*)::int AS n FROM ride_offers
WHERE ride_id = ${rideId} AND status = 'offered'
`;
if ((inFlight[0]?.n ?? 0) > 0) return null;
const drivers = await driversForRequest(rideId);
if (drivers.length === 0) return 0;
const rideOrigin = await tx<{ lat: number; lng: number }>`
SELECT origin_latitude AS lat, origin_longitude AS lng
FROM rides WHERE ride_id = ${rideId}
`;
const origin = rideOrigin[0];
if (!origin) return null;
const { origin_address: origin, fare_price: fare } = claimed[0];
// Eligible: right service, online, fresh, a real account, not on an
// active ride, and not already offered/declined for THIS ride.
const candidates = await tx<EligibleDriver>`
SELECT d.id, d.latitude, d.longitude
FROM drivers d
WHERE d.service = ${ride.service}
AND d.online = TRUE
AND d.user_id IS NOT NULL
AND d.latitude IS NOT NULL
AND d.longitude IS NOT NULL
AND d.last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
AND NOT EXISTS (
SELECT 1 FROM rides r
WHERE r.driver_id = d.id AND r.status IN ('accepted', 'en_route')
)
AND NOT EXISTS (
SELECT 1 FROM ride_offers ro
WHERE ro.ride_id = ${rideId} AND ro.driver_id = d.id
)
`;
if (candidates.length === 0) return null;
// Nearest by great-circle distance to the pickup point.
candidates.sort((a, b) => {
const da = haversine(origin.lat, origin.lng, a.latitude, a.longitude);
const db = haversine(origin.lat, origin.lng, b.latitude, b.longitude);
return da - db;
// Not awaited: dispatch must not stall on Expo's service, and a driver
// still finds the request through the dashboard poll and the location
// heartbeat regardless.
for (const driverId of drivers) {
void sendPushToDriver(driverId, {
title: "New ride request nearby",
body: `${formatFare(Number(fare))} · pickup at ${origin}`,
channelId: OFFER_CHANNEL_ID,
data: { type: "ride_request", rideId },
});
const nearest = candidates[0];
}
await tx`
INSERT INTO ride_offers (ride_id, driver_id, status)
VALUES (${rideId}, ${nearest.id}, 'offered')
`;
return nearest.id;
});
return drivers.length;
} catch (error) {
console.error("[MATCH_NEXT_DRIVER]: ", error);
return null;
console.error("[BROADCAST_REQUEST]: ", error);
return 0;
}
};
+511
View File
@@ -0,0 +1,511 @@
// Reading a driver's documents with Google Cloud Vision, then pulling the
// four credential fields out of the text it returns.
//
// Two things shape this file. First, Lebanese documents are trilingual: a
// driving licence carries Arabic and French on the same card, an ID card is
// Arabic with Arabic-Indic digits, and a vehicle registration mixes both. So
// every label we look for has an Arabic, a French and an English spelling, and
// digits are normalised before anything is matched.
//
// Second, OCR is a suggestion, never an answer. Everything here is best-effort
// and each field is returned independently — a licence whose number reads
// cleanly but whose expiry is smudged yields the number and leaves expiry
// empty. The driver reviews and corrects every field before submitting, and a
// human reviewer still approves the profile against the stored scan. Nothing
// downstream trusts these values because they came from a scan.
const VISION_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate";
export const DOCUMENT_TYPES = ["license", "id", "vehicle_reg"] as const;
export type DocumentType = (typeof DOCUMENT_TYPES)[number];
export const isDocumentType = (v: unknown): v is DocumentType =>
typeof v === "string" && (DOCUMENT_TYPES as readonly string[]).includes(v);
/** Which drivers column stores the scan for each document type. */
export const DOCUMENT_COLUMNS: Record<DocumentType, string> = {
license: "license_image_url",
id: "id_image_url",
vehicle_reg: "vehicle_reg_image_url",
};
/**
* The subset of the onboarding form a scan can fill. Every key is optional:
* a field is present only when it was actually read off the document.
*/
export type ExtractedFields = {
license_number?: string;
/** Always normalised to YYYY-MM-DD, whatever the card printed. */
license_expiry?: string;
national_id?: string;
plate_number?: string;
car_model?: string;
};
// --- Normalisation --------------------------------------------------------
/**
* Lebanese ID cards print Arabic-Indic digits (٠١٢), and Vision returns them
* verbatim. Everything downstream the date parser, the digit-run fallbacks,
* the form itself expects ASCII, so fold them first. Both the Arabic-Indic
* (U+0660) and Extended Arabic-Indic (U+06F0, used by some fonts) ranges show
* up in practice.
*/
const toAsciiDigits = (text: string): string =>
text.replace(/[٠-٩۰-۹]/g, (char) => {
const code = char.charCodeAt(0);
const base = code >= 0x06f0 ? 0x06f0 : 0x0660;
return String(code - base);
});
/**
* Arabic tashkeel (short-vowel marks) and the tatweel stretcher are decorative
* and appear inconsistently in OCR output, so a label match must not depend on
* them. Latin text is uppercased so one pattern covers "Permis" and "PERMIS".
*/
const normalise = (text: string): string =>
toAsciiDigits(text)
.replace(/[ً-ٟـٰ]/g, "")
.replace(/[--]/g, "")
.toUpperCase();
const lines = (text: string): string[] =>
text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
// --- Dates ----------------------------------------------------------------
const MONTH_NAMES: Record<string, number> = {
JAN: 1,
FEV: 2,
FEB: 2,
MAR: 3,
AVR: 4,
APR: 4,
MAI: 5,
MAY: 5,
JUN: 6,
JUIN: 6,
JUL: 7,
JUIL: 7,
AOU: 8,
AUG: 8,
SEP: 9,
OCT: 10,
NOV: 11,
DEC: 12,
};
const isoDate = (year: number, month: number, day: number): string | null => {
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
if (year < 1900 || year > 2100) return null;
const date = new Date(Date.UTC(year, month - 1, day));
// Rejects the likes of 31/02 that survive the range checks above.
if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day)
return null;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
};
/**
* Every date on a line, normalised to YYYY-MM-DD.
*
* Lebanese documents print day-first (the French convention), so an ambiguous
* pair like 03/04 is read as 3 April. When the second component is above 12 the
* card must be month-first after all, so that reading wins instead which is
* how a US-formatted document still parses correctly.
*/
const datesIn = (line: string): string[] => {
const found: string[] = [];
// Year-first: 2027-03-14
for (const match of line.matchAll(
/\b(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})\b/g,
)) {
const iso = isoDate(+match[1], +match[2], +match[3]);
if (iso) found.push(iso);
}
// Day-first or month-first: 14/03/2027
for (const match of line.matchAll(
/\b(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\b/g,
)) {
const [, a, b, year] = match;
const iso = +b > 12 ? isoDate(+year, +a, +b) : isoDate(+year, +b, +a);
if (iso) found.push(iso);
}
// Spelled-out month: 14 MAR 2027
for (const match of line.matchAll(
/\b(\d{1,2})\s+([A-Z]{3,4})\.?\s+(\d{4})\b/g,
)) {
const month = MONTH_NAMES[match[2]];
const iso = month ? isoDate(+match[3], month, +match[1]) : null;
if (iso) found.push(iso);
}
return found;
};
// Label vocabularies. Arabic first because that is what an ID card leads with.
const EXPIRY_LABELS =
/صلاحية|الصلاحية|تنتهي|انتهاء|ينتهي|EXPIR|VALABLE|VALIDIT|VALID|JUSQU|UNTIL/;
const ISSUE_LABELS =
/اصدار|الاصدار|تاريخ الاصدار|DELIVR|ISSUE|ISSUED|EMIS|EMISSION/;
const BIRTH_LABELS = /ولادة|الولادة|مواليد|NAISSANCE|BIRTH|NE LE|DOB/;
/**
* The expiry date, which is the one date on a licence we actually want.
*
* A licence shows three dates birth, issue, expiry and picking the wrong
* one fails the driver's submission on a date they never typed. So a labelled
* expiry wins outright. Failing that, dates sitting on a birth or issue line
* are excluded, along with any the caller already identified by field code,
* and the latest remaining future date is taken expiry is the only one of
* the three that can be in the future.
*/
const findExpiry = (
docLines: string[],
exclude: Set<string> = new Set(),
): string | undefined => {
const today = new Date().toISOString().slice(0, 10);
const unlabelled: string[] = [];
for (const line of docLines) {
const onLine = datesIn(line).filter((date) => !exclude.has(date));
if (onLine.length === 0) continue;
if (EXPIRY_LABELS.test(line)) {
// A line reading "issued 14/03/2022 expires 14/03/2027" carries both, and
// the later one is the expiry.
const future = onLine.filter((date) => date > today).sort();
if (future.length > 0) return future[future.length - 1];
return onLine.sort()[onLine.length - 1];
}
if (ISSUE_LABELS.test(line) || BIRTH_LABELS.test(line)) continue;
unlabelled.push(...onLine);
}
const future = unlabelled.filter((date) => date > today).sort();
return future.length > 0 ? future[future.length - 1] : undefined;
};
/**
* Value printed against a numbered field code.
*
* The Lebanese licence is an EU-format card (Directive 2006/126/EC), which
* means its fields are identified by a printed number rather than a word: 1 is
* the surname, 2 the given names, 3 the date of birth, 4a the issue date, 4b
* the expiry, 4c the issuing authority, 5 the licence number. Reading those
* codes is far more reliable than hunting for "expiry" in three languages,
* because the card never prints the word in any of them the only prose on it
* is the "PERMIS DE CONDUIRE / DRIVING LICENSE" title.
*
* The value normally sits on the same line as its code; when Vision splits the
* label column from the value column, it lands on the next line instead, so
* both layouts are handled.
*
* `shape` is what makes the second layout safe. Reading a card whose codes are
* stacked ("1 / 2 / 3 / 4a / 4b / 5") followed by the values in their own
* block, "the line after code 5" is the *first* value, not the fifth on the
* sample licence that is the surname. Requiring the value to look like the
* field it claims to be rejects that mismatch and lets the caller fall through
* to a fallback that gets it right.
*/
const ANY_FIELD_CODE = /^\d{1,2}[ABCD]?[.):]?$/;
const numberedField = (
docLines: string[],
code: string,
shape?: RegExp,
): string | undefined => {
// The leading (^|\s) is what stops code "5" matching inside "15." and code
// "3" matching inside "13B" — both of which are printed on this card.
const inline = new RegExp(`(?:^|\\s)${code}[.):\\s]\\s*(\\S.*)$`);
const bare = new RegExp(`^${code}[.):]?$`);
const fits = (value: string) =>
value.length > 0 && (!shape || shape.test(value));
for (let index = 0; index < docLines.length; index += 1) {
const match = docLines[index].match(inline);
const sameLine = match?.[1]?.trim();
if (sameLine && fits(sameLine)) return sameLine;
if (!bare.test(docLines[index])) continue;
const next = docLines[index + 1]?.trim();
// A code followed by another code is a label column; there is no value
// there to take.
if (next && !ANY_FIELD_CODE.test(next) && fits(next)) return next;
}
return undefined;
};
/** The single date in a numbered field's value, if it holds one. */
const numberedDate = (docLines: string[], code: string): string | undefined => {
const value = numberedField(
docLines,
code,
/\d{1,4}[-/.]\d{1,2}[-/.]\d{2,4}/,
);
return value ? datesIn(value)[0] : undefined;
};
// --- Field extraction -----------------------------------------------------
/**
* OCR routinely drops the separator between a label and its value, so a capture
* can arrive as "N 123456" or with trailing label text from the next column.
* Keep the leading run of value-shaped characters and drop the rest.
*/
const cleanValue = (raw: string, allowed: RegExp): string | undefined => {
const value = raw
.trim()
.replace(/^[:.\-–—\s]+/, "")
.split(/\s{2,}/)[0]
.trim();
const kept = value
.split("")
.filter((char) => allowed.test(char))
.join("")
.trim();
return kept.length >= 3 ? kept : undefined;
};
/** First capture across a list of patterns, tried in order of confidence. */
const firstMatch = (
docLines: string[],
patterns: RegExp[],
allowed: RegExp,
): string | undefined => {
for (const pattern of patterns) {
for (const line of docLines) {
const match = line.match(pattern);
const value = match?.[1] ? cleanValue(match[1], allowed) : undefined;
if (value) return value;
}
}
return undefined;
};
/**
* Last resort when no label was recognised: the longest plausible run of
* digits on the card. Dates are stripped first, otherwise "14/03/2027" reads
* as an eight-digit licence number.
*/
const longestDigitRun = (
docLines: string[],
min: number,
max: number,
): string | undefined => {
let best: string | undefined;
for (const line of docLines) {
const withoutDates = line
.replace(/\b\d{1,4}[-/.]\d{1,2}[-/.]\d{2,4}\b/g, " ")
.replace(/\b(19|20)\d{2}\b/g, " ");
for (const match of withoutDates.matchAll(/\d[\d\s-]{2,}\d/g)) {
const digits = match[0].replace(/[\s-]/g, "");
if (digits.length < min || digits.length > max) continue;
if (!best || digits.length > best.length) best = digits;
}
}
return best;
};
const ALPHANUMERIC = /[A-Z0-9/-]/;
const DIGITS_ONLY = /[0-9]/;
/** Lebanese plates pair digits with a letter group, Arabic or Latin. */
const PLATE_CHARS = /[A-Z0-9ء-ي/-]/;
const MODEL_CHARS = /[A-Z0-9 .-]/;
const extractLicense = (docLines: string[]): ExtractedFields => {
// Field 5 is the licence number on the EU-format card, and it is by far the
// most reliable read — so it is tried before any worded label. The word
// patterns cover older Lebanese licences that predate the numbered layout,
// and the digit-run fallback covers a card whose codes didn't survive OCR.
// A licence number is a run of digits, so requiring some is what keeps a
// stacked-label card from handing back the holder's surname here.
const field5 = numberedField(docLines, "5", /\d{3,}/);
const license_number =
(field5 ? cleanValue(field5, ALPHANUMERIC) : undefined) ??
firstMatch(
docLines,
[
/(?:رقم\s*(?:الرخصة|الاجازة|الرخصه)?|PERMIS\s*(?:DE\s*CONDUIRE\s*)?N|N[°ºO]\s*(?:DE\s*)?PERMIS|LICEN[CS]E\s*(?:NO|NUMBER|N[°ºO]))\s*[:.\-]?\s*([A-Z0-9][A-Z0-9/\- ]{3,19})/,
/\bN[°ºO]\s*[:.\-]?\s*([A-Z0-9][A-Z0-9/\- ]{4,19})/,
],
ALPHANUMERIC,
) ??
longestDigitRun(docLines, 5, 15);
// 3 is the date of birth and 4a the date of issue. Naming them explicitly
// does double duty: 4b gives the expiry outright, and knowing the other two
// keeps them out of the fallback, which would otherwise be free to mistake a
// recent issue date for an expiry.
const birth = numberedDate(docLines, "3");
const issued = numberedDate(docLines, "4A");
const expires = numberedDate(docLines, "4B");
const excluded = new Set([birth, issued].filter(Boolean) as string[]);
// A card that reads 4b but whose expiry has already passed is a real answer,
// not a misread — surface it so the driver sees why the form rejects it,
// rather than silently leaving the field blank.
const license_expiry = expires ?? findExpiry(docLines, excluded);
// A Lebanese licence carries the holder's register number too, but only
// behind an explicit label — a bare digit run on a licence is far more
// likely to be the licence number itself.
const national_id = firstMatch(
docLines,
[
/(?:رقم\s*(?:الهوية|السجل)|REGISTRE|SEJEL|ID\s*(?:NO|NUMBER)|IDENTITY\s*(?:NO|NUMBER))\s*[:.\-]?\s*([0-9][0-9\- ]{4,19})/,
],
DIGITS_ONLY,
);
return { license_number, license_expiry, national_id };
};
const extractId = (docLines: string[]): ExtractedFields => ({
national_id:
firstMatch(
docLines,
[
/(?:رقم\s*(?:الهوية|السجل|البطاقة)|N[°ºO]\s*(?:DE\s*)?(?:CARTE|REGISTRE)|REGISTRE|SEJEL|ID\s*(?:NO|NUMBER)|IDENTITY\s*(?:NO|NUMBER))\s*[:.\-]?\s*([0-9][0-9\- ]{4,19})/,
/\bرقم\s*[:.\-]?\s*([0-9][0-9\- ]{5,19})/,
],
DIGITS_ONLY,
) ?? longestDigitRun(docLines, 6, 14),
});
const extractVehicleRegistration = (docLines: string[]): ExtractedFields => {
const plate_number =
firstMatch(
docLines,
[
/(?:رقم\s*(?:اللوحة|السيارة)|اللوحة|PLAQUE|IMMATRICULATION|PLATE\s*(?:NO|NUMBER)?|REGISTRATION\s*(?:NO|NUMBER)?)\s*[:.\-]?\s*([0-9ء-يA-Z][0-9A-Zء-ي/\- ]{2,14})/,
// Unlabelled but unmistakable: digits, a slash, then the letter group.
/\b(\d{1,7}\s*\/\s*[A-Zء-ي]{1,3})\b/,
],
PLATE_CHARS,
) ?? undefined;
const car_model = firstMatch(
docLines,
[
/(?:نوع\s*(?:السيارة|المركبة)?|الطراز|MARQUE(?:\s*ET\s*TYPE)?|MODELE|MODÈLE|MAKE|MODEL)\s*[:.\-]?\s*([A-Z][A-Z0-9 .-]{2,29})/,
],
MODEL_CHARS,
);
return { plate_number, car_model };
};
/** Drops keys whose value came back empty so callers can spread the result. */
const compact = (fields: ExtractedFields): ExtractedFields =>
Object.fromEntries(
Object.entries(fields).filter(([, value]) => Boolean(value)),
) as ExtractedFields;
/** Pulls the credential fields out of already-recognised document text. */
export const parseDocumentText = (
text: string,
docType: DocumentType,
): ExtractedFields => {
const docLines = lines(normalise(text));
if (docLines.length === 0) return {};
switch (docType) {
case "license":
return compact(extractLicense(docLines));
case "id":
return compact(extractId(docLines));
case "vehicle_reg":
return compact(extractVehicleRegistration(docLines));
}
};
// --- Google Cloud Vision --------------------------------------------------
export class OcrUnavailableError extends Error {}
type VisionResponse = {
responses?: {
fullTextAnnotation?: { text?: string };
error?: { message?: string };
}[];
};
/**
* Runs Vision's document OCR over a scan and returns the recognised text.
*
* DOCUMENT_TEXT_DETECTION (rather than plain TEXT_DETECTION) is the dense-text
* model: it keeps the line structure of a card, which is what every label
* pattern above depends on. The language hints are the three that appear on
* Lebanese documents without them Vision often transliterates Arabic instead
* of reading it.
*/
export const recogniseDocument = async (image: Buffer): Promise<string> => {
const key = process.env.GOOGLE_VISION_API_KEY;
if (!key) {
throw new OcrUnavailableError("GOOGLE_VISION_API_KEY is not configured.");
}
let response: Response;
try {
response = await fetch(
`${VISION_ENDPOINT}?key=${encodeURIComponent(key)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
requests: [
{
image: { content: image.toString("base64") },
features: [{ type: "DOCUMENT_TEXT_DETECTION", maxResults: 1 }],
imageContext: { languageHints: ["ar", "fr", "en"] },
},
],
}),
// A driver is watching a spinner; failing over to manual entry beats
// holding the screen while Vision is slow.
signal: AbortSignal.timeout(20_000),
},
);
} catch (error) {
throw new OcrUnavailableError(
`Vision request failed: ${(error as Error).message}`,
);
}
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new OcrUnavailableError(
`Vision responded ${response.status}: ${detail.slice(0, 200)}`,
);
}
const body = (await response.json()) as VisionResponse;
const result = body.responses?.[0];
// Vision reports per-image failures inside a 200 response, so the status
// code alone does not tell you the scan was read.
if (result?.error?.message) {
throw new OcrUnavailableError(`Vision error: ${result.error.message}`);
}
return result?.fullTextAnnotation?.text ?? "";
};
+45
View File
@@ -0,0 +1,45 @@
import Constants from "expo-constants";
/**
* Turns whatever sits in `drivers.profile_image_url` into something an
* `<Image>` can load.
*
* That column holds one of two things. A driver who took their photo in the
* app stores an opaque name ("a1b2….jpg") that only means anything to
* /(api)/driver/photo; an owner who filled the field in from the admin
* dashboard stores a full external URL. Both have to render, so the shape of
* the value decides how it is read which also means older profiles carrying
* a real URL keep working untouched.
*/
const ABSOLUTE = /^(https?:|data:|file:|blob:)/i;
/**
* The origin an <Image> should fetch from.
*
* `fetchAPI` gets away with relative paths because expo-router resolves them,
* and in development it resolves them against the Metro dev server rather than
* the configured origin. An <Image> URL has to be absolute, so it has to make
* the same choice by hand otherwise every API call goes to the laptop while
* every avatar goes to production (or, worse, to the placeholder origin in
* .env, and silently renders nothing).
*/
const apiOrigin = (): string => {
if (__DEV__) {
const hostUri = Constants.expoConfig?.hostUri;
if (hostUri) return `http://${hostUri}`;
}
return (process.env.EXPO_PUBLIC_SERVER_URL ?? "").replace(/\/+$/, "");
};
export const driverPhotoUri = (value?: string | null): string | undefined => {
if (!value) return undefined;
if (ABSOLUTE.test(value)) return value;
const origin = apiOrigin();
if (!origin) return undefined;
// The literal "(api)" is part of the path — this app's routes are addressed
// that way throughout, not as an expo-router group that gets stripped.
return `${origin}/(api)/driver/photo?name=${encodeURIComponent(value)}`;
};
+65 -4
View File
@@ -10,11 +10,32 @@ import type { ServiceId } from "@/constants/services";
type Auth = { userId: string; email: string };
/**
* Vetting state of a driver profile.
* pending onboarded, waiting on an owner review. Cannot go online.
* approved cleared to drive. The only state dispatch will match.
* rejected review failed; the driver sees why and can resubmit.
* suspended was approved, pulled by an owner.
*/
export const DRIVER_APPROVAL_STATUSES = [
"pending",
"approved",
"rejected",
"suspended",
] as const;
export type DriverApprovalStatus = (typeof DRIVER_APPROVAL_STATUSES)[number];
export const isApprovalStatus = (v: unknown): v is DriverApprovalStatus =>
typeof v === "string" &&
(DRIVER_APPROVAL_STATUSES as readonly string[]).includes(v);
export type DriverProfile = {
auth: Auth;
driverId: number;
service: ServiceId;
online: boolean;
approvalStatus: DriverApprovalStatus;
};
export type AuthError = { error: Response };
@@ -32,8 +53,14 @@ export const requireDriverProfile = async (
const auth = requireAuth(req);
if ("error" in auth) return { error: auth.error };
const rows = await sql<{ id: number; service: ServiceId; online: boolean }>`
SELECT id, service, online FROM drivers WHERE user_id = ${auth.userId}
const rows = await sql<{
id: number;
service: ServiceId;
online: boolean;
approval_status: DriverApprovalStatus;
}>`
SELECT id, service, online, approval_status
FROM drivers WHERE user_id = ${auth.userId}
`;
if (!rows[0]) {
@@ -45,6 +72,40 @@ export const requireDriverProfile = async (
};
}
const { id, service, online } = rows[0];
return { auth, driverId: id, service, online };
const { id, service, online, approval_status } = rows[0];
return {
auth,
driverId: id,
service,
online,
approvalStatus: approval_status,
};
};
/**
* Gate for anything a driver can only do once they've been cleared to drive:
* going online, taking an offer, moving a ride through its states. Returns a
* ready-to-return 403 carrying the current status, so the client can show the
* pending / rejected screen instead of a bare error.
*/
export const requireApprovedDriver = async (
req: Request,
): Promise<DriverProfile | AuthError> => {
const result = await requireDriverProfile(req);
if ("error" in result) return result;
if (result.approvalStatus !== "approved") {
return {
error: Response.json(
{
error: "Your driver account is not approved yet.",
code: "NOT_APPROVED",
approval_status: result.approvalStatus,
},
{ status: 403 },
),
};
}
return result;
};
+52 -1
View File
@@ -4,22 +4,56 @@ import { useState, useEffect, useCallback } from "react";
// callers never have to await SecureStore before every request.
let authToken: string | null = null;
// Whether the current token has already been reported dead. A signed-in screen
// usually has several requests in flight — the driver dashboard poll, the call
// watcher, a profile load — and a token that has expired fails all of them
// within a few milliseconds. Without this latch each one would separately tear
// the session down, and signing out is not free: it releases the push device
// and stops the location foreground service.
let unauthorizedNotified = false;
let onUnauthorized: (() => void) | null = null;
export const setAuthToken = (token: string) => {
authToken = token;
unauthorizedNotified = false;
};
export const clearAuthToken = () => {
authToken = null;
};
/**
* Registers what to do when the server rejects a token we actually sent.
*
* Lives at module scope for the same reason the token does: `fetchAPI` is a
* plain function called from stores, effects and helpers that have no React
* context to read. lib/session.tsx registers the real handler on mount.
*/
export const setUnauthorizedHandler = (handler: (() => void) | null) => {
onUnauthorized = handler;
};
/** Carries the HTTP status so callers can branch on it instead of on text. */
export class ApiError extends Error {
status: number;
/**
* The parsed error body, when there was one. Routes that reject with a
* recoverable state attach what the client needs to recover a 409 on ride
* creation carries the `ride_id` already in progress, so the screen can send
* the rider there instead of just apologising.
*/
body: Record<string, unknown> | null;
constructor(status: number, message: string) {
constructor(
status: number,
message: string,
body: Record<string, unknown> | null = null,
) {
super(message);
this.name = "ApiError";
this.status = status;
this.body = body;
}
}
@@ -30,6 +64,13 @@ export const fetchAPI = async (url: string, options?: RequestInit) => {
headers.set("Authorization", `Bearer ${authToken}`);
}
// Only requests that carried a token can tell us anything about that
// token. Signing in is itself a 401 when the password is wrong, and that
// request is unauthenticated by definition — treating it as a dead session
// would sign the user out of the account they are in the middle of
// signing in to.
const authenticated = headers.has("Authorization");
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
@@ -37,9 +78,19 @@ export const fetchAPI = async (url: string, options?: RequestInit) => {
// what actually went wrong instead of guessing from a status code.
const body = await response.json().catch(() => null);
// The token is gone or expired. Callers still get the ApiError — a
// screen may want to stop polling or hide a spinner — but none of them
// can recover from this one, and leaving the session in place is what
// let an expired token look like a missing driver profile.
if (response.status === 401 && authenticated && !unauthorizedNotified) {
unauthorizedNotified = true;
onUnauthorized?.();
}
throw new ApiError(
response.status,
body?.error ?? `Request failed with status ${response.status}.`,
body,
);
}
+57
View File
@@ -0,0 +1,57 @@
import { requireOptionalNativeModule } from "expo-modules-core";
import type * as ImagePickerModule from "expo-image-picker";
export type ImagePickerApi = typeof ImagePickerModule;
/** The native module expo-image-picker is a JS wrapper around. */
const NATIVE_MODULE = "ExponentImagePicker";
/**
* Whether photo capture can work at all in this build.
*
* `requireOptionalNativeModule` is the non-throwing twin of the
* `requireNativeModule` call that expo-image-picker makes as it loads: it
* returns null instead of raising `Cannot find native module
* 'ExponentImagePicker'`. Asking first means the error is never constructed,
* never logged, and never has a chance to escape into a driver's face which
* beats importing the package and catching the throw, because a throw that
* happens while a module is evaluating can surface in places a try/catch
* around the import does not cover.
*
* expo-modules-core itself is part of every Expo binary, so importing it here
* is safe on exactly the old builds this is guarding against.
*/
export const isImagePickerAvailable = (): boolean =>
requireOptionalNativeModule(NATIVE_MODULE) !== null;
/**
* Loads expo-image-picker, or returns null when this binary predates it.
*
* The package resolves its native counterpart at *import* time, so importing
* it at the top of a screen doesn't fail politely at the camera button: it
* fails while the route tree is being built, taking the whole app down
* riders included on any build made before the package was added. Deferring
* the require moves that failure to the one tap that needs it and makes it
* recoverable.
*
* A null return means one thing only: the app needs rebuilding. Photo capture
* genuinely requires the native module; nothing here can substitute for it.
*/
let cached: ImagePickerApi | null = null;
export const loadImagePicker = (): ImagePickerApi | null => {
if (cached) return cached;
if (!isImagePickerAvailable()) return null;
try {
// Static string so Metro still bundles it — only the evaluation is
// deferred, not the packaging.
// eslint-disable-next-line @typescript-eslint/no-var-requires
cached = require("expo-image-picker") as ImagePickerApi;
return cached;
} catch (error) {
console.log("[IMAGE_PICKER_UNAVAILABLE]: ", error);
return null;
}
};
+214
View File
@@ -0,0 +1,214 @@
// Background location for drivers.
//
// Dispatch drops any driver whose last position ping is over 60 seconds old.
// The old foreground-only watch stopped the moment the app was backgrounded,
// so a driver who locked their phone went stale within a minute and quietly
// left the match pool — while the app still showed them as "Online".
//
// expo-location's task-based updates keep running behind an Android foreground
// service (the persistent "Waseel is finding you rides" notification), which
// both keeps the process alive and makes the location use visible to the
// driver, as it should be.
//
// The task must be defined at module scope, not inside a component: Android
// can restart the app process headlessly to deliver a location update, and the
// task has to already be registered when the JS bundle finishes evaluating.
// This module is imported from app/_layout.tsx for exactly that reason.
import * as Location from "expo-location";
import * as TaskManager from "expo-task-manager";
import { AppState } from "react-native";
import { fetchAPI, setAuthToken } from "@/lib/fetch";
import { notifyRequest } from "@/lib/notifications";
import { readStoredToken } from "@/lib/token-store";
export const DRIVER_LOCATION_TASK = "waseel-driver-location";
// Relative API paths ("/(api)/...") are resolved against the router origin,
// which is set up when the app's React tree boots. A location update can be
// delivered to a process Android restarted headlessly, where that hasn't
// necessarily happened — so the background ping addresses the server
// explicitly. Falls back to the relative path when no origin is configured,
// which is the normal in-app case.
const API_ORIGIN = (process.env.EXPO_PUBLIC_SERVER_URL ?? "").replace(
/\/+$/,
"",
);
const endpoint = (path: string): string =>
API_ORIGIN ? `${API_ORIGIN}${path}` : path;
// Last position we know about, shared between the background task and the
// foreground hook. The heartbeat re-sends this on a timer even when nothing
// new arrives, because "where the driver is" and "is the driver still there"
// are different questions and only the second one has a deadline.
export type DriverFix = {
latitude: number;
longitude: number;
/** Degrees clockwise from north, or null when the device can't tell. */
heading?: number | null;
/** km/h, or null when unknown. */
speedKph?: number | null;
};
let lastKnownCoords: DriverFix | null = null;
export const setLastKnownCoords = (fix: DriverFix): void => {
lastKnownCoords = fix;
};
export const getLastKnownCoords = () => lastKnownCoords;
// Set while the driver screen's heartbeat timer is running, so the background
// task doesn't send a second ping for the same position. When the app has been
// restarted headlessly there is no hook and no timer, and the task pings.
let heartbeatActive = false;
export const setHeartbeatActive = (active: boolean): void => {
heartbeatActive = active;
};
/**
* POST a position to the server and act on whatever came back with it.
*
* Exported because the foreground hook's heartbeat uses the same path one
* place that knows how a ping is made, so the background and foreground routes
* can't drift apart.
*/
export const pingDriverLocation = async (fix: DriverFix): Promise<void> => {
setLastKnownCoords(fix);
// On a headless restart the module-level auth token in lib/fetch is empty —
// no React tree has run to set it — so seed it from secure storage before
// the request. A no-op in the normal foreground case.
const token = await readStoredToken();
if (!token) return;
setAuthToken(token);
const res = await fetchAPI(endpoint("/(api)/driver/location"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latitude: fix.latitude,
longitude: fix.longitude,
heading: fix.heading ?? null,
speed_kph: fix.speedKph ?? null,
}),
});
// The heartbeat carries the nearest open request this driver could take.
// When the app is in the foreground the dashboard already lists it, so we
// only interrupt with a notification when they can't see the screen.
const request = res?.data?.pending_request;
if (request && AppState.currentState !== "active") {
await notifyRequest(request);
}
};
/**
* Normalise an expo-location fix into what the server stores.
*
* expo-location reports -1 for an unknown heading and can report a negative
* speed on some devices; both mean "no reading", not "north" and "reversing".
*/
export const fixFromCoords = (
coords: Location.LocationObjectCoords,
): DriverFix => ({
latitude: coords.latitude,
longitude: coords.longitude,
heading:
typeof coords.heading === "number" && coords.heading >= 0
? coords.heading
: null,
speedKph:
typeof coords.speed === "number" && coords.speed >= 0
? coords.speed * 3.6
: null,
});
TaskManager.defineTask(DRIVER_LOCATION_TASK, async ({ data, error }) => {
if (error) {
console.log("[DRIVER_LOCATION_TASK]: ", error.message);
return;
}
const { locations } = (data ?? {}) as {
locations?: Location.LocationObject[];
};
const last = locations?.[locations.length - 1];
if (!last) return;
// Always record the position — this is what the heartbeat timer re-sends.
setLastKnownCoords(fixFromCoords(last.coords));
// The driver screen's timer owns the heartbeat whenever it's running. The
// task only pings when there is no timer, i.e. Android restarted the process
// headlessly to deliver this update and no React tree ever mounted.
if (heartbeatActive) return;
try {
await pingDriverLocation(fixFromCoords(last.coords));
} catch (err) {
// A failed ping is non-fatal — the next one retries. What takes a driver
// out of the match pool is last_seen going stale, not a single 500.
console.log("[DRIVER_LOCATION_TASK_PING]: ", err);
}
});
/** Is the background task currently delivering updates? */
export const isTrackingLocation = async (): Promise<boolean> => {
try {
return await Location.hasStartedLocationUpdatesAsync(DRIVER_LOCATION_TASK);
} catch {
return false;
}
};
/**
* Start background tracking. Returns false when the OS refused, so the caller
* can fall back to the foreground-only watch rather than leaving the driver
* with no tracking at all.
*/
export const startBackgroundTracking = async (): Promise<boolean> => {
try {
if (await isTrackingLocation()) return true;
await Location.startLocationUpdatesAsync(DRIVER_LOCATION_TASK, {
accuracy: Location.Accuracy.Balanced,
timeInterval: 5000,
// Deliberately 0, not a displacement threshold. On Android the time and
// distance conditions are AND-ed (distanceInterval becomes
// setSmallestDisplacement), so a driver parked at a taxi stand — the
// single most common way to wait for a ride — produces no updates at
// all, goes stale after 60s and silently drops out of the match pool
// while the app still says "Online". The ping IS the liveness signal, so
// it has to fire whether or not the car has moved.
distanceInterval: 0,
// Position updates are worthless late, and Android will otherwise hold
// them back to save battery.
deferredUpdatesInterval: 0,
pausesUpdatesAutomatically: false,
foregroundService: {
notificationTitle: "Waseel — you're online",
notificationBody: "Receiving ride requests. Tap to open.",
notificationColor: "#0286FF",
},
});
return true;
} catch (error) {
console.log("[DRIVER_LOCATION_START]: ", error);
return false;
}
};
/** Stop background tracking and tear down the foreground service. */
export const stopBackgroundTracking = async (): Promise<void> => {
try {
if (await isTrackingLocation()) {
await Location.stopLocationUpdatesAsync(DRIVER_LOCATION_TASK);
}
} catch (error) {
console.log("[DRIVER_LOCATION_STOP]: ", error);
}
};
+1 -1
View File
@@ -142,7 +142,7 @@ export const calculateDriverTimes = async ({
}
};
// A single trip-leg fare estimate for the confirm-ride screen. One Directions
// A single trip-leg fare estimate for the request 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.
+191
View File
@@ -0,0 +1,191 @@
// Client half of ride-offer notifications.
//
// The driver dashboard polls every few seconds, but a poll only runs while the
// app is foregrounded and an offer expires in 15 seconds — so a locked phone
// was silently skipped by dispatch and the driver never learned a ride had
// been offered to them.
//
// There are two ways to fix that, and this app uses both:
//
// 1. LOCAL notifications, which work with no credentials at all. While a
// driver is online the app runs a location foreground service (see
// lib/location-task.ts), so JS is alive even with the screen off. Each
// location ping tells us whether an offer is waiting, and we raise a
// local notification for it. This is the path that works today.
//
// 2. REMOTE push through Expo, which additionally reaches a driver whose app
// has been killed outright. It needs an EAS project id and FCM/APNs
// credentials, neither of which is configured yet — so registerForPush
// returns null and the server simply has no tokens to send to. Nothing
// breaks; it lights up on its own once those credentials exist.
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
import { OFFER_CHANNEL_ID } from "@/constants/dispatch";
import { fetchAPI } from "@/lib/fetch";
/**
* How a notification behaves when it lands while the app is open. A ride offer
* is time-critical, so it is shown rather than swallowed the driver may be
* on another screen, and four seconds of poll latency is a quarter of the
* window they have to answer.
*/
export const configureNotificationHandler = (): void => {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
};
/**
* Android routes every notification through a channel, and the channel not
* the message decides whether it makes a sound, vibrates, or is allowed to
* interrupt. A ride offer needs all three, so it gets its own channel at MAX
* importance instead of riding on the default one.
*/
export const ensureOfferChannel = async (): Promise<void> => {
if (Platform.OS !== "android") return;
try {
await Notifications.setNotificationChannelAsync(OFFER_CHANNEL_ID, {
name: "Ride requests",
importance: Notifications.AndroidImportance.MAX,
// Distinctive double-buzz so an offer is recognisable from a pocket.
vibrationPattern: [0, 250, 150, 400],
sound: "default",
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
lightColor: "#0286FF",
});
} catch (error) {
console.log("[NOTIF_CHANNEL]: ", error);
}
};
/**
* Ask for the notification permission. Called when a driver goes online, which
* is the first moment the app has a concrete reason to interrupt them.
*/
export const ensureNotificationPermission = async (): Promise<boolean> => {
try {
await ensureOfferChannel();
const existing = await Notifications.getPermissionsAsync();
if (existing.status === "granted") return true;
const asked = await Notifications.requestPermissionsAsync();
return asked.status === "granted";
} catch (error) {
console.log("[NOTIF_PERMISSION]: ", error);
return false;
}
};
// An open request is re-reported by every location ping until this driver
// offers on it or it dies, so the notification has to be raised once per ride
// rather than once per ping — otherwise a driver gets a buzz every five
// seconds. Module-level because the location task is not a React component
// and has no state of its own.
let lastNotifiedRideId: number | null = null;
/**
* Raise a local notification for an open request nearby, at most once per
* ride. Returns whether a notification was actually presented.
*/
export const notifyRequest = async (request: {
ride_id: number;
origin_address: string;
fare_price: number;
}): Promise<boolean> => {
if (lastNotifiedRideId === request.ride_id) return false;
lastNotifiedRideId = request.ride_id;
try {
await ensureOfferChannel();
await Notifications.scheduleNotificationAsync({
content: {
title: "New ride request nearby",
body: `$${(request.fare_price / 100).toFixed(2)} · pickup at ${request.origin_address}`,
sound: "default",
priority: Notifications.AndroidNotificationPriority.MAX,
vibrate: [0, 250, 150, 400],
data: { type: "ride_request", rideId: request.ride_id },
},
// null means "present it now" rather than scheduling for later.
trigger: null,
});
return true;
} catch (error) {
console.log("[NOTIF_REQUEST]: ", error);
return false;
}
};
/** Clear the dedupe memory — called when the driver goes offline. */
export const resetOfferNotifications = (): void => {
lastNotifiedRideId = null;
};
/**
* Register this device for REMOTE push. Dormant until an EAS project id and
* FCM/APNs credentials are configured: without them getExpoPushTokenAsync
* throws, we log it and return null, and the server just has no token to send
* to. Local notifications above are unaffected.
*/
// Remembered so sign-out can release this device without the caller having to
// thread the token through the session.
let currentPushToken: string | null = null;
export const registerForPush = async (): Promise<string | null> => {
try {
const granted = await ensureNotificationPermission();
if (!granted) return null;
const { data: token } = await Notifications.getExpoPushTokenAsync();
if (!token) return null;
await fetchAPI("/(api)/push/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, platform: Platform.OS }),
});
currentPushToken = token;
return token;
} catch (error) {
// Expected until push credentials exist. Not fatal by design.
console.log("[PUSH_REGISTER]: ", error);
return null;
}
};
/**
* Hand this device back on sign-out. Phones get shared without this the
* previous account keeps receiving ride offers on a phone someone else is now
* signed in on. Safe to call when nothing was ever registered.
*/
export const releaseCurrentPush = async (): Promise<void> => {
const token = currentPushToken;
currentPushToken = null;
resetOfferNotifications();
if (token) await unregisterPush(token);
};
/**
* Release this device on sign-out, so the next person to use the phone doesn't
* receive the previous account's ride offers.
*/
export const unregisterPush = async (token: string): Promise<void> => {
try {
await fetchAPI("/(api)/push/token", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
} catch (error) {
console.log("[PUSH_UNREGISTER]: ", error);
}
};
+46
View File
@@ -17,6 +17,52 @@ export const FARE = {
// Parallel market rate used for the L.B.P. cash equivalent shown in-app.
export const LBP_RATE = 89500;
/**
* The platform's cut of each completed fare.
*
* Until this existed, the driver's earnings and the rider's fare were the same
* number: the dashboard summed `fare_price` and called it "Today's earnings",
* so a rider reading their receipt was reading the driver's revenue, and the
* company's own books had no line of its own. Splitting the fare is what makes
* "what the driver keeps" and "what the company earns" separate, answerable
* questions.
*/
export const COMMISSION_RATE = 0.2;
export type FareSplit = {
/** What the rider pays. */
fareCents: number;
/** What the platform keeps. */
platformFeeCents: number;
/** What the driver is owed. */
driverPayoutCents: number;
/** The rate this split was computed at. */
rate: number;
};
/**
* Split a fare into the driver's payout and the platform's fee.
*
* The fee is rounded and the payout is the remainder, so the two always add
* back up to exactly the fare computing both by multiplication would let a
* rounding cent go missing or be paid twice, which is the kind of discrepancy
* that surfaces months later as an unreconcilable ledger.
*/
export const splitFare = (
fareCents: number,
rate: number = COMMISSION_RATE,
): FareSplit => {
const fare = Math.max(0, Math.round(fareCents));
const platformFeeCents = Math.round(fare * rate);
return {
fareCents: fare,
platformFeeCents,
driverPayoutCents: fare - platformFeeCents,
rate,
};
};
export const calculateFare = (
{
distanceMeters,
+137
View File
@@ -0,0 +1,137 @@
// Server-side push delivery through Expo's push service.
//
// This is what makes dispatch work on a phone that is locked or in a pocket.
// The driver dashboard polls every few seconds, but a poll only runs while the
// app is foregrounded — and an offer expires in 15 seconds. Without a push, a
// driver who put their phone down is silently skipped by the matcher and never
// learns a ride was offered to them.
//
// No credentials are needed: Expo push tokens are addressed to Expo's service,
// which holds the FCM/APNs keys for the project. Delivery is best-effort by
// design — a failed push must never fail the request that triggered it, since
// the in-app poll is still there as a fallback.
import { sql } from "@/lib/db";
const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
// Expo rejects a batch larger than this.
const MAX_BATCH = 100;
export type PushMessage = {
title: string;
body: string;
/** Delivered to the app so a tap can route to the right screen. */
data?: Record<string, unknown>;
/** Android channel; must match one created on the client. */
channelId?: string;
};
type ExpoTicket = {
status: "ok" | "error";
id?: string;
message?: string;
details?: { error?: string };
};
/**
* Drop tokens Expo tells us are dead. A token goes stale when the app is
* uninstalled or its notification credentials are rotated; left in the table
* it would be retried on every single dispatch, forever.
*/
const pruneDeadTokens = async (
tokens: string[],
tickets: ExpoTicket[],
): Promise<void> => {
const dead = tickets
.map((ticket, i) => ({ ticket, token: tokens[i] }))
.filter(
({ ticket }) =>
ticket?.status === "error" &&
ticket.details?.error === "DeviceNotRegistered",
)
.map(({ token }) => token)
.filter(Boolean);
if (dead.length === 0) return;
await sql`DELETE FROM push_tokens WHERE token = ANY(${`{${dead.join(",")}}`}::text[])`;
};
/** Send one message to a set of device tokens. Never throws. */
export const sendPush = async (
tokens: string[],
message: PushMessage,
): Promise<void> => {
if (tokens.length === 0) return;
for (let i = 0; i < tokens.length; i += MAX_BATCH) {
const batch = tokens.slice(i, i + MAX_BATCH);
try {
const response = await fetch(EXPO_PUSH_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(
batch.map((to) => ({
to,
title: message.title,
body: message.body,
data: message.data ?? {},
sound: "default",
// A ride offer is worthless a few seconds late, so it must wake the
// device rather than being batched into a maintenance window.
priority: "high",
channelId: message.channelId ?? "default",
// Matches the offer TTL: if it hasn't been delivered by then, the
// ride has already moved to another driver.
ttl: 20,
})),
),
});
if (!response.ok) {
console.error("[PUSH_SEND]: HTTP", response.status);
continue;
}
const body = (await response.json()) as { data?: ExpoTicket[] };
if (body.data) await pruneDeadTokens(batch, body.data);
} catch (error) {
// Best-effort: the in-app poll still catches the offer.
console.error("[PUSH_SEND]: ", error);
}
}
};
/** Every device signed in as this user. */
export const tokensForUser = async (userId: string): Promise<string[]> => {
const rows = await sql<{ token: string }>`
SELECT token FROM push_tokens WHERE user_id = ${userId}
`;
return rows.map((r) => r.token);
};
/** Every device signed in as the account behind this driver profile. */
export const tokensForDriver = async (driverId: number): Promise<string[]> => {
const rows = await sql<{ token: string }>`
SELECT p.token
FROM push_tokens p
JOIN drivers d ON d.user_id = p.user_id
WHERE d.id = ${driverId}
`;
return rows.map((r) => r.token);
};
export const sendPushToUser = async (
userId: string,
message: PushMessage,
): Promise<void> => sendPush(await tokensForUser(userId), message);
export const sendPushToDriver = async (
driverId: number,
message: PushMessage,
): Promise<void> => sendPush(await tokensForDriver(driverId), message);
+63 -43
View File
@@ -4,33 +4,26 @@ import { ApiError, fetchAPI } from "@/lib/fetch";
import type { ServiceId } from "@/constants/services";
import type { Ride } from "@/types/type";
// The rider's request flow, used by the confirm-ride screen. This is the card
// (Areeba hosted checkout -> server verify -> consume order -> create ride)
// and cash (create ride directly) paths, now unified behind one entry point so
// the screen doesn't re-implement the gateway dance.
// The rider's side of dispatch, in two steps that used to be one.
//
// The ride is always created with status='requested' and driver_id=null; the
// server's auto-match engine assigns a driver asynchronously. Returns the
// created ride so the caller can navigate to the status screen.
// Asking for a ride and paying for it are now separate moments: the request
// goes out to nearby drivers the instant the rider taps "Find now", they watch
// offers come back, and money only changes hands once they have picked the
// driver they want. Nothing is charged for a ride nobody takes.
export type RequestInput = {
method: "cash" | "card";
service: ServiceId;
user: { name: string; email: string };
// Location snapshot at request time.
origin: { address: string; latitude: number; longitude: number };
destination: { address: string; latitude: number; longitude: number };
rideTimeSeconds: number;
fareCents: number;
};
export type RequestResult = { ride: Ride };
const recordRide = async (
input: RequestInput,
method: "cash" | "card",
orderId?: string,
): Promise<Ride> => {
/**
* Step one: open the request. Returns the created ride, which is already
* being broadcast to drivers by the time this resolves.
*/
export const createRideRequest = async (input: RequestInput): Promise<Ride> => {
const res = await fetchAPI("/(api)/ride/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -43,44 +36,50 @@ const recordRide = async (
destination_longitude: input.destination.longitude,
ride_time: Math.round(input.rideTimeSeconds),
fare_price: input.fareCents,
payment_method: method,
service: input.service,
...(orderId ? { payment_order_id: orderId } : {}),
}),
});
return res.data as Ride;
};
export const requestRide = async (input: RequestInput): Promise<RequestResult> => {
if (input.method === "cash") {
const ride = await recordRide(input, "cash");
return { ride };
}
/**
* Take a card payment for a ride that already exists, and return the paid
* order id for the selection call.
*
* The order is only *consumed* when the driver is assigned, so if the pick
* then fails the driver took another job while the rider was in the payment
* sheet the same order id can be used for the next driver rather than the
* rider paying twice.
*/
export const payByCard = async (input: {
ride: Ride;
user: { name: string; email: string };
}): Promise<string> => {
const { ride, user } = input;
// Card: create an Areeba checkout session on our server.
const { orderId, checkoutUrl, error } = await fetchAPI(
"/(api)/(areeba)/create",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: input.user.name || input.user.email,
email: input.user.email,
fare_cents: input.fareCents,
origin_address: input.origin.address,
destination_address: input.destination.address,
origin_latitude: input.origin.latitude,
origin_longitude: input.origin.longitude,
destination_latitude: input.destination.latitude,
destination_longitude: input.destination.longitude,
ride_time: Math.round(input.rideTimeSeconds),
name: user.name || user.email,
email: user.email,
fare_cents: ride.fare_price,
origin_address: ride.origin_address,
destination_address: ride.destination_address,
origin_latitude: ride.origin_latitude,
origin_longitude: ride.origin_longitude,
destination_latitude: ride.destination_latitude,
destination_longitude: ride.destination_longitude,
ride_time: ride.ride_time,
}),
},
);
if (error || !checkoutUrl) throw new Error(error || "No checkout URL");
// Open Areeba's hosted payment page. After payment the gateway redirects
// back to the app (waseel://book-ride).
// Areeba's hosted payment page. After payment the gateway redirects back to
// the app (waseel://book-ride).
const browserResult = await WebBrowser.openAuthSessionAsync(
checkoutUrl,
"waseel://book-ride",
@@ -88,12 +87,13 @@ export const requestRide = async (input: RequestInput): Promise<RequestResult> =
let resultIndicator: string | undefined;
if (browserResult.type === "success" && browserResult.url) {
resultIndicator = new URL(browserResult.url).searchParams.get(
"resultIndicator",
) ?? undefined;
resultIndicator =
new URL(browserResult.url).searchParams.get("resultIndicator") ??
undefined;
}
// Verify the payment server-side.
// The gateway's word is never taken from the client: the server asks Areeba
// directly before the order is marked paid.
const verification = await fetchAPI("/(api)/(areeba)/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -107,6 +107,26 @@ export const requestRide = async (input: RequestInput): Promise<RequestResult> =
);
}
const ride = await recordRide(input, "card", orderId);
return { ride };
return orderId as string;
};
/**
* Step two: pick a driver. This is the call that assigns the ride, records how
* it will be paid, and releases every other driver who offered.
*/
export const selectDriver = async (input: {
rideId: number;
offerId: number;
method: "cash" | "card";
orderId?: string;
}): Promise<void> => {
await fetchAPI(`/(api)/ride/${input.rideId}/select`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
offer_id: input.offerId,
payment_method: input.method,
...(input.orderId ? { payment_order_id: input.orderId } : {}),
}),
});
};
+46
View File
@@ -0,0 +1,46 @@
// Turning coordinates back into something a person recognises.
//
// Shared by the initial location fix and the map pin adjuster, so the address
// a rider sees while dragging the pin is formatted exactly like the one that
// was filled in for them automatically — two different shapes for the same
// place would read as a bug.
//
// Uses expo-location's on-device geocoder rather than the Places API: it costs
// nothing, works without the Google key, and this is a label, not a search.
import * as Location from "expo-location";
import { tr } from "@/lib/i18n";
/**
* A short, human address for a point "Hamra, Beirut" or the generic "your
* location" label when the geocoder has nothing useful. Never throws: a failed
* lookup costs the label, never the coordinates.
*/
export const addressForCoords = async (
latitude: number,
longitude: number,
): Promise<string> => {
try {
const [place] = await Location.reverseGeocodeAsync({ latitude, longitude });
if (!place) return tr("common.yourLocation");
// Street-level first, falling back through progressively coarser fields:
// a pin dropped in the middle of a field still deserves a name.
const line = [
place.name ?? place.street,
place.district ?? place.city ?? place.subregion,
place.region,
]
.filter(Boolean)
// The geocoder often repeats a value across fields ("Beirut, Beirut").
.filter((part, index, all) => all.indexOf(part) === index)
.slice(0, 2)
.join(", ");
return line || tr("common.yourLocation");
} catch (error) {
console.log("[REVERSE_GEOCODE]: ", error);
return tr("common.yourLocation");
}
};
+226
View File
@@ -0,0 +1,226 @@
// The ride state machine, shared by every endpoint that touches it.
//
// requested ──rider picks an offer──▶ accepted ──arrive──▶ arrived
// │ │ │ pickup code
// │ │ ▼
// │ │ en_route ──▶ completed
// │ │ │ │
// │ └─────────────┴────────────┴──── cancel ──▶ cancelled
// └── nobody offered in time ──▶ expired
//
// Dispatch is a broadcast, not a hand-off: a new request is put in front of
// every eligible driver near the pickup at once, each of them can volunteer
// for it (a row in ride_offers), and the rider chooses between whoever did.
// So a ride has exactly one moment of assignment — the rider's pick — rather
// than a driver claiming it and the rider being told after the fact.
//
// Keeping the status sets here (rather than inlining string arrays in each
// route) is what stops a new state like 'arrived' from being handled in one
// query and silently ignored in the next.
import { query, sql, type SqlValue } from "@/lib/db";
import { REQUEST_TTL_SECONDS } from "@/constants/dispatch";
export const RIDE_STATUSES = [
"requested",
"accepted",
"arrived",
"en_route",
"completed",
"cancelled",
"expired",
] as const;
export type RideStatus = (typeof RIDE_STATUSES)[number];
/** Ride is in flight for the rider: they should be on the tracking screen. */
export const ACTIVE_RIDE_STATUSES = [
"requested",
"accepted",
"arrived",
"en_route",
] as const;
/**
* Driver is committed to a ride and must not be shown new requests. Offering
* on a request costs a driver nothing and can be withdrawn, so it is the
* assignment not the offer that takes them off the board.
*/
export const DRIVER_BUSY_STATUSES = [
"accepted",
"arrived",
"en_route",
] as const;
/** Nothing more will happen to the ride. */
export const TERMINAL_RIDE_STATUSES = [
"completed",
"cancelled",
"expired",
] as const;
/** Chat and calls are open between the two parties in these states. */
export const CONNECTED_RIDE_STATUSES = [
"accepted",
"arrived",
"en_route",
] as const;
export const isTerminal = (status: string): boolean =>
(TERMINAL_RIDE_STATUSES as readonly string[]).includes(status);
// Postgres array literals for the sets above. Passed as a bound parameter and
// cast in the query — `status = ANY(${ACTIVE_STATUS_ARRAY}::text[])` — so a
// status list is defined once here instead of being retyped inline in every
// route, where adding a state means remembering every literal that needs it.
const pgArray = (v: readonly string[]): string => `{${v.join(",")}}`;
/** The rider may still call it off in these states — nobody is moving yet. */
export const RIDER_CANCELLABLE_STATUSES = [
"requested",
"accepted",
"arrived",
] as const;
/** The assigned driver's cancellation window: from being picked to the pickup. */
export const DRIVER_CANCELLABLE_STATUSES = [
"accepted",
"arrived",
] as const;
export const ACTIVE_STATUS_ARRAY = pgArray(ACTIVE_RIDE_STATUSES);
export const DRIVER_BUSY_ARRAY = pgArray(DRIVER_BUSY_STATUSES);
export const CONNECTED_STATUS_ARRAY = pgArray(CONNECTED_RIDE_STATUSES);
export const TERMINAL_STATUS_ARRAY = pgArray(TERMINAL_RIDE_STATUSES);
export const RIDER_CANCELLABLE_ARRAY = pgArray(RIDER_CANCELLABLE_STATUSES);
export const DRIVER_CANCELLABLE_ARRAY = pgArray(DRIVER_CANCELLABLE_STATUSES);
// Cancellation reasons the clients may send. Free text is rejected: these
// codes are what makes cancellations countable in the admin portal, and an
// open text field would turn that into an unqueryable mess.
export const CANCELLATION_REASONS = [
"changed_mind",
"wait_too_long",
"wrong_address",
"driver_no_show",
"rider_no_show",
"unreachable",
"vehicle_issue",
"other",
] as const;
export type CancellationReason = (typeof CANCELLATION_REASONS)[number];
export const isCancellationReason = (v: unknown): v is CancellationReason =>
typeof v === "string" &&
(CANCELLATION_REASONS as readonly string[]).includes(v);
// 4-digit pickup code. Not a secret worth hardening — it only has to be hard
// to guess on the first try in a parking lot, and the driver can only try it
// against a ride already assigned to them.
export const generatePickupCode = (): string =>
String(Math.floor(1000 + Math.random() * 9000));
/**
* Give up on `requested` rides nobody was picked for within
* REQUEST_TTL_SECONDS, and close whatever offers were sitting on them.
*
* Called from the lazy paths that stand in for a background worker the
* rider's status poll, the driver's dashboard poll so there is no daemon to
* keep alive.
*
* Unlike the old hand-off dispatch, a request with offers on it is expired
* like any other. Offers are volunteers, not commitments: a rider who never
* picked one has left three drivers holding a job that is never going to
* start, and the honest end of that is to close it and free them.
*/
export const expireStaleRequests = async (rideId?: number): Promise<number> => {
const values: SqlValue[] = [REQUEST_TTL_SECONDS];
let scope = "";
if (rideId !== undefined) {
values.push(rideId);
scope = ` AND ride_id = $${values.length}`;
}
try {
const rows = await query<{ ride_id: number }>(
`UPDATE rides
SET status = 'expired',
cancelled_at = CURRENT_TIMESTAMP,
cancelled_by = 'system',
cancellation_reason = 'no_drivers_available'
WHERE status = 'requested'
AND created_at < CURRENT_TIMESTAMP - make_interval(secs => $1)${scope}
RETURNING ride_id`,
values,
);
if (rows.length > 0) {
// Same sweep, so a driver's "waiting for the rider" card can never
// outlive the request it belongs to.
// Passed as a Postgres array literal and cast in the query, the same way
// the status sets above travel — SqlValue is deliberately scalar-only.
await query(
`UPDATE ride_offers
SET status = 'expired', responded_at = CURRENT_TIMESTAMP
WHERE status = 'offered'
AND ride_id = ANY($1::int[])`,
[`{${rows.map((r) => r.ride_id).join(",")}}`],
);
}
return rows.length;
} catch (error) {
console.error("[EXPIRE_STALE_REQUESTS]: ", error);
return 0;
}
};
/**
* Recompute a driver's headline rating from the ratings riders left them.
* Denormalised onto drivers.rating because every driver card and every match
* candidate reads it. Drivers with no ratings yet keep the 5.0 they onboard
* with, so a new driver isn't shown as unrated-and-therefore-bad.
*/
export const refreshDriverRating = async (driverId: number): Promise<void> => {
try {
await sql`
UPDATE drivers d
SET rating = COALESCE(agg.avg_rating, 5.0),
rating_count = COALESCE(agg.n, 0)
FROM (
SELECT
ROUND(AVG(rr.rating)::numeric, 1) AS avg_rating,
COUNT(*)::int AS n
FROM ride_ratings rr
JOIN rides r ON r.ride_id = rr.ride_id
WHERE rr.rater_type = 'rider' AND r.driver_id = ${driverId}
) AS agg
WHERE d.id = ${driverId}
`;
} catch (error) {
console.error("[REFRESH_DRIVER_RATING]: ", error);
}
};
/** The mirror of the above: what drivers thought of a rider. */
export const refreshRiderRating = async (userId: string): Promise<void> => {
try {
await sql`
UPDATE users u
SET rating = agg.avg_rating,
rating_count = COALESCE(agg.n, 0)
FROM (
SELECT
ROUND(AVG(rr.rating)::numeric, 1) AS avg_rating,
COUNT(*)::int AS n
FROM ride_ratings rr
JOIN rides r ON r.ride_id = rr.ride_id
WHERE rr.rater_type = 'driver' AND r.user_id = ${userId}
) AS agg
WHERE u.id = ${userId}
`;
} catch (error) {
console.error("[REFRESH_RIDER_RATING]: ", error);
}
};
+80
View File
@@ -0,0 +1,80 @@
// Shared ownership + liveness checks for anything scoped to a ride that both
// the rider and the assigned driver can touch (chat messages, calls). A
// rider authenticates via requireAuth (users.id UUID); a driver authenticates
// via requireDriverProfile (drivers.id INT). Because a single user account can
// be both a rider and a driver, we check the RIDER path first — otherwise a
// user who is also a driver would be misrouted to the driver branch for their
// own ride.
import { requireAuth } from "@/lib/jwt";
import { requireDriverProfile } from "@/lib/driver";
import { sql } from "@/lib/db";
import { CONNECTED_STATUS_ARRAY } from "@/lib/ride-lifecycle";
export type RideParticipant =
| { role: "rider"; userId: string; driverId: null }
| { role: "driver"; userId: string; driverId: number };
export type ParticipantError = { error: Response };
// Proves the caller is the ride's rider or its assigned driver and returns
// which one, so the caller can stamp sender_type / caller_type. Returns a
// ready-to-ship 403/401 error Response otherwise.
export const requireRideParticipant = async (
req: Request,
rideId: number,
): Promise<RideParticipant | ParticipantError> => {
// Rider path first: a user who owns the ride.
const auth = requireAuth(req);
if (!("error" in auth)) {
const riderRows = await sql<{ user_id: string }>`
SELECT user_id FROM rides WHERE ride_id = ${rideId} AND user_id = ${auth.userId}
`;
if (riderRows[0]) {
return { role: "rider", userId: auth.userId, driverId: null };
}
}
// Driver path: a user with a driver profile assigned to the ride.
const driver = await requireDriverProfile(req);
if ("error" in driver) {
// If the request had no valid auth at all, surface that 401 rather than a
// generic 403, so the client can re-authenticate.
if ("error" in auth) return { error: auth.error };
return {
error: Response.json(
{ error: "You are not part of this ride." },
{ status: 403 },
),
};
}
const driverRows = await sql<{ ride_id: number }>`
SELECT ride_id FROM rides WHERE ride_id = ${rideId} AND driver_id = ${driver.driverId}
`;
if (!driverRows[0]) {
return {
error: Response.json(
{ error: "You are not part of this ride." },
{ status: 403 },
),
};
}
return {
role: "driver",
userId: driver.auth.userId,
driverId: driver.driverId,
};
};
// A ride is "active" (chat/call allowed) while a driver is assigned and the
// ride is en route to or past acceptance but not yet terminal.
export const rideIsActive = async (rideId: number): Promise<boolean> => {
const rows = await sql<{ status: string }>`
SELECT status FROM rides
WHERE ride_id = ${rideId} AND driver_id IS NOT NULL
AND status = ANY(${CONNECTED_STATUS_ARRAY}::text[])
`;
return Boolean(rows[0]);
};
+27 -2
View File
@@ -9,9 +9,11 @@ import {
type ReactNode,
} from "react";
import { setAuthToken, clearAuthToken } from "./fetch";
import { setAuthToken, clearAuthToken, setUnauthorizedHandler } from "./fetch";
import { stopBackgroundTracking } from "./location-task";
import { releaseCurrentPush } from "./notifications";
import { TOKEN_KEY } from "./token-store";
const TOKEN_KEY = "waseel_auth_token";
const USER_KEY = "waseel_auth_user";
const REMEMBERED_EMAIL_KEY = "waseel_remembered_email";
const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
@@ -171,6 +173,17 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => {
);
const signOut = useCallback(async () => {
// Hand the device back before the token goes away — the release call needs
// to authenticate as the account that currently holds it. Phones get
// shared, and without this the previous account keeps receiving ride
// offers on a phone someone else is now signed in on.
await releaseCurrentPush();
// A driver who signs out is off shift: tear down the location foreground
// service too, or they're left with a "you're online" notification and a
// GPS drain for a session that has ended.
await stopBackgroundTracking();
clearAuthToken();
setUser(null);
@@ -178,6 +191,18 @@ export const SessionProvider = ({ children }: { children: ReactNode }) => {
await SecureStore.deleteItemAsync(USER_KEY);
}, []);
// The other half of the bargain struck in restore(): a token we cannot prove
// is expired stays in use until the server rejects it, and this is what
// happens when it does. Without it the app kept the dead token and every
// screen behind the session had to invent its own meaning for the resulting
// 401 — driver-home read it as "this user has no driver profile" and showed
// an onboarding form to a driver who had finished onboarding weeks ago.
useEffect(() => {
setUnauthorizedHandler(() => void signOut());
return () => setUnauthorizedHandler(null);
}, [signOut]);
const value = useMemo<SessionContextValue>(
() => ({
isLoaded,
+89
View File
@@ -0,0 +1,89 @@
// Who is holding the money, and who still has to hand it over.
//
// A completed ride splits into a driver payout and a platform fee, but the
// split alone doesn't say whether anyone has actually been paid. That depends
// on how the rider paid, because it decides who ends up holding the cash:
//
// Card — the rider pays the platform. The company already has its fee the
// moment the card settles, and now OWES THE DRIVER their payout.
//
// Cash — the driver takes the whole fare at the kerb. They already have
// their payout in their pocket, and now OWE THE COMPANY its fee.
//
// Unpaid — a cash ride the driver couldn't collect. Nobody has been paid and
// nothing is owed between them; the fare itself is simply lost.
//
// So "has the company collected?" has no single answer per ride — it's one
// question for card rides and the opposite question for cash ones. This module
// is the single place that knows the difference, so the admin ledger, the
// driver's balance and the settle endpoint can't drift apart.
/** Payment states in which a completed ride actually produced money. */
export const SETTLED_PAYMENT_STATUSES = ["paid", "cash_collected"] as const;
export const isPaidRide = (paymentStatus: string): boolean =>
(SETTLED_PAYMENT_STATUSES as readonly string[]).includes(paymentStatus);
/** Which side of a ride's money a settlement action refers to. */
export const SETTLEMENT_SIDES = ["platform_fee", "driver_payout"] as const;
export type SettlementSide = (typeof SETTLEMENT_SIDES)[number];
export const isSettlementSide = (v: unknown): v is SettlementSide =>
typeof v === "string" && (SETTLEMENT_SIDES as readonly string[]).includes(v);
export type RideMoney = {
status: string;
payment_status: string;
platform_fee_cents: number | null;
driver_payout_cents: number | null;
platform_fee_settled_at: string | null;
driver_payout_settled_at: string | null;
};
/**
* What a single completed ride still owes, and to whom.
*
* `companyOwedCents` is money the company is waiting on a cash ride whose
* fee the driver hasn't remitted. `driverOwedCents` is money the company still
* has to pay out a card ride the driver hasn't been paid for. A ride that
* never happened, or was never paid for, owes nothing in either direction.
*/
export const rideBalance = (
ride: RideMoney,
): { companyOwedCents: number; driverOwedCents: number } => {
if (ride.status !== "completed" || !isPaidRide(ride.payment_status)) {
return { companyOwedCents: 0, driverOwedCents: 0 };
}
const fee = ride.platform_fee_cents ?? 0;
const payout = ride.driver_payout_cents ?? 0;
return {
companyOwedCents: ride.platform_fee_settled_at === null ? fee : 0,
driverOwedCents: ride.driver_payout_settled_at === null ? payout : 0,
};
};
/**
* The settlement timestamps a ride should be born with, given how it was paid.
*
* Whoever physically ends up holding their own share is settled the instant
* the ride completes there is no transfer left to make. Only the other side
* is left outstanding, and that's the one somebody has to act on.
*/
export const initialSettlement = (
paymentStatus: string,
): { platformFeeSettled: boolean; driverPayoutSettled: boolean } => {
switch (paymentStatus) {
// Company holds the fare: its fee is in hand, the driver is owed.
case "paid":
return { platformFeeSettled: true, driverPayoutSettled: false };
// Driver holds the fare: their payout is in hand, the company is owed.
case "cash_collected":
return { platformFeeSettled: false, driverPayoutSettled: true };
// Nobody was paid; there is nothing to settle between them.
default:
return { platformFeeSettled: false, driverPayoutSettled: false };
}
};
+22
View File
@@ -0,0 +1,22 @@
// Where the session token lives on disk.
//
// Split out of lib/session.tsx so the background location task can read the
// token without importing the session provider: the task module is imported
// by app/_layout at bundle evaluation, and sign-out needs to stop the task —
// pointing those two at each other would be an import cycle. Both depend on
// this leaf instead.
import * as SecureStore from "expo-secure-store";
export const TOKEN_KEY = "waseel_auth_token";
/**
* The stored session token, read straight from secure storage.
*
* Normal requests use the in-memory copy in lib/fetch, which the session
* provider sets on sign-in. The background location task can't rely on that:
* Android may restart the app process headlessly to deliver a location update,
* with no React tree run and therefore no token in memory.
*/
export const readStoredToken = (): Promise<string | null> =>
SecureStore.getItemAsync(TOKEN_KEY);
+314 -3
View File
@@ -135,6 +135,9 @@ export const ar = {
recentRides: "الرحلات الأخيرة",
noRecent: "لا توجد رحلات حديثة.",
noRecentAlt: "لا توجد رحلات حديثة",
activeRideWithDriver: "{name} في الطريق",
rateLastRide: "كيف كانت رحلتك الأخيرة؟",
rate: "قيّم",
},
rides: {
@@ -155,7 +158,38 @@ export const ar = {
title: "الدردشة",
messageAlt: "رسالة",
noMessages: "لا توجد رسائل بعد",
startConversation: "ابدأ محادثة مع أصدقائك وعائلتك",
startConversation: "تفتح المحادثة مع سائقك بمجرد مطابقة الرحلة.",
inputPlaceholder: "رسالة…",
send: "إرسال",
loadError: "تعذّر تحميل الرسائل. اسحب للأسفل لإعادة المحاولة.",
sendError: "تعذّر إرسال الرسالة. حاول مرة أخرى.",
cannotMessage: "لم تعد هذه الرحلة نشطة.",
call: "اتصال",
},
call: {
incoming: "مكالمة واردة",
outgoing: "جارٍ الاتصال…",
connecting: "جارٍ التوصيل…",
inCall: "أثناء المكالمة",
ended: "انتهت المكالمة",
missed: "مكالمة فائتة",
declined: "تم رفض المكالمة",
failed: "فشل الاتصال",
unavailable: "لا توجد رحلة نشطة للاتصال.",
accept: "قبول",
decline: "رفض",
end: "إنهاء المكالمة",
mute: "كتم",
unmute: "إلغاء الكتم",
speaker: "مكبر الصوت",
speakerOff: "إيقاف مكبر الصوت",
cancel: "إلغاء",
connectingWith: "جارٍ الاتصال بـ {name}…",
micDeniedTitle: "الميكروفون محظور",
micDeniedBody:
"يحتاج وسيط إلى ميكروفون لإجراء المكالمات. فعّله من الإعدادات.",
audioFailed: "تعذّر بدء الصوت. حاول مرة أخرى.",
},
profile: {
@@ -169,14 +203,34 @@ export const ar = {
emailPlaceholder: "بريدك الإلكتروني",
},
adjustPin: {
pickupLabel: "نقطة الصعود",
destinationLabel: "نقطة النزول",
locating: "جارٍ تحديد المكان…",
hint: "حرّك الخريطة لوضع الدبوس في المكان الذي تريده تمامًا.",
confirmPickup: "تأكيد نقطة الصعود",
confirmDestination: "تأكيد نقطة النزول",
recenter: "الذهاب إلى موقعي",
},
findRide: {
adjustOnMap: "حدّدها على الخريطة",
title: "الرحلة",
from: "من",
to: "إلى",
findNow: "ابحث الآن",
service: "نوع الرحلة",
nAvailable: "{n} قريب",
estimatedFare: "الأجرة التقديرية",
setBothPoints: "حدّد نقطة الانطلاق والوجهة",
payLaterHint:
"سيرى السائقون القريبون طلبك. أنت تختار من يأخذه، وتدفع بعد ذلك.",
sending: "جارٍ إرسال طلبك…",
},
confirmRide: {
noDriversInRadius: "لا يوجد سائقو {service} ضمن {km} كم",
tryInstead: "المتاح قربك الآن:",
title: "طلب رحلة",
yourTrip: "رحلتك",
pickup: "نقطة الصعود",
@@ -189,6 +243,13 @@ export const ar = {
lbpEstimate: "≈ {lbp}",
noDrivers: "لا يوجد سائقو {service} متصلون الآن",
findingDrivers: "جارٍ البحث عن سائقين قريبين…",
driversNearby: {
one: "سائق واحد قريب",
other: "{n} سائقين قريبين",
zero: "لا سائقين قريبين",
},
withinRadius: "ضمن {km} كم منك",
searchingRadius: "البحث ضمن {km} كم…",
nearestDriver: "أقرب سائق ≈ {eta} دقيقة",
requesting: "جارٍ الطلب…",
noDriversOnline: "لا يوجد سائقون متصلون",
@@ -202,20 +263,60 @@ export const ar = {
alertErrorFallback: "حدث خطأ أثناء حجز رحلتك. حاول مرة أخرى.",
alertPayCardTitle: "الدفع بالبطاقة",
alertPayCardBody: "سيتم خصم ${fare} من بطاقتك.",
alertInProgressTitle: "لديك رحلة جارية",
alertInProgressBody: "لديك رحلة جارية. أنهِها أو ألغِها قبل حجز رحلة أخرى.",
viewRide: "عرض الرحلة",
},
bookRide: {
status: {
requested: "جارٍ البحث عن سائقك…",
choosing: "اختر سائقك",
accepted: "تم تعيين سائق — في طريقه إليك",
enRoute: "أنت في الرحلة",
completed: "وصلت!",
cancelled: "تم إلغاء الرحلة",
arrived: "سائقك وصل",
expired: "لا يوجد سائق متاح",
},
rideNotFound: "الرحلة غير موجودة.",
couldNotLoad: "تعذّر تحميل هذه الرحلة.",
backHome: "العودة للرئيسية",
matchingDriver: "نطابقك مع أقرب سائق {service}.",
searchingFor: "جارٍ البحث منذ {seconds} ثانية",
match: {
driverFallback: "سائقك",
alertBody: "حدث خطأ ما. حاول مرة أخرى.",
},
offers: {
title: "سائقون متاحون",
count: {
one: "عرض واحد",
other: "{n} عروض",
zero: "لا عروض بعد",
},
away: "{eta} دقيقة · {distance}",
seats: {
one: "مقعد واحد",
other: "{n} مقاعد",
zero: "",
},
pick: "اختر",
goneTitle: "لم يعد هذا السائق متاحًا",
goneBody: "ارتبط برحلة أخرى. اختر سائقًا آخر من القائمة.",
goneBodyPaid:
"ارتبط برحلة أخرى. لم يُستخدم دفعك — اختر سائقًا آخر وسيذهب المبلغ إليه.",
},
payment: {
title: "كيف تريد الدفع؟",
titleNamed: "رحلة مع {name}",
subtitle: "الأجرة ${fare}",
cash: "الدفع نقدًا",
cashHint: "سلّم الأجرة للسائق عند الوصول.",
card: "الدفع بالبطاقة",
cardHint: "يُخصم الآن، قبل انطلاق السائق.",
working: "جارٍ التنفيذ…",
},
ratingFallback: "—",
paymentCash: "💵 نقدًا للسائق",
paymentCard: "💳 مدفوع بالبطاقة",
@@ -226,10 +327,25 @@ export const ar = {
cancelling: "جارٍ الإلغاء…",
alertErrorTitle: "خطأ",
alertErrorBody: "تعذّر إلغاء هذه الرحلة. حاول مرة أخرى.",
pickupCodeLabel: "رمز الصعود",
pickupCodeHint: "أعطِ هذا الرمز للسائق لبدء الرحلة.",
driverHere: "سائقك في الخارج",
cashDue: "ادفع ${amount} نقدًا للسائق.",
youRated: "قيّمت هذه الرحلة {n}★",
rateDriver: "قيّم سائقك",
noDriversFound:
"لم يقبل أي سائق طلبك. لم يتم خصم أي مبلغ — حاول مرة أخرى بعد قليل.",
cancelledByDriver: "ألغى السائق هذه الرحلة.",
enRouteNotice: "رحلة سعيدة — سينهي السائق الرحلة عند الوصول.",
},
driver: {
home: {
owesCompany: "العمولة المستحقة عليك",
owesCompanyHint: "من رحلاتك النقدية — سلّمها في المكتب.",
owedToDriver: "الشركة مدينة لك",
owedToDriverHint: "رحلات البطاقة، تُدفع لك.",
afterFee: "بعد عمولة ${fee}",
signOutAlt: "تسجيل الخروج",
driverMode: "وضع السائق",
online: "● متصل — يتلقّى طلبات الرحلات",
@@ -238,6 +354,7 @@ export const ar = {
completedToday: "المكتملة اليوم",
incomingRequests: "الطلبات الواردة",
incomingRequestsOffline: "الطلبات الواردة (غير متصل)",
finishCurrentRide: "أنهِ رحلتك الحالية لرؤية الطلبات الجديدة.",
waitingRequests: "بانتظار طلبات الرحلات…",
goOnlineStart: "اتصل لبدء القيادة.",
welcome: "أهلاً {name}",
@@ -256,35 +373,216 @@ export const ar = {
alertCreateBody: "تعذّر إنشاء ملف السائق. حاول مرة أخرى.",
alertToggleBody: "تعذّر تغيير حالتك. حاول مرة أخرى.",
noRequestsAlt: "لا توجد رحلات حديثة",
cashInHand: "النقد المحصّل اليوم",
uncollected: "أجرة غير محصّلة اليوم",
ratingCount: "{n} تقييم",
ratingNew: "سائق جديد",
alertOfflineBlocked: "أنهِ رحلتك الحالية أو ألغِها قبل قطع الاتصال.",
},
credentials: {
title: "أوراقك الثبوتية",
intro:
"ندقّق في أوراق كل سائق قبل أن يبدأ العمل. سيراجع فريقنا هذه المعلومات.",
licenseNumber: "رقم رخصة السوق",
licenseNumberPlaceholder: "كما هو مدوّن على الرخصة",
licenseExpiry: "تاريخ انتهاء الرخصة",
nationalId: "رقم الهوية",
nationalIdPlaceholder: "رقم بطاقة الهوية",
plateNumber: "رقم اللوحة",
plateNumberPlaceholder: "مثال: 123456/B",
reviewNote:
"يبقى حسابك غير متصل إلى أن تتم الموافقة عليه، وعادةً ما يستغرق ذلك أقل من يوم.",
submit: "إرسال للمراجعة",
errorTitle: "راجع معلوماتك",
errorMissing: "رقم الرخصة ورقم الهوية ورقم اللوحة كلها مطلوبة.",
errorExpiryFormat: "أدخل تاريخ انتهاء الرخصة بصيغة YYYY-MM-DD.",
errorExpired: "هذه الرخصة منتهية الصلاحية.",
errorScanRequired: "صوّر رخصة السوق قبل الإرسال.",
alertResubmitBody: "تعذّر إعادة إرسال معلوماتك. حاول مرة أخرى.",
},
captureUnavailable:
"التقاط الصور غير متوفّر في هذه النسخة من التطبيق. حدّث التطبيق إلى آخر إصدار وحاول مجددًا.",
photo: {
title: "صورتك",
hint: "تُلتقط الآن بالكاميرا، لا من معرض الصور. يراها الركاب بجانب اسمك عند اختيار السائق، ويتأكدون بها أنك أنت عند نقطة الانطلاق. انظر إلى الكاميرا في إضاءة جيدة.",
take: "التقط صورة",
retake: "أعد الالتقاط",
required: "التقط صورة شخصية قبل الإرسال.",
permissionTitle: "الإذن مطلوب",
permissionCamera:
"يحتاج وصيل إلى الكاميرا لالتقاط صورتك. اسمح بالوصول إلى الكاميرا للمتابعة.",
permissionCameraBlocked:
"الوصول إلى الكاميرا معطّل لتطبيق وصيل، ولن يسألك أندرويد مرة أخرى من هنا. افتح الإعدادات ثم فعّل «الكاميرا» ضمن الأذونات.",
errorTitle: "تعذّر حفظ الصورة",
errorBody: "حدث خطأ ما. حاول مرة أخرى.",
errorTooLarge: "الصورة كبيرة جدًا. جرّب التقاط صورة جديدة.",
errorRateLimit: "عدد المحاولات كبير. انتظر بضع دقائق وحاول مجددًا.",
errorUnsupported: "استخدم صورة بصيغة JPEG أو PNG أو WebP.",
},
scan: {
licenseLabel: "رخصة السوق",
licenseHint:
"ضعها على سطح مستوٍ واملأ بها الإطار. نقرأ منها الرقم وتاريخ الانتهاء.",
idLabel: "بطاقة الهوية",
idHint: "الوجه الذي يظهر فيه رقم الهوية.",
vehicle_regLabel: "رخصة سير السيارة",
vehicle_regHint: "الصفحة التي يظهر فيها رقم اللوحة ونوع السيارة.",
optional: "اختياري",
take: "التقط صورة",
retake: "أعد التصوير",
choose: "اختر صورة",
reading: "نقرأ المستند…",
filled: {
one: "عبّأنا معلومة واحدة — راجعها أدناه.",
other: "عبّأنا {n} معلومات — راجعها أدناه.",
},
savedNoFields:
"حفظنا الصورة، لكن تعذّرت قراءة المعلومات. أدخلها يدويًا أدناه.",
savedUnreadable:
"حفظنا الصورة. خدمة القراءة غير متوفّرة حاليًا — أدخل المعلومات يدويًا أدناه.",
alreadyOnFile: "لدينا صورة محفوظة مسبقًا. أعد التصوير عند الحاجة فقط.",
allRead: "قرأناها من أوراقك",
missingPrompt:
"تعذّرت قراءة هذه المعلومات من أوراقك. أضفها وينتهي الأمر.",
edit: "راجع المعلومات أو عدّلها",
done: "تم",
checkPrompt: "صحّح أي معلومة قُرئت خطأً، ثم اضغط تم.",
permissionTitle: "الإذن مطلوب",
permissionCamera:
"اسمح بالوصول إلى الكاميرا لتصوير أوراقك، أو اختر صورة موجودة بدلًا من ذلك.",
permissionCameraBlocked:
"الوصول إلى الكاميرا معطّل لتطبيق وصيل، ولن يسألك أندرويد مرة أخرى من هنا. افتح الإعدادات وفعّل «الكاميرا» ضمن الأذونات، أو اختر صورة موجودة بدلًا من ذلك.",
permissionLibrary: "اسمح بالوصول إلى الصور لاختيار صورة لأوراقك.",
permissionLibraryBlocked:
"الوصول إلى الصور معطّل لتطبيق وصيل، ولن يسألك أندرويد مرة أخرى من هنا. افتح الإعدادات وفعّل «الصور» ضمن الأذونات، أو التقط صورة بالكاميرا بدلًا من ذلك.",
errorTitle: "تعذّرت القراءة",
errorBody: "حدث خطأ ما. حاول مجددًا أو أدخل المعلومات يدويًا أدناه.",
errorTooLarge: "الصورة كبيرة جدًا. جرّب التقاط صورة جديدة.",
errorRateLimit: "عدد المحاولات كبير. انتظر بضع دقائق وحاول مجددًا.",
errorUnsupported: "استخدم صورة بصيغة JPEG أو PNG أو WebP.",
errorRetry: "لم يتم الرفع. حاول مجددًا أو أدخل المعلومات يدويًا أدناه.",
},
review: {
pendingTitle: "قيد المراجعة",
pendingBody:
"نراجع معلوماتك الآن. ستتمكّن من الاتصال فور الموافقة على حسابك.",
rejectedTitle: "لم تتم الموافقة",
rejectedBody: "لم تجتز معلوماتك المراجعة. صحّحها أدناه وأعد إرسالها.",
suspendedTitle: "الحساب موقوف",
suspendedBody:
"تم إيقاف حساب السائق الخاص بك. تواصل مع الدعم لمعالجة الأمر.",
approvedTitle: "تمت الموافقة",
approvedBody: "أنت جاهز للعمل.",
reasonLabel: "السبب",
checkAgain: "تحقّق مجددًا",
resubmitTitle: "صحّح معلوماتك",
resubmitIntro: "صحّح ما هو خاطئ وسنراجعه من جديد.",
resubmit: "إعادة الإرسال",
},
offerCard: {
youEarn: "أرباحك",
newRequest: "طلب جديد · {service}",
openFor: "متاح لمدة",
seconds: "{n} ث",
awayFromPickup: "{km} كم عن نقطة الصعود",
firstIn: "ستكون الأول",
rivals: {
one: "سائق آخر تقدّم",
other: "{n} سائقين آخرين تقدّموا",
zero: "ستكون الأول",
},
cash: "💵 نقدًا",
card: "💳 بطاقة",
fromAlt: "من",
toAlt: "إلى",
tripTime: "زمن الرحلة",
fare: "الأجرة",
decline: "رفض",
accept: "قبول",
offer: "تقدّم لهذه الرحلة",
withdraw: "سحب عرضي",
waitingOnRider: "تم التقديم — بانتظار اختيار الراكب",
lostTitle: "أُغلق الطلب",
lostBody:
"اختار الراكب سائقًا آخر، أو انتهت مهلة الطلب. أنت متاح للطلب التالي.",
alertOfferBody: "تعذّر إرسال عرضك. حاول مرة أخرى.",
alertWithdrawBody: "تعذّر سحب عرضك. حاول مرة أخرى.",
},
activeRide: {
youEarn: "أرباحك",
headToPickup: "اتجه إلى نقطة الصعود",
tripInProgress: "الرحلة جارية",
rider: "{name}",
pickupPin: "موقع صعود الراكب",
dropoffPin: "نقطة النزول",
navigateToPickup: "التوجّه إلى نقطة الصعود",
navigateToDropoff: "التوجّه إلى نقطة النزول",
alertNavigateBody: "تعذّر فتح تطبيق ملاحة على هذا الهاتف.",
fromAlt: "من",
toAlt: "إلى",
fare: "الأجرة",
message: "رسالة",
call: "اتصال",
startTrip: "ابدأ الرحلة",
completeTrip: "أنهِ الرحلة",
cancelRide: "إلغاء الرحلة",
cancelConfirmTitle: "إلغاء هذه الرحلة؟",
cancelConfirmBody: "سيتم إخطار الراكب وستُعلَّم الرحلة كملغاة.",
cancelConfirmDismiss: "الاحتفاظ بالرحلة",
cancelConfirmConfirm: "إلغاء الرحلة",
alertErrorTitle: "خطأ",
alertAcceptBody: "تعذّر قبول هذه الرحلة. ربما حُجزت أو انتهت صلاحيتها.",
alertDeclineBody: "تعذّر رفض هذه الرحلة. حاول مرة أخرى.",
alertUpdateBody: "تعذّر تحديث الرحلة. حاول مرة أخرى.",
alertCancelBody: "تعذّر إلغاء الرحلة. حاول مرة أخرى.",
imHere: "لقد وصلت",
atPickup: "عند نقطة الصعود — بانتظار الراكب",
askForCode: "اطلب من الراكب رمز الصعود المكوّن من 4 أرقام.",
cashConfirmTitle: "تحصيل الأجرة",
cashConfirmBody: "هل حصّلت ${amount} نقدًا من الراكب؟",
cashCollected: "نعم، حصّلتها",
cashNotCollected: "لم أحصّلها",
},
},
rating: {
rateDriverTitle: "كيف كانت رحلتك مع {name}؟",
rateRiderTitle: "كيف كان {name} كراكب؟",
subtitle: "تقييمك يبقى خاصًا ولا يُعرض للطرف الآخر.",
starLabel: "{n} نجوم",
commentPlaceholder: "أضف تعليقًا (اختياري)",
submit: "إرسال التقييم",
notNow: "ليس الآن",
error: "تعذّر إرسال تقييمك. حاول مرة أخرى.",
},
cancelSheet: {
title: "إلغاء هذه الرحلة؟",
subtitleRider: "أخبرنا بالسبب لتحسين المطابقة.",
subtitleDriver: "سيتم إخطار الراكب وستُعلَّم الرحلة كملغاة.",
confirm: "إلغاء الرحلة",
keepRide: "الاحتفاظ بالرحلة",
cancelling: "جارٍ الإلغاء…",
reasons: {
changed_mind: "غيّرت رأيي",
wait_too_long: "الانتظار طويل جدًا",
wrong_address: "عنوان صعود خاطئ",
driver_no_show: "السائق لم يصل",
rider_no_show: "الراكب لم يحضر",
unreachable: "تعذّر التواصل معه",
vehicle_issue: "مشكلة في المركبة",
other: "سبب آخر",
},
},
pickupCode: {
title: "ابدأ الرحلة",
subtitle: "أدخل الرمز المكوّن من 4 أرقام من شاشة الراكب.",
startTrip: "ابدأ الرحلة",
wrongCode: "الرمز غير مطابق. تحقّق مع الراكب.",
},
services: {
nearbyCount: "{n} قريب",
noneNearby: "لا يوجد قريب",
car: { label: "سيارة", tagline: "رحلة يومية، حتى 4 مقاعد." },
moto: { label: "موتور", tagline: "تجنّب الزحام — راكب واحد، بدون أمتعة." },
courier: {
@@ -374,6 +672,16 @@ export const ar = {
"الخريطة غير متاحة على الويب.\nافتح التطبيق على أندرويد أو iOS للتجربة الكاملة.",
},
rideCard: {
outcomeCompleted: "مكتملة",
outcomeCancelled: "ملغاة",
outcomeExpired: "لم يتم العثور على سائق",
cancelledByYou: "ألغيتها",
cancelledByDriver: "ألغاها السائق",
cancelledBySystem: "لا يوجد سائق متاح",
noDriver: "لم يُعيَّن سائق",
paymentNotCharged: "لم يتم الخصم",
paymentRefundDue: "مستحق الاسترداد",
paymentCashCollected: "دُفعت نقدًا",
mapAlt: "خريطة",
originAlt: "المصدر",
destinationAlt: "الوجهة",
@@ -470,6 +778,9 @@ export const ar = {
rtlRestartBody:
"سيُطبَّق التخطيط العربي بالكامل في المرة القادمة التي تفتح فيها التطبيق.",
},
general: {
title: "عام",
},
keepAwake: {
title: "عدم قفل الشاشة",
description: "إبقاء الشاشة مضاءة أثناء فتح التطبيق.",
+322 -3
View File
@@ -136,6 +136,9 @@ export const en = {
recentRides: "Recent Rides",
noRecent: "No recent rides found.",
noRecentAlt: "No recent rides found",
activeRideWithDriver: "{name} is on the way",
rateLastRide: "How was your last ride?",
rate: "Rate",
},
rides: {
@@ -156,7 +159,38 @@ export const en = {
title: "Chat",
messageAlt: "message",
noMessages: "No Messages Yet",
startConversation: "Start a conversation with your friends and family",
startConversation: "Messages open with your driver once a ride is matched.",
inputPlaceholder: "Message…",
send: "Send",
loadError: "Couldn't load messages. Pull to retry.",
sendError: "Couldn't send message. Try again.",
cannotMessage: "This ride is no longer active.",
call: "Call",
},
call: {
incoming: "Incoming call",
outgoing: "Calling…",
connecting: "Connecting…",
inCall: "In call",
ended: "Call ended",
missed: "Missed call",
declined: "Call declined",
failed: "Call failed",
unavailable: "No active ride to call.",
accept: "Accept",
decline: "Decline",
end: "End call",
mute: "Mute",
unmute: "Unmute",
speaker: "Speaker",
speakerOff: "Speaker off",
cancel: "Cancel",
connectingWith: "Connecting with {name}…",
micDeniedTitle: "Microphone blocked",
micDeniedBody:
"Waseel needs microphone access to make calls. Enable it in Settings.",
audioFailed: "Couldn't start audio. Please try again.",
},
profile: {
@@ -170,14 +204,34 @@ export const en = {
emailPlaceholder: "Your Email address",
},
adjustPin: {
pickupLabel: "Pickup point",
destinationLabel: "Drop-off point",
locating: "Finding this place…",
hint: "Drag the map to move the pin exactly where you want it.",
confirmPickup: "Confirm pickup point",
confirmDestination: "Confirm drop-off point",
recenter: "Go to my location",
},
findRide: {
adjustOnMap: "Set it on the map",
title: "Ride",
from: "From",
to: "To",
findNow: "Find now",
service: "Ride type",
nAvailable: "{n} nearby",
estimatedFare: "Estimated fare",
setBothPoints: "Set a pickup and destination",
payLaterHint:
"Drivers nearby will see your request. You choose who takes it, and pay after.",
sending: "Sending your request…",
},
confirmRide: {
noDriversInRadius: "No {service} drivers within {km} km",
tryInstead: "Available near you right now:",
title: "Request Ride",
yourTrip: "Your trip",
pickup: "Pickup",
@@ -190,6 +244,13 @@ export const en = {
lbpEstimate: "≈ {lbp}",
noDrivers: "No {service} drivers online right now",
findingDrivers: "Finding drivers nearby…",
driversNearby: {
one: "1 driver nearby",
other: "{n} drivers nearby",
zero: "No drivers nearby",
},
withinRadius: "Within {km} km of you",
searchingRadius: "Searching within {km} km…",
nearestDriver: "Nearest driver ≈ {eta} min away",
requesting: "Requesting…",
noDriversOnline: "No drivers online",
@@ -204,20 +265,61 @@ export const en = {
"Something went wrong while booking your ride. Please try again.",
alertPayCardTitle: "Pay by card",
alertPayCardBody: "Your card will be charged ${fare}.",
alertInProgressTitle: "Ride already in progress",
alertInProgressBody:
"You have a ride in progress. Finish or cancel it before booking another.",
viewRide: "View ride",
},
bookRide: {
status: {
requested: "Finding your driver…",
choosing: "Choose your driver",
accepted: "Driver assigned — heading to you",
enRoute: "On your trip",
completed: "You've arrived!",
cancelled: "Ride cancelled",
arrived: "Your driver is here",
expired: "No driver available",
},
rideNotFound: "Ride not found.",
couldNotLoad: "Could not load this ride.",
backHome: "Back Home",
matchingDriver: "We're matching you with the nearest {service} driver.",
searchingFor: "Searching for {seconds}s",
match: {
driverFallback: "Your driver",
alertBody: "Something went wrong. Please try again.",
},
offers: {
title: "Drivers available",
count: {
one: "1 offer",
other: "{n} offers",
zero: "No offers yet",
},
away: "{eta} min away · {distance}",
seats: {
one: "1 seat",
other: "{n} seats",
zero: "",
},
pick: "Choose",
goneTitle: "That driver is gone",
goneBody: "They took another ride. Pick someone else from the list.",
goneBodyPaid:
"They took another ride. Your payment hasn't been used — pick someone else and it will go to them.",
},
payment: {
title: "How would you like to pay?",
titleNamed: "Ride with {name}",
subtitle: "Fare ${fare}",
cash: "Pay cash",
cashHint: "Hand the fare to your driver at drop-off.",
card: "Pay by card",
cardHint: "Charged now, before your driver sets off.",
working: "Working…",
},
ratingFallback: "—",
paymentCash: "💵 Cash to driver",
paymentCard: "💳 Paid by card",
@@ -228,10 +330,26 @@ export const en = {
cancelling: "Cancelling…",
alertErrorTitle: "Error",
alertErrorBody: "Could not cancel this ride. Please try again.",
pickupCodeLabel: "Your pickup code",
pickupCodeHint: "Give this to your driver to start the trip.",
driverHere: "Your driver is outside",
cashDue: "Pay ${amount} in cash to your driver.",
youRated: "You rated this ride {n}★",
rateDriver: "Rate your driver",
noDriversFound:
"No driver picked up your request. Nothing was charged — try again in a moment.",
cancelledByDriver: "Your driver cancelled this ride.",
enRouteNotice:
"Enjoy your ride — your driver will end the trip on arrival.",
},
driver: {
home: {
owesCompany: "Commission you owe",
owesCompanyHint: "Your cash rides — hand this in at the office.",
owedToDriver: "The company owes you",
owedToDriverHint: "Card rides, paid out to you.",
afterFee: "after ${fee} platform fee",
signOutAlt: "Sign out",
driverMode: "Driver mode",
online: "● Online — receiving ride requests",
@@ -240,6 +358,7 @@ export const en = {
completedToday: "Completed today",
incomingRequests: "Incoming requests",
incomingRequestsOffline: "Incoming requests (offline)",
finishCurrentRide: "Finish your current ride to see new requests.",
waitingRequests: "Waiting for ride requests…",
goOnlineStart: "Go online to start driving.",
welcome: "Welcome, {name}",
@@ -260,36 +379,223 @@ export const en = {
"Could not create your driver profile. Please try again.",
alertToggleBody: "Could not change your status. Please try again.",
noRequestsAlt: "No recent rides found",
cashInHand: "Cash collected today",
uncollected: "Uncollected fares today",
ratingCount: "{n} ratings",
ratingNew: "New driver",
alertOfflineBlocked:
"Finish or cancel your current ride before going offline.",
},
credentials: {
title: "Your credentials",
intro:
"We check every driver before they can take a ride. These details are reviewed by our team.",
licenseNumber: "Driving licence number",
licenseNumberPlaceholder: "As printed on your licence",
licenseExpiry: "Licence expiry",
nationalId: "National ID number",
nationalIdPlaceholder: "Your ID card number",
plateNumber: "Plate number",
plateNumberPlaceholder: "e.g. 123456/B",
reviewNote:
"Your account stays offline until a reviewer approves it. This usually takes less than a day.",
submit: "Submit for review",
errorTitle: "Check your details",
errorMissing:
"Licence number, national ID and plate number are all required.",
errorExpiryFormat: "Enter the licence expiry as YYYY-MM-DD.",
errorExpired: "That licence has already expired.",
errorScanRequired: "Scan your driving licence before submitting.",
alertResubmitBody: "Could not resubmit your details. Please try again.",
},
captureUnavailable:
"Photo capture isn't available in this version of the app. Please update to the latest version and try again.",
photo: {
title: "Your photo",
hint: "Taken now with the camera, not from your gallery. Riders see it next to your name when they pick a driver, and use it to check it's you at pickup. Face the camera in good light.",
take: "Take photo",
retake: "Retake",
required: "Take a profile photo before submitting.",
permissionTitle: "Permission needed",
permissionCamera:
"Waseel needs the camera to take your photo. Allow camera access to continue.",
permissionCameraBlocked:
"Camera access is turned off for Waseel, and Android won't ask again from here. Open Settings, then turn on Camera under Permissions.",
errorTitle: "Couldn't save that photo",
errorBody: "Something went wrong. Please try again.",
errorTooLarge: "That photo is too large. Try taking a new one.",
errorRateLimit: "Too many uploads. Wait a few minutes and try again.",
errorUnsupported: "Use a JPEG, PNG or WebP photo.",
},
scan: {
licenseLabel: "Driving licence",
licenseHint:
"Lay it flat and fill the frame. We read the number and expiry off it.",
idLabel: "ID card",
idHint: "The side showing your ID number.",
vehicle_regLabel: "Vehicle registration",
vehicle_regHint: "The page showing the plate number and the car model.",
optional: "Optional",
take: "Take photo",
retake: "Rescan",
choose: "Choose photo",
reading: "Reading your document…",
filled: {
one: "Filled in 1 detail — check it below.",
other: "Filled in {n} details — check them below.",
},
savedNoFields:
"Photo saved, but we couldn't read the details. Type them in below.",
savedUnreadable:
"Photo saved. Scanning is unavailable right now — type the details in below.",
alreadyOnFile: "A scan is already on file. Rescan only if you need to.",
allRead: "Read from your documents",
missingPrompt:
"We couldn't read these off your documents. Please add them and you're done.",
edit: "Check or edit details",
done: "Done",
checkPrompt: "Correct anything that was read wrongly, then tap Done.",
permissionTitle: "Permission needed",
permissionCamera:
"Allow camera access to photograph your documents, or choose a photo instead.",
permissionCameraBlocked:
"Camera access is turned off for Waseel, and Android won't ask again from here. Open Settings and turn on Camera under Permissions — or choose an existing photo instead.",
permissionLibrary:
"Allow photo access to pick a picture of your documents.",
permissionLibraryBlocked:
"Photo access is turned off for Waseel, and Android won't ask again from here. Open Settings and turn on Photos under Permissions — or take a photo with the camera instead.",
errorTitle: "Couldn't scan that",
errorBody:
"Something went wrong. Try again, or type the details in below.",
errorTooLarge: "That photo is too large. Try taking a new one.",
errorRateLimit: "Too many scans. Wait a few minutes and try again.",
errorUnsupported: "Use a JPEG, PNG or WebP photo.",
errorRetry: "Not uploaded. Try again, or type the details in below.",
},
review: {
pendingTitle: "Under review",
pendingBody:
"We're checking your details. You'll be able to go online as soon as you're approved.",
rejectedTitle: "Not approved",
rejectedBody:
"Your details didn't pass review. Correct them below and submit again.",
suspendedTitle: "Account suspended",
suspendedBody:
"Your driver account has been suspended. Contact support to sort this out.",
approvedTitle: "Approved",
approvedBody: "You're cleared to drive.",
reasonLabel: "Reason",
checkAgain: "Check again",
resubmitTitle: "Correct your details",
resubmitIntro: "Fix what's wrong and we'll review it again.",
resubmit: "Submit again",
},
offerCard: {
youEarn: "You earn",
newRequest: "New request · {service}",
openFor: "On the board for",
seconds: "{n}s",
awayFromPickup: "{km} km from pickup",
firstIn: "You'd be first",
rivals: {
one: "1 other driver offered",
other: "{n} other drivers offered",
zero: "You'd be first",
},
cash: "💵 Cash",
card: "💳 Card",
fromAlt: "From",
toAlt: "To",
tripTime: "Trip time",
fare: "Fare",
decline: "Decline",
accept: "Accept",
offer: "Offer this ride",
withdraw: "Withdraw my offer",
waitingOnRider: "Offered — waiting for the rider to choose",
lostTitle: "Request closed",
lostBody:
"The rider went with another driver, or the request timed out. You're free for the next one.",
alertOfferBody: "Could not send your offer. Please try again.",
alertWithdrawBody: "Could not withdraw your offer. Please try again.",
},
activeRide: {
youEarn: "You earn",
headToPickup: "Head to pickup",
tripInProgress: "Trip in progress",
rider: "{name}",
pickupPin: "Rider pickup",
dropoffPin: "Drop-off",
navigateToPickup: "Navigate to pickup",
navigateToDropoff: "Navigate to drop-off",
alertNavigateBody: "Could not open a navigation app on this phone.",
fromAlt: "From",
toAlt: "To",
fare: "Fare",
message: "Message",
call: "Call",
startTrip: "Start trip",
completeTrip: "Complete trip",
cancelRide: "Cancel ride",
cancelConfirmTitle: "Cancel this ride?",
cancelConfirmBody:
"The rider will be notified and the ride will be marked as cancelled.",
cancelConfirmDismiss: "Keep ride",
cancelConfirmConfirm: "Cancel ride",
alertErrorTitle: "Error",
alertAcceptBody:
"Could not accept this ride. It may have been taken or expired.",
alertDeclineBody: "Could not decline this ride. Please try again.",
alertUpdateBody: "Could not update the ride. Please try again.",
alertCancelBody: "Could not cancel the ride. Please try again.",
imHere: "I've arrived",
atPickup: "At pickup — waiting for rider",
askForCode: "Ask the rider for their 4-digit pickup code.",
cashConfirmTitle: "Collect the fare",
cashConfirmBody: "Did you collect ${amount} in cash from the rider?",
cashCollected: "Yes, collected",
cashNotCollected: "Not collected",
},
},
rating: {
rateDriverTitle: "How was your ride with {name}?",
rateRiderTitle: "How was {name} as a passenger?",
subtitle: "Your rating stays private to the other person.",
starLabel: "{n} stars",
commentPlaceholder: "Add a comment (optional)",
submit: "Submit rating",
notNow: "Not now",
error: "Could not submit your rating. Please try again.",
},
cancelSheet: {
title: "Cancel this ride?",
subtitleRider: "Tell us why so we can improve matching.",
subtitleDriver: "The rider will be notified and the ride marked cancelled.",
confirm: "Cancel ride",
keepRide: "Keep ride",
cancelling: "Cancelling…",
reasons: {
changed_mind: "I changed my mind",
wait_too_long: "The wait is too long",
wrong_address: "Wrong pickup address",
driver_no_show: "The driver never arrived",
rider_no_show: "The rider never showed up",
unreachable: "I couldn't reach them",
vehicle_issue: "Vehicle problem",
other: "Another reason",
},
},
pickupCode: {
title: "Start the trip",
subtitle: "Enter the 4-digit code from the rider's screen.",
startTrip: "Start trip",
wrongCode: "That code doesn't match. Check with the rider.",
},
services: {
nearbyCount: "{n} nearby",
noneNearby: "None nearby",
car: { label: "Car", tagline: "An everyday ride, up to 4 seats." },
moto: {
label: "Moto",
@@ -385,6 +691,16 @@ export const en = {
"Map is not available on web.\nRun on Android/iOS for the full experience.",
},
rideCard: {
outcomeCompleted: "Completed",
outcomeCancelled: "Cancelled",
outcomeExpired: "No driver found",
cancelledByYou: "You cancelled",
cancelledByDriver: "Driver cancelled",
cancelledBySystem: "No driver available",
noDriver: "No driver assigned",
paymentNotCharged: "Not charged",
paymentRefundDue: "Refund due",
paymentCashCollected: "Cash paid",
mapAlt: "Map",
originAlt: "Origin",
destinationAlt: "Destination",
@@ -485,6 +801,9 @@ export const en = {
rtlRestartBody:
"Arabic layout will apply fully the next time you open the app.",
},
general: {
title: "General",
},
keepAwake: {
title: "Do not lock screen",
description: "Keep the display on while the app is open.",
+330 -3
View File
@@ -138,6 +138,9 @@ export const fr = {
recentRides: "Courses récentes",
noRecent: "Aucune course récente.",
noRecentAlt: "Aucune course récente",
activeRideWithDriver: "{name} arrive",
rateLastRide: "Comment s'est passée votre dernière course ?",
rate: "Noter",
},
rides: {
@@ -159,7 +162,38 @@ export const fr = {
messageAlt: "message",
noMessages: "Pas encore de messages",
startConversation:
"Démarrez une conversation avec vos amis et votre famille",
"La conversation s'ouvre avec votre chauffeur une fois la course attribuée.",
inputPlaceholder: "Message…",
send: "Envoyer",
loadError: "Impossible de charger les messages. Tirez pour réessayer.",
sendError: "Impossible d'envoyer le message. Réessayez.",
cannotMessage: "Cette course n'est plus active.",
call: "Appeler",
},
call: {
incoming: "Appel entrant",
outgoing: "Appel en cours…",
connecting: "Connexion…",
inCall: "En appel",
ended: "Appel terminé",
missed: "Appel manqué",
declined: "Appel refusé",
failed: "Échec de l'appel",
unavailable: "Aucune course active à appeler.",
accept: "Accepter",
decline: "Refuser",
end: "Terminer l'appel",
mute: "Muet",
unmute: "Activer le micro",
speaker: "Haut-parleur",
speakerOff: "Haut-parleur off",
cancel: "Annuler",
connectingWith: "Connexion à {name}…",
micDeniedTitle: "Microphone bloqué",
micDeniedBody:
"Waseel a besoin du microphone pour passer des appels. Activez-le dans les réglages.",
audioFailed: "Impossible de démarrer l'audio. Réessayez.",
},
profile: {
@@ -173,14 +207,35 @@ export const fr = {
emailPlaceholder: "Votre adresse e-mail",
},
adjustPin: {
pickupLabel: "Point de départ",
destinationLabel: "Point de dépose",
locating: "Localisation en cours…",
hint: "Faites glisser la carte pour placer le repère exactement où vous voulez.",
confirmPickup: "Confirmer le départ",
confirmDestination: "Confirmer la dépose",
recenter: "Aller à ma position",
},
findRide: {
adjustOnMap: "Placer sur la carte",
title: "Course",
from: "De",
to: "À",
findNow: "Rechercher",
service: "Type de course",
nAvailable: "{n} à proximité",
estimatedFare: "Tarif estimé",
setBothPoints: "Indiquez un départ et une destination",
payLaterHint:
"Les chauffeurs proches verront votre demande. Vous choisissez qui vous prend, et payez ensuite.",
sending: "Envoi de votre demande…",
},
confirmRide: {
noDriversInRadius: "Aucun chauffeur {service} dans un rayon de {km} km",
tryInstead: "Disponible près de vous maintenant :",
searchingRadius: "Recherche dans un rayon de {km} km…",
title: "Demander une course",
yourTrip: "Votre trajet",
pickup: "Départ",
@@ -193,6 +248,12 @@ export const fr = {
lbpEstimate: "≈ {lbp}",
noDrivers: "Aucun chauffeur {service} en ligne pour l'instant",
findingDrivers: "Recherche de chauffeurs à proximité…",
driversNearby: {
one: "1 chauffeur à proximité",
other: "{n} chauffeurs à proximité",
zero: "Aucun chauffeur à proximité",
},
withinRadius: "Dans un rayon de {km} km",
nearestDriver: "Chauffeur le plus proche ≈ {eta} min",
requesting: "Demande en cours…",
noDriversOnline: "Aucun chauffeur en ligne",
@@ -208,21 +269,63 @@ export const fr = {
"Une erreur est survenue lors de la réservation. Réessayez.",
alertPayCardTitle: "Payer par carte",
alertPayCardBody: "Votre carte sera débitée de ${fare}.",
alertInProgressTitle: "Course déjà en cours",
alertInProgressBody:
"Vous avez une course en cours. Terminez-la ou annulez-la avant d'en réserver une autre.",
viewRide: "Voir la course",
},
bookRide: {
status: {
requested: "Recherche de votre chauffeur…",
choosing: "Choisissez votre chauffeur",
accepted: "Chauffeur assigné — en route vers vous",
enRoute: "Course en cours",
completed: "Vous êtes arrivé !",
cancelled: "Course annulée",
arrived: "Votre chauffeur est arrivé",
expired: "Aucun chauffeur disponible",
},
rideNotFound: "Course introuvable.",
couldNotLoad: "Chargement de cette course impossible.",
backHome: "Retour à l'accueil",
matchingDriver:
"Nous vous mettons en relation avec le chauffeur {service} le plus proche.",
searchingFor: "Recherche depuis {seconds} s",
match: {
driverFallback: "Votre chauffeur",
alertBody: "Une erreur est survenue. Réessayez.",
},
offers: {
title: "Chauffeurs disponibles",
count: {
one: "1 proposition",
other: "{n} propositions",
zero: "Aucune proposition",
},
away: "{eta} min · {distance}",
seats: {
one: "1 place",
other: "{n} places",
zero: "",
},
pick: "Choisir",
goneTitle: "Ce chauffeur n'est plus libre",
goneBody:
"Il a pris une autre course. Choisissez-en un autre dans la liste.",
goneBodyPaid:
"Il a pris une autre course. Votre paiement n'a pas été utilisé — choisissez-en un autre et il lui sera versé.",
},
payment: {
title: "Comment souhaitez-vous payer ?",
titleNamed: "Course avec {name}",
subtitle: "Tarif ${fare}",
cash: "Payer en espèces",
cashHint: "Réglez la course au chauffeur à l'arrivée.",
card: "Payer par carte",
cardHint: "Débité maintenant, avant le départ du chauffeur.",
working: "En cours…",
},
ratingFallback: "—",
paymentCash: "💵 Espèces au chauffeur",
paymentCard: "💳 Payé par carte",
@@ -233,10 +336,26 @@ export const fr = {
cancelling: "Annulation…",
alertErrorTitle: "Erreur",
alertErrorBody: "Annulation de cette course impossible. Réessayez.",
pickupCodeLabel: "Votre code de prise en charge",
pickupCodeHint: "Donnez-le à votre chauffeur pour démarrer la course.",
driverHere: "Votre chauffeur est dehors",
cashDue: "Payez ${amount} en espèces à votre chauffeur.",
youRated: "Vous avez noté cette course {n}★",
rateDriver: "Noter votre chauffeur",
noDriversFound:
"Aucun chauffeur n'a accepté votre demande. Rien n'a été débité — réessayez dans un instant.",
cancelledByDriver: "Votre chauffeur a annulé cette course.",
enRouteNotice:
"Bonne route — votre chauffeur terminera la course à l'arrivée.",
},
driver: {
home: {
owesCompany: "Commission que vous devez",
owesCompanyHint: "Vos courses en espèces — à remettre au bureau.",
owedToDriver: "L'entreprise vous doit",
owedToDriverHint: "Courses par carte, à vous verser.",
afterFee: "après ${fee} de commission",
signOutAlt: "Déconnexion",
driverMode: "Mode chauffeur",
online: "● En ligne — réception des demandes",
@@ -245,6 +364,8 @@ export const fr = {
completedToday: "Terminées aujourd'hui",
incomingRequests: "Demandes entrantes",
incomingRequestsOffline: "Demandes entrantes (hors ligne)",
finishCurrentRide:
"Terminez votre course en cours pour voir les nouvelles demandes.",
waitingRequests: "En attente de demandes…",
goOnlineStart: "Passez en ligne pour commencer à rouler.",
welcome: "Bienvenue, {name}",
@@ -264,36 +385,229 @@ export const fr = {
alertCreateBody: "Création du profil chauffeur impossible. Réessayez.",
alertToggleBody: "Changement de statut impossible. Réessayez.",
noRequestsAlt: "Aucune course récente",
cashInHand: "Espèces encaissées aujourd'hui",
uncollected: "Courses non encaissées aujourd'hui",
ratingCount: "{n} évaluations",
ratingNew: "Nouveau chauffeur",
alertOfflineBlocked:
"Terminez ou annulez votre course en cours avant de passer hors ligne.",
},
credentials: {
title: "Vos documents",
intro:
"Chaque chauffeur est vérifié avant sa première course. Notre équipe examine ces informations.",
licenseNumber: "Numéro de permis de conduire",
licenseNumberPlaceholder: "Tel qu'inscrit sur le permis",
licenseExpiry: "Expiration du permis",
nationalId: "Numéro de carte d'identité",
nationalIdPlaceholder: "Votre numéro d'identité",
plateNumber: "Numéro de plaque",
plateNumberPlaceholder: "ex. 123456/B",
reviewNote:
"Votre compte reste hors ligne jusqu'à validation, généralement en moins d'une journée.",
submit: "Envoyer pour vérification",
errorTitle: "Vérifiez vos informations",
errorMissing:
"Le numéro de permis, la carte d'identité et la plaque sont tous obligatoires.",
errorExpiryFormat:
"Saisissez l'expiration du permis au format AAAA-MM-JJ.",
errorExpired: "Ce permis est déjà expiré.",
errorScanRequired: "Scannez votre permis de conduire avant d'envoyer.",
alertResubmitBody: "Renvoi de vos informations impossible. Réessayez.",
},
captureUnavailable:
"La prise de photo n'est pas disponible dans cette version de l'application. Mettez-la à jour et réessayez.",
photo: {
title: "Votre photo",
hint: "Prise maintenant avec l'appareil photo, pas depuis votre galerie. Les passagers la voient à côté de votre nom au moment de choisir un chauffeur, et s'en servent pour vérifier que c'est bien vous au départ. Regardez l'objectif, dans une bonne lumière.",
take: "Prendre une photo",
retake: "Reprendre",
required: "Prenez une photo de profil avant d'envoyer.",
permissionTitle: "Autorisation requise",
permissionCamera:
"Waseel a besoin de l'appareil photo pour prendre votre photo. Autorisez-y l'accès pour continuer.",
permissionCameraBlocked:
"L'accès à l'appareil photo est désactivé pour Waseel, et Android ne le redemandera plus depuis ici. Ouvrez les réglages, puis activez Appareil photo dans les autorisations.",
errorTitle: "Photo non enregistrée",
errorBody: "Une erreur s'est produite. Réessayez.",
errorTooLarge: "Cette photo est trop lourde. Prenez-en une nouvelle.",
errorRateLimit: "Trop d'envois. Attendez quelques minutes et réessayez.",
errorUnsupported: "Utilisez une photo JPEG, PNG ou WebP.",
},
scan: {
licenseLabel: "Permis de conduire",
licenseHint:
"Posez-le à plat et remplissez le cadre. Nous y lisons le numéro et l'expiration.",
idLabel: "Carte d'identité",
idHint: "La face où figure votre numéro d'identité.",
vehicle_regLabel: "Carte grise",
vehicle_regHint:
"La page où figurent le numéro de plaque et le modèle du véhicule.",
optional: "Facultatif",
take: "Photographier",
retake: "Rescanner",
choose: "Choisir une photo",
reading: "Lecture de votre document…",
filled: {
one: "1 information remplie — vérifiez-la ci-dessous.",
other: "{n} informations remplies — vérifiez-les ci-dessous.",
},
savedNoFields:
"Photo enregistrée, mais les informations n'ont pas pu être lues. Saisissez-les ci-dessous.",
savedUnreadable:
"Photo enregistrée. La lecture est indisponible pour le moment — saisissez les informations ci-dessous.",
alreadyOnFile:
"Un scan est déjà enregistré. Rescannez seulement si nécessaire.",
allRead: "Lu sur vos documents",
missingPrompt:
"Nous n'avons pas pu lire ces informations sur vos documents. Ajoutez-les et c'est terminé.",
edit: "Vérifier ou modifier",
done: "Terminé",
checkPrompt: "Corrigez ce qui a été mal lu, puis appuyez sur Terminé.",
permissionTitle: "Autorisation requise",
permissionCamera:
"Autorisez l'appareil photo pour photographier vos documents, ou choisissez une photo existante.",
permissionCameraBlocked:
"L'accès à l'appareil photo est désactivé pour Waseel, et Android ne le redemandera plus depuis ici. Ouvrez les réglages et activez Appareil photo dans les autorisations — ou choisissez une photo existante.",
permissionLibrary:
"Autorisez l'accès aux photos pour choisir une image de vos documents.",
permissionLibraryBlocked:
"L'accès aux photos est désactivé pour Waseel, et Android ne le redemandera plus depuis ici. Ouvrez les réglages et activez Photos dans les autorisations — ou prenez une photo avec l'appareil photo.",
errorTitle: "Scan impossible",
errorBody:
"Une erreur s'est produite. Réessayez ou saisissez les informations ci-dessous.",
errorTooLarge: "Cette photo est trop lourde. Prenez-en une nouvelle.",
errorRateLimit: "Trop de scans. Attendez quelques minutes et réessayez.",
errorUnsupported: "Utilisez une photo JPEG, PNG ou WebP.",
errorRetry:
"Envoi échoué. Réessayez ou saisissez les informations ci-dessous.",
},
review: {
pendingTitle: "En cours de vérification",
pendingBody:
"Nous vérifions vos informations. Vous pourrez passer en ligne dès validation.",
rejectedTitle: "Non validé",
rejectedBody:
"Vos informations n'ont pas été validées. Corrigez-les ci-dessous et renvoyez-les.",
suspendedTitle: "Compte suspendu",
suspendedBody:
"Votre compte chauffeur a été suspendu. Contactez le support pour régulariser.",
approvedTitle: "Validé",
approvedBody: "Vous êtes autorisé à rouler.",
reasonLabel: "Motif",
checkAgain: "Vérifier à nouveau",
resubmitTitle: "Corrigez vos informations",
resubmitIntro: "Corrigez ce qui ne va pas et nous vérifierons à nouveau.",
resubmit: "Renvoyer",
},
offerCard: {
youEarn: "Vous gagnez",
newRequest: "Nouvelle demande · {service}",
openFor: "Disponible encore",
seconds: "{n} s",
awayFromPickup: "{km} km du point de départ",
firstIn: "Vous seriez le premier",
rivals: {
one: "1 autre chauffeur s'est proposé",
other: "{n} autres chauffeurs se sont proposés",
zero: "Vous seriez le premier",
},
cash: "💵 Espèces",
card: "💳 Carte",
fromAlt: "De",
toAlt: "À",
tripTime: "Durée",
fare: "Tarif",
decline: "Refuser",
accept: "Accepter",
offer: "Proposer cette course",
withdraw: "Retirer ma proposition",
waitingOnRider: "Proposé — en attente du choix du passager",
lostTitle: "Demande close",
lostBody:
"Le passager a choisi un autre chauffeur, ou la demande a expiré. Vous êtes libre pour la suivante.",
alertOfferBody: "Envoi de votre proposition impossible. Réessayez.",
alertWithdrawBody: "Retrait de votre proposition impossible. Réessayez.",
},
activeRide: {
youEarn: "Vous gagnez",
dropoffPin: "Dépose",
navigateToPickup: "Naviguer vers le départ",
navigateToDropoff: "Naviguer vers la dépose",
alertNavigateBody:
"Impossible d'ouvrir une application de navigation sur ce téléphone.",
headToPickup: "Direction le départ",
tripInProgress: "Course en cours",
rider: "{name}",
pickupPin: "Départ du passager",
fromAlt: "De",
toAlt: "À",
fare: "Tarif",
message: "Message",
call: "Appeler",
startTrip: "Démarrer la course",
completeTrip: "Terminer la course",
cancelRide: "Annuler la course",
cancelConfirmTitle: "Annuler cette course ?",
cancelConfirmBody:
"Le passager sera averti et la course sera marquée comme annulée.",
cancelConfirmDismiss: "Garder la course",
cancelConfirmConfirm: "Annuler la course",
alertErrorTitle: "Erreur",
alertAcceptBody:
"Acceptation impossible. La course a peut-être été prise ou a expiré.",
alertDeclineBody: "Refus de la course impossible. Réessayez.",
alertUpdateBody: "Mise à jour de la course impossible. Réessayez.",
alertCancelBody: "Annulation de la course impossible. Réessayez.",
imHere: "Je suis arrivé",
atPickup: "Sur place — en attente du passager",
askForCode: "Demandez au passager son code à 4 chiffres.",
cashConfirmTitle: "Encaisser la course",
cashConfirmBody: "Avez-vous encaissé ${amount} en espèces ?",
cashCollected: "Oui, encaissé",
cashNotCollected: "Non encaissé",
},
},
rating: {
rateDriverTitle: "Comment s'est passée votre course avec {name} ?",
rateRiderTitle: "Comment était {name} comme passager ?",
subtitle: "Votre note reste privée vis-à-vis de l'autre personne.",
starLabel: "{n} étoiles",
commentPlaceholder: "Ajouter un commentaire (facultatif)",
submit: "Envoyer la note",
notNow: "Plus tard",
error: "Envoi de votre note impossible. Réessayez.",
},
cancelSheet: {
title: "Annuler cette course ?",
subtitleRider: "Dites-nous pourquoi pour améliorer nos attributions.",
subtitleDriver:
"Le passager sera averti et la course sera marquée comme annulée.",
confirm: "Annuler la course",
keepRide: "Garder la course",
cancelling: "Annulation…",
reasons: {
changed_mind: "J'ai changé d'avis",
wait_too_long: "L'attente est trop longue",
wrong_address: "Mauvaise adresse de départ",
driver_no_show: "Le chauffeur n'est jamais arrivé",
rider_no_show: "Le passager ne s'est pas présenté",
unreachable: "Impossible de le joindre",
vehicle_issue: "Problème de véhicule",
other: "Autre raison",
},
},
pickupCode: {
title: "Démarrer la course",
subtitle: "Saisissez le code à 4 chiffres affiché chez le passager.",
startTrip: "Démarrer",
wrongCode: "Ce code ne correspond pas. Vérifiez avec le passager.",
},
services: {
nearbyCount: "{n} à proximité",
noneNearby: "Aucun à proximité",
car: {
label: "Voiture",
tagline: "Une course quotidienne, jusqu'à 4 places.",
@@ -392,6 +706,16 @@ export const fr = {
"La carte n'est pas disponible sur le web.\nUtilisez Android ou iOS pour l'expérience complète.",
},
rideCard: {
outcomeCompleted: "Terminée",
outcomeCancelled: "Annulée",
outcomeExpired: "Aucun chauffeur trouvé",
cancelledByYou: "Vous avez annulé",
cancelledByDriver: "Le chauffeur a annulé",
cancelledBySystem: "Aucun chauffeur disponible",
noDriver: "Aucun chauffeur attribué",
paymentNotCharged: "Non débité",
paymentRefundDue: "Remboursement dû",
paymentCashCollected: "Payé en espèces",
mapAlt: "Carte",
originAlt: "Origine",
destinationAlt: "Destination",
@@ -492,6 +816,9 @@ export const fr = {
rtlRestartBody:
"La mise en page arabe s'appliquera pleinement à la prochaine ouverture de l'application.",
},
general: {
title: "Général",
},
keepAwake: {
title: "Ne pas verrouiller l'écran",
description:
+194
View File
@@ -0,0 +1,194 @@
// On-disk storage for the images a driver uploads.
//
// Two kinds, kept in separate directories because they have opposite audiences
// and must never be reachable through each other's route:
//
// "document" — licence, ID card and vehicle registration scans. Identity
// documents, so they are deliberately NOT served from a public static
// directory: every file gets an unguessable name, is written outside the
// web root, and is read back only through /(api)/driver/documents?name=…,
// which checks the caller owns the document or is an owner reviewing it.
//
// "photo" — the driver's profile photo, which exists precisely to be shown
// to riders choosing between drivers. Served unauthenticated (see
// /(api)/driver/photo) because it is rendered by plain <Image> tags all
// over the rider app; the unguessable name is what keeps it from being
// enumerable, and the route still refuses any name no driver row points at.
//
// The separate directories are the guarantee: a name that addresses a scan
// cannot resolve under the photo directory, so a bug in the public route can
// never hand out someone's ID card.
//
// The `drivers.profile_image_url` / `license_image_url` / `id_image_url` /
// `vehicle_reg_image_url` columns hold the bare stored name ("a1b2….jpg"), not
// a URL — the mobile app and the admin dashboard reach the API on different
// origins and each builds its own URL from the name. `profile_image_url` is
// the exception that also accepts a full external URL, because an owner can
// set one from the admin dashboard.
import { randomBytes } from "crypto";
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "fs/promises";
import path from "path";
export type UploadKind = "document" | "photo";
/** Uploads live outside the bundle so a rebuild never wipes them. */
const uploadRoot = (): string =>
process.env.UPLOAD_DIR
? path.resolve(process.env.UPLOAD_DIR)
: path.join(process.cwd(), ".uploads");
const SUBDIRECTORY: Record<UploadKind, string> = {
document: "driver-documents",
photo: "driver-photos",
};
const uploadDir = (kind: UploadKind): string =>
path.join(uploadRoot(), SUBDIRECTORY[kind]);
/** Phone cameras produce JPEG; PNG and WebP cover gallery picks and screenshots. */
const EXTENSIONS: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
};
export const SUPPORTED_IMAGE_TYPES = Object.keys(EXTENSIONS);
/**
* A document scan of a national ID at readable resolution is ~13 MB. 10 MB
* leaves room for a high-end camera without letting a client push arbitrary
* amounts of data onto the disk.
*/
export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
/** Names are generated here, so anything not matching this was not. */
const NAME_PATTERN = /^[a-f0-9]{32}\.(jpg|png|webp)$/;
export const isStoredUploadName = (value: unknown): value is string =>
typeof value === "string" && NAME_PATTERN.test(value);
const MIME_BY_EXTENSION: Record<string, string> = {
jpg: "image/jpeg",
png: "image/png",
webp: "image/webp",
};
export const uploadMimeType = (name: string): string =>
MIME_BY_EXTENSION[name.split(".").pop() ?? ""] ?? "application/octet-stream";
/**
* Trusting the client's declared media type would let a caller store a .jpg
* that is really something else, so the magic bytes decide. Returns null when
* the buffer is not one of the formats we accept.
*/
export const sniffImageType = (buffer: Buffer): string | null => {
if (buffer.length < 12) return null;
// JPEG: FF D8 FF
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return "image/jpeg";
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (buffer.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) {
return "image/png";
}
// WebP: "RIFF" .... "WEBP"
if (
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
buffer.subarray(8, 12).toString("ascii") === "WEBP"
) {
return "image/webp";
}
return null;
};
/** Writes an upload under a random name and returns that name. */
export const storeUpload = async (
buffer: Buffer,
mimeType: string,
kind: UploadKind,
): Promise<string> => {
const extension = EXTENSIONS[mimeType];
if (!extension) throw new Error(`Unsupported image type: ${mimeType}`);
const dir = uploadDir(kind);
await mkdir(dir, { recursive: true });
const name = `${randomBytes(16).toString("hex")}.${extension}`;
await writeFile(path.join(dir, name), buffer);
return name;
};
/** Reads a stored upload back, or null when it is gone. */
export const readUpload = async (
name: string,
kind: UploadKind,
): Promise<Buffer | null> => {
if (!isStoredUploadName(name)) return null;
try {
// The name pattern already rules out separators and "..", so this join
// cannot escape the directory — the check above is the guard, not this.
return await readFile(path.join(uploadDir(kind), name));
} catch {
return null;
}
};
export const deleteUpload = async (
name: string,
kind: UploadKind,
): Promise<void> => {
if (!isStoredUploadName(name)) return;
try {
await unlink(path.join(uploadDir(kind), name));
} catch {
// Already gone, which is the state we wanted.
}
};
/**
* A driver who scans their licence and then abandons onboarding leaves a file
* behind that no row references. Sweeping anything older than a day that isn't
* referenced keeps identity documents from piling up indefinitely; the grace
* period is what keeps an upload alive between the upload and the submit.
*
* `referenced` must be the full set of names still in use for that kind
* passing a partial set would delete live files, so the caller queries every
* column that can hold one.
*/
const ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000;
export const pruneOrphanUploads = async (
referenced: Set<string>,
kind: UploadKind,
): Promise<number> => {
let removed = 0;
try {
const dir = uploadDir(kind);
const names = await readdir(dir);
const cutoff = Date.now() - ORPHAN_GRACE_MS;
for (const name of names) {
if (!isStoredUploadName(name) || referenced.has(name)) continue;
const info = await stat(path.join(dir, name)).catch(() => null);
if (!info || info.mtimeMs >= cutoff) continue;
await deleteUpload(name, kind);
removed += 1;
}
} catch {
// The directory may not exist yet. Nothing to prune either way.
}
return removed;
};
+439
View File
@@ -0,0 +1,439 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Platform, PermissionsAndroid } from "react-native";
import InCallManager from "react-native-incall-manager";
import {
mediaDevices,
RTCPeerConnection,
type MediaStream,
} from "react-native-webrtc";
import { fetchAPI } from "@/lib/fetch";
import type { CallRecord, CallStatus } from "@/types/type";
// Poll cadence for call signaling — faster than chat (2.5s) and ride-status
// (3s) so the callee sees a ring without a long wait, but not so fast it
// hammers the DB.
const POLL_MS = 2000;
// Cap ICE gathering so a slow network can't stall the call forever; whatever
// candidates were gathered by then are sent (non-trickle).
const ICE_GATHER_TIMEOUT_MS = 3000;
// A serializable SDP. react-native-webrtc's RTCSessionDescriptionInit isn't
// exported, so we keep our own shape and pass it straight to
// setLocalDescription/setRemoteDescription (both accept { type, sdp }).
type SdpPayload = { type: "offer" | "answer"; sdp: string };
const sdpToString = (
desc: { type: string | null; sdp: string } | null,
): string =>
desc && desc.type ? JSON.stringify({ type: desc.type, sdp: desc.sdp }) : "";
const parseSdp = (raw: string | null | undefined): SdpPayload | null => {
if (!raw) return null;
try {
return JSON.parse(raw) as SdpPayload;
} catch {
return null;
}
};
const iceServers = (): RTCIceServer[] => {
const servers: RTCIceServer[] = [];
const stun = process.env.EXPO_PUBLIC_STUN_URL;
if (stun) servers.push({ urls: [stun] });
const turn = process.env.EXPO_PUBLIC_TURN_URL;
if (turn) {
servers.push({
urls: [turn],
username: process.env.EXPO_PUBLIC_TURN_USERNAME || "",
credential: process.env.EXPO_PUBLIC_TURN_CREDENTIAL || "",
});
}
return servers;
};
// Android needs the RECORD_AUDIO permission granted before getUserMedia; iOS
// prompts automatically on first getUserMedia call. Exported so callers (the
// chat screen, the driver dashboard) can prime it as soon as a ride is
// matched, rather than the first ask landing mid-handshake when the user taps
// Call — PermissionsAndroid.request no-ops instantly once already granted, so
// priming early costs nothing on the actual call attempt.
export const ensureMicPermission = async (): Promise<boolean> => {
if (Platform.OS !== "android") return true;
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
{
title: "Microphone permission",
message: "Waseel needs microphone access to make calls.",
buttonPositive: "Allow",
},
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
};
// Resolve once ICE gathering is complete (candidate === null), or when the
// timeout fires — whichever first. Non-trickle: the caller waits for this so
// the local SDP it ships already contains all candidates.
const waitForIceGathering = (pc: RTCPeerConnection): Promise<void> =>
new Promise((resolve) => {
if (pc.iceGatheringState === "complete") return resolve();
let done = false;
const finish = () => {
if (done) return;
done = true;
pc.onicecandidate = null;
resolve();
};
// RN-webrtc types the icecandidate event as a bare Event; the candidate
// payload is on the runtime object, so cast to read it.
pc.onicecandidate = ((e: { candidate: unknown }) => {
if (e.candidate === null) finish();
}) as never;
setTimeout(finish, ICE_GATHER_TIMEOUT_MS);
});
type UseCallResult = {
status: CallStatus;
peerName: string | null;
incoming: CallRecord | null;
localStream: MediaStream | null;
remoteStream: MediaStream | null;
micError: boolean;
muted: boolean;
speakerOn: boolean;
toggleMute: () => void;
toggleSpeaker: () => void;
/** Caller: place the call. */
startCall: (
rideId: number,
role: "rider" | "driver",
peerName: string,
) => Promise<void>;
/** Callee: attach to a ride and poll for an incoming offer (no offer created). */
watch: (rideId: number, role: "rider" | "driver", peerName?: string) => void;
answerCall: () => Promise<void>;
declineCall: () => Promise<void>;
endCall: () => Promise<void>;
};
// Drive a WebRTC audio call over the DB-backed polling transport. The peer
// connection and streams live in refs (non-serializable); only the call
// status and streams the UI binds to are state. One active ride at a time.
export const useCall = (): UseCallResult => {
const [status, setStatus] = useState<CallStatus>("idle");
const [peerName, setPeerName] = useState<string | null>(null);
const [incoming, setIncoming] = useState<CallRecord | null>(null);
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [micError, setMicError] = useState(false);
// Earpiece by default (standard telephony UX); the user opts into speaker.
const [speakerOn, setSpeakerOn] = useState(false);
const [muted, setMuted] = useState(false);
const pcRef = useRef<RTCPeerConnection | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const rideIdRef = useRef<number | null>(null);
const roleRef = useRef<"rider" | "driver" | null>(null);
const callIdRef = useRef<number | null>(null);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const teardown = useCallback(() => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
try {
pcRef.current?.close();
} catch {
// ignore
}
pcRef.current = null;
localStreamRef.current?.getTracks().forEach((t) => t.stop());
localStreamRef.current = null;
setLocalStream(null);
setRemoteStream(null);
setIncoming(null);
callIdRef.current = null;
InCallManager.stop();
setSpeakerOn(false);
setMuted(false);
}, []);
// Set up the peer connection with the local mic, wire the remote-track
// handler, and return the stream to attach.
const createPeer =
useCallback(async (): Promise<RTCPeerConnection | null> => {
const ok = await ensureMicPermission();
if (!ok) {
setMicError(true);
return null;
}
setMicError(false);
const stream = await mediaDevices.getUserMedia({
audio: true,
video: false,
});
localStreamRef.current = stream;
setLocalStream(stream);
// Routes audio through the earpiece/speaker and engages the proximity
// sensor, same as the native phone dialer. Must start before the
// speaker/mute toggles below have any effect.
InCallManager.start({ media: "audio" });
const pc = new RTCPeerConnection({ iceServers: iceServers() });
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
// RN-webrtc delivers the remote stream via ontrack's event payload; the
// type is a bare Event so cast to read .streams.
pc.ontrack = ((e: { streams: MediaStream[] }) => {
const remote = e.streams[0];
if (remote) setRemoteStream(remote);
}) as never;
pc.oniceconnectionstatechange = (() => {
const state = pc.iceConnectionState;
if (
state === "failed" ||
state === "disconnected" ||
state === "closed"
) {
// The peer connection died — end the call through the server so the
// other side sees it too.
if (rideIdRef.current) void endCallInternal("ended");
}
}) as never;
pcRef.current = pc;
return pc;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stopPolling = useCallback(() => {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}, []);
// PATCH the call row to a terminal action. Kept outside the hook's public
// endCall so the iceconnectionstatechange handler can call it too.
const endCallInternal = useCallback(
async (action: "ended" | "declined") => {
const rideId = rideIdRef.current;
if (rideId === null) return;
try {
await fetchAPI(`/(api)/ride/${rideId}/call`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: action === "ended" ? "end" : "decline",
}),
});
} catch (err) {
console.log("[CALL_END]: ", err);
}
setStatus("ended");
teardown();
},
[teardown],
);
// Poll the call row and drive the state machine. The caller waits for the
// callee's answer (sdp_answer) to complete the handshake; the callee, while
// idle, watches for an incoming ringing offer to surface as `incoming`.
const poll = useCallback(async () => {
const rideId = rideIdRef.current;
if (rideId === null) return;
try {
const res = await fetchAPI(`/(api)/ride/${rideId}/call`);
const call = (res.data ?? null) as CallRecord | null;
if (!call) return;
callIdRef.current = call.id;
const isCaller = call.is_caller;
// Caller side: connect once the callee has answered with an SDP answer.
if (isCaller && call.status === "answered" && call.sdp_answer) {
const pc = pcRef.current;
const answer = parseSdp(call.sdp_answer);
if (pc && answer && pc.remoteDescription === null) {
await pc.setRemoteDescription(answer);
setStatus("in-call");
}
return;
}
// Either side: a terminal status ends the call locally.
if (
call.status === "ended" ||
call.status === "declined" ||
call.status === "missed"
) {
setStatus("ended");
teardown();
return;
}
// Callee side: an incoming ringing offer surfaces as `incoming` until
// answered/declined. Don't overwrite it if we're already past idle.
if (!isCaller && call.status === "ringing" && call.sdp_offer) {
setStatus((current) => {
if (current === "idle" || current === "incoming") {
setIncoming(call);
return "incoming";
}
return current;
});
}
} catch (err) {
console.log("[CALL_POLL]: ", err);
}
}, [teardown]);
const startPolling = useCallback(() => {
stopPolling();
pollingRef.current = setInterval(() => void poll(), POLL_MS);
}, [poll, stopPolling]);
// --- Caller flow: place a call. ---
const startCall = useCallback(
async (rideId: number, role: "rider" | "driver", name: string) => {
rideIdRef.current = rideId;
roleRef.current = role;
setPeerName(name);
setStatus("outgoing");
const pc = await createPeer();
if (!pc) {
setStatus("idle");
return;
}
try {
const offer = await pc.createOffer({ iceRestart: false });
await pc.setLocalDescription(offer);
await waitForIceGathering(pc);
const localOffer = pc.localDescription
? sdpToString(pc.localDescription)
: sdpToString(offer);
await fetchAPI(`/(api)/ride/${rideId}/call`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sdp_offer: localOffer }),
});
startPolling();
} catch (err) {
console.log("[CALL_START]: ", err);
setStatus("failed" as CallStatus);
teardown();
}
},
[createPeer, startPolling, teardown],
);
// --- Callee flow: answer an incoming call. ---
const answerCall = useCallback(async () => {
const rideId = rideIdRef.current;
const offer = incoming?.sdp_offer;
if (rideId === null || !offer) return;
const pc = await createPeer();
if (!pc) {
setStatus("idle");
return;
}
try {
const remoteOffer = parseSdp(offer);
if (!remoteOffer) throw new Error("bad offer");
await pc.setRemoteDescription(remoteOffer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGathering(pc);
const localAnswer = pc.localDescription
? sdpToString(pc.localDescription)
: sdpToString(answer);
await fetchAPI(`/(api)/ride/${rideId}/call`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "answer", sdp_answer: localAnswer }),
});
setStatus("in-call");
setIncoming(null);
startPolling();
} catch (err) {
console.log("[CALL_ANSWER]: ", err);
setStatus("failed" as CallStatus);
teardown();
}
}, [createPeer, incoming, startPolling, teardown]);
const declineCall = useCallback(async () => {
await endCallInternal("declined");
}, [endCallInternal]);
const endCall = useCallback(async () => {
await endCallInternal("ended");
}, [endCallInternal]);
const toggleMute = useCallback(() => {
setMuted((current) => {
const next = !current;
// Mute at the WebRTC track level rather than InCallManager's OS-level
// mute: it's what actually stops audio reaching the peer, and it works
// the same on both platforms.
localStreamRef.current
?.getAudioTracks()
.forEach((track) => (track.enabled = !next));
return next;
});
}, []);
const toggleSpeaker = useCallback(() => {
setSpeakerOn((current) => {
const next = !current;
InCallManager.setSpeakerphoneOn(next);
return next;
});
}, []);
// --- Callee idle-watch: attach to a ride and poll for an incoming offer ---
// without creating one. Used by the call screen when opened for an incoming
// call (the call row already exists as 'ringing'); the poll surfaces it as
// `incoming` for the Accept/Decline UI.
const watch = useCallback(
(rideId: number, role: "rider" | "driver", name?: string) => {
rideIdRef.current = rideId;
roleRef.current = role;
if (name) setPeerName(name);
startPolling();
},
[startPolling],
);
// Clean up the peer connection on unmount.
useEffect(() => () => teardown(), [teardown]);
return {
status,
peerName,
incoming,
localStream,
remoteStream,
micError,
muted,
speakerOn,
toggleMute,
toggleSpeaker,
startCall,
watch,
answerCall,
declineCall,
endCall,
};
};
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError, fetchAPI } from "@/lib/fetch";
import type { Message } from "@/types/type";
// Poll cadence for new messages. Staggered away from the ride-status poll
// (3s) and the call poll (2s) so the two tabs don't hammer the DB in lockstep.
const POLL_MS = 2500;
type UseChatResult = {
messages: Message[];
loading: boolean;
sending: boolean;
error: string | null;
sendMessage: (body: string) => Promise<void>;
reload: () => Promise<void>;
};
// Ride-scoped chat. Fetches the full history once, then polls for messages
// with id greater than the last one seen. Optimistic on send: the row the
// server returns is appended immediately, so the bubble appears before the
// next poll. `role` is the caller's role ("rider" | "driver") and is only
// used by the screen to align bubbles — the hook itself doesn't need it, but
// it takes it so the screen has one source of truth for the conversation.
export const useChat = (
rideId: number | null,
role: "rider" | "driver" | null,
): UseChatResult => {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
// Last message id we've seen — the polling cursor. Kept in a ref so the
// interval closure always reads the latest value without re-arming.
const cursorRef = useRef<number>(0);
const loadInitial = useCallback(async (id: number) => {
setLoading(true);
setError(null);
try {
const res = await fetchAPI(`/(api)/ride/${id}/messages`);
const rows = (res.data ?? []) as Message[];
setMessages(rows);
cursorRef.current = rows.length ? rows[rows.length - 1].id : 0;
} catch (err) {
console.log("[CHAT_LOAD]: ", err);
setError("chat.loadError");
} finally {
setLoading(false);
}
}, []);
const poll = useCallback(async (id: number) => {
try {
const res = await fetchAPI(
`/(api)/ride/${id}/messages?since=${cursorRef.current}`,
);
const rows = (res.data ?? []) as Message[];
if (rows.length) {
setMessages((prev) => [...prev, ...rows]);
cursorRef.current = rows[rows.length - 1].id;
}
} catch (err) {
// Swallow poll errors — a transient blip shouldn't wipe the list or
// flash an error banner; the next tick will retry.
console.log("[CHAT_POLL]: ", err);
}
}, []);
// Initial load + polling lifecycle. Re-arms when the ride id changes.
useEffect(() => {
if (rideId === null) {
setMessages([]);
cursorRef.current = 0;
return;
}
void loadInitial(rideId);
const timer = setInterval(() => void poll(rideId), POLL_MS);
return () => clearInterval(timer);
}, [rideId, loadInitial, poll]);
const sendMessage = useCallback(
async (body: string) => {
if (rideId === null) return;
const text = body.trim();
if (!text || sending) return;
setSending(true);
try {
const res = await fetchAPI(`/(api)/ride/${rideId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: text }),
});
const message = res.data as Message;
setMessages((prev) => [...prev, message]);
cursorRef.current = Math.max(cursorRef.current, message.id);
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
// Ride no longer active — surface that specifically so the UI can
// disable the input instead of retrying forever.
setError("chat.cannotMessage");
} else {
console.log("[CHAT_SEND]: ", err);
setError("chat.sendError");
}
} finally {
setSending(false);
}
},
[rideId, sending],
);
const reload = useCallback(async () => {
if (rideId !== null) await loadInitial(rideId);
}, [rideId, loadInitial]);
// `role` is accepted for API symmetry but the hook doesn't read it; keep the
// param so the screen's single conversation object carries the caller's role.
void role;
return { messages, loading, sending, error, sendMessage, reload };
};

Some files were not shown because too many files have changed in this diff Show More