diff --git a/.env.example b/.env.example index 69a6d13..272bc31 100644 --- a/.env.example +++ b/.env.example @@ -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="" diff --git a/.gitignore b/.gitignore index ab92a84..7f5aa6b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/app.config.js b/app.config.js index d200237..0f17386 100644 --- a/app.config.js +++ b/app.config.js @@ -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: { diff --git a/app/(api)/admin/drivers+api.ts b/app/(api)/admin/drivers+api.ts index 8438940..97c612e 100644 --- a/app/(api)/admin/drivers+api.ts +++ b/app/(api)/admin/drivers+api.ts @@ -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 }), ); } diff --git a/app/(api)/admin/drivers/[id]+api.ts b/app/(api)/admin/drivers/[id]+api.ts index e7f8fd2..addba5e 100644 --- a/app/(api)/admin/drivers/[id]+api.ts +++ b/app/(api)/admin/drivers/[id]+api.ts @@ -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 }), ); } diff --git a/app/(api)/admin/rides+api.ts b/app/(api)/admin/rides+api.ts index be1ac33..dcb9094 100644 --- a/app/(api)/admin/rides+api.ts +++ b/app/(api)/admin/rides+api.ts @@ -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 }), ); } diff --git a/app/(api)/admin/settle+api.ts b/app/(api)/admin/settle+api.ts new file mode 100644 index 0000000..2984ec7 --- /dev/null +++ b/app/(api)/admin/settle+api.ts @@ -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 }), + ); + } +} diff --git a/app/(api)/admin/stats+api.ts b/app/(api)/admin/stats+api.ts index 1e090ea..e5fbf66 100644 --- a/app/(api)/admin/stats+api.ts +++ b/app/(api)/admin/stats+api.ts @@ -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 }), ); } diff --git a/app/(api)/admin/users+api.ts b/app/(api)/admin/users+api.ts index 7d8388e..6a3733f 100644 --- a/app/(api)/admin/users+api.ts +++ b/app/(api)/admin/users+api.ts @@ -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 }), ); } diff --git a/app/(api)/admin/users/[id]+api.ts b/app/(api)/admin/users/[id]+api.ts index 3f22f3b..ef5a187 100644 --- a/app/(api)/admin/users/[id]+api.ts +++ b/app/(api)/admin/users/[id]+api.ts @@ -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 }), ); } diff --git a/app/(api)/chat/active+api.ts b/app/(api)/chat/active+api.ts new file mode 100644 index 0000000..b6aa8c0 --- /dev/null +++ b/app/(api)/chat/active+api.ts @@ -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` + 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` + 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 }); + } +} diff --git a/app/(api)/driver+api.ts b/app/(api)/driver+api.ts deleted file mode 100644 index dbb7cd2..0000000 --- a/app/(api)/driver+api.ts +++ /dev/null @@ -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 }); - } -} \ No newline at end of file diff --git a/app/(api)/driver/availability+api.ts b/app/(api)/driver/availability+api.ts new file mode 100644 index 0000000..ec60db0 --- /dev/null +++ b/app/(api)/driver/availability+api.ts @@ -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 = {}; + 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 }); + } +} diff --git a/app/(api)/driver/documents+api.ts b/app/(api)/driver/documents+api.ts new file mode 100644 index 0000000..34d45b0 --- /dev/null +++ b/app/(api)/driver/documents+api.ts @@ -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 +// 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 }), + ); + } +} diff --git a/app/(api)/driver/location+api.ts b/app/(api)/driver/location+api.ts index fdf5dc8..b74ac92 100644 --- a/app/(api)/driver/location+api.ts +++ b/app/(api)/driver/location+api.ts @@ -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,20 +28,112 @@ 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 }); } -} \ No newline at end of file +} diff --git a/app/(api)/driver/nearby+api.ts b/app/(api)/driver/nearby+api.ts index 56ecd4b..574142f 100644 --- a/app/(api)/driver/nearby+api.ts +++ b/app/(api)/driver/nearby+api.ts @@ -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,22 +42,47 @@ 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 }); } -} \ No newline at end of file +} diff --git a/app/(api)/driver/photo+api.ts b/app/(api)/driver/photo+api.ts new file mode 100644 index 0000000..e574589 --- /dev/null +++ b/app/(api)/driver/photo+api.ts @@ -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 / 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(); + +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 => { + 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 }); + } +} diff --git a/app/(api)/driver/profile+api.ts b/app/(api)/driver/profile+api.ts index 6aa1d1b..f842759 100644 --- a/app/(api)/driver/profile+api.ts +++ b/app/(api)/driver/profile+api.ts @@ -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 = { + 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,9 +369,14 @@ 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); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/app/(api)/driver/rides+api.ts b/app/(api)/driver/rides+api.ts index 5927368..fe27ad7 100644 --- a/app/(api)/driver/rides+api.ts +++ b/app/(api)/driver/rides+api.ts @@ -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` + 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` + 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 = (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; -}; \ No newline at end of file +}; + +type PendingRatingRow = { + ride_id: number; + rider_name: string | null; +}; diff --git a/app/(api)/driver/scan+api.ts b/app/(api)/driver/scan+api.ts new file mode 100644 index 0000000..f4a2ead --- /dev/null +++ b/app/(api)/driver/scan+api.ts @@ -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(); + +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 => { + 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(); + 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 }); + } +} diff --git a/app/(api)/push/token+api.ts b/app/(api)/push/token+api.ts new file mode 100644 index 0000000..4d33430 --- /dev/null +++ b/app/(api)/push/token+api.ts @@ -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 }); + } +} diff --git a/app/(api)/ride/[id]+api.ts b/app/(api)/ride/[id]+api.ts index 173891e..5168064 100644 --- a/app/(api)/ride/[id]+api.ts +++ b/app/(api)/ride/[id]+api.ts @@ -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,60 +160,223 @@ 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 }); } -} \ No newline at end of file +} diff --git a/app/(api)/ride/[id]/call+api.ts b/app/(api)/ride/[id]/call+api.ts new file mode 100644 index 0000000..d3c4871 --- /dev/null +++ b/app/(api)/ride/[id]/call+api.ts @@ -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` + 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 }); + } +} diff --git a/app/(api)/ride/[id]/messages+api.ts b/app/(api)/ride/[id]/messages+api.ts new file mode 100644 index 0000000..907b92f --- /dev/null +++ b/app/(api)/ride/[id]/messages+api.ts @@ -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=` 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` + 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` + 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` + 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 }); + } +} diff --git a/app/(api)/ride/[id]/offer+api.ts b/app/(api)/ride/[id]/offer+api.ts new file mode 100644 index 0000000..1e6f327 --- /dev/null +++ b/app/(api)/ride/[id]/offer+api.ts @@ -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 }); + } +} diff --git a/app/(api)/ride/[id]/rate+api.ts b/app/(api)/ride/[id]/rate+api.ts new file mode 100644 index 0000000..cf12d1c --- /dev/null +++ b/app/(api)/ride/[id]/rate+api.ts @@ -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 }); + } +} diff --git a/app/(api)/ride/[id]/respond+api.ts b/app/(api)/ride/[id]/respond+api.ts deleted file mode 100644 index 010238b..0000000 --- a/app/(api)/ride/[id]/respond+api.ts +++ /dev/null @@ -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 }); - } -} \ No newline at end of file diff --git a/app/(api)/ride/[id]/select+api.ts b/app/(api)/ride/[id]/select+api.ts new file mode 100644 index 0000000..81b11b1 --- /dev/null +++ b/app/(api)/ride/[id]/select+api.ts @@ -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 }); + } +} diff --git a/app/(api)/ride/active+api.ts b/app/(api)/ride/active+api.ts new file mode 100644 index 0000000..1b3adac --- /dev/null +++ b/app/(api)/ride/active+api.ts @@ -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 }); + } +} diff --git a/app/(api)/ride/create+api.ts b/app/(api)/ride/create+api.ts index fb0a1a9..6bcdc11 100644 --- a/app/(api)/ride/create+api.ts +++ b/app/(api)/ride/create+api.ts @@ -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,11 +114,15 @@ 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) { console.error("[CREATE_RIDES]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/app/(api)/ride/list+api.ts b/app/(api)/ride/list+api.ts index 5edebc5..04e8124 100644 --- a/app/(api)/ride/list+api.ts +++ b/app/(api)/ride/list+api.ts @@ -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 `; @@ -50,4 +55,4 @@ export async function GET(req: Request) { console.error("[GET_RIDE_LIST]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/app/(api)/user+api.ts b/app/(api)/user+api.ts index c65ed25..fbfe571 100644 --- a/app/(api)/user+api.ts +++ b/app/(api)/user+api.ts @@ -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] }); diff --git a/app/(root)/(tabs)/_layout.tsx b/app/(root)/(tabs)/_layout.tsx index 125c2cc..dde59d3 100644 --- a/app/(root)/(tabs)/_layout.tsx +++ b/app/(root)/(tabs)/_layout.tsx @@ -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, diff --git a/app/(root)/(tabs)/chat.tsx b/app/(root)/(tabs)/chat.tsx index 57945a3..584f4b7 100644 --- a/app/(root)/(tabs)/chat.tsx +++ b/app/(root)/(tabs)/chat.tsx @@ -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(); +const Chat = () => ; - return ( - - - - {t("chat.title")} - - - - {t("chat.messageAlt")} - - - {t("chat.noMessages")} - - - - {t("chat.startConversation")} - - - - - ); -}; - -export default Chat; \ No newline at end of file +export default Chat; diff --git a/app/(root)/(tabs)/home.tsx b/app/(root)/(tabs)/home.tsx index 6e14305..97f4026 100644 --- a/app/(root)/(tabs)/home.tsx +++ b/app/(root)/(tabs)/home.tsx @@ -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 = () => { + {/* Unfinished ride or unrated trip — the way back into a ride the + rider navigated away from. */} + + { <> {/* The map draws straight away on the Beirut fallback so the slot never sits empty while the fix is still coming. */} - + {locationStatus === "pending" ? ( diff --git a/app/(root)/(tabs)/settings.tsx b/app/(root)/(tabs)/settings.tsx index c3c44e8..796647e 100644 --- a/app/(root)/(tabs)/settings.tsx +++ b/app/(root)/(tabs)/settings.tsx @@ -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["name"]; @@ -22,15 +28,45 @@ const SectionHeader = ({ title }: { title: string }) => ( ); -const Card = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); +/** + * 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" ? : null` and `options.map(...)` both work. + */ +const SettingsCard = ({ + description, + children, +}: { + description?: string; + children: React.ReactNode; +}) => { + const rows = Children.toArray(children); + + return ( + + {description ? ( + + + {description} + + + ) : null} + {rows.map((row, index) => ( + + {index > 0 ? ( + + ) : null} + {row} + + ))} + + ); +}; 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 */} - + { value={locationStatusLabel} /> {status !== "granted" ? ( - - - + ) : null} - + {/* 2. Appearance */} - - - - {t("settings.appearance.description")} - - - {appearanceOptions.map((option, index) => ( - + {appearanceOptions.map((option) => ( + 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") } - > - setMode(option.mode)} - /> - + right="check" + selected={mode === option.mode} + onPress={() => setMode(option.mode)} + /> ))} - + {/* 3. Safety */} - + { onPress={callEmergency} /> {safetyTiles.map((tile) => ( - - - setExpandedSafety((current) => - current === tile.key ? null : tile.key, - ) - } - /> - + icon={tile.icon} + title={tile.title} + subtitle={expandedSafety === tile.key ? undefined : tile.body} + right="chevron" + onPress={() => + setExpandedSafety((current) => + current === tile.key ? null : tile.key, + ) + } + /> ))} - + {/* 4. Language */} - - - - {t("settings.language.description")} - - - {languageOptions.map((option, index) => ( - + {languageOptions.map((option) => ( + 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") } - > - chooseLanguage(option.lang)} - /> - + right="check" + selected={lang === option.lang} + onPress={() => chooseLanguage(option.lang)} + /> ))} - + - {/* 5. Keep awake */} - - + {/* 5. General — keep-awake toggle + (Android) display-over-other-apps */} + + { switchValue={keepAwake} onSwitchChange={setKeepAwake} /> - - - {/* 6. Display over other apps (Android only) */} - {Platform.OS === "android" ? ( - <> - - - - - - ) : null} + {Platform.OS === "android" ? ( + + ) : null} + ); diff --git a/app/(root)/_layout.tsx b/app/(root)/_layout.tsx index 6d7fee3..11e8219 100644 --- a/app/(root)/_layout.tsx +++ b/app/(root)/_layout.tsx @@ -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 ; + return ( - - - - - - - - + <> + {/* 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. */} + + + + + + + + + + + + ); }; diff --git a/app/(root)/adjust-pin.tsx b/app/(root)/adjust-pin.tsx new file mode 100644 index 0000000..41aebd2 --- /dev/null +++ b/app/(root)/adjust-pin.tsx @@ -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(initial); + const [address, setAddress] = useState(null); + const [resolving, setResolving] = useState(true); + const debounce = useRef>(); + + 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 ( + + setResolving(true)} + onSettled={resolve} + /> + + + + 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" + > + + + + + + + + + + + + + + {mode === "origin" + ? t("adjustPin.pickupLabel") + : t("adjustPin.destinationLabel")} + + + + {resolving ? ( + <> + + + {t("adjustPin.locating")} + + + ) : ( + + {address} + + )} + + + + {t("adjustPin.hint")} + + + + + + + + ); +}; + +export default AdjustPin; diff --git a/app/(root)/book-ride.tsx b/app/(root)/book-ride.tsx index 40ffd1b..a344093 100644 --- a/app/(root)/book-ride.tsx +++ b/app/(root)/book-ride.tsx @@ -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 = { 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(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(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(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(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 ( - + - + {/* 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. */} + - {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} - {/* Searching state */} - {ride.status === "requested" ? ( - + {/* 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 ? ( + {t("bookRide.matchingDriver", { service: ride.service })} + + {t("bookRide.searchingFor", { seconds: searchSeconds })} + ) : 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 ? ( + + ) : 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 ? ( + + + {ride.status === "arrived" + ? t("bookRide.driverHere") + : t("bookRide.pickupCodeLabel")} + + + {ride.pickup_code} + + + {t("bookRide.pickupCodeHint")} + + + ) : 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" ? ( @@ -171,20 +378,48 @@ const BookRide = () => { ) : null} - - {driver.service ?? ride.service} - + + + {driver.service ?? ride.service} + + {/* Call the driver — only while the ride is active. */} + {!terminal ? ( + + 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" + > + + + ) : null} + - + {ride.origin_address} - + {ride.destination_address} @@ -212,14 +447,41 @@ const BookRide = () => { {t("bookRide.tripTime", { time: formatTime(ride.ride_time) })} + {/* A cash ride the driver hasn't marked collected is money still + owed — say so rather than showing a clean "all done". */} + {cashDue ? ( + + {t("bookRide.cashDue", { + amount: (ride.fare_price / 100).toFixed(2), + })} + + ) : null} + {ride.my_rating ? ( + + {t("bookRide.youRated", { n: ride.my_rating })} + + ) : ( + setRatingOpen(true)} + className="mt-3" + > + + {t("bookRide.rateDriver")} + + + )} ) : null} - {/* Cancelled */} - {ride.status === "cancelled" ? ( + {/* Cancelled / expired */} + {ride.status === "cancelled" || ride.status === "expired" ? ( - - {t("bookRide.rideCancelled")} + + {ride.status === "expired" + ? t("bookRide.noDriversFound") + : ride.cancelled_by === "driver" + ? t("bookRide.cancelledByDriver") + : t("bookRide.rideCancelled")} ) : null} @@ -230,21 +492,67 @@ 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. + + {t("bookRide.enRouteNotice")} + ) : ( 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" > - {cancelling ? t("bookRide.cancelling") : t("bookRide.cancelRide")} + {cancelling + ? t("bookRide.cancelling") + : t("bookRide.cancelRide")} )} - + + + void pay(method)} + onCancel={() => setPicked(null)} + /> + + setCancelOpen(false)} + onConfirm={(reason) => void cancel(reason)} + /> + + { + setRatingOpen(false); + setRatingHandled(true); + void load(); + }} + onSkip={() => { + setRatingOpen(false); + setRatingHandled(true); + }} + /> ); }; -export default BookRide; \ No newline at end of file +export default BookRide; diff --git a/app/(root)/call.tsx b/app/(root)/call.tsx new file mode 100644 index 0000000..91edbaa --- /dev/null +++ b/app/(root)/call.tsx @@ -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(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 ( + + + {t("call.connecting")} + + + ); + } + + if (!active) { + return ( + + + {t("call.unavailable")} + + router.back()} + className="mt-6 px-6 py-3 rounded-full bg-general-400" + > + + {t("call.cancel")} + + + + ); + } + + return ( + + {/* Audio sink — hidden; keeps the native audio pipeline attached even + though this is an audio-only call (RTCView is the stream sink). */} + {call.remoteStream ? ( + + ) : null} + + {/* Peer identity + status */} + + + + {(peerName.trim()[0] ?? "?").toUpperCase()} + + + + {peerName} + + + {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")} + + + + {/* Controls vary by state */} + + {call.status === "incoming" ? ( + <> + + + + ) : ( + <> + + + + + )} + + + ); +}; + +const CallButton = ({ + icon, + color, + label, + onPress, +}: { + icon: React.ComponentProps["name"]; + color: string; + label: string; + onPress: () => void; +}) => ( + + + + + + {label} + + +); + +export default Call; diff --git a/app/(root)/confirm-ride.tsx b/app/(root)/confirm-ride.tsx deleted file mode 100644 index 1bd47aa..0000000 --- a/app/(root)/confirm-ride.tsx +++ /dev/null @@ -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("cash"); - const [estimate, setEstimate] = useState<{ - fare: string; - durationSeconds: number; - } | null>(null); - const [nearestEta, setNearestEta] = useState(null); - const [driversOnline, setDriversOnline] = useState(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 ( - - - {t("confirmRide.yourTrip")} - - - - - {t("confirmRide.pickup")} - - - - {userAddress} - - - - - {t("confirmRide.destination")} - - - - {destinationAddress} - - - - - - {t(selected.labelKey)} · {t(selected.taglineKey)} - - - {t("confirmRide.tripTime", { - time: estimate ? formatTime(estimate.durationSeconds / 60) : "…", - })} - - - - - {estimating - ? "…" - : estimate - ? t("confirmRide.fareDisplay", { fare: estimate.fare }) - : "—"} - - {estimate ? ( - - {t("confirmRide.lbpEstimate", { - lbp: formatLBP(parseFloat(estimate.fare)), - })} - - ) : null} - - - - - {driversOnline === 0 - ? t("confirmRide.noDrivers", { service: t(selected.labelKey) }) - : nearestEta == null - ? t("confirmRide.findingDrivers") - : t("confirmRide.nearestDriver", { eta: nearestEta })} - - - - {t("confirmRide.paymentMethod")} - - - 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" - }`} - > - - {t("confirmRide.cash")} - - - 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" - }`} - > - - {t("confirmRide.card")} - - - - - - - ); -}; - -export default ConfirmRide; \ No newline at end of file diff --git a/app/(root)/driver-chat.tsx b/app/(root)/driver-chat.tsx new file mode 100644 index 0000000..5809bfd --- /dev/null +++ b/app/(root)/driver-chat.tsx @@ -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 = () => ; + +export default DriverChat; diff --git a/app/(root)/driver-home.tsx b/app/(root)/driver-home.tsx index de0eedd..c8decbd 100644 --- a/app/(root)/driver-home.tsx +++ b/app/(root)/driver-home.tsx @@ -1,31 +1,52 @@ import { MaterialCommunityIcons } from "@expo/vector-icons"; import { router } from "expo-router"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, Image, + Linking, ScrollView, Text, TextInput, TouchableOpacity, View, } from "react-native"; +import ReactNativeModal from "react-native-modal"; import { SafeAreaView } from "react-native-safe-area-context"; +import { driverPhotoUri } from "@/lib/driver-photo"; +import { CancelSheet } from "@/components/cancel-sheet"; import { CustomButton } from "@/components/custom-button"; +import { + DocumentScanner, + type DocumentType, + type ScannedFields, +} from "@/components/document-scanner"; +import { Map } from "@/components/map"; +import { PickupCodeSheet } from "@/components/pickup-code-sheet"; +import { ProfilePhotoPicker } from "@/components/profile-photo-picker"; +import { RatingSheet } from "@/components/rating-sheet"; import { icons, images } from "@/constants"; +import { REQUEST_TTL_SECONDS } from "@/constants/dispatch"; import { SERVICES, type ServiceId } from "@/constants/services"; import { ApiError, fetchAPI } from "@/lib/fetch"; import { useT } from "@/lib/i18n"; +import { + ensureNotificationPermission, + registerForPush, +} from "@/lib/notifications"; import { useSession } from "@/lib/session"; import { useTheme } from "@/lib/theme"; +import { ensureMicPermission } from "@/lib/use-call"; import { useDriverLocation } from "@/lib/use-driver-location"; -import { formatTime } from "@/lib/utils"; +import { formatTime, haversine } from "@/lib/utils"; // Poll cadence for the driver dashboard (offers / active ride / earnings). const POLL_MS = 4000; +type ApprovalStatus = "pending" | "approved" | "rejected" | "suspended"; + type Profile = { id: number; first_name: string; @@ -34,21 +55,102 @@ type Profile = { car_image_url: string | null; car_seats: number; rating: number; + rating_count: number; service: ServiceId; online: boolean; car_model: string | null; + approval_status: ApprovalStatus; + rejection_reason: string | null; + license_number: string | null; + license_expiry: string | null; + plate_number: string | null; + /** Stored scan names, or null where nothing has been uploaded. */ + license_image_url: string | null; + id_image_url: string | null; + vehicle_reg_image_url: string | null; }; -type Offer = { - offer_id: number; - offered_at: string; +// The credentials a driver submits for vetting: the numbers an owner checks a +// driver against before letting them near a rider. They are normally read off +// a scan rather than typed, but the driver owns every value in the end — the +// scan prefills the form, it does not submit it. +type Credentials = { + license_number: string; + license_expiry: string; + national_id: string; + plate_number: string; +}; + +const CREDENTIAL_KEYS = [ + "license_number", + "license_expiry", + "national_id", + "plate_number", +] as const; + +/** + * Credential fields the resubmit form seeds from the rejected profile. They + * are treated as replaceable by a fresh scan — see CredentialCapture's + * `prefilled`. national_id is not among them: the API never sends it back. + */ +const PREFILLED_ON_RESUBMIT = [ + "license_number", + "license_expiry", + "plate_number", +] as const satisfies readonly (keyof Credentials)[]; + +const EMPTY_CREDENTIALS: Credentials = { + license_number: "", + license_expiry: "", + national_id: "", + plate_number: "", +}; + +// The stored scans that go up with a submission, keyed the way the API expects +// them. Null means "nothing scanned in this session" — which on a resubmission +// is different from "nothing on file", since the profile may already have one. +type DocumentRefs = { + license_document: string | null; + id_document: string | null; + vehicle_reg_document: string | null; +}; + +const EMPTY_DOCUMENTS: DocumentRefs = { + license_document: null, + id_document: null, + vehicle_reg_document: null, +}; + +const DOCUMENT_KEY: Record = { + license: "license_document", + id: "id_document", + vehicle_reg: "vehicle_reg_document", +}; + +// An open request on the board. Under the broadcast model this is not +// addressed to this driver — it is a job several of them can see and any of +// them can volunteer for, which is why it carries how many have already +// offered and whether this driver is one of them. +type OpenRequest = { ride_id: number; + created_at: string; origin_address: string; destination_address: string; + origin_latitude: number; + origin_longitude: number; ride_time: number; fare_price: number; - payment_status: string; + /** What the driver keeps after the platform fee. */ + payout_cents: number; service: string; + rider_name: string | null; + rider_rating: number | null; + /** This driver's live offer on it, or null if they haven't offered. */ + my_offer_id: number | null; + /** How many drivers are in the running, this one included. */ + offer_count: number; + /** Metres from this driver's last position to the pickup. */ + pickup_distance_m: number; }; type ActiveRide = { @@ -58,17 +160,33 @@ type ActiveRide = { payment_status: string; origin_address: string; destination_address: string; + origin_latitude: number; + origin_longitude: number; + destination_latitude: number; + destination_longitude: number; ride_time: number; fare_price: number; + /** What the driver keeps after the platform fee. */ + payout_cents: number; + arrived_at: string | null; rider_name: string | null; - rider_phone: string | null; + rider_rating: number | null; }; type Dashboard = { - offers: Offer[]; + now: string; + requests: OpenRequest[]; active: ActiveRide | null; recent: { ride_id: number; fare_price: number; service: string }[]; earnings: number; + platform_fees: number; + cash_collected: number; + cash_owed: number; + /** Cash commission this driver is holding on the company's behalf. */ + owes_company: number; + /** Card payouts the company still owes this driver. */ + owed_to_driver: number; + pending_rating: { ride_id: number; rider_name: string | null } | null; }; const DriverHome = () => { @@ -80,6 +198,15 @@ const DriverHome = () => { const [online, setOnline] = useState(false); const [dashboard, setDashboard] = useState(null); const [busy, setBusy] = useState(false); + const [codeOpen, setCodeOpen] = useState(false); + const [codeError, setCodeError] = useState(null); + const [cancelOpen, setCancelOpen] = useState(false); + // Rating prompts are dismissible; remember which rides were dismissed this + // session so the next poll doesn't re-open the sheet the driver just closed. + const [ratingSkipped, setRatingSkipped] = useState([]); + // Retaking the profile photo. A driver whose photo turned out dark or + // half-cropped is the one rider-facing detail they can't otherwise fix. + const [photoOpen, setPhotoOpen] = useState(false); const loadProfile = useCallback(async () => { try { @@ -104,19 +231,51 @@ const DriverHome = () => { }, [loadProfile]); // Keep the location heartbeat running only while the driver is online and - // has completed onboarding. - useDriverLocation(online && profile !== null); + // has completed onboarding. Also gives us the driver's own last-known + // position, so the active-ride card can show it next to the rider's pickup. + const driverCoords = useDriverLocation(online && profile !== null); // Poll the dashboard while online. useCallback keeps the fetcher stable so the // interval effect doesn't re-subscribe on every render. + // Difference between the server's clock and this phone's, refreshed on every + // poll. The offer countdown is drawn against server time because that's the + // clock dispatch expires offers on — a phone a few seconds out would + // otherwise show a timer that runs out early or lingers past the offer. + const clockOffset = useRef(0); + + // Rides this driver has a live offer on. Offering is a bid, not a booking: + // the rider may pick someone else and the card simply vanishes on the next + // poll, which is the most confusing thing that can happen on this screen if + // nobody says why. + const myOffers = useRef([]); + const fetchDashboard = useCallback(async () => { try { const res = await fetchAPI("/(api)/driver/rides"); - setDashboard(res.data as Dashboard); + const data = res.data as Dashboard; + if (data.now) clockOffset.current = Date.parse(data.now) - Date.now(); + + const stillListed = new Set(data.requests.map((r) => r.ride_id)); + const wonId = data.active?.ride_id ?? null; + const lost = myOffers.current.filter( + (rideId) => !stillListed.has(rideId) && rideId !== wonId, + ); + if (lost.length > 0) { + Alert.alert( + t("driver.offerCard.lostTitle"), + t("driver.offerCard.lostBody"), + ); + } + + myOffers.current = data.requests + .filter((r) => r.my_offer_id !== null) + .map((r) => r.ride_id); + + setDashboard(data); } catch (err) { console.log("[DRIVER_DASHBOARD_POLL]: ", err); } - }, []); + }, [t]); useEffect(() => { if (!online || !profile) return; @@ -125,11 +284,29 @@ const DriverHome = () => { return () => clearInterval(timer); }, [online, profile, fetchDashboard]); + // Prime the mic permission as soon as the Message/Call buttons appear on + // the active-ride card, so the OS prompt lands here instead of mid-handshake + // after the driver has already tapped Call. + const activeRideId = dashboard?.active?.ride_id ?? null; + useEffect(() => { + if (activeRideId !== null) void ensureMicPermission(); + }, [activeRideId]); + const toggleOnline = async () => { if (!profile) return; const next = !online; setBusy(true); try { + // Going online is the first moment there's a concrete reason to + // interrupt this driver, so the notification prompt lands here rather + // than at app start where it would read as a random demand. Registering + // for remote push is attempted at the same time; it is a no-op until + // push credentials are configured. + if (next) { + await ensureNotificationPermission(); + void registerForPush(); + } + await fetchAPI("/(api)/driver/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, @@ -140,46 +317,178 @@ const DriverHome = () => { if (!next) setDashboard(null); } catch (err) { console.log("[DRIVER_TOGGLE]: ", err); - Alert.alert(t("driver.home.alertErrorTitle"), t("driver.home.alertToggleBody")); - } finally { - setBusy(false); - } - }; - const respond = async (offer: Offer, action: "accept" | "decline") => { - setBusy(true); - try { - await fetchAPI(`/(api)/ride/${offer.ride_id}/respond`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action }), - }); - await fetchDashboard(); - } catch (err) { - console.log("[DRIVER_RESPOND]: ", err); + // 403 means the profile lost its approval while the app was open (an + // owner suspended it). Reload so the review screen takes over rather + // than leaving the driver tapping a toggle that will never work. + if (err instanceof ApiError && err.status === 403) { + await loadProfile(); + return; + } + + // The server refuses to take a driver offline mid-ride — going dark on a + // rider who is waiting for you is the one case worth blocking outright. Alert.alert( - t("driver.activeRide.alertErrorTitle"), - action === "accept" - ? t("driver.activeRide.alertAcceptBody") - : t("driver.activeRide.alertDeclineBody"), + t("driver.home.alertErrorTitle"), + err instanceof ApiError && err.status === 409 + ? t("driver.home.alertOfflineBlocked") + : t("driver.home.alertToggleBody"), ); } finally { setBusy(false); } }; - const advance = async (rideId: number, status: "en_route" | "completed") => { + // Volunteer for a request, or take the offer back. Neither is an + // assignment: the rider decides, and until they do this driver stays on the + // board and free to offer on other jobs. + const respond = async ( + request: OpenRequest, + action: "offer" | "withdraw", + ) => { + setBusy(true); + try { + await fetchAPI(`/(api)/ride/${request.ride_id}/offer`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + // Don't warn about a request disappearing that this driver just walked + // away from themselves. + if (action === "withdraw") { + myOffers.current = myOffers.current.filter( + (id) => id !== request.ride_id, + ); + } + await fetchDashboard(); + } catch (err) { + console.log("[DRIVER_OFFER]: ", err); + // A 409 is the ordinary outcome of several drivers wanting the same job: + // somebody was picked while this one was reading it. + if (err instanceof ApiError && err.status === 409) { + Alert.alert( + t("driver.offerCard.lostTitle"), + t("driver.offerCard.lostBody"), + ); + myOffers.current = myOffers.current.filter( + (id) => id !== request.ride_id, + ); + await fetchDashboard(); + return; + } + Alert.alert( + t("driver.activeRide.alertErrorTitle"), + action === "offer" + ? t("driver.offerCard.alertOfferBody") + : t("driver.offerCard.alertWithdrawBody"), + ); + } finally { + setBusy(false); + } + }; + + // Generic state push for transitions the driver can make unilaterally + // ("I'm at the pickup point", "the trip is done"). + const advance = async ( + rideId: number, + status: "arrived" | "completed", + extra?: Record, + ) => { setBusy(true); try { await fetchAPI(`/(api)/ride/${rideId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status }), + body: JSON.stringify({ status, ...extra }), }); await fetchDashboard(); } catch (err) { console.log("[DRIVER_ADVANCE]: ", err); - Alert.alert(t("driver.activeRide.alertErrorTitle"), t("driver.activeRide.alertUpdateBody")); + Alert.alert( + t("driver.activeRide.alertErrorTitle"), + t("driver.activeRide.alertUpdateBody"), + ); + } finally { + setBusy(false); + } + }; + + // Starting the trip needs the rider's pickup code, so it goes through the + // code sheet rather than a plain state push. + const startTrip = async (rideId: number, code: string) => { + setBusy(true); + setCodeError(null); + try { + await fetchAPI(`/(api)/ride/${rideId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "en_route", pickup_code: code }), + }); + setCodeOpen(false); + await fetchDashboard(); + } catch (err) { + console.log("[DRIVER_START_TRIP]: ", err); + // 403 is specifically "that code doesn't match" — keep the sheet open so + // the driver can re-read it off the rider's phone and try again. + setCodeError( + err instanceof ApiError && err.status === 403 + ? t("pickupCode.wrongCode") + : t("driver.activeRide.alertUpdateBody"), + ); + } finally { + setBusy(false); + } + }; + + // Completing a cash ride also settles the money: the driver confirms they + // took the fare, which is what turns it from owed into collected. + const completeRide = (ride: ActiveRide) => { + if (ride.payment_status !== "cash") { + void advance(ride.ride_id, "completed"); + return; + } + + Alert.alert( + t("driver.activeRide.cashConfirmTitle"), + t("driver.activeRide.cashConfirmBody", { + amount: (ride.fare_price / 100).toFixed(2), + }), + [ + // "Not collected" still completes the trip — the rider has been + // dropped off either way. It leaves the fare marked as owed so it + // shows up as an unsettled balance instead of vanishing. + { + text: t("driver.activeRide.cashNotCollected"), + onPress: () => void advance(ride.ride_id, "completed"), + }, + { + text: t("driver.activeRide.cashCollected"), + onPress: () => + void advance(ride.ride_id, "completed", { cash_collected: true }), + }, + ], + ); + }; + + // Only allowed before the trip starts ('accepted' / 'arrived'): once en + // route the driver already has the rider, so aborting is "complete the + // trip", not "cancel" it. + const cancelRide = async (rideId: number, reason: string) => { + setBusy(true); + try { + await fetchAPI(`/(api)/ride/${rideId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "cancelled", reason }), + }); + setCancelOpen(false); + await fetchDashboard(); + } catch (err) { + console.log("[DRIVER_CANCEL]: ", err); + Alert.alert( + t("driver.activeRide.alertErrorTitle"), + t("driver.activeRide.alertCancelBody"), + ); } finally { setBusy(false); } @@ -188,19 +497,46 @@ const DriverHome = () => { if (loading) { return ( - + ); } if (!profile) { return ( - + + ); + } + + // A profile is not a licence to drive. Until an owner has reviewed the + // driver's credentials the dashboard is replaced by the review screen — + // there is nothing here they can act on, and the server would refuse the + // online toggle anyway. + if (profile.approval_status !== "approved") { + return ( + ); } const earnings = dashboard?.earnings ?? 0; const rideCount = dashboard?.recent.length ?? 0; + const cashCollected = dashboard?.cash_collected ?? 0; + const cashOwed = dashboard?.cash_owed ?? 0; + const platformFees = dashboard?.platform_fees ?? 0; + const owesCompany = dashboard?.owes_company ?? 0; + const owedToDriver = dashboard?.owed_to_driver ?? 0; + const pendingRating = dashboard?.pending_rating ?? null; return ( @@ -209,14 +545,51 @@ const DriverHome = () => { contentContainerStyle={{ paddingBottom: 40 }} > - - {t("driver.home.driverMode")} - + + setPhotoOpen(true)} + className="w-12 h-12 rounded-full bg-white dark:bg-neutral-900 items-center justify-center overflow-hidden mr-3" + > + {profile.profile_image_url ? ( + {t("driver.photo.title")} + ) : ( + + )} + + + + {t("driver.home.driverMode")} + + {/* A driver's own rating drives whether they keep working here, and + it was being fetched but never shown. */} + + ★ {Number(profile.rating).toFixed(1)} + {profile.rating_count > 0 + ? ` · ${t("driver.home.ratingCount", { + n: String(profile.rating_count), + })}` + : ` · ${t("driver.home.ratingNew")}`} + + + - {t("driver.home.signOutAlt")} + {t("driver.home.signOutAlt")} @@ -242,54 +615,551 @@ const DriverHome = () => { ${(earnings / 100).toFixed(2)} + {/* Says where the difference went. A driver who charged $20 in + fares and sees $16 needs the missing $4 accounted for on the + same screen, or the number reads as an error. */} + {platformFees > 0 ? ( + + {t("driver.home.afterFee", { + fee: (platformFees / 100).toFixed(2), + })} + + ) : null} {t("driver.home.completedToday")} - {rideCount} + + {rideCount} + + {/* Cash in hand. Separated from earnings because it's money the driver + is holding on the platform's behalf, and the figure they'll be + reconciled against at the end of the day. */} + {cashCollected > 0 ? ( + + + {t("driver.home.cashInHand")} + + + ${(cashCollected / 100).toFixed(2)} + + + ) : null} + + {/* The running balance with the company, both directions. Kept + separate from today's earnings because it doesn't reset at + midnight — commission a driver is holding from Tuesday is still + owed on Friday, and a driver who can't see it has no way to know + what they'll be asked for. */} + {owesCompany > 0 || owedToDriver > 0 ? ( + + {owesCompany > 0 ? ( + + + + {t("driver.home.owesCompany")} + + + {t("driver.home.owesCompanyHint")} + + + + ${(owesCompany / 100).toFixed(2)} + + + ) : null} + + {owesCompany > 0 && owedToDriver > 0 ? ( + + ) : null} + + {owedToDriver > 0 ? ( + + + + {t("driver.home.owedToDriver")} + + + {t("driver.home.owedToDriverHint")} + + + + ${(owedToDriver / 100).toFixed(2)} + + + ) : null} + + ) : null} + + {/* Fares that were never collected. These no longer count towards the + earnings headline, so they're shown here instead of silently + inflating a number the driver won't be paid. */} + {cashOwed > 0 ? ( + + + {t("driver.home.uncollected")} + + + ${(cashOwed / 100).toFixed(2)} + + + ) : null} + {/* Active ride */} {dashboard?.active ? ( void advance(rideId, "arrived")} + onStartTrip={() => { + setCodeError(null); + setCodeOpen(true); + }} + onComplete={completeRide} + onCancel={() => setCancelOpen(true)} + driverCoords={driverCoords} /> ) : null} - {/* Incoming offers */} + {/* The board: open requests near this driver, newest and nearest + first. Hidden while they're on a ride — a driver mid-trip taking a + second job is the one thing this screen must not make easy. */} {online ? t("driver.home.incomingRequests") : t("driver.home.incomingRequestsOffline")} - {!online ? null : dashboard?.offers.length ? ( - dashboard.offers.map((offer) => ( - ( + respond(offer, "accept")} - onDecline={() => respond(offer, "decline")} + clockOffset={clockOffset.current} + onOffer={() => respond(request, "offer")} + onWithdraw={() => respond(request, "withdraw")} /> )) ) : ( - + - {online ? t("driver.home.waitingRequests") : t("driver.home.goOnlineStart")} + {!online + ? t("driver.home.goOnlineStart") + : dashboard?.active + ? t("driver.home.finishCurrentRide") + : t("driver.home.waitingRequests")} )} + + {dashboard?.active ? ( + <> + setCodeOpen(false)} + onSubmit={(code) => void startTrip(dashboard.active!.ride_id, code)} + /> + setCancelOpen(false)} + onConfirm={(reason) => + void cancelRide(dashboard.active!.ride_id, reason) + } + /> + + ) : null} + + {/* Rate the rider once the trip is done. Prompted from the dashboard + rather than at drop-off so it survives the driver immediately + accepting their next request. */} + {pendingRating && !ratingSkipped.includes(pendingRating.ride_id) ? ( + { + setRatingSkipped((prev) => [...prev, pendingRating.ride_id]); + void fetchDashboard(); + }} + onSkip={() => + setRatingSkipped((prev) => [...prev, pendingRating.ride_id]) + } + /> + ) : null} + + {/* Retaking the profile photo. The upload attaches itself server-side + for a driver who already has a profile, so all this has to do + afterwards is reload the profile and let the new photo show. */} + setPhotoOpen(false)} + > + + void loadProfile()} + /> + setPhotoOpen(false)} + /> + + ); }; +// --- Credentials -------------------------------------------------------- + +// A labelled text input matching the onboarding form's styling. Pulled out +// because onboarding and the resubmit-after-rejection flow collect the same +// four fields and must not drift apart. +const Field = ({ + label, + value, + onChange, + placeholder, + keyboardType, + autoCapitalize = "characters", +}: { + label: string; + value: string; + onChange: (next: string) => void; + placeholder: string; + keyboardType?: "default" | "number-pad" | "numbers-and-punctuation"; + autoCapitalize?: "none" | "characters" | "words"; +}) => { + const { isDark } = useTheme(); + + return ( + <> + + {label} + + + + ); +}; + +const CredentialFields = ({ + values, + onChange, + only, +}: { + values: Credentials; + onChange: (next: Credentials) => void; + /** + * Render just these fields. Used to ask for the one value a scan couldn't + * read without putting the three it did read back in front of the driver as + * a form to re-check. + */ + only?: readonly (keyof Credentials)[]; +}) => { + const t = useT(); + const set = (key: keyof Credentials) => (next: string) => + onChange({ ...values, [key]: next }); + const show = (key: keyof Credentials) => !only || only.includes(key); + + return ( + <> + {show("license_number") && ( + + )} + {show("license_expiry") && ( + + )} + {show("national_id") && ( + + )} + {show("plate_number") && ( + + )} + + ); +}; + +/** + * What the scans read, shown back as a receipt rather than a form. + * + * The driver is not asked to check these — a human reviewer does that against + * the stored scan before the profile is ever approved. This is here so the + * driver can see that something was actually read off their documents, and + * spot a wrong number if one happens to catch their eye. + */ +const CredentialSummary = ({ values }: { values: Credentials }) => { + const t = useT(); + + const rows: [string, string][] = [ + [t("driver.credentials.licenseNumber"), values.license_number], + [t("driver.credentials.licenseExpiry"), values.license_expiry], + [t("driver.credentials.nationalId"), values.national_id], + [t("driver.credentials.plateNumber"), values.plate_number], + ]; + + return ( + + + + + {t("driver.scan.allRead")} + + + + {rows.map(([label, value]) => ( + + + {label} + + + {value} + + + ))} + + ); +}; + +/** + * The three document scanners, and the credential fields they usually make + * unnecessary. + * + * The driver's job here is to photograph their documents — not to transcribe + * them and not to proof-read a form. So when a scan yields everything, no + * inputs are rendered at all: the values are shown back as a receipt and the + * driver submits. Typing only ever appears for what a scan genuinely could not + * read, which is the one case where hiding the field would leave the driver + * stuck with no way forward. + * + * What makes that safe is that nothing downstream trusts a scanned value: + * an owner reviews every profile against the stored image before it can take a + * ride, so a misread is caught by a person rather than by asking every driver + * to check every field on the off-chance. + * + * Onboarding and the resubmit-after-rejection flow both use this, which is + * what keeps the two paths from drifting — a rejected driver re-scans exactly + * the documents a new one scans. + * + * The merge rule is the other half. A scanned value is written into a field + * that is empty, or into one an earlier scan filled; a value the driver typed + * themselves is never overwritten. Without that, correcting a misread expiry + * and then re-scanning a blurry ID card would quietly stamp the correction + * back out — and the driver would submit a number they had already fixed once. + */ +const CredentialCapture = ({ + values, + onChange, + documents, + onDocuments, + onFile, + prefilled, + onCarModel, +}: { + values: Credentials; + onChange: (next: Credentials) => void; + documents: DocumentRefs; + onDocuments: (next: DocumentRefs) => void; + /** Documents already stored on the profile, for the resubmission flow. */ + onFile?: Partial>; + /** + * Fields whose starting value came from an earlier submission rather than + * from the driver typing it now. A resubmission is usually a rejection over + * exactly one of those values, so a rescan has to be allowed to replace + * them — otherwise re-photographing the licence leaves the wrong number the + * reviewer already rejected sitting in the form. + */ + prefilled?: readonly (keyof Credentials)[]; + /** Called when a vehicle registration yields a car model. */ + onCarModel?: (model: string) => void; +}) => { + const t = useT(); + const [autofilled, setAutofilled] = useState>( + () => new Set(prefilled), + ); + + // A scan is on file already when resubmitting, so the summary and the + // "needed" set have to reflect the values that came back with the profile — + // not wait for a rescan that the driver may not have to make. + const hasScan = + documents.license_document !== null || Boolean(onFile?.license); + + /** + * Fields a scan left empty, frozen at the moment the scan completed rather + * than derived from the current values. Deriving it live would make each + * input vanish the instant the driver typed the first character into it. + */ + const [needed, setNeeded] = useState(() => + hasScan ? CREDENTIAL_KEYS.filter((key) => !values[key].trim()) : [], + ); + + // Opened by the driver when they spot a wrong value. Never the default: + // the point of scanning is that there is no form to work through. + const [editing, setEditing] = useState(false); + + const applyScan = ( + docType: DocumentType, + document: string, + fields: ScannedFields, + ) => { + onDocuments({ ...documents, [DOCUMENT_KEY[docType]]: document }); + + const next = { ...values }; + const filled = new Set(autofilled); + + for (const field of CREDENTIAL_KEYS) { + const scanned = fields[field]?.trim(); + if (!scanned) continue; + if (next[field].trim() && !filled.has(field)) continue; + + next[field] = scanned; + filled.add(field); + } + + setAutofilled(filled); + setNeeded(CREDENTIAL_KEYS.filter((key) => !next[key].trim())); + onChange(next); + + if (fields.car_model) onCarModel?.(fields.car_model); + }; + + const scanners: { docType: DocumentType; optional?: boolean }[] = [ + { docType: "license" }, + { docType: "id", optional: true }, + { docType: "vehicle_reg", optional: true }, + ]; + + return ( + <> + {scanners.map(({ docType, optional }) => ( + applyScan(docType, document, fields)} + /> + ))} + + {/* Everything read: no form, just what we got. */} + {hasScan && needed.length === 0 && !editing ? ( + + ) : null} + + {/* Something didn't read, or the driver opened the details to fix a + value. Only the unreadable fields are asked for — the ones that + scanned cleanly stay out of the way unless editing is open. */} + {needed.length > 0 || editing ? ( + <> + + {editing + ? t("driver.scan.checkPrompt") + : t("driver.scan.missingPrompt")} + + + + + ) : null} + + {/* An escape hatch for a driver who spots a wrong digit, deliberately + understated so it doesn't read as a step they have to complete. */} + {hasScan ? ( + setEditing((open) => !open)} + className="self-start mb-4" + > + + {editing ? t("driver.scan.done") : t("driver.scan.edit")} + + + ) : null} + + ); +}; + +// Client-side mirror of the server's checks, so an obvious mistake is caught +// before a round trip. The server re-validates regardless. +const credentialError = ( + values: Credentials, + hasLicenseScan: boolean, +): string | null => { + if (!hasLicenseScan) return "driver.credentials.errorScanRequired"; + + if ( + !values.license_number.trim() || + !values.national_id.trim() || + !values.plate_number.trim() + ) { + return "driver.credentials.errorMissing"; + } + + if (!/^\d{4}-\d{2}-\d{2}$/.test(values.license_expiry.trim())) { + return "driver.credentials.errorExpiryFormat"; + } + + const expiry = new Date(`${values.license_expiry.trim()}T00:00:00Z`); + if (Number.isNaN(expiry.getTime()) || expiry.getTime() <= Date.now()) { + return "driver.credentials.errorExpired"; + } + + return null; +}; + // --- Onboarding form ------------------------------------------------------ const Onboarding = ({ @@ -306,14 +1176,39 @@ const Onboarding = ({ const [service, setService] = useState("car"); const [carModel, setCarModel] = useState(""); const [carSeats, setCarSeats] = useState("4"); + const [credentials, setCredentials] = + useState(EMPTY_CREDENTIALS); + const [documents, setDocuments] = useState(EMPTY_DOCUMENTS); + const [photo, setPhoto] = useState(null); const [submitting, setSubmitting] = useState(false); const submit = async () => { - const seats = Number(carSeats); - if (!Number.isInteger(seats) || seats < 1 || seats > 8) { - Alert.alert(t("driver.home.alertSeatsTitle"), t("driver.home.alertSeatsBody")); + if (!photo) { + Alert.alert( + t("driver.credentials.errorTitle"), + t("driver.photo.required"), + ); return; } + + const seats = Number(carSeats); + if (!Number.isInteger(seats) || seats < 1 || seats > 8) { + Alert.alert( + t("driver.home.alertSeatsTitle"), + t("driver.home.alertSeatsBody"), + ); + return; + } + + const problem = credentialError( + credentials, + documents.license_document !== null, + ); + if (problem) { + Alert.alert(t("driver.credentials.errorTitle"), t(problem)); + return; + } + setSubmitting(true); try { await fetchAPI("/(api)/driver/profile", { @@ -323,12 +1218,21 @@ const Onboarding = ({ service, car_model: carModel.trim() || null, car_seats: seats, + license_number: credentials.license_number.trim(), + license_expiry: credentials.license_expiry.trim(), + national_id: credentials.national_id.trim(), + plate_number: credentials.plate_number.trim(), + profile_photo: photo, + ...documents, }), }); await onCreated(); } catch (err) { console.log("[DRIVER_ONBOARD]: ", err); - Alert.alert(t("driver.home.alertErrorTitle"), t("driver.home.alertCreateBody")); + Alert.alert( + t("driver.home.alertErrorTitle"), + t("driver.home.alertCreateBody"), + ); } finally { setSubmitting(false); } @@ -336,7 +1240,10 @@ const Onboarding = ({ return ( - + {t("driver.home.welcome", { @@ -347,7 +1254,11 @@ const Onboarding = ({ onPress={signOut} className="w-10 h-10 rounded-full bg-neutral-100 dark:bg-neutral-900 items-center justify-center" > - {t("driver.home.signOutAlt")} + {t("driver.home.signOutAlt")} @@ -355,6 +1266,11 @@ const Onboarding = ({ {t("driver.home.setupIntro")} + {/* First thing in the form, because it is the first thing a rider + sees: the photo shown beside this driver's name when riders pick + between offers. */} + + {t("driver.home.whatDrive")} @@ -412,8 +1328,32 @@ const Onboarding = ({ className="bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-8" /> + + {t("driver.credentials.title")} + + + {t("driver.credentials.intro")} + + + setCarModel((prev) => prev.trim() || model)} + /> + + + {t("driver.credentials.reviewNote")} + + @@ -422,43 +1362,326 @@ const Onboarding = ({ ); }; -// --- Offer card ----------------------------------------------------------- +// --- Review status -------------------------------------------------------- -const OfferCard = ({ - offer, - busy, - onAccept, - onDecline, +// What a driver sees between submitting their credentials and being cleared to +// drive. Pending and suspended are read-only; a rejection is actionable, so it +// carries the owner's reason and a form to correct and resubmit. +const REVIEW_POLL_MS = 20000; + +const ReviewStatus = ({ + profile, + onRefresh, + signOut, }: { - offer: Offer; - busy: boolean; - onAccept: () => void; - onDecline: () => void; + profile: Profile; + onRefresh: () => Promise; + signOut: () => Promise; }) => { const t = useT(); + const status = profile.approval_status; + const [credentials, setCredentials] = useState({ + license_number: profile.license_number ?? "", + license_expiry: profile.license_expiry + ? profile.license_expiry.slice(0, 10) + : "", + national_id: "", + plate_number: profile.plate_number ?? "", + }); + const [documents, setDocuments] = useState(EMPTY_DOCUMENTS); + const [submitting, setSubmitting] = useState(false); + + // A decision can land at any moment and there's nothing else on this screen + // to do, so poll rather than making the driver pull to refresh. + useEffect(() => { + const timer = setInterval(() => void onRefresh(), REVIEW_POLL_MS); + return () => clearInterval(timer); + }, [onRefresh]); + + const resubmit = async () => { + // A rejection is often about the numbers, not the scan, so a driver may + // resubmit on the licence photo already on file rather than re-taking it. + const hasLicenseScan = + documents.license_document !== null || profile.license_image_url !== null; + + const problem = credentialError(credentials, hasLicenseScan); + if (problem) { + Alert.alert(t("driver.credentials.errorTitle"), t(problem)); + return; + } + + setSubmitting(true); + try { + await fetchAPI("/(api)/driver/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + license_number: credentials.license_number.trim(), + license_expiry: credentials.license_expiry.trim(), + national_id: credentials.national_id.trim(), + plate_number: credentials.plate_number.trim(), + ...documents, + }), + }); + await onRefresh(); + } catch (err) { + console.log("[DRIVER_RESUBMIT]: ", err); + Alert.alert( + t("driver.home.alertErrorTitle"), + t("driver.credentials.alertResubmitBody"), + ); + } finally { + setSubmitting(false); + } + }; + + const tone = + status === "rejected" || status === "suspended" + ? { + badge: "bg-rose-500/10 border-rose-500", + text: "text-rose-500", + icon: "alert-circle-outline" as const, + } + : { + badge: "bg-amber-500/10 border-amber-500", + text: "text-amber-600 dark:text-amber-400", + icon: "clock-outline" as const, + }; + return ( - + + + + + {t("driver.home.driverMode")} + + + {t("driver.home.signOutAlt")} + + + + + + + {t(`driver.review.${status}Title`)} + + + {t(`driver.review.${status}Body`)} + + + + {status === "rejected" && profile.rejection_reason ? ( + + + {t("driver.review.reasonLabel")} + + + {profile.rejection_reason} + + + ) : null} + + {status === "pending" ? ( + void onRefresh()} + className="rounded-full py-3 mt-4 items-center border border-neutral-300 dark:border-neutral-800" + > + + {t("driver.review.checkAgain")} + + + ) : null} + + {status === "rejected" ? ( + + + {t("driver.review.resubmitTitle")} + + + {t("driver.review.resubmitIntro")} + + + + + + + ) : null} + + + ); +}; + +// --- Request card --------------------------------------------------------- + +const RequestCard = ({ + request, + busy, + clockOffset, + onOffer, + onWithdraw, +}: { + request: OpenRequest; + busy: boolean; + clockOffset: number; + onOffer: () => void; + onWithdraw: () => void; +}) => { + const t = useT(); + + // Redraw once a second so the countdown actually counts. The dashboard poll + // is every 4s, which is too coarse for a clock the driver is reading. + const [, setTick] = useState(0); + useEffect(() => { + const timer = setInterval(() => setTick((n) => n + 1), 1000); + return () => clearInterval(timer); + }, []); + + // How long the job stays on the board. Under the broadcast model this is + // the request's own life, not a per-driver deadline: nothing passes to + // anybody when it runs out, the request simply dies unpicked. + const elapsed = + (Date.now() + clockOffset - Date.parse(request.created_at)) / 1000; + const remaining = Math.max(0, Math.ceil(REQUEST_TTL_SECONDS - elapsed)); + const fraction = Math.max(0, Math.min(1, remaining / REQUEST_TTL_SECONDS)); + const urgent = remaining <= 20; + + const offered = request.my_offer_id !== null; + const km = Math.round(request.pickup_distance_m / 100) / 10; + + // Somebody else wanting the job is information the driver is entitled to: + // it's the difference between "I'll think about it" and "offer now". + const rivals = Math.max(0, request.offer_count - (offered ? 1 : 0)); + + return ( + - {t("driver.offerCard.newRequest", { service: offer.service })} + {t("driver.offerCard.newRequest", { service: request.service })} - {offer.payment_status === "cash" - ? t("driver.offerCard.cash") - : t("driver.offerCard.card")} + {rivals > 0 + ? t("driver.offerCard.rivals", undefined, rivals) + : t("driver.offerCard.firstIn")} + {/* How long the job is on the board for. */} + + + + {t("driver.offerCard.openFor")} + + + {t("driver.offerCard.seconds", { n: String(remaining) })} + + + + + + + + {/* Distance to the pickup: the single most useful thing to know before + putting your name on a job. Computed server-side from the same + position dispatch matched on, so it agrees with what got you here. */} + + + + + {t("driver.offerCard.awayFromPickup", { km })} + + + {request.rider_name ? ( + + + {request.rider_name} + + {request.rider_rating ? ( + + ★ {Number(request.rider_rating).toFixed(1)} + + ) : null} + + ) : null} + + - {t("driver.offerCard.fromAlt")} - - {offer.origin_address} + {t("driver.offerCard.fromAlt")} + + {request.origin_address} - {t("driver.offerCard.toAlt")} - - {offer.destination_address} + {t("driver.offerCard.toAlt")} + + {request.destination_address} @@ -467,38 +1690,51 @@ const OfferCard = ({ {t("driver.offerCard.tripTime")} - {formatTime(offer.ride_time)} + {formatTime(request.ride_time)} - {t("driver.offerCard.fare")} + {t("driver.offerCard.youEarn")} - - ${(offer.fare_price / 100).toFixed(2)} + + ${(request.payout_cents / 100).toFixed(2)} - + {/* Offered already: the wait is on the rider, and the only move left is + to take it back. Said plainly, because an offer that looks like a + booking is how a driver ends up parked outside a pickup that was + never theirs. */} + {offered ? ( + <> + + + + {t("driver.offerCard.waitingOnRider")} + + + + + {t("driver.offerCard.withdraw")} + + + + ) : ( - - {t("driver.offerCard.decline")} - - - - {busy ? "…" : t("driver.offerCard.accept")} + {busy ? "…" : t("driver.offerCard.offer")} - + )} ); }; @@ -508,19 +1744,53 @@ const OfferCard = ({ const ActiveRideCard = ({ ride, busy, - onAdvance, + onArrived, + onStartTrip, + onComplete, + onCancel, + driverCoords, }: { ride: ActiveRide; busy: boolean; - onAdvance: (rideId: number, status: "en_route" | "completed") => void; + onArrived: (rideId: number) => void; + onStartTrip: () => void; + onComplete: (ride: ActiveRide) => void; + onCancel: () => void; + driverCoords: { latitude: number; longitude: number } | null; }) => { const t = useT(); const statusLabel = ride.status === "accepted" ? t("driver.activeRide.headToPickup") - : ride.status === "en_route" - ? t("driver.activeRide.tripInProgress") - : ride.status; + : ride.status === "arrived" + ? t("driver.activeRide.atPickup") + : ride.status === "en_route" + ? t("driver.activeRide.tripInProgress") + : ride.status; + + // Before the rider is aboard the driver is heading to the pickup; after, + // to the drop-off. Both the map and the navigation handoff follow this. + const heading = ride.status === "en_route" ? "dropoff" : "pickup"; + + const openNavigation = () => { + const lat = + heading === "pickup" ? ride.origin_latitude : ride.destination_latitude; + const lng = + heading === "pickup" ? ride.origin_longitude : ride.destination_longitude; + + // The universal Maps URL opens the native Google Maps app when it's + // installed and falls back to the browser when it isn't, on both + // platforms — no per-platform scheme juggling and no extra dependency. + const url = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}&travelmode=driving`; + + Linking.openURL(url).catch((err) => { + console.log("[DRIVER_NAVIGATE]: ", err); + Alert.alert( + t("driver.activeRide.alertErrorTitle"), + t("driver.activeRide.alertNavigateBody"), + ); + }); + }; return ( @@ -528,54 +1798,188 @@ const ActiveRideCard = ({ ● {statusLabel} - {ride.service} + + {ride.service} + {ride.rider_name ? ( - - {t("driver.activeRide.rider", { name: ride.rider_name })} - + + + + {t("driver.activeRide.rider", { name: ride.rider_name })} + + {/* Riders are rated too — a driver should know who they're + picking up before they pull over for them. */} + {ride.rider_rating ? ( + + ★ {Number(ride.rider_rating).toFixed(1)} + + ) : null} + + + router.push("/(root)/driver-chat")} + accessibilityLabel={t("driver.activeRide.message")} + className="w-9 h-9 rounded-full bg-primary-500 items-center justify-center" + > + + + + router.push({ + pathname: "/(root)/call", + params: { + rideId: String(ride.ride_id), + role: "driver", + mode: "start", + }, + }) + } + accessibilityLabel={t("driver.activeRide.call")} + className="w-9 h-9 rounded-full bg-emerald-500 items-center justify-center" + > + + + + ) : null} + {/* The map stays up for the whole ride. It used to disappear the moment + the trip started — exactly when the driver needs the route most — + leaving them with a destination address as plain text. Before pickup + it points at the rider; once they're aboard, at the drop-off. */} + + + + + {/* Hand off to whatever navigation app the driver actually uses. The + in-app map shows the shape of the trip; it is not turn-by-turn. */} + + + + {heading === "pickup" + ? t("driver.activeRide.navigateToPickup") + : t("driver.activeRide.navigateToDropoff")} + + + - {t("driver.activeRide.fromAlt")} - + {t("driver.activeRide.fromAlt")} + {ride.origin_address} - {t("driver.activeRide.toAlt")} - + {t("driver.activeRide.toAlt")} + {ride.destination_address} - {t("driver.activeRide.fare")} + {t("driver.activeRide.youEarn")} - - ${(ride.fare_price / 100).toFixed(2)} + + ${(ride.payout_cents / 100).toFixed(2)} + {/* Heading to the pickup: the only forward action is "I'm here". */} {ride.status === "accepted" ? ( - onAdvance(ride.ride_id, "en_route")} - className="mb-2" - /> + <> + onArrived(ride.ride_id)} + className="mb-2" + /> + + + {t("driver.activeRide.cancelRide")} + + + ) : null} + + {/* At the pickup, waiting on the rider and their code. */} + {ride.status === "arrived" ? ( + <> + + {t("driver.activeRide.askForCode")} + + + + + {t("driver.activeRide.cancelRide")} + + + + ) : null} + {ride.status === "en_route" ? ( onAdvance(ride.ride_id, "completed")} + onPress={() => onComplete(ride)} /> ) : null} ); }; -export default DriverHome; \ No newline at end of file +export default DriverHome; diff --git a/app/(root)/find-ride.tsx b/app/(root)/find-ride.tsx index ff7a2fb..20e9d2e 100644 --- a/app/(root)/find-ride.tsx +++ b/app/(root)/find-ride.tsx @@ -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 ( + + 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" + > + + + {t("findRide.adjustOnMap")} + + + ); +}; + +/** + * 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; + onSelect: (id: ServiceId) => void; +}) => { + const t = useT(); + + return ( + + {SERVICES.map((item) => { + const active = item.id === service; + const available = counts[item.id] ?? 0; + + return ( + 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" + }`} + > + + + {t(item.labelKey)} + + {/* 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. */} + 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-general-200 dark:text-neutral-500" + }`} + > + {available > 0 ? t("findRide.nAvailable", { n: available }) : "—"} + + + ); + })} + + ); +}; 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 ( @@ -39,6 +269,8 @@ const FindRide = () => { containerStyles="bg-neutral-100 dark:bg-neutral-800" handlePress={setUserLocation} /> + + @@ -52,16 +284,59 @@ const FindRide = () => { containerStyles="bg-neutral-100 dark:bg-neutral-800" handlePress={setDestinationLocation} /> + + + + {t("findRide.service")} + + + + {/* 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. */} + + + + {t("findRide.estimatedFare")} + + + {estimate + ? t("confirmRide.tripTime", { + time: formatTime(estimate.durationSeconds / 60), + }) + : t("findRide.setBothPoints")} + + + + + {estimating ? "…" : estimate ? `$${estimate.fare}` : "—"} + + {estimate ? ( + + {t("confirmRide.lbpEstimate", { + lbp: formatLBP(parseFloat(estimate.fare)), + })} + + ) : null} + + + + + {t("findRide.payLaterHint")} + + 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" : ""}`} /> ); }; -export default FindRide; \ No newline at end of file +export default FindRide; diff --git a/app/_layout.tsx b/app/_layout.tsx index 6416e12..243164a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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"), diff --git a/components/active-ride-banner.tsx b/components/active-ride-banner.tsx new file mode 100644 index 0000000..562b984 --- /dev/null +++ b/components/active-ride-banner.tsx @@ -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 = { + 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(null); + const [pending, setPending] = useState(null); + const [ratingOpen, setRatingOpen] = useState(false); + const [dismissed, setDismissed] = useState([]); + + 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 ( + + 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" + > + + + {STATUS_KEY[active.status] + ? t(STATUS_KEY[active.status]) + : active.status} + + + {active.driver_name + ? t("home.activeRideWithDriver", { name: active.driver_name }) + : active.destination_address} + + + + + ); + } + + if (pending && !dismissed.includes(pending.ride_id)) { + return ( + <> + + + + {t("home.rateLastRide")} + + + {pending.destination_address} + + + setRatingOpen(true)} + className="bg-primary-500 rounded-full px-4 py-2 ml-3" + > + + {t("home.rate")} + + + + + { + setRatingOpen(false); + setDismissed((prev) => [...prev, pending.ride_id]); + void load(); + }} + onSkip={() => { + setRatingOpen(false); + setDismissed((prev) => [...prev, pending.ride_id]); + }} + /> + + ); + } + + return null; +}; diff --git a/components/call-watcher.tsx b/components/call-watcher.tsx new file mode 100644 index 0000000..b827e3f --- /dev/null +++ b/components/call-watcher.tsx @@ -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(null); + // The call id we've already navigated to, so we don't re-push the screen. + const handledCallIdRef = useRef(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; diff --git a/components/cancel-sheet.tsx b/components/cancel-sheet.tsx new file mode 100644 index 0000000..16bc15d --- /dev/null +++ b/components/cancel-sheet.tsx @@ -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(null); + const reasons = audience === "rider" ? RIDER_REASONS : DRIVER_REASONS; + + return ( + + + + {t("cancelSheet.title")} + + + {audience === "rider" + ? t("cancelSheet.subtitleRider") + : t("cancelSheet.subtitleDriver")} + + + {reasons.map((code) => { + const selected = reason === code; + return ( + 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" + }`} + > + + {t(`cancelSheet.reasons.${code}`)} + + + ); + })} + + reason && onConfirm(reason)} + disabled={!reason || submitting} + className={`rounded-full py-3 items-center mt-3 bg-rose-500 ${ + !reason || submitting ? "opacity-50" : "" + }`} + > + + {submitting + ? t("cancelSheet.cancelling") + : t("cancelSheet.confirm")} + + + + + + {t("cancelSheet.keepRide")} + + + + + ); +}; diff --git a/components/chat-thread.tsx b/components/chat-thread.tsx new file mode 100644 index 0000000..7a0d031 --- /dev/null +++ b/components/chat-thread.tsx @@ -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(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 ( + + + + {item.body} + + + + ); + }, + [role], + ); + + const emptyConversation = useMemo( + () => ( + + {t("chat.messageAlt")} + + {t("chat.noMessages")} + + + {t("chat.startConversation")} + + + ), + [t], + ); + + if (resolving) { + return ( + + + + ); + } + + return ( + + {/* Conversation header — only when a ride is matched */} + {active && peer ? ( + + + router.push({ + pathname: "/(root)/book-ride", + params: { id: String(active.ride_id) }, + }) + } + className="flex-row items-center flex-1" + > + {peer.avatar ? ( + + ) : ( + + + {initials(peerName)} + + + )} + + + {peerName} + + {peer.car_model ? ( + + {peer.car_model} + + ) : null} + + + + + + + + ) : null} + + {active && peer ? ( + <> + {loading && messages.length === 0 ? ( + + + + ) : ( + String(m.id)} + renderItem={renderBubble} + contentContainerStyle={{ + flexGrow: 1, + paddingHorizontal: 16, + paddingVertical: 12, + }} + onScrollBeginDrag={Keyboard.dismiss} + keyboardShouldPersistTaps="never" + ListEmptyComponent={emptyConversation} + /> + )} + + {/* Composer */} + + + + + + + + ) : ( + {emptyConversation} + )} + + ); +}; diff --git a/components/custom-button.tsx b/components/custom-button.tsx index 467aeda..8c67896 100644 --- a/components/custom-button.tsx +++ b/components/custom-button.tsx @@ -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) => ( - {IconRight && } - + ); diff --git a/components/document-scanner.tsx b/components/document-scanner.tsx new file mode 100644 index 0000000..5404d35 --- /dev/null +++ b/components/document-scanner.tsx @@ -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(null); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(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 ( + + + + {label} + + {optional ? ( + + {t("driver.scan.optional")} + + ) : null} + + + + {hint} + + + + {scanned ? ( + {label} + ) : null} + + + void capture("camera")} + disabled={busy} + className="flex-1 flex-row items-center justify-center rounded-full bg-primary-500 py-3 px-2" + > + {busy ? ( + + ) : ( + <> + + + {scanned ? t("driver.scan.retake") : t("driver.scan.take")} + + + )} + + + 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" + > + + + {t("driver.scan.choose")} + + + + + + {busy ? ( + + {t("driver.scan.reading")} + + ) : status ? ( + + + + {status} + + + ) : failed ? ( + + + + {t("driver.scan.errorRetry")} + + + ) : onFile ? ( + // Resubmitting after a rejection: the reviewer already has a scan, so + // say so rather than making the driver wonder whether it was lost. + + + + {t("driver.scan.alreadyOnFile")} + + + ) : null} + + ); +}; diff --git a/components/driver-card.tsx b/components/driver-card.tsx index 3615d13..f208d62 100644 --- a/components/driver-card.tsx +++ b/components/driver-card.tsx @@ -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`} > {tr("components.driverCard.avatarAlt")} @@ -32,14 +33,24 @@ export const DriverCard = ({ - {tr("components.driverCard.starAlt")} - {item.rating} + {tr("components.driverCard.starAlt")} + + {item.rating} + - {tr("components.driverCard.dollarAlt")} + {tr("components.driverCard.dollarAlt")} ${item.price} diff --git a/components/google-text-input.tsx b/components/google-text-input.tsx index e9c3ab9..8f432a0 100644 --- a/components/google-text-input.tsx +++ b/components/google-text-input.tsx @@ -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 = ({ /> + {/* 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 && ( - item.placeId} - keyboardShouldPersistTaps="handled" - renderItem={({ item }) => ( - onSelect(item)} - className="p-3 border-b border-general-700 dark:border-neutral-700" - > - - {item.text} - - - )} - /> + {suggestions.map((item) => ( + onSelect(item)} + className="p-3 border-b border-general-700 dark:border-neutral-700" + > + + {item.text} + + + ))} )} diff --git a/components/map.tsx b/components/map.tsx index f0eadc5..7d8f3a9 100644 --- a/components/map.tsx +++ b/components/map.tsx @@ -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) => { + 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 ( + + + {/* 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 ? ( + + + + ) : null} + + + + + + + ); +}; + // "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( - `/(api)/driver/nearby?service=${service}&lat=${lat}&lng=${lng}`, - ); + const { drivers } = useNearbyDrivers(service, lat, lng); const [markers, setMarkers] = useState([]); const mapRef = useRef(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 ( { userInterfaceStyle={isDark ? "dark" : "light"} > {markers.map((marker) => ( - ))} - {userLatitude && + {destinationOverride ? ( + + ) : ( + !routeless && + userLatitude && userLongitude && destinationLatitude && destinationLongitude && ( @@ -188,7 +470,8 @@ export const Map = () => { strokeWidth={3} /> - )} + ) + )} ); }; diff --git a/components/map.web.tsx b/components/map.web.tsx index 7eb0859..a58b990 100644 --- a/components/map.web.tsx +++ b/components/map.web.tsx @@ -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 ( diff --git a/components/offer-list.tsx b/components/offer-list.tsx new file mode 100644 index 0000000..8719e2f --- /dev/null +++ b/components/offer-list.tsx @@ -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 ( + + + + {t("bookRide.offers.title")} + + + {t("bookRide.offers.count", undefined, offers.length)} + + + + {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 ( + + {photo ? ( + + ) : ( + + + + )} + + + + {name || t("bookRide.match.driverFallback")} + + + + + + + {offer.rating != null + ? Number(offer.rating).toFixed(1) + : t("bookRide.ratingFallback")} + + + {vehicle(offer) ? ( + + {vehicle(offer)} + + ) : null} + + + {eta !== null ? ( + + {t("bookRide.offers.away", { + eta, + distance: distanceLabel(distance) ?? "", + })} + + ) : null} + + + onPick(offer)} + disabled={busy} + className={`rounded-full px-5 py-2.5 ml-2 ${ + busy && !taking ? "bg-emerald-500/40" : "bg-emerald-500" + }`} + > + {taking ? ( + + ) : ( + + {t("bookRide.offers.pick")} + + )} + + + ); + })} + + ); +}; diff --git a/components/payment-choice-sheet.tsx b/components/payment-choice-sheet.tsx new file mode 100644 index 0000000..dddb0d1 --- /dev/null +++ b/components/payment-choice-sheet.tsx @@ -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 ( + + + + {driverName + ? t("bookRide.payment.titleNamed", { name: driverName }) + : t("bookRide.payment.title")} + + + {t("bookRide.payment.subtitle", { fare })} + + + 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" + > + + + + {t("bookRide.payment.cash")} + + + {t("bookRide.payment.cashHint")} + + + + + + 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" + > + + + + {t("bookRide.payment.card")} + + + {t("bookRide.payment.cardHint")} + + + + + + + + {submitting ? t("bookRide.payment.working") : t("common.cancel")} + + + + + ); +}; diff --git a/components/pickup-code-sheet.tsx b/components/pickup-code-sheet.tsx new file mode 100644 index 0000000..6b10d8f --- /dev/null +++ b/components/pickup-code-sheet.tsx @@ -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 ( + + + + {t("pickupCode.title")} + + + {t("pickupCode.subtitle")} + + + 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 ? ( + + {error} + + ) : null} + + onSubmit(code)} + disabled={code.length < 4 || submitting} + className={code.length < 4 ? "opacity-50" : ""} + /> + + + + {t("common.cancel")} + + + + + ); +}; diff --git a/components/pin-adjuster.tsx b/components/pin-adjuster.tsx new file mode 100644 index 0000000..8249314 --- /dev/null +++ b/components/pin-adjuster.tsx @@ -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(null); + + const region: Region = { + latitude: initial.latitude, + longitude: initial.longitude, + latitudeDelta: ZOOM_DELTA, + longitudeDelta: ZOOM_DELTA, + }; + + return ( + + + onSettled({ latitude: next.latitude, longitude: next.longitude }) + } + /> + + + + + + + ); +}; diff --git a/components/pin-adjuster.web.tsx b/components/pin-adjuster.web.tsx new file mode 100644 index 0000000..a6bc26c --- /dev/null +++ b/components/pin-adjuster.web.tsx @@ -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 ( + + + {t("components.map.webUnavailable")} + + + ); +}; diff --git a/components/profile-photo-picker.tsx b/components/profile-photo-picker.tsx new file mode 100644 index 0000000..d7c5a77 --- /dev/null +++ b/components/profile-photo-picker.tsx @@ -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(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 ( + + 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 ? ( + + ) : shown ? ( + + ) : ( + + )} + + + + {t("driver.photo.title")} + + + {t("driver.photo.hint")} + + + void capture()} + disabled={busy} + className="flex-row items-center rounded-full bg-primary-500 py-2.5 px-5 mt-3" + > + + + {shown ? t("driver.photo.retake") : t("driver.photo.take")} + + + + ); +}; diff --git a/components/rating-sheet.tsx b/components/rating-sheet.tsx new file mode 100644 index 0000000..a485397 --- /dev/null +++ b/components/rating-sheet.tsx @@ -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(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 ( + + + {subjectAvatar ? ( + + ) : null} + + + {audience === "rider" + ? t("rating.rateDriverTitle", { name: subjectName ?? "" }) + : t("rating.rateRiderTitle", { name: subjectName ?? "" })} + + + {t("rating.subtitle")} + + + + {[1, 2, 3, 4, 5].map((value) => ( + setStars(value)} + hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }} + accessibilityLabel={t("rating.starLabel", { n: value })} + > + + + ))} + + + + + {error ? ( + + {error} + + ) : null} + + + + {onSkip ? ( + + + {t("rating.notNow")} + + + ) : null} + + + ); +}; diff --git a/components/ride-card.tsx b/components/ride-card.tsx index 4ac01f1..ad4cbb3 100644 --- a/components/ride-card.tsx +++ b/components/ride-card.tsx @@ -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 ( + {outcome ? ( + + + + {tr(outcome.labelKey)} + + + + {/* Who ended it, and why — the two things a rider looking back at + a cancelled trip actually wants to know. */} + {didNotHappen && cancelled_by ? ( + + {cancelled_by === "system" + ? tr("components.rideCard.cancelledBySystem") + : tr( + cancelled_by === "driver" + ? "components.rideCard.cancelledByDriver" + : "components.rideCard.cancelledByYou", + )} + {cancellation_reason + ? ` · ${tr(`cancelSheet.reasons.${cancellation_reason}`)}` + : ""} + + ) : null} + + ) : null} + { - {driver.first_name} {driver.last_name} + {driverName || tr("components.rideCard.noDriver")} @@ -98,14 +164,35 @@ export const RideCard = ({ ride }: { ride: Ride }) => { {tr("components.rideCard.paymentStatus")} + {/* 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". */} - {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, + })} diff --git a/components/ride-layout.tsx b/components/ride-layout.tsx index 3e3a80a..237bf8d 100644 --- a/components/ride-layout.tsx +++ b/components/ride-layout.tsx @@ -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) => { const bottomSheetRef = useRef(null); const { isDark } = useTheme(); + const insets = useSafeAreaInsets(); return ( @@ -57,14 +59,21 @@ export const RideLayout = ({ backgroundColor: isDark ? "#525252" : "#d4d4d4", }} > - {children} - + diff --git a/components/service-selector.tsx b/components/service-selector.tsx index 1550920..01940b3 100644 --- a/components/service-selector.tsx +++ b/components/service-selector.tsx @@ -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)} + + {/* Availability. Hidden until the first count lands so the tiles + don't flash "none nearby" while the request is still out. */} + 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")} + ); })} diff --git a/components/settings-row.tsx b/components/settings-row.tsx index 931e41f..ca8361d 100644 --- a/components/settings-row.tsx +++ b/components/settings-row.tsx @@ -5,7 +5,7 @@ import { useTheme } from "@/lib/theme"; type IconName = React.ComponentProps["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 = ( @@ -83,6 +86,10 @@ export const SettingsRow = ({ ) : null} + {right === "check" && selected ? ( + + ) : null} + {right === "chevron" ? ( ( 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 `` 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 => { + 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()); +}; diff --git a/dashboard/src/pages/Drivers.tsx b/dashboard/src/pages/Drivers.tsx index 803167b..4e8b57f 100644 --- a/dashboard/src/pages/Drivers.tsx +++ b/dashboard/src/pages/Drivers.tsx @@ -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([]); const [error, setError] = useState(null); const [editing, setEditing] = useState(null); + const [balances, setBalances] = useState>({}); + + // 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(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() { Service Seats Rating + Vetting Online Rides Revenue + Owes company + Owed to driver @@ -87,11 +135,53 @@ export default function Drivers() { {d.car_seats} {d.rating} + + + {d.approval_status} + + {d.online ? "● online" : "○ off"} {d.total_rides} {d.revenue.toLocaleString()} + + {balances[d.id]?.owes_company_cents ? ( + {money(balances[d.id].owes_company_cents)} + ) : ( + + )} + + + {balances[d.id]?.owed_to_driver_cents ? ( + {money(balances[d.id].owed_to_driver_cents)} + ) : ( + + )} +
+ {balances[d.id]?.owes_company_cents ? ( + + ) : null} + {balances[d.id]?.owed_to_driver_cents ? ( + + ) : null} + @@ -105,6 +195,29 @@ export default function Drivers() { + {settleFor && ( + setSettleFor(null)} + onSettled={() => { + setSettleFor(null); + load(); + }} + /> + )} + + {reviewing && ( + setReviewing(null)} + onDecided={() => { + setReviewing(null); + load(); + }} + /> + )} + {editing && ( would 401. +function DocumentScan({ name, label }: { name: string; label: string }) { + const [src, setSrc] = useState(null); + const [error, setError] = useState(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 ( +
+
+ {label} +
+ {error ? ( +
{error}
+ ) : 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. + + {label} + + ) : ( +
Loading…
+ )} +
+ ); +} + +// The driver's profile photo. Unlike a document scan this route is public, so +// the browser can load it straight from a — 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 ( + Driver profile photo + ); +} + +/** + * 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(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 ( +
+
e.stopPropagation()}> +

+ Vetting — {driver.first_name} {driver.last_name} (#{driver.id}) +

+ + {/* 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 && ( +
+ + + Shown to riders choosing a driver + +
+ )} + +

+ Status: {driver.approval_status} + {driver.submitted_at + ? ` · submitted ${new Date(driver.submitted_at).toLocaleString()}` + : ""} +

+ + + + + + + + + + + + + + + + + + + + + + + + +
Licence number + {driver.license_number ?? } +
Licence expiry + {driver.license_expiry ? ( + driver.license_expiry.slice(0, 10) + ) : ( + + )} +
National ID{driver.national_id ?? }
Plate{driver.plate_number ?? }
Car{driver.car_model ?? }
+ + {present.length > 0 ? ( +
+ {present.map(([name, label]) => ( + + ))} +
+ ) : ( +
+ No scans on file — this profile predates document capture. +
+ )} + + setReason(e.target.value)} + /> + + {error &&
{error}
} + +
+ + + {driver.approval_status === "approved" && ( + + )} + +
+
+
+ ); +} + function DriverForm({ initial, onClose, @@ -206,3 +574,206 @@ function DriverForm({
); } + +// 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([]); + const [picked, setPicked] = useState>(new Set()); + const [note, setNote] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+
e.stopPropagation()}> +

+ {collecting ? "Collect commission from" : "Pay out"}{" "} + {driver.first_name} {driver.last_name} +

+

+ {collecting + ? "Cash rides where this driver still owes the platform fee." + : "Card rides where the platform still owes this driver."} +

+ + {error ?

{error}

: null} + + {loading ? ( +

Loading rides…

+ ) : rides.length === 0 ? ( +

Nothing outstanding.

+ ) : ( + <> + + +
+ + + + + + + + + + + + {rides.map((r) => ( + + + + + + + + ))} + +
RideRouteFare{collecting ? "Commission" : "Payout"}
+ toggle(r.ride_id)} + /> + #{r.ride_id} + {r.origin_address} → {r.destination_address} +
+ {new Date(r.completed_at).toLocaleDateString()} +
+
{money(r.fare_price)} + {money(r.amount_cents)} +
+
+ + setNote(e.target.value)} + /> + +

+ + {picked.size} of {rides.length} ride + {rides.length === 1 ? "" : "s"} · {money(total)} + +

+ + )} + +
+ + +
+
+
+ ); +} diff --git a/dashboard/src/pages/Rides.tsx b/dashboard/src/pages/Rides.tsx index 4522796..18a2049 100644 --- a/dashboard/src/pages/Rides.tsx +++ b/dashboard/src/pages/Rides.tsx @@ -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 = { + completed: "paid", + cancelled: "unpaid", + expired: "unpaid", +}; + export default function Rides() { const [rides, setRides] = useState([]); const [meta, setMeta] = useState({ total: 0, page: 1, pages: 1 }); @@ -78,9 +103,18 @@ export default function Rides() { setPage(1); }} > - - - + {/* "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. */} + + + + + + +