import { MaterialCommunityIcons } from "@expo/vector-icons"; import { router } from "expo-router"; 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, 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; last_name: string; profile_image_url: string | null; 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; }; // 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; /** 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 = { ride_id: number; status: string; service: string; 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_rating: number | null; }; type Dashboard = { 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 = () => { const { signOut, user } = useSession(); const { isDark } = useTheme(); const t = useT(); const [loading, setLoading] = useState(true); const [profile, setProfile] = useState(null); 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 { const res = await fetchAPI("/(api)/driver/profile"); const p = res.data as Profile; setProfile(p); setOnline(p.online); } catch (err) { // 403 with code ONBOARD means no profile yet — show the onboarding form. if (err instanceof ApiError && err.status === 403) { setProfile(null); } else { console.log("[DRIVER_PROFILE_LOAD]: ", err); } } finally { setLoading(false); } }, []); useEffect(() => { void loadProfile(); }, [loadProfile]); // Keep the location heartbeat running only while the driver is online and // has completed onboarding. 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"); 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; void fetchDashboard(); const timer = setInterval(() => void fetchDashboard(), POLL_MS); 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" }, body: JSON.stringify({ online: next }), }); setOnline(next); setProfile({ ...profile, online: next }); if (!next) setDashboard(null); } catch (err) { console.log("[DRIVER_TOGGLE]: ", 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.home.alertErrorTitle"), err instanceof ApiError && err.status === 409 ? t("driver.home.alertOfflineBlocked") : t("driver.home.alertToggleBody"), ); } finally { setBusy(false); } }; // 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, ...extra }), }); await fetchDashboard(); } catch (err) { console.log("[DRIVER_ADVANCE]: ", err); 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); } }; 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 ( 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")} {/* Online / offline toggle */} {online ? t("driver.home.online") : t("driver.home.goOnline")} {/* Earnings summary */} {t("driver.home.todaysEarnings")} ${(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} {/* 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} {/* 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 || dashboard?.active ? null : dashboard?.requests.length ? ( dashboard.requests.map((request) => ( respond(request, "offer")} onWithdraw={() => respond(request, "withdraw")} /> )) ) : ( {!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 = ({ onCreated, signOut, userName, }: { onCreated: () => Promise; signOut: () => Promise; userName?: string | null; }) => { const t = useT(); const { isDark } = useTheme(); 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 () => { 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", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ 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"), ); } finally { setSubmitting(false); } }; return ( {t("driver.home.welcome", { name: userName?.split(" ")[0] || t("driver.home.welcomeFallback"), })} {t("driver.home.signOutAlt")} {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")} {SERVICES.map((item) => { const active = item.id === service; return ( setService(item.id)} className={`flex-1 items-center rounded-2xl border py-3 ${ active ? "border-primary-500 bg-primary-500/10" : "border-neutral-100 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900" }`} > {t(item.labelKey)} ); })} {t("driver.home.carModel")} {t("driver.home.carSeats")} {t("driver.credentials.title")} {t("driver.credentials.intro")} setCarModel((prev) => prev.trim() || model)} /> {t("driver.credentials.reviewNote")} ); }; // --- Review status -------------------------------------------------------- // 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, }: { 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: request.service })} {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")} {request.origin_address} {t("driver.offerCard.toAlt")} {request.destination_address} {t("driver.offerCard.tripTime")} {formatTime(request.ride_time)} {t("driver.offerCard.youEarn")} ${(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")} ) : ( {busy ? "…" : t("driver.offerCard.offer")} )} ); }; // --- Active ride card ----------------------------------------------------- const ActiveRideCard = ({ ride, busy, onArrived, onStartTrip, onComplete, onCancel, driverCoords, }: { ride: ActiveRide; busy: boolean; 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 === "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 ( ● {statusLabel} {ride.service} {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")} {ride.origin_address} {t("driver.activeRide.toAlt")} {ride.destination_address} {t("driver.activeRide.youEarn")} ${(ride.payout_cents / 100).toFixed(2)} {/* Heading to the pickup: the only forward action is "I'm here". */} {ride.status === "accepted" ? ( <> 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" ? ( onComplete(ride)} /> ) : null} ); }; export default DriverHome;