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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+330 -3
View File
@@ -138,6 +138,9 @@ export const fr = {
recentRides: "Courses récentes",
noRecent: "Aucune course récente.",
noRecentAlt: "Aucune course récente",
activeRideWithDriver: "{name} arrive",
rateLastRide: "Comment s'est passée votre dernière course ?",
rate: "Noter",
},
rides: {
@@ -159,7 +162,38 @@ export const fr = {
messageAlt: "message",
noMessages: "Pas encore de messages",
startConversation:
"Démarrez une conversation avec vos amis et votre famille",
"La conversation s'ouvre avec votre chauffeur une fois la course attribuée.",
inputPlaceholder: "Message…",
send: "Envoyer",
loadError: "Impossible de charger les messages. Tirez pour réessayer.",
sendError: "Impossible d'envoyer le message. Réessayez.",
cannotMessage: "Cette course n'est plus active.",
call: "Appeler",
},
call: {
incoming: "Appel entrant",
outgoing: "Appel en cours…",
connecting: "Connexion…",
inCall: "En appel",
ended: "Appel terminé",
missed: "Appel manqué",
declined: "Appel refusé",
failed: "Échec de l'appel",
unavailable: "Aucune course active à appeler.",
accept: "Accepter",
decline: "Refuser",
end: "Terminer l'appel",
mute: "Muet",
unmute: "Activer le micro",
speaker: "Haut-parleur",
speakerOff: "Haut-parleur off",
cancel: "Annuler",
connectingWith: "Connexion à {name}…",
micDeniedTitle: "Microphone bloqué",
micDeniedBody:
"Waseel a besoin du microphone pour passer des appels. Activez-le dans les réglages.",
audioFailed: "Impossible de démarrer l'audio. Réessayez.",
},
profile: {
@@ -173,14 +207,35 @@ export const fr = {
emailPlaceholder: "Votre adresse e-mail",
},
adjustPin: {
pickupLabel: "Point de départ",
destinationLabel: "Point de dépose",
locating: "Localisation en cours…",
hint: "Faites glisser la carte pour placer le repère exactement où vous voulez.",
confirmPickup: "Confirmer le départ",
confirmDestination: "Confirmer la dépose",
recenter: "Aller à ma position",
},
findRide: {
adjustOnMap: "Placer sur la carte",
title: "Course",
from: "De",
to: "À",
findNow: "Rechercher",
service: "Type de course",
nAvailable: "{n} à proximité",
estimatedFare: "Tarif estimé",
setBothPoints: "Indiquez un départ et une destination",
payLaterHint:
"Les chauffeurs proches verront votre demande. Vous choisissez qui vous prend, et payez ensuite.",
sending: "Envoi de votre demande…",
},
confirmRide: {
noDriversInRadius: "Aucun chauffeur {service} dans un rayon de {km} km",
tryInstead: "Disponible près de vous maintenant :",
searchingRadius: "Recherche dans un rayon de {km} km…",
title: "Demander une course",
yourTrip: "Votre trajet",
pickup: "Départ",
@@ -193,6 +248,12 @@ export const fr = {
lbpEstimate: "≈ {lbp}",
noDrivers: "Aucun chauffeur {service} en ligne pour l'instant",
findingDrivers: "Recherche de chauffeurs à proximité…",
driversNearby: {
one: "1 chauffeur à proximité",
other: "{n} chauffeurs à proximité",
zero: "Aucun chauffeur à proximité",
},
withinRadius: "Dans un rayon de {km} km",
nearestDriver: "Chauffeur le plus proche ≈ {eta} min",
requesting: "Demande en cours…",
noDriversOnline: "Aucun chauffeur en ligne",
@@ -208,21 +269,63 @@ export const fr = {
"Une erreur est survenue lors de la réservation. Réessayez.",
alertPayCardTitle: "Payer par carte",
alertPayCardBody: "Votre carte sera débitée de ${fare}.",
alertInProgressTitle: "Course déjà en cours",
alertInProgressBody:
"Vous avez une course en cours. Terminez-la ou annulez-la avant d'en réserver une autre.",
viewRide: "Voir la course",
},
bookRide: {
status: {
requested: "Recherche de votre chauffeur…",
choosing: "Choisissez votre chauffeur",
accepted: "Chauffeur assigné — en route vers vous",
enRoute: "Course en cours",
completed: "Vous êtes arrivé !",
cancelled: "Course annulée",
arrived: "Votre chauffeur est arrivé",
expired: "Aucun chauffeur disponible",
},
rideNotFound: "Course introuvable.",
couldNotLoad: "Chargement de cette course impossible.",
backHome: "Retour à l'accueil",
matchingDriver:
"Nous vous mettons en relation avec le chauffeur {service} le plus proche.",
searchingFor: "Recherche depuis {seconds} s",
match: {
driverFallback: "Votre chauffeur",
alertBody: "Une erreur est survenue. Réessayez.",
},
offers: {
title: "Chauffeurs disponibles",
count: {
one: "1 proposition",
other: "{n} propositions",
zero: "Aucune proposition",
},
away: "{eta} min · {distance}",
seats: {
one: "1 place",
other: "{n} places",
zero: "",
},
pick: "Choisir",
goneTitle: "Ce chauffeur n'est plus libre",
goneBody:
"Il a pris une autre course. Choisissez-en un autre dans la liste.",
goneBodyPaid:
"Il a pris une autre course. Votre paiement n'a pas été utilisé — choisissez-en un autre et il lui sera versé.",
},
payment: {
title: "Comment souhaitez-vous payer ?",
titleNamed: "Course avec {name}",
subtitle: "Tarif ${fare}",
cash: "Payer en espèces",
cashHint: "Réglez la course au chauffeur à l'arrivée.",
card: "Payer par carte",
cardHint: "Débité maintenant, avant le départ du chauffeur.",
working: "En cours…",
},
ratingFallback: "—",
paymentCash: "💵 Espèces au chauffeur",
paymentCard: "💳 Payé par carte",
@@ -233,10 +336,26 @@ export const fr = {
cancelling: "Annulation…",
alertErrorTitle: "Erreur",
alertErrorBody: "Annulation de cette course impossible. Réessayez.",
pickupCodeLabel: "Votre code de prise en charge",
pickupCodeHint: "Donnez-le à votre chauffeur pour démarrer la course.",
driverHere: "Votre chauffeur est dehors",
cashDue: "Payez ${amount} en espèces à votre chauffeur.",
youRated: "Vous avez noté cette course {n}★",
rateDriver: "Noter votre chauffeur",
noDriversFound:
"Aucun chauffeur n'a accepté votre demande. Rien n'a été débité — réessayez dans un instant.",
cancelledByDriver: "Votre chauffeur a annulé cette course.",
enRouteNotice:
"Bonne route — votre chauffeur terminera la course à l'arrivée.",
},
driver: {
home: {
owesCompany: "Commission que vous devez",
owesCompanyHint: "Vos courses en espèces — à remettre au bureau.",
owedToDriver: "L'entreprise vous doit",
owedToDriverHint: "Courses par carte, à vous verser.",
afterFee: "après ${fee} de commission",
signOutAlt: "Déconnexion",
driverMode: "Mode chauffeur",
online: "● En ligne — réception des demandes",
@@ -245,6 +364,8 @@ export const fr = {
completedToday: "Terminées aujourd'hui",
incomingRequests: "Demandes entrantes",
incomingRequestsOffline: "Demandes entrantes (hors ligne)",
finishCurrentRide:
"Terminez votre course en cours pour voir les nouvelles demandes.",
waitingRequests: "En attente de demandes…",
goOnlineStart: "Passez en ligne pour commencer à rouler.",
welcome: "Bienvenue, {name}",
@@ -264,36 +385,229 @@ export const fr = {
alertCreateBody: "Création du profil chauffeur impossible. Réessayez.",
alertToggleBody: "Changement de statut impossible. Réessayez.",
noRequestsAlt: "Aucune course récente",
cashInHand: "Espèces encaissées aujourd'hui",
uncollected: "Courses non encaissées aujourd'hui",
ratingCount: "{n} évaluations",
ratingNew: "Nouveau chauffeur",
alertOfflineBlocked:
"Terminez ou annulez votre course en cours avant de passer hors ligne.",
},
credentials: {
title: "Vos documents",
intro:
"Chaque chauffeur est vérifié avant sa première course. Notre équipe examine ces informations.",
licenseNumber: "Numéro de permis de conduire",
licenseNumberPlaceholder: "Tel qu'inscrit sur le permis",
licenseExpiry: "Expiration du permis",
nationalId: "Numéro de carte d'identité",
nationalIdPlaceholder: "Votre numéro d'identité",
plateNumber: "Numéro de plaque",
plateNumberPlaceholder: "ex. 123456/B",
reviewNote:
"Votre compte reste hors ligne jusqu'à validation, généralement en moins d'une journée.",
submit: "Envoyer pour vérification",
errorTitle: "Vérifiez vos informations",
errorMissing:
"Le numéro de permis, la carte d'identité et la plaque sont tous obligatoires.",
errorExpiryFormat:
"Saisissez l'expiration du permis au format AAAA-MM-JJ.",
errorExpired: "Ce permis est déjà expiré.",
errorScanRequired: "Scannez votre permis de conduire avant d'envoyer.",
alertResubmitBody: "Renvoi de vos informations impossible. Réessayez.",
},
captureUnavailable:
"La prise de photo n'est pas disponible dans cette version de l'application. Mettez-la à jour et réessayez.",
photo: {
title: "Votre photo",
hint: "Prise maintenant avec l'appareil photo, pas depuis votre galerie. Les passagers la voient à côté de votre nom au moment de choisir un chauffeur, et s'en servent pour vérifier que c'est bien vous au départ. Regardez l'objectif, dans une bonne lumière.",
take: "Prendre une photo",
retake: "Reprendre",
required: "Prenez une photo de profil avant d'envoyer.",
permissionTitle: "Autorisation requise",
permissionCamera:
"Waseel a besoin de l'appareil photo pour prendre votre photo. Autorisez-y l'accès pour continuer.",
permissionCameraBlocked:
"L'accès à l'appareil photo est désactivé pour Waseel, et Android ne le redemandera plus depuis ici. Ouvrez les réglages, puis activez Appareil photo dans les autorisations.",
errorTitle: "Photo non enregistrée",
errorBody: "Une erreur s'est produite. Réessayez.",
errorTooLarge: "Cette photo est trop lourde. Prenez-en une nouvelle.",
errorRateLimit: "Trop d'envois. Attendez quelques minutes et réessayez.",
errorUnsupported: "Utilisez une photo JPEG, PNG ou WebP.",
},
scan: {
licenseLabel: "Permis de conduire",
licenseHint:
"Posez-le à plat et remplissez le cadre. Nous y lisons le numéro et l'expiration.",
idLabel: "Carte d'identité",
idHint: "La face où figure votre numéro d'identité.",
vehicle_regLabel: "Carte grise",
vehicle_regHint:
"La page où figurent le numéro de plaque et le modèle du véhicule.",
optional: "Facultatif",
take: "Photographier",
retake: "Rescanner",
choose: "Choisir une photo",
reading: "Lecture de votre document…",
filled: {
one: "1 information remplie — vérifiez-la ci-dessous.",
other: "{n} informations remplies — vérifiez-les ci-dessous.",
},
savedNoFields:
"Photo enregistrée, mais les informations n'ont pas pu être lues. Saisissez-les ci-dessous.",
savedUnreadable:
"Photo enregistrée. La lecture est indisponible pour le moment — saisissez les informations ci-dessous.",
alreadyOnFile:
"Un scan est déjà enregistré. Rescannez seulement si nécessaire.",
allRead: "Lu sur vos documents",
missingPrompt:
"Nous n'avons pas pu lire ces informations sur vos documents. Ajoutez-les et c'est terminé.",
edit: "Vérifier ou modifier",
done: "Terminé",
checkPrompt: "Corrigez ce qui a été mal lu, puis appuyez sur Terminé.",
permissionTitle: "Autorisation requise",
permissionCamera:
"Autorisez l'appareil photo pour photographier vos documents, ou choisissez une photo existante.",
permissionCameraBlocked:
"L'accès à l'appareil photo est désactivé pour Waseel, et Android ne le redemandera plus depuis ici. Ouvrez les réglages et activez Appareil photo dans les autorisations — ou choisissez une photo existante.",
permissionLibrary:
"Autorisez l'accès aux photos pour choisir une image de vos documents.",
permissionLibraryBlocked:
"L'accès aux photos est désactivé pour Waseel, et Android ne le redemandera plus depuis ici. Ouvrez les réglages et activez Photos dans les autorisations — ou prenez une photo avec l'appareil photo.",
errorTitle: "Scan impossible",
errorBody:
"Une erreur s'est produite. Réessayez ou saisissez les informations ci-dessous.",
errorTooLarge: "Cette photo est trop lourde. Prenez-en une nouvelle.",
errorRateLimit: "Trop de scans. Attendez quelques minutes et réessayez.",
errorUnsupported: "Utilisez une photo JPEG, PNG ou WebP.",
errorRetry:
"Envoi échoué. Réessayez ou saisissez les informations ci-dessous.",
},
review: {
pendingTitle: "En cours de vérification",
pendingBody:
"Nous vérifions vos informations. Vous pourrez passer en ligne dès validation.",
rejectedTitle: "Non validé",
rejectedBody:
"Vos informations n'ont pas été validées. Corrigez-les ci-dessous et renvoyez-les.",
suspendedTitle: "Compte suspendu",
suspendedBody:
"Votre compte chauffeur a été suspendu. Contactez le support pour régulariser.",
approvedTitle: "Validé",
approvedBody: "Vous êtes autorisé à rouler.",
reasonLabel: "Motif",
checkAgain: "Vérifier à nouveau",
resubmitTitle: "Corrigez vos informations",
resubmitIntro: "Corrigez ce qui ne va pas et nous vérifierons à nouveau.",
resubmit: "Renvoyer",
},
offerCard: {
youEarn: "Vous gagnez",
newRequest: "Nouvelle demande · {service}",
openFor: "Disponible encore",
seconds: "{n} s",
awayFromPickup: "{km} km du point de départ",
firstIn: "Vous seriez le premier",
rivals: {
one: "1 autre chauffeur s'est proposé",
other: "{n} autres chauffeurs se sont proposés",
zero: "Vous seriez le premier",
},
cash: "💵 Espèces",
card: "💳 Carte",
fromAlt: "De",
toAlt: "À",
tripTime: "Durée",
fare: "Tarif",
decline: "Refuser",
accept: "Accepter",
offer: "Proposer cette course",
withdraw: "Retirer ma proposition",
waitingOnRider: "Proposé — en attente du choix du passager",
lostTitle: "Demande close",
lostBody:
"Le passager a choisi un autre chauffeur, ou la demande a expiré. Vous êtes libre pour la suivante.",
alertOfferBody: "Envoi de votre proposition impossible. Réessayez.",
alertWithdrawBody: "Retrait de votre proposition impossible. Réessayez.",
},
activeRide: {
youEarn: "Vous gagnez",
dropoffPin: "Dépose",
navigateToPickup: "Naviguer vers le départ",
navigateToDropoff: "Naviguer vers la dépose",
alertNavigateBody:
"Impossible d'ouvrir une application de navigation sur ce téléphone.",
headToPickup: "Direction le départ",
tripInProgress: "Course en cours",
rider: "{name}",
pickupPin: "Départ du passager",
fromAlt: "De",
toAlt: "À",
fare: "Tarif",
message: "Message",
call: "Appeler",
startTrip: "Démarrer la course",
completeTrip: "Terminer la course",
cancelRide: "Annuler la course",
cancelConfirmTitle: "Annuler cette course ?",
cancelConfirmBody:
"Le passager sera averti et la course sera marquée comme annulée.",
cancelConfirmDismiss: "Garder la course",
cancelConfirmConfirm: "Annuler la course",
alertErrorTitle: "Erreur",
alertAcceptBody:
"Acceptation impossible. La course a peut-être été prise ou a expiré.",
alertDeclineBody: "Refus de la course impossible. Réessayez.",
alertUpdateBody: "Mise à jour de la course impossible. Réessayez.",
alertCancelBody: "Annulation de la course impossible. Réessayez.",
imHere: "Je suis arrivé",
atPickup: "Sur place — en attente du passager",
askForCode: "Demandez au passager son code à 4 chiffres.",
cashConfirmTitle: "Encaisser la course",
cashConfirmBody: "Avez-vous encaissé ${amount} en espèces ?",
cashCollected: "Oui, encaissé",
cashNotCollected: "Non encaissé",
},
},
rating: {
rateDriverTitle: "Comment s'est passée votre course avec {name} ?",
rateRiderTitle: "Comment était {name} comme passager ?",
subtitle: "Votre note reste privée vis-à-vis de l'autre personne.",
starLabel: "{n} étoiles",
commentPlaceholder: "Ajouter un commentaire (facultatif)",
submit: "Envoyer la note",
notNow: "Plus tard",
error: "Envoi de votre note impossible. Réessayez.",
},
cancelSheet: {
title: "Annuler cette course ?",
subtitleRider: "Dites-nous pourquoi pour améliorer nos attributions.",
subtitleDriver:
"Le passager sera averti et la course sera marquée comme annulée.",
confirm: "Annuler la course",
keepRide: "Garder la course",
cancelling: "Annulation…",
reasons: {
changed_mind: "J'ai changé d'avis",
wait_too_long: "L'attente est trop longue",
wrong_address: "Mauvaise adresse de départ",
driver_no_show: "Le chauffeur n'est jamais arrivé",
rider_no_show: "Le passager ne s'est pas présenté",
unreachable: "Impossible de le joindre",
vehicle_issue: "Problème de véhicule",
other: "Autre raison",
},
},
pickupCode: {
title: "Démarrer la course",
subtitle: "Saisissez le code à 4 chiffres affiché chez le passager.",
startTrip: "Démarrer",
wrongCode: "Ce code ne correspond pas. Vérifiez avec le passager.",
},
services: {
nearbyCount: "{n} à proximité",
noneNearby: "Aucun à proximité",
car: {
label: "Voiture",
tagline: "Une course quotidienne, jusqu'à 4 places.",
@@ -392,6 +706,16 @@ export const fr = {
"La carte n'est pas disponible sur le web.\nUtilisez Android ou iOS pour l'expérience complète.",
},
rideCard: {
outcomeCompleted: "Terminée",
outcomeCancelled: "Annulée",
outcomeExpired: "Aucun chauffeur trouvé",
cancelledByYou: "Vous avez annulé",
cancelledByDriver: "Le chauffeur a annulé",
cancelledBySystem: "Aucun chauffeur disponible",
noDriver: "Aucun chauffeur attribué",
paymentNotCharged: "Non débité",
paymentRefundDue: "Remboursement dû",
paymentCashCollected: "Payé en espèces",
mapAlt: "Carte",
originAlt: "Origine",
destinationAlt: "Destination",
@@ -492,6 +816,9 @@ export const fr = {
rtlRestartBody:
"La mise en page arabe s'appliquera pleinement à la prochaine ouverture de l'application.",
},
general: {
title: "Général",
},
keepAwake: {
title: "Ne pas verrouiller l'écran",
description: