Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8807ff41c5 | ||
|
|
1d84003e0a | ||
|
|
899ca93cd5 | ||
|
|
90cc487c32 | ||
|
|
f50ff27e11 | ||
|
|
4e0a7cca51 | ||
|
|
59e336c23d | ||
|
|
bc23c94ea2 | ||
|
|
eceb6b45d5 |
+44
-7
@@ -14,20 +14,57 @@ EXPO_PUBLIC_GOOGLE_AUTH_WEB_CLIENT_ID=XXXXXXXX.apps.googleusercontent.com
|
||||
EXPO_PUBLIC_GOOGLE_AUTH_IOS_CLIENT_ID=XXXXXXXX.apps.googleusercontent.com
|
||||
EXPO_PUBLIC_GOOGLE_AUTH_ANDROID_CLIENT_ID=XXXXXXXX.apps.googleusercontent.com
|
||||
|
||||
# gmail api (oauth refresh token with gmail.send scope; leave blank to log codes to server console)
|
||||
GMAIL_CLIENT_ID=
|
||||
GMAIL_CLIENT_SECRET=
|
||||
GMAIL_REFRESH_TOKEN=
|
||||
GMAIL_FROM="Waseel <you@gmail.com>"
|
||||
# gmail smtp (app password, needs 2-step verification; leave blank to log codes to server console)
|
||||
# host/port are optional -- default to smtp.gmail.com:465, use 587 if 465 is blocked
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=you@gmail.com
|
||||
SMTP_PASS=your-16-char-app-password
|
||||
SMTP_FROM="Waseel <you@gmail.com>"
|
||||
|
||||
# geoapify api key
|
||||
# geoapify api key (static map tiles only)
|
||||
EXPO_PUBLIC_GEOAPIFY_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
# google api key
|
||||
# google api key — powers Places autocomplete, Places Nearby Search
|
||||
# (mall/hospital/pharmacy/restaurant chips), and the per-marker Directions
|
||||
# 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(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=""
|
||||
|
||||
+10
@@ -21,3 +21,13 @@ expo-env.d.ts
|
||||
|
||||
# env
|
||||
.env
|
||||
|
||||
# Native projects generated by `expo prebuild` / `expo run:*`.
|
||||
/android
|
||||
/ios
|
||||
|
||||
# admin dashboard build output
|
||||
dashboard/dist/
|
||||
|
||||
# Driver uploads (licence/ID/vehicle scans, profile photos) — personal data.
|
||||
.uploads/
|
||||
|
||||
@@ -193,7 +193,15 @@ EXPO_PUBLIC_GEOAPIFY_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
- Go to the [Google Cloud Console](https://console.cloud.google.com/).
|
||||
- Create a new project (or use an existing one).
|
||||
- Navigate to the "APIs & Services" section and enable the required APIs (Places API, Directions API).
|
||||
- Navigate to the "APIs & Services" section and enable **all** of the required APIs:
|
||||
- **Maps SDK for Android** — draws the map itself. Without it Android renders an
|
||||
empty grey tile area and logcat shows an authorization failure; the app looks
|
||||
like the map "didn't load."
|
||||
- **Maps SDK for iOS** — only needed if `components/map.tsx` is switched from
|
||||
`PROVIDER_DEFAULT` (Apple Maps) to `PROVIDER_GOOGLE`.
|
||||
- **Places API (New)** — destination search in `components/google-text-input.tsx`.
|
||||
The legacy Places web service is unavailable to newer Google Cloud projects.
|
||||
- **Directions API** — route polyline and driver ETAs.
|
||||
- Go to the "Credentials" tab and click on "Create Credentials."
|
||||
- Select "API Key."
|
||||
- Copy the generated **API Key** and add it to your `.env` file:
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
// The Gradle build resolves this config in a plain node process that does not
|
||||
// read .env, unlike `expo start` / `expo prebuild`. Without this the release
|
||||
// APK was written with the placeholder origin below and could not reach the
|
||||
// API at all. @expo/env is Expo's own loader — the same one the CLI uses.
|
||||
require("@expo/env").load(__dirname);
|
||||
|
||||
// Dynamic Expo config. We use a JS config (rather than static app.json) so the
|
||||
// Android Google Maps API key can be pulled from EXPO_PUBLIC_GOOGLE_API_KEY
|
||||
// at build time without committing the key to the repo.
|
||||
//
|
||||
// react-native-maps renders blank tiles (just the Google logo, nothing else)
|
||||
// on Android when no Maps API key is set in the AndroidManifest. Expo's
|
||||
// prebuild reads `android.config.googleMaps.apiKey` and writes it to the
|
||||
// manifest as com.google.android.geo.API_KEY — that is what makes the map
|
||||
// actually draw. On iOS the default provider is Apple Maps, which needs no
|
||||
// key, so nothing is injected there.
|
||||
|
||||
const googleMapsApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY;
|
||||
|
||||
// Expo Router resolves relative API-route fetches ("/(api)/auth/login") against
|
||||
// this origin. In development it is overridden with the dev server URL, so the
|
||||
// placeholder never mattered; a release build has no such override and would
|
||||
// send every request to example.com. Point it at the same host that serves the
|
||||
// API routes.
|
||||
const serverOrigin =
|
||||
process.env.EXPO_PUBLIC_SERVER_URL || "https://example.com/";
|
||||
|
||||
// Adds the SYSTEM_ALERT_WINDOW permission to the AndroidManifest so the app
|
||||
// can request "display over other apps". The grant itself is a special
|
||||
// permission the user must toggle in system settings — it can't be requested
|
||||
// at runtime — but the manifest entry is what makes that system screen offer
|
||||
// the switch for our app.
|
||||
const { withAndroidManifest } = require("@expo/config-plugins");
|
||||
// Release builds block cleartext HTTP: only src/debug/AndroidManifest.xml opts
|
||||
// in. A LAN test build talks to the dev server over http://, so allow it for
|
||||
// every build type. Drop this plugin once the API is served over https.
|
||||
const withCleartextTraffic = (config) =>
|
||||
withAndroidManifest(config, (cfg) => {
|
||||
const application = cfg.modResults.manifest.application?.[0];
|
||||
|
||||
if (application) {
|
||||
application.$["android:usesCleartextTraffic"] = "true";
|
||||
}
|
||||
|
||||
return cfg;
|
||||
});
|
||||
|
||||
const withOverlayPermission = (config) =>
|
||||
withAndroidManifest(config, (cfg) => {
|
||||
const manifest = cfg.modResults.manifest;
|
||||
manifest["uses-permission"] = manifest["uses-permission"] || [];
|
||||
|
||||
const alreadyDeclared = manifest["uses-permission"].some(
|
||||
(entry) => entry.$ && entry.$["android:name"] === "android.permission.SYSTEM_ALERT_WINDOW",
|
||||
);
|
||||
|
||||
if (!alreadyDeclared) {
|
||||
manifest["uses-permission"].push({
|
||||
$: { "android:name": "android.permission.SYSTEM_ALERT_WINDOW" },
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
description: "Find your perfect ride with Waseel.",
|
||||
githubUrl: "https://github.com/sanidhyy/uber-clone",
|
||||
slug: "waseel",
|
||||
version: "1.0.0",
|
||||
orientation: "portrait",
|
||||
icon: "./assets/images/icon.png",
|
||||
scheme: "waseel",
|
||||
userInterfaceStyle: "automatic",
|
||||
splash: {
|
||||
image: "./assets/images/splash.png",
|
||||
resizeMode: "contain",
|
||||
backgroundColor: "#2F80ED",
|
||||
},
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
bundleIdentifier: "com.waseel.app",
|
||||
infoPlist: {
|
||||
NSMicrophoneUsageDescription:
|
||||
"Waseel uses the microphone for in-app calls with your driver.",
|
||||
},
|
||||
},
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
foregroundImage: "./assets/images/adaptive-icon.png",
|
||||
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 ?? "",
|
||||
},
|
||||
},
|
||||
},
|
||||
web: {
|
||||
bundler: "metro",
|
||||
output: "server",
|
||||
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: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
extra: {
|
||||
router: {
|
||||
origin: serverOrigin,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Waseel",
|
||||
"description": "Find your perfect ride with Waseel.",
|
||||
"githubUrl": "https://github.com/sanidhyy/uber-clone",
|
||||
"slug": "waseel",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "waseel",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/images/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#2F80ED"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.waseel.app"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.waseel.app"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "server",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-router",
|
||||
{
|
||||
"origin": "https://example.com/"
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": "https://example.com/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,123 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { createCheckoutSession } from "@/lib/areeba";
|
||||
import { createOrder } from "@/lib/payment-orders";
|
||||
|
||||
// Only these return URLs may be handed to the gateway. Anything else
|
||||
// (including open redirects) is rejected and we fall back to the deep link.
|
||||
const isAllowedReturnUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// The app's own deep link is always allowed.
|
||||
if (parsed.protocol === "waseel:") return true;
|
||||
|
||||
// The configured server origin (EXPO_PUBLIC_SERVER_URL), if set.
|
||||
const serverUrl = process.env.EXPO_PUBLIC_SERVER_URL;
|
||||
if (serverUrl) {
|
||||
const server = new URL(serverUrl);
|
||||
if (parsed.protocol === server.protocol && parsed.host === server.host)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve the fare to integer cents. Accept fare_cents directly, or fare /
|
||||
// amount in dollars (legacy client field) and convert.
|
||||
const resolveAmountCents = (fareCents: unknown, fare: unknown, amount: unknown): number | null => {
|
||||
let cents: number;
|
||||
if (fareCents !== undefined && fareCents !== null) {
|
||||
cents = Math.round(Number(fareCents));
|
||||
} else if (fare !== undefined && fare !== null) {
|
||||
cents = Math.round(Number(fare) * 100);
|
||||
} else if (amount !== undefined && amount !== null) {
|
||||
cents = Math.round(Number(amount) * 100);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(cents) || cents <= 0) return null;
|
||||
return cents;
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const { name, email, amount, returnUrl } = body;
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
if (!name || !email || !amount)
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required payment information." }),
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const {
|
||||
name,
|
||||
email,
|
||||
amount,
|
||||
fare_cents,
|
||||
fare,
|
||||
returnUrl,
|
||||
driver_id,
|
||||
origin_address,
|
||||
destination_address,
|
||||
origin_latitude,
|
||||
origin_longitude,
|
||||
destination_latitude,
|
||||
destination_longitude,
|
||||
ride_time,
|
||||
} = body;
|
||||
|
||||
if (!name || !email)
|
||||
return Response.json(
|
||||
{ error: "Missing required payment information." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const amountCents = resolveAmountCents(fare_cents, fare, amount);
|
||||
if (amountCents === null)
|
||||
return Response.json({ error: "Invalid fare amount." }, { status: 400 });
|
||||
|
||||
const finalReturnUrl =
|
||||
typeof returnUrl === "string" && isAllowedReturnUrl(returnUrl)
|
||||
? returnUrl
|
||||
: "waseel://book-ride";
|
||||
|
||||
try {
|
||||
const orderId = `waseel-${Date.now()}`;
|
||||
const orderId = randomUUID();
|
||||
|
||||
const session = await createCheckoutSession({
|
||||
orderId,
|
||||
amount: parseFloat(amount),
|
||||
amount: amountCents / 100,
|
||||
currency: "USD",
|
||||
description: `Waseel ride payment for ${name}`,
|
||||
returnUrl: returnUrl || "waseel://book-ride",
|
||||
returnUrl: finalReturnUrl,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
orderId,
|
||||
checkoutUrl: session.checkoutUrl,
|
||||
successIndicator: session.successIndicator,
|
||||
}),
|
||||
);
|
||||
// The successIndicator stays server-side; the client never sees it.
|
||||
if (!session.successIndicator)
|
||||
throw new Error("Areeba did not return a successIndicator.");
|
||||
|
||||
await createOrder({
|
||||
order_id: orderId,
|
||||
user_id: auth.userId,
|
||||
amount_cents: amountCents,
|
||||
currency: "USD",
|
||||
driver_id: driver_id ?? null,
|
||||
origin_address: origin_address ?? null,
|
||||
destination_address: destination_address ?? null,
|
||||
origin_latitude: origin_latitude ?? null,
|
||||
origin_longitude: origin_longitude ?? null,
|
||||
destination_latitude: destination_latitude ?? null,
|
||||
destination_longitude: destination_longitude ?? null,
|
||||
ride_time: ride_time ?? null,
|
||||
success_indicator: session.successIndicator,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
return Response.json({ orderId, checkoutUrl: session.checkoutUrl });
|
||||
} catch (err) {
|
||||
console.log("[AREEBA_PAYMENT_CREATE]: ", err);
|
||||
|
||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
||||
status: 500,
|
||||
});
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,69 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { retrieveOrder } from "@/lib/areeba";
|
||||
import { getOrder, markPaid } from "@/lib/payment-orders";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json();
|
||||
const { orderId, resultIndicator, successIndicator } = body;
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
if (!orderId)
|
||||
return new Response(JSON.stringify({ error: "Missing order id." }), {
|
||||
status: 400,
|
||||
});
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { orderId, resultIndicator } = body;
|
||||
|
||||
if (!orderId || !resultIndicator)
|
||||
return Response.json(
|
||||
{ error: "Missing order id or result indicator." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
try {
|
||||
const order = await retrieveOrder(orderId);
|
||||
// Look the order up server-side — never trust a client-supplied
|
||||
// successIndicator value, only the one we persisted at creation time.
|
||||
const order = await getOrder(orderId);
|
||||
if (!order)
|
||||
return Response.json({ error: "Order not found." }, { status: 404 });
|
||||
|
||||
// The gateway appends resultIndicator to the return URL after payment;
|
||||
// it must match the successIndicator issued when the session was created.
|
||||
const indicatorMatches =
|
||||
!successIndicator || resultIndicator === successIndicator;
|
||||
if (order.user_id !== auth.userId)
|
||||
return Response.json({ error: "Unauthorized." }, { status: 403 });
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: order.paid && indicatorMatches,
|
||||
status: order.status,
|
||||
amount: order.amount,
|
||||
currency: order.currency,
|
||||
}),
|
||||
// An order can only be verified once. A 'paid' or 'consumed' order has
|
||||
// already settled — rejecting here is the primary double-spend defense: it
|
||||
// stops a client from re-verifying an order it already used for a ride.
|
||||
if (order.status !== "pending")
|
||||
return Response.json(
|
||||
{ error: "This payment order is no longer pending." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
if (
|
||||
!order.success_indicator ||
|
||||
order.success_indicator !== resultIndicator
|
||||
)
|
||||
return Response.json(
|
||||
{ error: "Payment verification failed." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
const retrieved = await retrieveOrder(orderId);
|
||||
|
||||
if (!retrieved.paid)
|
||||
return Response.json({ error: "Payment not captured." }, { status: 400 });
|
||||
|
||||
// Reconcile the gateway's amount/currency against what we stored, so a
|
||||
// tampered or partial payment cannot mark a full-fare order paid.
|
||||
const gatewayCents = Math.round(Number(retrieved.amount) * 100);
|
||||
const gatewayCurrency = retrieved.currency ?? "USD";
|
||||
if (gatewayCents !== order.amount_cents || gatewayCurrency !== order.currency)
|
||||
return Response.json(
|
||||
{ error: "Payment amount mismatch." },
|
||||
{ status: 400 },
|
||||
);
|
||||
|
||||
await markPaid(orderId);
|
||||
|
||||
return Response.json({ success: true, orderId });
|
||||
} catch (err) {
|
||||
console.log("[AREEBA_PAYMENT_VERIFY]: ", err);
|
||||
|
||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
||||
status: 500,
|
||||
});
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
+102
-52
@@ -1,69 +1,119 @@
|
||||
import { requireOwner, withCors, preflight } from "@/lib/admin";
|
||||
import { sql } from "@/lib/db";
|
||||
import { query, type SqlValue } from "@/lib/db";
|
||||
import { RIDE_STATUSES as LIFECYCLE_STATUSES } from "@/lib/ride-lifecycle";
|
||||
|
||||
export async function OPTIONS() {
|
||||
return preflight();
|
||||
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,
|
||||
r.origin_address,
|
||||
r.destination_address,
|
||||
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,
|
||||
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
|
||||
) END AS driver
|
||||
FROM rides r
|
||||
LEFT JOIN drivers d ON d.id = r.driver_id
|
||||
INNER JOIN users u ON u.id = r.user_id
|
||||
`;
|
||||
|
||||
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);
|
||||
const status = url.searchParams.get("status")?.trim().toLowerCase() ?? "";
|
||||
const q = url.searchParams.get("q")?.trim() ?? "";
|
||||
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
|
||||
|
||||
const rows = status
|
||||
? await sql`
|
||||
SELECT
|
||||
r.ride_id,
|
||||
r.origin_address,
|
||||
r.destination_address,
|
||||
r.ride_time,
|
||||
r.fare_price,
|
||||
r.payment_status,
|
||||
r.created_at,
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
json_build_object(
|
||||
'driver_id', d.id,
|
||||
'name', d.first_name || ' ' || d.last_name,
|
||||
'rating', d.rating
|
||||
) AS driver
|
||||
FROM rides r
|
||||
INNER JOIN drivers d ON d.id = r.driver_id
|
||||
INNER JOIN users u ON u.id = r.user_id
|
||||
WHERE LOWER(r.payment_status) = ${status}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 500
|
||||
`
|
||||
: await sql`
|
||||
SELECT
|
||||
r.ride_id,
|
||||
r.origin_address,
|
||||
r.destination_address,
|
||||
r.ride_time,
|
||||
r.fare_price,
|
||||
r.payment_status,
|
||||
r.created_at,
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
json_build_object(
|
||||
'driver_id', d.id,
|
||||
'name', d.first_name || ' ' || d.last_name,
|
||||
'rating', d.rating
|
||||
) AS driver
|
||||
FROM rides r
|
||||
INNER JOIN drivers d ON d.id = r.driver_id
|
||||
INNER JOIN users u ON u.id = r.user_id
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 500
|
||||
`;
|
||||
const conds: string[] = [];
|
||||
const params: SqlValue[] = [];
|
||||
|
||||
return withCors(Response.json({ data: rows }));
|
||||
// `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);
|
||||
const n = params.length;
|
||||
conds.push(
|
||||
RIDE_STATUSES.includes(status)
|
||||
? `LOWER(r.status) = $${n}`
|
||||
: `LOWER(r.payment_status) = $${n}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (q) {
|
||||
params.push(`%${q}%`);
|
||||
const n = params.length;
|
||||
conds.push(
|
||||
`(u.email ILIKE $${n} OR (d.first_name || ' ' || d.last_name) ILIKE $${n} OR ` +
|
||||
`r.origin_address ILIKE $${n} OR r.destination_address ILIKE $${n})`,
|
||||
);
|
||||
}
|
||||
|
||||
const where = conds.length ? ` WHERE ${conds.join(" AND ")}` : "";
|
||||
|
||||
const [{ count }] = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM rides r
|
||||
LEFT JOIN drivers d ON d.id = r.driver_id
|
||||
INNER JOIN users u ON u.id = r.user_id${where}`,
|
||||
params,
|
||||
);
|
||||
|
||||
const rows = await query(
|
||||
`${SELECT_RIDES}${where}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
[...params, PAGE_SIZE, (page - 1) * PAGE_SIZE],
|
||||
);
|
||||
|
||||
return withCors(request,
|
||||
Response.json({
|
||||
data: rows,
|
||||
total: count,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
pages: Math.max(1, Math.ceil(count / PAGE_SIZE)),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[ADMIN_RIDES]: ", error);
|
||||
return withCors(
|
||||
return withCors(request,
|
||||
Response.json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
+107
-13
@@ -1,33 +1,121 @@
|
||||
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;
|
||||
pending_revenue: number;
|
||||
new_users_7d: number;
|
||||
}>`
|
||||
SELECT
|
||||
(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), 0)::int 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 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'), 0)::int 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,
|
||||
@@ -38,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'), 0)::int 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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,47 @@ 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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { id }: { id: string }) {
|
||||
const auth = await requireOwner(request);
|
||||
if ("error" in auth) return withCors(request, auth.error);
|
||||
|
||||
if (id === auth.userId) {
|
||||
return withCors(request,
|
||||
Response.json(
|
||||
{ error: "You cannot delete your own account." },
|
||||
{ status: 400 },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// rides.user_id is ON DELETE CASCADE, so a rider's rides go with them.
|
||||
const rows = await sql<{ id: string }>`
|
||||
DELETE FROM users WHERE id = ${id} RETURNING id
|
||||
`;
|
||||
|
||||
if (!rows[0]) {
|
||||
return withCors(request,
|
||||
Response.json({ error: "User not found." }, { status: 404 }),
|
||||
);
|
||||
}
|
||||
|
||||
return withCors(request, Response.json({ data: rows[0] }));
|
||||
} catch (error) {
|
||||
console.error("[ADMIN_USER_DELETE]: ", error);
|
||||
return withCors(request,
|
||||
Response.json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { sql, transaction } from "@/lib/db";
|
||||
import { isDevOtpExposed, sendEmail } from "@/lib/mailer";
|
||||
import {
|
||||
CODE_TTL_MINUTES,
|
||||
generateCode,
|
||||
hashCode,
|
||||
resetEmail,
|
||||
} from "@/lib/otp";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email } = await req.json();
|
||||
|
||||
if (!email?.trim()) {
|
||||
return Response.json({ error: "Email is required." }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalized = email.trim().toLowerCase();
|
||||
|
||||
try {
|
||||
const users = await sql<{ id: string }>`
|
||||
SELECT id FROM users WHERE email = ${normalized}
|
||||
`;
|
||||
|
||||
// Don't reveal whether the address is registered: always answer the same.
|
||||
if (!users[0]) {
|
||||
return Response.json({ data: { sent: false } });
|
||||
}
|
||||
|
||||
const code = await transaction(async (tx) => {
|
||||
const generated = generateCode();
|
||||
|
||||
await tx`
|
||||
INSERT INTO password_reset_codes (email, code_hash, expires_at)
|
||||
VALUES (
|
||||
${normalized},
|
||||
${hashCode(normalized, generated)},
|
||||
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
code_hash = EXCLUDED.code_hash,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
attempts = 0
|
||||
`;
|
||||
|
||||
return generated;
|
||||
});
|
||||
|
||||
const mail = resetEmail(code);
|
||||
const delivered = await sendEmail(normalized, mail.subject, mail.text);
|
||||
|
||||
return Response.json({
|
||||
data: {
|
||||
sent: delivered,
|
||||
// Without SMTP configured there is nothing to receive, so surface the
|
||||
// code to keep the reset flow usable on a self-hosted box. Never
|
||||
// expose the code in production, even on delivery failure.
|
||||
...(delivered || !isDevOtpExposed() ? {} : { devCode: code }),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[FORGOT_PASSWORD]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
import { sql } from "@/lib/db";
|
||||
import { verifyPassword } from "@/lib/password";
|
||||
import { SESSION_TTL_SECONDS, SHORT_SESSION_TTL_SECONDS } from "@/lib/jwt";
|
||||
import { hashPassword, verifyPassword } from "@/lib/password";
|
||||
import { issueSession, toProfile } from "@/lib/users";
|
||||
|
||||
// Compared against when no account matches, so a wrong email costs the same
|
||||
// scrypt work as a wrong password instead of answering noticeably faster.
|
||||
const DUMMY_HASH = hashPassword("waseel-no-such-account");
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, password } = await req.json();
|
||||
const { email, password, remember } = await req.json();
|
||||
|
||||
if (!email?.trim() || !password) {
|
||||
// Names only, never values: this is the one 400 that looks like a server
|
||||
// fault from the app, so say which field arrived empty.
|
||||
console.warn(
|
||||
"[LOGIN]: rejected empty",
|
||||
[!email?.trim() && "email", !password && "password"]
|
||||
.filter(Boolean)
|
||||
.join(" + "),
|
||||
);
|
||||
|
||||
return Response.json(
|
||||
{ error: "Email and password are required." },
|
||||
{ status: 400 },
|
||||
@@ -29,8 +41,9 @@ export async function POST(req: Request) {
|
||||
`;
|
||||
|
||||
const user = rows[0];
|
||||
const valid = verifyPassword(password, user?.password_hash ?? DUMMY_HASH);
|
||||
|
||||
if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) {
|
||||
if (!user || !user.password_hash || !valid) {
|
||||
return Response.json(
|
||||
{ error: "Invalid email or password." },
|
||||
{ status: 401 },
|
||||
@@ -44,7 +57,10 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const session = issueSession(user);
|
||||
const session = issueSession(
|
||||
user,
|
||||
remember === false ? SHORT_SESSION_TTL_SECONDS : SESSION_TTL_SECONDS,
|
||||
);
|
||||
|
||||
return Response.json({
|
||||
data: { token: session.token, user: toProfile(user) },
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { createHash, randomInt } from "crypto";
|
||||
|
||||
import { sql } from "@/lib/db";
|
||||
import { sql, transaction } from "@/lib/db";
|
||||
import { hashPassword } from "@/lib/password";
|
||||
import { sendEmail } from "@/lib/mailer";
|
||||
|
||||
const normalizePhone = (raw: string): string => {
|
||||
const cleaned = raw.replace(/[^\d+]/g, "");
|
||||
if (cleaned.startsWith("+")) return cleaned;
|
||||
return `+961${cleaned.replace(/^0+/, "")}`;
|
||||
};
|
||||
|
||||
const hashCode = (email: string, code: string): string =>
|
||||
createHash("sha256").update(`${email}:${code}`).digest("hex");
|
||||
import { isDevOtpExposed, sendEmail } from "@/lib/mailer";
|
||||
import {
|
||||
CODE_TTL_MINUTES,
|
||||
generateCode,
|
||||
hashCode,
|
||||
verificationEmail,
|
||||
} from "@/lib/otp";
|
||||
import { normalizePhone } from "@/lib/utils";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { name, email, phone, password } = await req.json();
|
||||
const { name, email, phone, password, role } = await req.json();
|
||||
|
||||
if (!name?.trim() || !email?.trim() || !password) {
|
||||
return Response.json(
|
||||
@@ -23,6 +19,8 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedRole = role === "driver" ? "driver" : "rider";
|
||||
|
||||
if (typeof password !== "string" || password.length < 8) {
|
||||
return Response.json(
|
||||
{ error: "Password must be at least 8 characters." },
|
||||
@@ -43,29 +41,33 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
// Unverified rows may be re-registered (e.g. the first mail never arrived).
|
||||
await sql`
|
||||
INSERT INTO users (name, email, phone, password_hash, email_verified)
|
||||
const code = await transaction(async (tx) => {
|
||||
await tx`
|
||||
INSERT INTO users (name, email, phone, password_hash, email_verified, role)
|
||||
VALUES (
|
||||
${name.trim()},
|
||||
${email.trim().toLowerCase()},
|
||||
${phone ? normalizePhone(phone) : null},
|
||||
${hashPassword(password)},
|
||||
FALSE
|
||||
FALSE,
|
||||
${normalizedRole}
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
phone = COALESCE(EXCLUDED.phone, users.phone),
|
||||
password_hash = EXCLUDED.password_hash
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = EXCLUDED.role
|
||||
WHERE users.email_verified = FALSE
|
||||
`;
|
||||
|
||||
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
|
||||
const generated = generateCode();
|
||||
|
||||
await sql`
|
||||
await tx`
|
||||
INSERT INTO email_verification_codes (email, code_hash, expires_at)
|
||||
VALUES (
|
||||
${email.trim().toLowerCase()},
|
||||
${hashCode(email.trim().toLowerCase(), code)},
|
||||
CURRENT_TIMESTAMP + INTERVAL '15 minutes'
|
||||
${hashCode(email.trim().toLowerCase(), generated)},
|
||||
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||
)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
code_hash = EXCLUDED.code_hash,
|
||||
@@ -73,13 +75,28 @@ export async function POST(req: Request) {
|
||||
attempts = 0
|
||||
`;
|
||||
|
||||
await sendEmail(
|
||||
return generated;
|
||||
});
|
||||
|
||||
const mail = verificationEmail(code);
|
||||
const delivered = await sendEmail(
|
||||
email.trim().toLowerCase(),
|
||||
"Your Waseel verification code",
|
||||
`Welcome to Waseel!\n\nYour verification code is: ${code}\n\nIt expires in 15 minutes.`,
|
||||
mail.subject,
|
||||
mail.text,
|
||||
);
|
||||
|
||||
return Response.json({ data: { sent: true } }, { status: 201 });
|
||||
return Response.json(
|
||||
{
|
||||
data: {
|
||||
sent: delivered,
|
||||
// Without SMTP/Gmail configured there is nothing to receive, so
|
||||
// surface the code to keep self-hosted sign-up usable. Never expose
|
||||
// the code in production, even on delivery failure.
|
||||
...(delivered || !isDevOtpExposed() ? {} : { devCode: code }),
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[REGISTER]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { transaction } from "@/lib/db";
|
||||
import { MAX_CODE_ATTEMPTS, codeMatches } from "@/lib/otp";
|
||||
import { hashPassword } from "@/lib/password";
|
||||
import { issueSession, toProfile } from "@/lib/users";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, code, password } = await req.json();
|
||||
|
||||
if (!email?.trim() || !/^\d{6}$/.test(code ?? "")) {
|
||||
return Response.json(
|
||||
{ error: "Email and a 6-digit code are required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof password !== "string" || password.length < 8) {
|
||||
return Response.json(
|
||||
{ error: "Password must be at least 8 characters." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = email.trim().toLowerCase();
|
||||
|
||||
try {
|
||||
// Charge the attempt before comparing so concurrent guesses can't race
|
||||
// past the cap, and so a correct guess still costs one of the five. Wrap
|
||||
// the attempt charge, password update, and code cleanup in one
|
||||
// transaction.
|
||||
const result = await transaction(async (tx) => {
|
||||
const attempts = await tx<{ code_hash: string; attempts: number }>`
|
||||
UPDATE password_reset_codes
|
||||
SET attempts = attempts + 1
|
||||
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
||||
RETURNING code_hash, attempts
|
||||
`;
|
||||
|
||||
const record = attempts[0];
|
||||
|
||||
if (
|
||||
!record ||
|
||||
record.attempts > MAX_CODE_ATTEMPTS ||
|
||||
!codeMatches(record.code_hash, normalized, code)
|
||||
) {
|
||||
return { kind: "invalid" as const };
|
||||
}
|
||||
|
||||
// A successful reset also proves control of the mailbox, so verify it
|
||||
// too.
|
||||
const rows = await tx<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string | null;
|
||||
}>`
|
||||
UPDATE users
|
||||
SET password_hash = ${hashPassword(password)}, email_verified = TRUE
|
||||
WHERE email = ${normalized}
|
||||
RETURNING id, name, email, role
|
||||
`;
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
return { kind: "not_found" as const };
|
||||
}
|
||||
|
||||
await tx`DELETE FROM password_reset_codes WHERE email = ${normalized}`;
|
||||
|
||||
return { kind: "ok" as const, user };
|
||||
});
|
||||
|
||||
if (result.kind === "invalid") {
|
||||
return Response.json(
|
||||
{ error: "Invalid or expired reset code." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (result.kind === "not_found") {
|
||||
return Response.json({ error: "User not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = issueSession(result.user);
|
||||
|
||||
return Response.json({
|
||||
data: { token: session.token, user: toProfile(result.user) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[RESET_PASSWORD]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
import { sql } from "@/lib/db";
|
||||
import { transaction } from "@/lib/db";
|
||||
import {
|
||||
MAX_CODE_ATTEMPTS,
|
||||
codeMatches,
|
||||
} from "@/lib/otp";
|
||||
import { issueSession, toProfile } from "@/lib/users";
|
||||
|
||||
const hashCode = (email: string, code: string): string =>
|
||||
createHash("sha256").update(`${email}:${code}`).digest("hex");
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { email, code } = await req.json();
|
||||
|
||||
@@ -19,7 +18,28 @@ export async function POST(req: Request) {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
|
||||
try {
|
||||
const rows = await sql<{
|
||||
// Charge the attempt before comparing so concurrent guesses can't race
|
||||
// past the cap, and so a correct guess still costs one of the five. Wrap
|
||||
// the attempt charge, verification, and code cleanup in one transaction.
|
||||
const result = await transaction(async (tx) => {
|
||||
const attempts = await tx<{ code_hash: string; attempts: number }>`
|
||||
UPDATE email_verification_codes
|
||||
SET attempts = attempts + 1
|
||||
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
||||
RETURNING code_hash, attempts
|
||||
`;
|
||||
|
||||
const record = attempts[0];
|
||||
|
||||
if (
|
||||
!record ||
|
||||
record.attempts > MAX_CODE_ATTEMPTS ||
|
||||
!codeMatches(record.code_hash, normalized, code)
|
||||
) {
|
||||
return { kind: "invalid" as const };
|
||||
}
|
||||
|
||||
const rows = await tx<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -27,35 +47,35 @@ export async function POST(req: Request) {
|
||||
}>`
|
||||
UPDATE users SET email_verified = TRUE
|
||||
WHERE email = ${normalized}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM email_verification_codes
|
||||
WHERE email = ${normalized}
|
||||
AND code_hash = ${hashCode(normalized, code)}
|
||||
AND expires_at > CURRENT_TIMESTAMP
|
||||
)
|
||||
RETURNING id, name, email, role
|
||||
`;
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
await sql`
|
||||
UPDATE email_verification_codes SET attempts = attempts + 1
|
||||
WHERE email = ${normalized}
|
||||
`;
|
||||
return { kind: "not_found" as const };
|
||||
}
|
||||
|
||||
await tx`DELETE FROM email_verification_codes WHERE email = ${normalized}`;
|
||||
|
||||
return { kind: "ok" as const, user };
|
||||
});
|
||||
|
||||
if (result.kind === "invalid") {
|
||||
return Response.json(
|
||||
{ error: "Invalid or expired verification code." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await sql`DELETE FROM email_verification_codes WHERE email = ${normalized}`;
|
||||
if (result.kind === "not_found") {
|
||||
return Response.json({ error: "User not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
const session = issueSession(user);
|
||||
const session = issueSession(result.user);
|
||||
|
||||
return Response.json({
|
||||
data: { token: session.token, user: toProfile(user) },
|
||||
data: { token: session.token, user: toProfile(result.user) },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[VERIFY]: ", error);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { requireDriverProfile } from "@/lib/driver";
|
||||
import { sql } from "@/lib/db";
|
||||
import { CONNECTED_STATUS_ARRAY } from "@/lib/ride-lifecycle";
|
||||
|
||||
// GET — the Chat tab's default view. Returns the caller's currently-active
|
||||
// ride that has the other party assigned (so a conversation can open), or
|
||||
// null when there's nothing to chat about. The caller is auto-detected: a
|
||||
// rider by default, or a driver when ?role=driver is passed (the driver app
|
||||
// hits this with role=driver since the same account could in principle be a
|
||||
// rider elsewhere).
|
||||
//
|
||||
// We try the rider path first. If the signed-in user owns an active ride
|
||||
// with a driver assigned, that's their conversation. Otherwise, if they have
|
||||
// a driver profile, we look for a ride they're assigned to. Either way the
|
||||
// response carries the caller's `role` and a `peer` summary for the header.
|
||||
|
||||
type ActiveRideRow = {
|
||||
ride_id: number;
|
||||
status: string;
|
||||
role: "rider" | "driver";
|
||||
peer_name: string;
|
||||
peer_avatar: string | null;
|
||||
peer_service: string | null;
|
||||
peer_car_model: string | null;
|
||||
};
|
||||
|
||||
// The client (chat.tsx, call.tsx) expects `peer` nested per the ChatActiveRide
|
||||
// type, not the flat peer_* columns the query returns.
|
||||
const toActiveRide = (row: ActiveRideRow) => ({
|
||||
ride_id: row.ride_id,
|
||||
status: row.status,
|
||||
role: row.role,
|
||||
peer: {
|
||||
name: row.peer_name,
|
||||
avatar: row.peer_avatar,
|
||||
service: row.peer_service,
|
||||
car_model: row.peer_car_model,
|
||||
},
|
||||
});
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
const wantsDriver = new URL(req.url).searchParams.get("role") === "driver";
|
||||
|
||||
try {
|
||||
// Rider path: a ride this user owns that's active and has a driver.
|
||||
if (!wantsDriver) {
|
||||
const riderRides = await sql<ActiveRideRow>`
|
||||
SELECT
|
||||
r.ride_id,
|
||||
r.status,
|
||||
'rider' AS role,
|
||||
CONCAT_WS(' ', d.first_name, d.last_name) AS peer_name,
|
||||
d.profile_image_url AS peer_avatar,
|
||||
d.service AS peer_service,
|
||||
d.car_model AS peer_car_model
|
||||
FROM rides r
|
||||
JOIN drivers d ON d.id = r.driver_id
|
||||
WHERE r.user_id = ${auth.userId}
|
||||
AND r.status = ANY(${CONNECTED_STATUS_ARRAY}::text[])
|
||||
AND r.driver_id IS NOT NULL
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (riderRides[0])
|
||||
return Response.json({ data: toActiveRide(riderRides[0]) });
|
||||
}
|
||||
|
||||
// Driver path: a ride this user (as a driver) is assigned to and is active.
|
||||
const driver = await requireDriverProfile(req);
|
||||
if (!("error" in driver)) {
|
||||
const driverRides = await sql<ActiveRideRow>`
|
||||
SELECT
|
||||
r.ride_id,
|
||||
r.status,
|
||||
'driver' AS role,
|
||||
u.name AS peer_name,
|
||||
NULL::text AS peer_avatar,
|
||||
r.service AS peer_service,
|
||||
NULL::text AS peer_car_model
|
||||
FROM rides r
|
||||
JOIN users u ON u.id = r.user_id
|
||||
WHERE r.driver_id = ${driver.driverId}
|
||||
AND r.status = ANY(${CONNECTED_STATUS_ARRAY}::text[])
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (driverRides[0])
|
||||
return Response.json({ data: toActiveRide(driverRides[0]) });
|
||||
}
|
||||
|
||||
return Response.json({ data: null });
|
||||
} catch (error) {
|
||||
console.error("[GET_ACTIVE_CHAT]: ", error);
|
||||
return Response.json({ error: "Internal Server Error." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { sql } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const response = await sql`SELECT * FROM drivers`;
|
||||
|
||||
return Response.json({ data: response });
|
||||
} catch (error) {
|
||||
console.log("[GET_DRIVERS]: ", error);
|
||||
|
||||
return Response.json({ error }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { sql } from "@/lib/db";
|
||||
import { SERVICES } from "@/constants/services";
|
||||
import { boundingBox, haversine } from "@/lib/utils";
|
||||
import { DRIVER_STALE_SECONDS } from "@/constants/dispatch";
|
||||
|
||||
// GET — how many drivers of each service are within reach of a point.
|
||||
//
|
||||
// The rider map filters by the selected service, so an empty map is ambiguous:
|
||||
// it means "nobody at all" and "nobody driving a moto, though three cars are a
|
||||
// street away" identically. That's the state riders were getting stuck in —
|
||||
// staring at an empty map with no way to know that switching service would
|
||||
// fill it. This answers the question the map can't.
|
||||
//
|
||||
// Query: ?lat=33.89&lng=35.50&radius=20000
|
||||
//
|
||||
// Returns every known service, zeros included, so the client can render the
|
||||
// full picker without inventing missing keys.
|
||||
const DEFAULT_RADIUS_M = 20000;
|
||||
const MAX_RADIUS_M = 20000;
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const lat = Number(url.searchParams.get("lat"));
|
||||
const lng = Number(url.searchParams.get("lng"));
|
||||
|
||||
if (Number.isNaN(lat) || Number.isNaN(lng)) {
|
||||
return Response.json(
|
||||
{ error: "lat and lng query params are required numbers." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const requested = Number(url.searchParams.get("radius"));
|
||||
const radius =
|
||||
Number.isFinite(requested) && requested > 0
|
||||
? Math.min(requested, MAX_RADIUS_M)
|
||||
: DEFAULT_RADIUS_M;
|
||||
|
||||
const box = boundingBox(lat, lng, radius);
|
||||
|
||||
// Same visibility rules as /driver/nearby — vetted, online, fresh, real
|
||||
// account, positioned. A driver riders can't be matched to must not be
|
||||
// counted here either, or the hint sends them to an empty service.
|
||||
const rows = await sql<{
|
||||
service: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}>`
|
||||
SELECT service, latitude, longitude
|
||||
FROM drivers
|
||||
WHERE online = TRUE
|
||||
AND approval_status = 'approved'
|
||||
AND user_id IS NOT NULL
|
||||
AND last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
|
||||
AND latitude IS NOT NULL
|
||||
AND longitude IS NOT NULL
|
||||
AND latitude BETWEEN ${box.minLat} AND ${box.maxLat}
|
||||
AND longitude BETWEEN ${box.minLng} AND ${box.maxLng}
|
||||
`;
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const service of SERVICES) counts[service.id] = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (haversine(lat, lng, row.latitude, row.longitude) > radius) continue;
|
||||
if (counts[row.service] === undefined) continue;
|
||||
counts[row.service] += 1;
|
||||
}
|
||||
|
||||
return Response.json({ data: { radius, counts } });
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_AVAILABILITY]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { preflight, withCors } from "@/lib/admin";
|
||||
import { sql } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import { isStoredUploadName, readUpload, uploadMimeType } from "@/lib/uploads";
|
||||
|
||||
// GET /(api)/driver/documents?name=… — serve one stored document scan.
|
||||
//
|
||||
// These are identity documents, so they are not static files: every read is
|
||||
// authenticated and authorised here. Exactly two principals may fetch a scan —
|
||||
// the driver it belongs to, and an owner reviewing that driver. Knowing the
|
||||
// (unguessable) file name is not itself permission.
|
||||
//
|
||||
// The name travels as a query parameter rather than a path segment because it
|
||||
// ends in .jpg/.png/.webp, and a dotted final segment is exactly what static
|
||||
// asset middleware tends to claim before the router ever sees it. A query
|
||||
// parameter cannot be mistaken for a file on disk.
|
||||
//
|
||||
// CORS is applied because the admin dashboard is a separate origin; it fetches
|
||||
// the bytes with its bearer token and renders them from a blob URL, since an
|
||||
// <img src> cannot carry an Authorization header.
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return preflight(request);
|
||||
}
|
||||
|
||||
const notFound = (request: Request) =>
|
||||
withCors(request, Response.json({ error: "Not found." }, { status: 404 }));
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = requireAuth(request);
|
||||
if ("error" in auth) return withCors(request, auth.error);
|
||||
|
||||
const name = new URL(request.url).searchParams.get("name");
|
||||
|
||||
// Rejecting the name before it reaches the filesystem is what keeps a
|
||||
// crafted "../../.env" from ever being joined onto the upload directory.
|
||||
if (!isStoredUploadName(name)) return notFound(request);
|
||||
|
||||
try {
|
||||
const rows = await sql<{ role: string | null; owns: boolean }>`
|
||||
SELECT
|
||||
(SELECT role FROM users WHERE id = ${auth.userId}) AS role,
|
||||
EXISTS (
|
||||
SELECT 1 FROM drivers
|
||||
WHERE user_id = ${auth.userId}
|
||||
AND ${name} IN (
|
||||
license_image_url, id_image_url, vehicle_reg_image_url
|
||||
)
|
||||
) AS owns
|
||||
`;
|
||||
|
||||
const allowed = rows[0]?.role === "owner" || rows[0]?.owns === true;
|
||||
|
||||
// A 404 rather than a 403: a caller who is not entitled to the document
|
||||
// shouldn't learn whether it exists.
|
||||
if (!allowed) return notFound(request);
|
||||
|
||||
const bytes = await readUpload(name, "document");
|
||||
if (!bytes) return notFound(request);
|
||||
|
||||
return withCors(
|
||||
request,
|
||||
new Response(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
"Content-Type": uploadMimeType(name),
|
||||
"Content-Length": String(bytes.length),
|
||||
// Never let a shared cache hold somebody's ID card.
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Disposition": `inline; filename="${name}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_DOCUMENT_GET]: ", error);
|
||||
return withCors(
|
||||
request,
|
||||
Response.json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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
|
||||
// every few seconds while the driver's online toggle is on; going offline is
|
||||
// an explicit PATCH to /driver/profile, not the absence of pings.
|
||||
export async function POST(req: Request) {
|
||||
const result = await requireDriverProfile(req);
|
||||
if ("error" in result) return result.error;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { latitude, longitude, heading, speed_kph } = body;
|
||||
|
||||
if (
|
||||
typeof latitude !== "number" ||
|
||||
typeof longitude !== "number" ||
|
||||
Number.isNaN(latitude) ||
|
||||
Number.isNaN(longitude)
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "latitude and longitude must be numbers." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 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},
|
||||
-- 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, heading, speed_kph, last_seen, online
|
||||
`;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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
|
||||
// "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;
|
||||
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const service = url.searchParams.get("service") ?? "car";
|
||||
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);
|
||||
|
||||
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,
|
||||
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 - 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 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { preflight, withCors } from "@/lib/admin";
|
||||
import { sql } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import {
|
||||
deleteUpload,
|
||||
isStoredUploadName,
|
||||
MAX_UPLOAD_BYTES,
|
||||
pruneOrphanUploads,
|
||||
readUpload,
|
||||
sniffImageType,
|
||||
storeUpload,
|
||||
uploadMimeType,
|
||||
} from "@/lib/uploads";
|
||||
|
||||
// The driver's profile photo — the face a rider sees beside a driver's name
|
||||
// when picking between offers, and what they check the arriving car's driver
|
||||
// against.
|
||||
//
|
||||
// POST uploads it (authenticated, driver-role only). GET serves it, and unlike
|
||||
// the document route it does NOT require a token: this image is rendered by
|
||||
// plain <Image>/<img> tags across the rider app, the driver map and the admin
|
||||
// dashboard, none of which can attach an Authorization header without turning
|
||||
// every avatar into a bespoke fetch-and-blob dance. What protects it instead
|
||||
// is that the name is 128 bits of randomness and the route refuses any name no
|
||||
// driver row actually points at — so it cannot be enumerated, and it cannot be
|
||||
// used as a general-purpose anonymous image host for whatever somebody
|
||||
// uploaded and abandoned.
|
||||
//
|
||||
// This is the opposite trade to /(api)/driver/documents, which is why the two
|
||||
// live in separate directories on disk: a name that addresses a licence scan
|
||||
// resolves to nothing here.
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return preflight(request);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const name = new URL(request.url).searchParams.get("name");
|
||||
|
||||
const notFound = () =>
|
||||
withCors(request, Response.json({ error: "Not found." }, { status: 404 }));
|
||||
|
||||
// Rejecting the name before it reaches the filesystem is what keeps a
|
||||
// crafted "../../.env" from ever being joined onto the upload directory.
|
||||
if (!isStoredUploadName(name)) return notFound();
|
||||
|
||||
try {
|
||||
// Only photos a driver profile actually points at are served. Without
|
||||
// this, any signed-in driver could upload an arbitrary image and walk away
|
||||
// with a permanent public URL for it.
|
||||
const rows = await sql<{ used: boolean }>`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM drivers WHERE profile_image_url = ${name}
|
||||
) AS used
|
||||
`;
|
||||
|
||||
if (!rows[0]?.used) return notFound();
|
||||
|
||||
const bytes = await readUpload(name, "photo");
|
||||
if (!bytes) return notFound();
|
||||
|
||||
return withCors(
|
||||
request,
|
||||
new Response(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
"Content-Type": uploadMimeType(name),
|
||||
"Content-Length": String(bytes.length),
|
||||
// The name changes whenever the photo does, so the bytes behind a
|
||||
// given URL are immutable and can be cached hard. That matters: the
|
||||
// rider's nearby-drivers view re-renders these constantly.
|
||||
"Cache-Control": "public, max-age=604800, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_PHOTO_GET]: ", error);
|
||||
return withCors(
|
||||
request,
|
||||
Response.json({ error: "Internal Server Error" }, { status: 500 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Photos are cheap compared with a scan (no Vision call), but still a disk
|
||||
* write, so keep a lid on how fast one account can retake theirs.
|
||||
*/
|
||||
const PHOTO_LIMIT = 15;
|
||||
const PHOTO_WINDOW_MS = 60 * 60 * 1000;
|
||||
const recentUploads = new Map<string, number[]>();
|
||||
|
||||
const overPhotoLimit = (userId: string): boolean => {
|
||||
const now = Date.now();
|
||||
const cutoff = now - PHOTO_WINDOW_MS;
|
||||
const history = (recentUploads.get(userId) ?? []).filter((at) => at > cutoff);
|
||||
|
||||
if (history.length >= PHOTO_LIMIT) {
|
||||
recentUploads.set(userId, history);
|
||||
return true;
|
||||
}
|
||||
|
||||
history.push(now);
|
||||
recentUploads.set(userId, history);
|
||||
|
||||
if (recentUploads.size > 500) {
|
||||
for (const [key, times] of recentUploads) {
|
||||
if (times.every((at) => at <= cutoff)) recentUploads.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
||||
let lastPruneAt = 0;
|
||||
|
||||
/**
|
||||
* A driver who takes a photo and then abandons onboarding leaves a file
|
||||
* nothing points at. Same sweep as the scan route, over the photo directory.
|
||||
*/
|
||||
const pruneOrphansOccasionally = async (): Promise<void> => {
|
||||
if (Date.now() - lastPruneAt < PRUNE_INTERVAL_MS) return;
|
||||
lastPruneAt = Date.now();
|
||||
|
||||
try {
|
||||
const rows = await sql<{ profile_image_url: string | null }>`
|
||||
SELECT profile_image_url FROM drivers
|
||||
WHERE profile_image_url IS NOT NULL
|
||||
`;
|
||||
|
||||
const referenced = new Set(
|
||||
rows.map((row) => row.profile_image_url).filter(Boolean) as string[],
|
||||
);
|
||||
|
||||
await pruneOrphanUploads(referenced, "photo");
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_PHOTO_PRUNE]: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
// POST — upload or replace the driver's profile photo.
|
||||
//
|
||||
// A driver who already has a profile row gets it attached straight away, so
|
||||
// retaking a bad photo is one step. During onboarding there is no row yet, so
|
||||
// the name is just returned and travels up with the profile submission.
|
||||
export async function POST(req: Request) {
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
try {
|
||||
const users = await sql<{ role: string | null }>`
|
||||
SELECT role FROM users WHERE id = ${auth.userId}
|
||||
`;
|
||||
if (users[0]?.role !== "driver") {
|
||||
return Response.json(
|
||||
{ error: "Only driver accounts can upload a driver photo." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (overPhotoLimit(auth.userId)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Too many uploads. Wait a few minutes and try again.",
|
||||
code: "PHOTO_RATE_LIMIT",
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const raw = body.image_base64;
|
||||
|
||||
if (typeof raw !== "string" || raw.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "image_base64 is required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const encoded = raw.includes(",") ? raw.slice(raw.indexOf(",") + 1) : raw;
|
||||
|
||||
// Base64 inflates by 4/3, so reject on the encoded length before
|
||||
// allocating — otherwise an oversized upload is buffered just to be
|
||||
// refused.
|
||||
if (encoded.length > MAX_UPLOAD_BYTES * 1.4) {
|
||||
return Response.json(
|
||||
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const image = Buffer.from(encoded, "base64");
|
||||
|
||||
if (image.length > MAX_UPLOAD_BYTES) {
|
||||
return Response.json(
|
||||
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const mimeType = sniffImageType(image);
|
||||
if (!mimeType) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Upload a JPEG, PNG or WebP photo.",
|
||||
code: "UNSUPPORTED_IMAGE",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const photo = await storeUpload(image, mimeType, "photo");
|
||||
|
||||
// Attach it now if the driver already has a profile, so retaking a bad
|
||||
// photo is a single step. Mid-onboarding there is no row yet and the name
|
||||
// simply travels up with the profile submission instead.
|
||||
//
|
||||
// This deliberately does not touch approval_status: a driver swapping a
|
||||
// blurry photo for a clear one shouldn't be knocked out of service, and
|
||||
// the reviewer sees whatever the current photo is when they next open the
|
||||
// profile.
|
||||
const existing = await sql<{ profile_image_url: string | null }>`
|
||||
SELECT profile_image_url FROM drivers WHERE user_id = ${auth.userId}
|
||||
`;
|
||||
|
||||
const attached = existing.length > 0;
|
||||
|
||||
if (attached) {
|
||||
await sql`
|
||||
UPDATE drivers SET profile_image_url = ${photo}
|
||||
WHERE user_id = ${auth.userId}
|
||||
`;
|
||||
|
||||
// Only a name we stored is safe to unlink — an owner may have set an
|
||||
// external URL from the dashboard, and that is not ours to delete.
|
||||
const previous = existing[0].profile_image_url;
|
||||
if (previous && previous !== photo && isStoredUploadName(previous)) {
|
||||
await deleteUpload(previous, "photo");
|
||||
}
|
||||
}
|
||||
|
||||
void pruneOrphansOccasionally();
|
||||
|
||||
return Response.json({
|
||||
data: {
|
||||
/** Opaque stored name; send it with the profile if onboarding. */
|
||||
photo,
|
||||
attached,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_PHOTO_POST]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
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
|
||||
// they haven't onboarded yet. The client uses the code to show the form.
|
||||
export async function GET(req: Request) {
|
||||
const result = await requireDriverProfile(req);
|
||||
if ("error" in result) return result.error;
|
||||
|
||||
const { auth, driverId } = result;
|
||||
const rows = await sql`
|
||||
SELECT id, first_name, last_name, profile_image_url, car_image_url,
|
||||
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), 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, 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 }>`
|
||||
SELECT role, name FROM users WHERE id = ${auth.userId}
|
||||
`;
|
||||
if (!users[0] || users[0].role !== "driver") {
|
||||
return Response.json(
|
||||
{ error: "Only driver accounts can onboard a driver profile." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!isServiceId(service)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `service must be one of: ${SERVICES.map((s) => s.id).join(", ")}.`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const seats = Number(car_seats);
|
||||
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
|
||||
return Response.json(
|
||||
{ error: "car_seats must be a whole number between 1 and 8." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
// guarantees this at the DB level; surface a clean 409 on collision.
|
||||
try {
|
||||
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,
|
||||
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(" ") || ""},
|
||||
${profilePhoto},
|
||||
${car_image_url ?? null},
|
||||
${seats},
|
||||
5.0,
|
||||
${service as ServiceId},
|
||||
${car_model ?? null},
|
||||
FALSE,
|
||||
'pending',
|
||||
${licenseNumber},
|
||||
${licenseExpiry},
|
||||
${nationalId},
|
||||
${plateNumber},
|
||||
CURRENT_TIMESTAMP,
|
||||
${licenseDocument},
|
||||
${idDocument},
|
||||
${vehicleRegDocument}
|
||||
)
|
||||
RETURNING id, service, online, approval_status
|
||||
`;
|
||||
return Response.json({ data: rows[0] }, { status: 201 });
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === "23505") {
|
||||
return Response.json(
|
||||
{ error: "Driver profile already exists." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_PROFILE_POST]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH — update mutable profile fields, most importantly the online toggle.
|
||||
export async function PATCH(req: Request) {
|
||||
const result = await requireDriverProfile(req);
|
||||
if ("error" in result) return result.error;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { online, car_model, car_seats, service } = body;
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: (string | number | boolean | null)[] = [];
|
||||
let idx = 1;
|
||||
const push = (col: string, value: string | number | boolean | null) => {
|
||||
updates.push(`${col} = $${idx++}`);
|
||||
values.push(value);
|
||||
};
|
||||
|
||||
// A profile that hasn't been cleared cannot go online, and therefore can
|
||||
// never be matched. This is the gate the whole vetting flow rests on —
|
||||
// everything else (dispatch filters, the rider map) is defence in depth.
|
||||
if (online === true && result.approvalStatus !== "approved") {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Your driver account is not approved yet.",
|
||||
code: "NOT_APPROVED",
|
||||
approval_status: result.approvalStatus,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// A rejected driver may fix their details and resubmit, which puts them
|
||||
// back in the review queue rather than silently leaving them stuck. A
|
||||
// rejection is often about the scan rather than the numbers ("the photo is
|
||||
// unreadable"), so a fresh scan on its own counts as a resubmission.
|
||||
const resubmitted =
|
||||
result.approvalStatus === "rejected" &&
|
||||
(body.license_number !== undefined ||
|
||||
body.national_id !== undefined ||
|
||||
body.plate_number !== undefined ||
|
||||
body.license_expiry !== undefined ||
|
||||
body.license_document !== undefined ||
|
||||
body.id_document !== undefined ||
|
||||
body.vehicle_reg_document !== undefined);
|
||||
|
||||
// Scans replaced by this resubmission, deleted once the row actually
|
||||
// points at the new ones — an orphaned file is tidier than a row pointing
|
||||
// at a document that is no longer on disk.
|
||||
const superseded: string[] = [];
|
||||
|
||||
if (resubmitted) {
|
||||
const licenseNumber = trimmed(body.license_number, 60);
|
||||
const nationalId = trimmed(body.national_id, 60);
|
||||
const plateNumber = trimmed(body.plate_number, 20);
|
||||
const licenseExpiry = futureDate(body.license_expiry);
|
||||
|
||||
if (!licenseNumber || !nationalId || !plateNumber || !licenseExpiry) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
"Licence number, expiry (future date), national ID and plate number are all required to resubmit.",
|
||||
code: "CREDENTIALS_REQUIRED",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Only documents the driver re-scanned are sent; anything omitted keeps
|
||||
// the scan already on file.
|
||||
const replacements: Record<string, string | null> = {
|
||||
license_image_url: storedName(body.license_document),
|
||||
id_image_url: storedName(body.id_document),
|
||||
vehicle_reg_image_url: storedName(body.vehicle_reg_document),
|
||||
};
|
||||
|
||||
const existing = await sql<{
|
||||
license_image_url: string | null;
|
||||
id_image_url: string | null;
|
||||
vehicle_reg_image_url: string | null;
|
||||
}>`
|
||||
SELECT license_image_url, id_image_url, vehicle_reg_image_url
|
||||
FROM drivers WHERE id = ${result.driverId}
|
||||
`;
|
||||
|
||||
// Same rule as onboarding, applied to the state the row will be left in:
|
||||
// a driver may resubmit without re-scanning, but not end up with no
|
||||
// licence scan at all.
|
||||
if (!(replacements.license_image_url ?? existing[0]?.license_image_url)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Scan your driving licence before resubmitting.",
|
||||
code: "LICENSE_SCAN_REQUIRED",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
for (const [column, name] of Object.entries(replacements)) {
|
||||
if (!name) continue;
|
||||
|
||||
const previous = existing[0]?.[column as keyof (typeof existing)[0]];
|
||||
if (previous && previous !== name) superseded.push(previous);
|
||||
|
||||
push(column, name);
|
||||
}
|
||||
|
||||
push("license_number", licenseNumber);
|
||||
push("license_expiry", licenseExpiry);
|
||||
push("national_id", nationalId);
|
||||
push("plate_number", plateNumber);
|
||||
push("approval_status", "pending");
|
||||
push("rejection_reason", null);
|
||||
updates.push(`submitted_at = CURRENT_TIMESTAMP`);
|
||||
}
|
||||
|
||||
// Going offline mid-ride would strand the rider: dispatch stops seeing the
|
||||
// driver, the location heartbeat stops, and the rider's map freezes on a
|
||||
// car that never arrives — with no way to re-dispatch, since the ride is
|
||||
// already assigned. Finish or cancel the ride first.
|
||||
if (online === false) {
|
||||
const active = await sql<{ ride_id: number }>`
|
||||
SELECT ride_id FROM rides
|
||||
WHERE driver_id = ${result.driverId}
|
||||
AND status = ANY(${DRIVER_BUSY_ARRAY}::text[])
|
||||
LIMIT 1
|
||||
`;
|
||||
if (active[0]) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Finish or cancel your current ride before going offline.",
|
||||
code: "RIDE_IN_PROGRESS",
|
||||
ride_id: active[0].ride_id,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof online === "boolean") push("online", online);
|
||||
if (typeof car_model === "string" || car_model === null)
|
||||
push("car_model", car_model);
|
||||
if (car_seats !== undefined) {
|
||||
const seats = Number(car_seats);
|
||||
if (!Number.isInteger(seats) || seats < 1 || seats > 8) {
|
||||
return Response.json(
|
||||
{ error: "car_seats must be a whole number between 1 and 8." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
push("car_seats", seats);
|
||||
}
|
||||
if (service !== undefined) {
|
||||
if (!isServiceId(service)) {
|
||||
return Response.json({ error: "Invalid service." }, { status: 400 });
|
||||
}
|
||||
push("service", service as string);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return Response.json({ error: "No fields to update." }, { status: 400 });
|
||||
}
|
||||
|
||||
values.push(result.driverId);
|
||||
const rows = await query(
|
||||
`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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
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:
|
||||
// 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;
|
||||
|
||||
try {
|
||||
const { driverId } = result;
|
||||
|
||||
// This poll is one of the lazy paths that stands in for a background
|
||||
// worker, so it also buries requests nobody was picked for. Awaited: the
|
||||
// list read below should not include a request that just died.
|
||||
await expireStaleRequests();
|
||||
|
||||
// The driver's own position and state. A driver with no fix yet can't be
|
||||
// told what's near them, and one who is offline shouldn't be shown work.
|
||||
const [me] = await sql<{
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
service: string;
|
||||
online: boolean;
|
||||
}>`
|
||||
SELECT latitude, longitude, service, online
|
||||
FROM drivers WHERE id = ${driverId}
|
||||
`;
|
||||
|
||||
const canSeeRequests =
|
||||
me?.online === true && me.latitude !== null && me.longitude !== null;
|
||||
|
||||
// Coarse box in the index, great-circle pass afterwards — the same
|
||||
// two-step every other proximity query in this codebase uses.
|
||||
const box = canSeeRequests
|
||||
? boundingBox(me.latitude!, me.longitude!, BROADCAST_RADIUS_M)
|
||||
: null;
|
||||
|
||||
const openRequests = box
|
||||
? await sql<OpenRequestRow>`
|
||||
SELECT
|
||||
r.ride_id, r.origin_address, r.destination_address,
|
||||
r.origin_latitude, r.origin_longitude,
|
||||
r.destination_latitude, r.destination_longitude,
|
||||
r.ride_time, r.fare_price, r.service, r.created_at,
|
||||
u.name AS rider_name, u.rating AS rider_rating,
|
||||
mine.id AS my_offer_id,
|
||||
(SELECT COUNT(*)::int FROM ride_offers ro
|
||||
WHERE ro.ride_id = r.ride_id AND ro.status = 'offered')
|
||||
AS offer_count
|
||||
FROM rides r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
LEFT JOIN ride_offers mine
|
||||
ON mine.ride_id = r.ride_id
|
||||
AND mine.driver_id = ${driverId}
|
||||
AND mine.status = 'offered'
|
||||
WHERE r.status = 'requested'
|
||||
AND r.service = ${me.service}
|
||||
AND r.created_at > CURRENT_TIMESTAMP - make_interval(secs => ${REQUEST_TTL_SECONDS})
|
||||
AND r.origin_latitude BETWEEN ${box.minLat} AND ${box.maxLat}
|
||||
AND r.origin_longitude BETWEEN ${box.minLng} AND ${box.maxLng}
|
||||
ORDER BY r.created_at DESC
|
||||
`
|
||||
: [];
|
||||
|
||||
// Distance is computed here rather than in SQL so the filter and the
|
||||
// number the driver reads on the card are the same calculation.
|
||||
const requests = (openRequests as unknown as OpenRequestRow[])
|
||||
.map((row) => ({
|
||||
...row,
|
||||
pickup_distance_m: Math.round(
|
||||
haversine(
|
||||
me.latitude!,
|
||||
me.longitude!,
|
||||
Number(row.origin_latitude),
|
||||
Number(row.origin_longitude),
|
||||
),
|
||||
),
|
||||
}))
|
||||
.filter((row) => row.pickup_distance_m <= BROADCAST_RADIUS_M)
|
||||
.sort((a, b) => a.pickup_distance_m - b.pickup_distance_m);
|
||||
|
||||
// Note: pickup_code is deliberately NOT selected here. The whole point of
|
||||
// the code is that the driver has to get it from the rider at the car.
|
||||
//
|
||||
// The rider's phone number isn't selected either. It used to be shipped to
|
||||
// the driver client and never rendered — personal data in transit for
|
||||
// nothing. Driver↔rider contact goes through the in-app chat and WebRTC
|
||||
// call, which is this app's equivalent of a masked number.
|
||||
const active = await sql`
|
||||
SELECT
|
||||
r.ride_id, r.status, r.service, r.payment_status,
|
||||
r.origin_address, r.destination_address,
|
||||
r.origin_latitude, r.origin_longitude,
|
||||
r.destination_latitude, r.destination_longitude,
|
||||
r.ride_time, r.fare_price, r.created_at, 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 = ANY(${DRIVER_BUSY_ARRAY}::text[])
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
// driver_payout_cents is what the driver actually keeps; fare_price is
|
||||
// what the rider paid. Everything the driver sees is the payout — COALESCE
|
||||
// covers rides completed before the split existed.
|
||||
const recent = await sql<RecentRow>`
|
||||
SELECT ride_id, fare_price, service, payment_status, completed_at,
|
||||
COALESCE(driver_payout_cents, fare_price) AS payout_cents,
|
||||
COALESCE(platform_fee_cents, 0) AS fee_cents
|
||||
FROM rides
|
||||
WHERE driver_id = ${driverId} AND status = 'completed'
|
||||
AND completed_at >= CURRENT_DATE
|
||||
ORDER BY completed_at DESC
|
||||
`;
|
||||
|
||||
// The driver's running balance with the company, across all time rather
|
||||
// than just today — an unremitted commission doesn't stop mattering at
|
||||
// midnight. Two directions: cash commission they're holding for us, and
|
||||
// card payouts we still owe them.
|
||||
const [balance] = await sql<{
|
||||
owes_company_cents: number;
|
||||
owed_to_driver_cents: number;
|
||||
}>`
|
||||
SELECT
|
||||
COALESCE(SUM(platform_fee_cents)
|
||||
FILTER (WHERE platform_fee_settled_at IS NULL), 0)::int
|
||||
AS owes_company_cents,
|
||||
COALESCE(SUM(driver_payout_cents)
|
||||
FILTER (WHERE driver_payout_settled_at IS NULL), 0)::int
|
||||
AS owed_to_driver_cents
|
||||
FROM rides
|
||||
WHERE driver_id = ${driverId}
|
||||
AND status = 'completed'
|
||||
AND payment_status IN ('paid','cash_collected')
|
||||
`;
|
||||
|
||||
// A ride the driver finished recently and hasn't rated. Surfaced as a
|
||||
// prompt on the dashboard so the rating survives the driver immediately
|
||||
// accepting their next trip.
|
||||
const pendingRating = await sql`
|
||||
SELECT r.ride_id, u.name AS rider_name
|
||||
FROM rides r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE r.driver_id = ${driverId}
|
||||
AND r.status = 'completed'
|
||||
AND r.completed_at > CURRENT_TIMESTAMP - INTERVAL '1 day'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM ride_ratings rr
|
||||
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'driver'
|
||||
)
|
||||
ORDER BY r.completed_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const settled = (r: RecentRow) =>
|
||||
r.payment_status === "paid" || r.payment_status === "cash_collected";
|
||||
|
||||
const sumPayout = (rows: typeof recent) =>
|
||||
rows.reduce((sum, r) => sum + Number(r.payout_cents), 0);
|
||||
const sumFares = (rows: typeof recent) =>
|
||||
rows.reduce((sum, r) => sum + Number(r.fare_price), 0);
|
||||
|
||||
// Earnings count settled money only, and count the driver's share of it.
|
||||
// A cash ride the driver marked "not collected" is still an unpaid trip
|
||||
// and used to land in this headline anyway, so the figure a driver saw and
|
||||
// the figure they'd be paid against disagreed from day one.
|
||||
const earnings = sumPayout(recent.filter(settled));
|
||||
|
||||
// The platform's cut of the same rides, so the number above is explainable
|
||||
// rather than mysteriously smaller than the fares they remember charging.
|
||||
const platformFees = recent
|
||||
.filter(settled)
|
||||
.reduce((sum, r) => sum + Number(r.fee_cents), 0);
|
||||
|
||||
// Cash the driver has taken in hand today — the full fare, because that's
|
||||
// the physical money in their pocket, not their share of it. This is the
|
||||
// figure they'll be reconciled against, and the platform's cut of it is
|
||||
// owed back.
|
||||
const cashCollected = sumFares(
|
||||
recent.filter((r) => r.payment_status === "cash_collected"),
|
||||
);
|
||||
|
||||
// Fares that were never collected. Surfaced rather than hidden so an
|
||||
// unpaid trip is visible to the driver on the day it happened.
|
||||
const cashOwed = sumFares(recent.filter((r) => r.payment_status === "cash"));
|
||||
|
||||
// A driver deciding whether to take a ride cares what they'll be paid, not
|
||||
// what the rider is charged. The split isn't stored until completion, so
|
||||
// it's computed here from the same helper that stamps it later — the two
|
||||
// can't disagree, and the driver is never shown a number they won't get.
|
||||
const withPayout = <T extends { fare_price: number }>(row: T) => ({
|
||||
...row,
|
||||
payout_cents: splitFare(Number(row.fare_price)).driverPayoutCents,
|
||||
});
|
||||
|
||||
return Response.json({
|
||||
data: {
|
||||
// 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) {
|
||||
console.error("[DRIVER_RIDES]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
type OpenRequestRow = {
|
||||
ride_id: number;
|
||||
origin_address: string;
|
||||
destination_address: string;
|
||||
origin_latitude: number;
|
||||
origin_longitude: number;
|
||||
destination_latitude: number;
|
||||
destination_longitude: number;
|
||||
ride_time: number;
|
||||
fare_price: number;
|
||||
service: 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;
|
||||
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;
|
||||
created_at: string;
|
||||
arrived_at: string | null;
|
||||
rider_name: string | null;
|
||||
rider_rating: number | null;
|
||||
};
|
||||
|
||||
type RecentRow = {
|
||||
ride_id: number;
|
||||
fare_price: number;
|
||||
payout_cents: number;
|
||||
fee_cents: number;
|
||||
service: string;
|
||||
payment_status: string;
|
||||
completed_at: string;
|
||||
};
|
||||
|
||||
type PendingRatingRow = {
|
||||
ride_id: number;
|
||||
rider_name: string | null;
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import { sql } from "@/lib/db";
|
||||
import {
|
||||
isDocumentType,
|
||||
OcrUnavailableError,
|
||||
parseDocumentText,
|
||||
recogniseDocument,
|
||||
} from "@/lib/document-ocr";
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
import {
|
||||
MAX_UPLOAD_BYTES,
|
||||
pruneOrphanUploads,
|
||||
sniffImageType,
|
||||
storeUpload,
|
||||
} from "@/lib/uploads";
|
||||
|
||||
// POST — a driver photographs one of their documents; we keep the scan and
|
||||
// read what we can off it to prefill the onboarding form.
|
||||
//
|
||||
// The scan is stored whether or not OCR succeeds: the reviewer wants to see the
|
||||
// actual licence next to the numbers the driver submitted, and that value does
|
||||
// not depend on Vision having had a good day. When OCR fails the route still
|
||||
// answers 200 with an empty field set and a code the client uses to say "type
|
||||
// these in yourself" — an unreadable photo is a normal outcome, not an error.
|
||||
|
||||
/**
|
||||
* Scans are the most expensive call in the app (a paid Vision request plus a
|
||||
* disk write), so cap how fast one account can make them. In-process and
|
||||
* therefore per-server — enough to stop a stuck retry loop or a bored driver
|
||||
* burning the Vision quota, not a defence against a distributed attacker.
|
||||
*/
|
||||
const SCAN_LIMIT = 20;
|
||||
const SCAN_WINDOW_MS = 60 * 60 * 1000;
|
||||
const recentScans = new Map<string, number[]>();
|
||||
|
||||
const overScanLimit = (userId: string): boolean => {
|
||||
const now = Date.now();
|
||||
const cutoff = now - SCAN_WINDOW_MS;
|
||||
const history = (recentScans.get(userId) ?? []).filter((at) => at > cutoff);
|
||||
|
||||
if (history.length >= SCAN_LIMIT) {
|
||||
recentScans.set(userId, history);
|
||||
return true;
|
||||
}
|
||||
|
||||
history.push(now);
|
||||
recentScans.set(userId, history);
|
||||
|
||||
// Without this the map grows one entry per driver forever. Anything whose
|
||||
// whole history has aged out is a driver who isn't scanning any more.
|
||||
if (recentScans.size > 500) {
|
||||
for (const [key, times] of recentScans) {
|
||||
if (times.every((at) => at <= cutoff)) recentScans.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Abandoned onboarding leaves identity documents on disk that nothing points
|
||||
* at. Sweeping them here rather than on a cron keeps the deployment to one
|
||||
* process; once an hour is often enough for files that get a day's grace.
|
||||
*/
|
||||
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
||||
let lastPruneAt = 0;
|
||||
|
||||
const pruneOrphansOccasionally = async (): Promise<void> => {
|
||||
if (Date.now() - lastPruneAt < PRUNE_INTERVAL_MS) return;
|
||||
lastPruneAt = Date.now();
|
||||
|
||||
try {
|
||||
const rows = await sql<{
|
||||
license_image_url: string | null;
|
||||
id_image_url: string | null;
|
||||
vehicle_reg_image_url: string | null;
|
||||
}>`
|
||||
SELECT license_image_url, id_image_url, vehicle_reg_image_url
|
||||
FROM drivers
|
||||
WHERE license_image_url IS NOT NULL
|
||||
OR id_image_url IS NOT NULL
|
||||
OR vehicle_reg_image_url IS NOT NULL
|
||||
`;
|
||||
|
||||
const referenced = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const name of Object.values(row)) {
|
||||
if (name) referenced.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
await pruneOrphanUploads(referenced, "document");
|
||||
} catch (error) {
|
||||
// A failed sweep must never fail the driver's scan.
|
||||
console.error("[DRIVER_SCAN_PRUNE]: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { doc_type: docType } = body;
|
||||
|
||||
if (!isDocumentType(docType)) {
|
||||
return Response.json(
|
||||
{ error: "doc_type must be license, id or vehicle_reg." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Same gate as onboarding itself: only a driver-role account has any
|
||||
// business uploading driver documents.
|
||||
const users = await sql<{ role: string | null }>`
|
||||
SELECT role FROM users WHERE id = ${auth.userId}
|
||||
`;
|
||||
if (users[0]?.role !== "driver") {
|
||||
return Response.json(
|
||||
{ error: "Only driver accounts can scan documents." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (overScanLimit(auth.userId)) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Too many scans. Wait a few minutes and try again.",
|
||||
code: "SCAN_RATE_LIMIT",
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
const raw = body.image_base64;
|
||||
if (typeof raw !== "string" || raw.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "image_base64 is required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Some clients send a full data URI. Take the payload either way.
|
||||
const encoded = raw.includes(",") ? raw.slice(raw.indexOf(",") + 1) : raw;
|
||||
|
||||
// Base64 inflates by 4/3, so reject on the encoded length before
|
||||
// allocating — otherwise an oversized upload is buffered just to be
|
||||
// refused.
|
||||
if (encoded.length > MAX_UPLOAD_BYTES * 1.4) {
|
||||
return Response.json(
|
||||
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const image = Buffer.from(encoded, "base64");
|
||||
|
||||
if (image.length > MAX_UPLOAD_BYTES) {
|
||||
return Response.json(
|
||||
{ error: "That image is too large.", code: "IMAGE_TOO_LARGE" },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
// The magic bytes decide the type, not whatever the client claimed, so a
|
||||
// non-image can't be parked on the disk under a .jpg name.
|
||||
const mimeType = sniffImageType(image);
|
||||
if (!mimeType) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Upload a JPEG, PNG or WebP photo.",
|
||||
code: "UNSUPPORTED_IMAGE",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const document = await storeUpload(image, mimeType, "document");
|
||||
|
||||
void pruneOrphansOccasionally();
|
||||
|
||||
let fields = {};
|
||||
let ocrFailed = false;
|
||||
|
||||
try {
|
||||
const text = await recogniseDocument(image);
|
||||
fields = parseDocumentText(text, docType);
|
||||
} catch (error) {
|
||||
if (!(error instanceof OcrUnavailableError)) throw error;
|
||||
// Logged, not surfaced: the message can name the API key's failure mode
|
||||
// and the driver can do nothing with it but type the fields manually.
|
||||
console.error("[DRIVER_SCAN_OCR]: ", error.message);
|
||||
ocrFailed = true;
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
data: {
|
||||
doc_type: docType,
|
||||
/** Opaque stored name; submit it with the profile to attach the scan. */
|
||||
document,
|
||||
fields,
|
||||
...(ocrFailed ? { code: "OCR_UNAVAILABLE" } : {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[DRIVER_SCAN_POST]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+364
-28
@@ -1,46 +1,382 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
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.
|
||||
//
|
||||
// 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;
|
||||
|
||||
const rideId = Number(id);
|
||||
if (!Number.isInteger(rideId)) {
|
||||
return Response.json({ error: "Invalid ride id." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sql`
|
||||
const ride = await sql`
|
||||
SELECT status FROM rides WHERE ride_id = ${rideId} AND user_id = ${auth.userId}
|
||||
`;
|
||||
if (!ride[0]) {
|
||||
return Response.json({ error: "Ride not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
// 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") {
|
||||
await broadcastRequest(rideId);
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
rides.ride_id,
|
||||
rides.origin_address,
|
||||
rides.destination_address,
|
||||
rides.origin_latitude,
|
||||
rides.origin_longitude,
|
||||
rides.destination_latitude,
|
||||
rides.destination_longitude,
|
||||
rides.ride_time,
|
||||
rides.fare_price,
|
||||
rides.payment_status,
|
||||
rides.created_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.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(
|
||||
'driver_id', drivers.id,
|
||||
'first_name', drivers.first_name,
|
||||
'last_name', drivers.last_name,
|
||||
'profile_image_url', drivers.profile_image_url,
|
||||
'car_image_url', drivers.car_image_url,
|
||||
'car_seats', drivers.car_seats,
|
||||
'rating', drivers.rating
|
||||
'id', d.id,
|
||||
'first_name', d.first_name,
|
||||
'last_name', d.last_name,
|
||||
'car_seats', d.car_seats,
|
||||
'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,
|
||||
'longitude', d.longitude
|
||||
) AS driver
|
||||
FROM
|
||||
rides
|
||||
INNER JOIN
|
||||
drivers ON rides.driver_id = drivers.id
|
||||
WHERE
|
||||
rides.user_id = ${auth.userId}
|
||||
ORDER BY
|
||||
rides.created_at DESC;
|
||||
FROM rides r
|
||||
LEFT JOIN drivers d ON d.id = r.driver_id
|
||||
WHERE r.ride_id = ${rideId}
|
||||
`;
|
||||
|
||||
return Response.json({ data: response });
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH — ride lifecycle transitions.
|
||||
// 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;
|
||||
reason?: string;
|
||||
pickup_code?: string;
|
||||
cash_collected?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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 riderCancel = await sql<{ status: string }>`
|
||||
UPDATE rides
|
||||
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 = ANY(${RIDER_CANCELLABLE_ARRAY}::text[])
|
||||
RETURNING status
|
||||
`;
|
||||
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 } });
|
||||
}
|
||||
|
||||
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 === "arrived" || next === "en_route" || next === "completed") {
|
||||
const result = await requireDriverProfile(request);
|
||||
if ("error" in result) return result.error;
|
||||
|
||||
const { driverId } = result;
|
||||
|
||||
// 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,
|
||||
payment_status: rows[0].payment_status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { sql } from "@/lib/db";
|
||||
import { requireRideParticipant, rideIsActive } from "@/lib/ride-participants";
|
||||
|
||||
// In-app WebRTC audio call signaling, carried over the same DB-backed polling
|
||||
// pattern as chat (no WebSocket). Non-trickle ICE: each side gathers all
|
||||
// candidates locally and bundles them into a single SDP offer/answer stored as
|
||||
// text, so the whole handshake is a few polled round-trips.
|
||||
//
|
||||
// POST { sdp_offer } -> caller starts a call (status=ringing)
|
||||
// GET -> poll: callee reads the offer, both read
|
||||
// the answer + status; lazily sweeps stale
|
||||
// ringing calls to 'missed'.
|
||||
// PATCH { action, sdp_answer? } -> answer / decline / end
|
||||
|
||||
// A ringing call older than this with no answer is treated as missed. Swept
|
||||
// lazily inside GET, the way the broadcast advances on the ride-status poll.
|
||||
const RINGING_TTL_SECONDS = 30;
|
||||
|
||||
type CallRow = {
|
||||
id: number;
|
||||
ride_id: number;
|
||||
caller_type: "rider" | "driver";
|
||||
status: "ringing" | "answered" | "ended" | "declined" | "missed";
|
||||
sdp_offer: string | null;
|
||||
sdp_answer: string | null;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// POST — initiate a call. Rejects if the ride isn't active or a call is already
|
||||
// in flight for it, so two calls can't stack on one ride.
|
||||
export async function POST(req: Request, { id }: { id: string }) {
|
||||
const rideId = Number(id);
|
||||
if (!Number.isInteger(rideId)) {
|
||||
return Response.json({ error: "Invalid ride id." }, { status: 400 });
|
||||
}
|
||||
|
||||
const participant = await requireRideParticipant(req, rideId);
|
||||
if ("error" in participant) return participant.error;
|
||||
|
||||
let body: { sdp_offer?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const sdpOffer = body.sdp_offer;
|
||||
if (!sdpOffer || typeof sdpOffer !== "string") {
|
||||
return Response.json({ error: "Missing sdp_offer." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await rideIsActive(rideId))) {
|
||||
return Response.json(
|
||||
{ error: "This ride is no longer active." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Snapshot both parties onto the call row so authorization is one
|
||||
// equality check on poll and the call survives a driver reassignment.
|
||||
const ride = await sql<{ user_id: string; driver_id: number }>`
|
||||
SELECT user_id, driver_id FROM rides
|
||||
WHERE ride_id = ${rideId} AND driver_id IS NOT NULL
|
||||
`;
|
||||
if (!ride[0]) {
|
||||
return Response.json(
|
||||
{ error: "This ride has no driver assigned." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Only one non-terminal call per ride at a time.
|
||||
const inFlight = await sql<{ n: number }>`
|
||||
SELECT COUNT(*)::int AS n FROM calls
|
||||
WHERE ride_id = ${rideId} AND status IN ('ringing','answered')
|
||||
`;
|
||||
if ((inFlight[0]?.n ?? 0) > 0) {
|
||||
return Response.json(
|
||||
{ error: "A call is already in progress for this ride." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const inserted = await sql<{ id: number }>`
|
||||
INSERT INTO calls (ride_id, user_id, driver_id, caller_type, status, sdp_offer)
|
||||
VALUES (
|
||||
${rideId},
|
||||
${ride[0].user_id},
|
||||
${ride[0].driver_id},
|
||||
${participant.role},
|
||||
'ringing',
|
||||
${sdpOffer}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
return Response.json({ data: { callId: inserted[0].id } }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("[POST_CALL]: ", error);
|
||||
return Response.json({ error: "Internal Server Error." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET — poll the call for this ride. Returns the latest non-terminal call (or
|
||||
// the most recent terminal one so the caller sees ended/declined/missed), with
|
||||
// `is_caller` so each side knows whether it placed the call.
|
||||
export async function GET(req: Request, { id }: { id: string }) {
|
||||
const rideId = Number(id);
|
||||
if (!Number.isInteger(rideId)) {
|
||||
return Response.json({ error: "Invalid ride id." }, { status: 400 });
|
||||
}
|
||||
|
||||
const participant = await requireRideParticipant(req, rideId);
|
||||
if ("error" in participant) return participant.error;
|
||||
|
||||
try {
|
||||
// Lazy missed-call sweep: a ringing call nobody answered in time is
|
||||
// marked missed so the caller's screen can stop ringing.
|
||||
await sql`
|
||||
UPDATE calls
|
||||
SET status = 'missed', ended_at = CURRENT_TIMESTAMP
|
||||
WHERE ride_id = ${rideId}
|
||||
AND status = 'ringing'
|
||||
AND created_at < CURRENT_TIMESTAMP - make_interval(secs => ${RINGING_TTL_SECONDS})
|
||||
`;
|
||||
|
||||
const rows = await sql<CallRow>`
|
||||
SELECT id, ride_id, caller_type, status, sdp_offer, sdp_answer,
|
||||
started_at, ended_at, created_at
|
||||
FROM calls
|
||||
WHERE ride_id = ${rideId}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const call = rows[0] ?? null;
|
||||
return Response.json({
|
||||
data: call
|
||||
? { ...call, is_caller: call.caller_type === participant.role }
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[GET_CALL]: ", error);
|
||||
return Response.json({ error: "Internal Server Error." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH — answer (callee only), decline (callee only), or end (either).
|
||||
export async function PATCH(req: Request, { id }: { id: string }) {
|
||||
const rideId = Number(id);
|
||||
if (!Number.isInteger(rideId)) {
|
||||
return Response.json({ error: "Invalid ride id." }, { status: 400 });
|
||||
}
|
||||
|
||||
const participant = await requireRideParticipant(req, rideId);
|
||||
if ("error" in participant) return participant.error;
|
||||
|
||||
let body: { action?: string; sdp_answer?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const action = body.action;
|
||||
if (action !== "answer" && action !== "decline" && action !== "end") {
|
||||
return Response.json(
|
||||
{ error: "action must be 'answer', 'decline', or 'end'." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Answer/decline are the callee's moves; end is either party's.
|
||||
const isCaller = (callerType: string) => callerType === participant.role;
|
||||
const rows = await sql<{ caller_type: string; status: string }>`
|
||||
SELECT caller_type, status FROM calls
|
||||
WHERE ride_id = ${rideId} AND status IN ('ringing','answered')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`;
|
||||
const call = rows[0];
|
||||
if (!call) {
|
||||
return Response.json(
|
||||
{ error: "No active call for this ride." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "answer") {
|
||||
if (isCaller(call.caller_type)) {
|
||||
return Response.json(
|
||||
{ error: "Caller cannot answer their own call." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (call.status !== "ringing") {
|
||||
return Response.json(
|
||||
{ error: "Call is no longer ringing." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
const sdpAnswer = body.sdp_answer;
|
||||
if (!sdpAnswer || typeof sdpAnswer !== "string") {
|
||||
return Response.json({ error: "Missing sdp_answer." }, { status: 400 });
|
||||
}
|
||||
await sql`
|
||||
UPDATE calls
|
||||
SET status = 'answered', sdp_answer = ${sdpAnswer}, started_at = CURRENT_TIMESTAMP
|
||||
WHERE ride_id = ${rideId} AND status = 'ringing'
|
||||
`;
|
||||
return Response.json({ data: { action: "answered" } });
|
||||
}
|
||||
|
||||
if (action === "decline") {
|
||||
if (isCaller(call.caller_type)) {
|
||||
return Response.json(
|
||||
{ error: "Caller cannot decline their own call." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
await sql`
|
||||
UPDATE calls
|
||||
SET status = 'declined', ended_at = CURRENT_TIMESTAMP
|
||||
WHERE ride_id = ${rideId} AND status = 'ringing'
|
||||
`;
|
||||
return Response.json({ data: { action: "declined" } });
|
||||
}
|
||||
|
||||
// end — either party, while ringing or answered.
|
||||
await sql`
|
||||
UPDATE calls
|
||||
SET status = 'ended', ended_at = CURRENT_TIMESTAMP
|
||||
WHERE ride_id = ${rideId} AND status IN ('ringing','answered')
|
||||
`;
|
||||
return Response.json({ data: { action: "ended" } });
|
||||
} catch (error) {
|
||||
console.error("[PATCH_CALL]: ", error);
|
||||
return Response.json({ error: "Internal Server Error." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { sql } from "@/lib/db";
|
||||
import { requireRideParticipant, rideIsActive } from "@/lib/ride-participants";
|
||||
|
||||
// In-app chat for a ride. Both the rider and the assigned driver can read and
|
||||
// post, but only while the ride is active (accepted / en_route); a terminal
|
||||
// ride is read-only so the conversation is frozen once the trip ends.
|
||||
|
||||
type MessageRow = {
|
||||
id: number;
|
||||
ride_id: number;
|
||||
sender_type: "rider" | "driver";
|
||||
sender_id: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
sender_name: string;
|
||||
sender_avatar: string | null;
|
||||
};
|
||||
|
||||
// GET — messages for the ride. `?since=<id>` returns only rows with id > since
|
||||
// (the polling cursor), oldest-first so the client can append directly. With
|
||||
// no cursor the full history is returned for the initial load.
|
||||
export async function GET(req: Request, { id }: { id: string }) {
|
||||
const rideId = Number(id);
|
||||
if (!Number.isInteger(rideId)) {
|
||||
return Response.json({ error: "Invalid ride id." }, { status: 400 });
|
||||
}
|
||||
|
||||
const participant = await requireRideParticipant(req, rideId);
|
||||
if ("error" in participant) return participant.error;
|
||||
|
||||
const sinceParam = new URL(req.url).searchParams.get("since");
|
||||
const since = Number(sinceParam);
|
||||
const hasCursor = Number.isInteger(since) && since > 0;
|
||||
|
||||
try {
|
||||
// The optional `since` cursor can't be a nested sql fragment (sql executes
|
||||
// immediately), so branch into two queries that each take no extra params.
|
||||
const rows = hasCursor
|
||||
? await sql<MessageRow>`
|
||||
SELECT
|
||||
m.id,
|
||||
m.ride_id,
|
||||
m.sender_type,
|
||||
COALESCE(m.sender_user_id::text, m.sender_driver_id::text) AS sender_id,
|
||||
m.body,
|
||||
m.created_at,
|
||||
COALESCE(u.name, CONCAT_WS(' ', d.first_name, d.last_name)) AS sender_name,
|
||||
d.profile_image_url AS sender_avatar
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON u.id = m.sender_user_id
|
||||
LEFT JOIN drivers d ON d.id = m.sender_driver_id
|
||||
WHERE m.ride_id = ${rideId} AND m.id > ${since}
|
||||
ORDER BY m.id ASC
|
||||
`
|
||||
: await sql<MessageRow>`
|
||||
SELECT
|
||||
m.id,
|
||||
m.ride_id,
|
||||
m.sender_type,
|
||||
COALESCE(m.sender_user_id::text, m.sender_driver_id::text) AS sender_id,
|
||||
m.body,
|
||||
m.created_at,
|
||||
COALESCE(u.name, CONCAT_WS(' ', d.first_name, d.last_name)) AS sender_name,
|
||||
d.profile_image_url AS sender_avatar
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON u.id = m.sender_user_id
|
||||
LEFT JOIN drivers d ON d.id = m.sender_driver_id
|
||||
WHERE m.ride_id = ${rideId}
|
||||
ORDER BY m.id ASC
|
||||
`;
|
||||
|
||||
return Response.json({ data: rows });
|
||||
} catch (error) {
|
||||
console.error("[GET_MESSAGES]: ", error);
|
||||
return Response.json({ error: "Internal Server Error." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST — send a message. Rejected (409) if the ride is no longer active, so a
|
||||
// completed/cancelled trip can't receive new messages.
|
||||
export async function POST(req: Request, { id }: { id: string }) {
|
||||
const rideId = Number(id);
|
||||
if (!Number.isInteger(rideId)) {
|
||||
return Response.json({ error: "Invalid ride id." }, { status: 400 });
|
||||
}
|
||||
|
||||
const participant = await requireRideParticipant(req, rideId);
|
||||
if ("error" in participant) return participant.error;
|
||||
|
||||
let body: { body?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
|
||||
}
|
||||
|
||||
const text = (body.body ?? "").trim();
|
||||
if (!text) {
|
||||
return Response.json({ error: "Message body is empty." }, { status: 400 });
|
||||
}
|
||||
if (text.length > 4000) {
|
||||
return Response.json({ error: "Message is too long." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await rideIsActive(rideId))) {
|
||||
return Response.json(
|
||||
{ error: "This ride is no longer active." },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const inserted = await sql<MessageRow>`
|
||||
INSERT INTO messages (ride_id, sender_type, sender_user_id, sender_driver_id, body)
|
||||
VALUES (
|
||||
${rideId},
|
||||
${participant.role},
|
||||
${participant.role === "rider" ? participant.userId : null},
|
||||
${participant.role === "driver" ? participant.driverId : null},
|
||||
${text}
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
ride_id,
|
||||
sender_type,
|
||||
COALESCE(sender_user_id::text, sender_driver_id::text) AS sender_id,
|
||||
body,
|
||||
created_at
|
||||
`;
|
||||
|
||||
// Join the sender's name/avatar for the returned row so the client can
|
||||
// render the optimistic bubble identically to polled ones.
|
||||
const message = inserted[0];
|
||||
if (participant.role === "driver") {
|
||||
const driver = await sql<{ name: string; avatar: string | null }>`
|
||||
SELECT CONCAT_WS(' ', first_name, last_name) AS name, profile_image_url AS avatar
|
||||
FROM drivers WHERE id = ${participant.driverId}
|
||||
`;
|
||||
message.sender_name = driver[0]?.name ?? "";
|
||||
message.sender_avatar = driver[0]?.avatar ?? null;
|
||||
} else {
|
||||
const rider = await sql<{ name: string }>`
|
||||
SELECT name FROM users WHERE id = ${participant.userId}
|
||||
`;
|
||||
message.sender_name = rider[0]?.name ?? "";
|
||||
message.sender_avatar = null;
|
||||
}
|
||||
|
||||
return Response.json({ data: message }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("[POST_MESSAGE]: ", error);
|
||||
return Response.json({ error: "Internal Server Error." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
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 — 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;
|
||||
@@ -16,21 +35,18 @@ export async function POST(request: Request) {
|
||||
destination_longitude,
|
||||
ride_time,
|
||||
fare_price,
|
||||
payment_status,
|
||||
driver_id,
|
||||
service,
|
||||
} = body;
|
||||
|
||||
if (
|
||||
!origin_address ||
|
||||
!destination_address ||
|
||||
!origin_latitude ||
|
||||
!origin_longitude ||
|
||||
!destination_latitude ||
|
||||
!destination_longitude ||
|
||||
!ride_time ||
|
||||
!fare_price ||
|
||||
!payment_status ||
|
||||
!driver_id
|
||||
isMissing(origin_address) ||
|
||||
isMissing(destination_address) ||
|
||||
isMissing(origin_latitude) ||
|
||||
isMissing(origin_longitude) ||
|
||||
isMissing(destination_latitude) ||
|
||||
isMissing(destination_longitude) ||
|
||||
isMissing(ride_time) ||
|
||||
isMissing(fare_price)
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "Missing required fields" },
|
||||
@@ -38,6 +54,33 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const rideService = isServiceId(service) ? service : DEFAULT_SERVICE;
|
||||
const fareCents = Math.round(Number(fare_price));
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
const response = await sql`
|
||||
INSERT INTO rides (
|
||||
origin_address,
|
||||
@@ -50,7 +93,9 @@ export async function POST(request: Request) {
|
||||
fare_price,
|
||||
payment_status,
|
||||
driver_id,
|
||||
user_id
|
||||
user_id,
|
||||
status,
|
||||
service
|
||||
) VALUES (
|
||||
${origin_address},
|
||||
${destination_address},
|
||||
@@ -59,14 +104,22 @@ export async function POST(request: Request) {
|
||||
${destination_latitude},
|
||||
${destination_longitude},
|
||||
${ride_time},
|
||||
${fare_price},
|
||||
${payment_status},
|
||||
${driver_id},
|
||||
${auth.userId}
|
||||
${fareCents},
|
||||
'pending',
|
||||
NULL,
|
||||
${auth.userId},
|
||||
'requested',
|
||||
${rideService}
|
||||
)
|
||||
RETURNING *;
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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
|
||||
// the "Recent Rides" / "All rides" lists; the active/in-progress ride is
|
||||
// tracked separately on the book-ride status screen.
|
||||
export async function GET(req: Request) {
|
||||
const auth = requireAuth(req);
|
||||
if ("error" in auth) return auth.error;
|
||||
|
||||
try {
|
||||
const response = 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.payment_status,
|
||||
r.status,
|
||||
r.service,
|
||||
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,
|
||||
'last_name', d.last_name,
|
||||
'car_seats', d.car_seats,
|
||||
'profile_image_url', d.profile_image_url,
|
||||
'car_image_url', d.car_image_url,
|
||||
'rating', d.rating,
|
||||
'service', d.service,
|
||||
'car_model', d.car_model
|
||||
) AS driver
|
||||
FROM rides r
|
||||
LEFT JOIN drivers d ON d.id = r.driver_id
|
||||
WHERE r.user_id = ${auth.userId}
|
||||
AND r.status = ANY(${TERMINAL_STATUS_ARRAY}::text[])
|
||||
ORDER BY r.created_at DESC
|
||||
`;
|
||||
|
||||
return Response.json({ data: response });
|
||||
} catch (error) {
|
||||
console.error("[GET_RIDE_LIST]: ", error);
|
||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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,13 +41,29 @@ 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) {
|
||||
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] });
|
||||
} catch (error) {
|
||||
console.log("[PATCH_USER]: ", error);
|
||||
|
||||
+327
-31
@@ -1,23 +1,175 @@
|
||||
import { Link, useRouter } from "expo-router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Alert, Image, ScrollView, Text, View } from "react-native";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { InputField } from "@/components/input-field";
|
||||
import { OAuth } from "@/components/oauth";
|
||||
import { OtpField } from "@/components/otp-field";
|
||||
import { icons, images } from "@/constants";
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { getRememberedEmail, rememberEmail, useSession } from "@/lib/session";
|
||||
|
||||
const SignIn = () => {
|
||||
const router = useRouter();
|
||||
const { isLoaded, setSession } = useSession();
|
||||
const { setSession } = useSession();
|
||||
const t = useT();
|
||||
const [form, setForm] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// On by default: a rider signing in on their own phone shouldn't have to
|
||||
// opt into staying signed in. Unchecking it shortens the session to 12h and
|
||||
// stops the address being prefilled next time.
|
||||
const [remember, setRemember] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
getRememberedEmail()
|
||||
.then((email) => {
|
||||
if (!email || cancelled) return;
|
||||
|
||||
// SecureStore can resolve after the user has started typing, so only
|
||||
// fill a field that's still untouched.
|
||||
setForm((prevForm) =>
|
||||
prevForm.email ? prevForm : { ...prevForm, email },
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Nothing stored, or the keychain is unavailable: start blank.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Forgot-password flow: "request" collects the email, "reset" collects the
|
||||
// emailed code and a new password.
|
||||
const [reset, setReset] = useState({
|
||||
state: "closed" as "closed" | "request" | "reset",
|
||||
email: "",
|
||||
code: "",
|
||||
password: "",
|
||||
devCode: "",
|
||||
error: "",
|
||||
busy: false,
|
||||
});
|
||||
|
||||
const openReset = () =>
|
||||
setReset({
|
||||
state: "request",
|
||||
email: form.email,
|
||||
code: "",
|
||||
password: "",
|
||||
devCode: "",
|
||||
error: "",
|
||||
busy: false,
|
||||
});
|
||||
|
||||
const closeReset = () =>
|
||||
setReset((prev) => ({ ...prev, state: "closed" }));
|
||||
|
||||
const onRequestReset = async () => {
|
||||
if (!reset.email.trim()) {
|
||||
setReset((prev) => ({ ...prev, error: t("auth.signIn.errEmail") }));
|
||||
return;
|
||||
}
|
||||
|
||||
setReset((prev) => ({ ...prev, busy: true, error: "" }));
|
||||
|
||||
try {
|
||||
const response = await fetchAPI("/(api)/auth/forgot-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: reset.email.trim() }),
|
||||
});
|
||||
|
||||
setReset((prev) => ({
|
||||
...prev,
|
||||
state: "reset",
|
||||
busy: false,
|
||||
devCode:
|
||||
(response as { data?: { devCode?: string } })?.data?.devCode ?? "",
|
||||
}));
|
||||
} catch {
|
||||
// The endpoint hides whether the email exists, so move on regardless.
|
||||
setReset((prev) => ({ ...prev, state: "reset", busy: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitReset = async () => {
|
||||
if (!/^\d{6}$/.test(reset.code)) {
|
||||
setReset((prev) => ({ ...prev, error: t("auth.signIn.errCode") }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (reset.password.length < 8) {
|
||||
setReset((prev) => ({
|
||||
...prev,
|
||||
error: t("auth.signIn.errPassword"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setReset((prev) => ({ ...prev, busy: true, error: "" }));
|
||||
|
||||
try {
|
||||
const response = await fetchAPI("/(api)/auth/reset-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: reset.email.trim(),
|
||||
code: reset.code,
|
||||
password: reset.password,
|
||||
}),
|
||||
});
|
||||
|
||||
await setSession(response.data);
|
||||
await rememberEmail(remember ? reset.email.trim() : null);
|
||||
setReset((prev) => ({ ...prev, state: "closed", busy: false }));
|
||||
router.replace("/");
|
||||
} catch (err: any) {
|
||||
setReset((prev) => ({
|
||||
...prev,
|
||||
busy: false,
|
||||
error:
|
||||
err instanceof ApiError && err.status < 500
|
||||
? err.message
|
||||
: t("auth.signIn.errReset"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const onSignInPress = useCallback(async () => {
|
||||
if (busy) return;
|
||||
|
||||
// Catch the blank-field case here: the server answers 400 for it, which
|
||||
// otherwise surfaces as a generic "could not sign in".
|
||||
if (!form.email.trim() || !form.password) {
|
||||
Alert.alert(
|
||||
t("auth.signIn.alertMissingTitle"),
|
||||
t("auth.signIn.alertMissingBody"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const response = await fetchAPI("/(api)/auth/login", {
|
||||
method: "POST",
|
||||
@@ -25,47 +177,61 @@ const SignIn = () => {
|
||||
body: JSON.stringify({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
remember,
|
||||
}),
|
||||
});
|
||||
|
||||
await setSession(response.data);
|
||||
await rememberEmail(remember ? form.email.trim() : null);
|
||||
router.replace("/");
|
||||
} catch (err: any) {
|
||||
const status = String(err?.message ?? "");
|
||||
const message = status.includes("403")
|
||||
? "Please verify your email first."
|
||||
: status.includes("401")
|
||||
? "Invalid email or password."
|
||||
: "Could not sign in. Please try again.";
|
||||
const message =
|
||||
err instanceof ApiError && err.status < 500
|
||||
? err.message
|
||||
: t("auth.signIn.alertErrorFallback");
|
||||
|
||||
Alert.alert("Error", message);
|
||||
setForm((prevForm) => ({
|
||||
...prevForm,
|
||||
password: "",
|
||||
}));
|
||||
Alert.alert(t("auth.signIn.alertErrorTitle"), message);
|
||||
|
||||
// Only a rejected password is worth retyping. Clearing it after a
|
||||
// network blip or a 403 just makes the next attempt fail differently.
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setForm((prevForm) => ({ ...prevForm, password: "" }));
|
||||
}
|
||||
}, [isLoaded, form.email, form.password, setSession, router]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, form.email, form.password, remember, setSession, router, t]);
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
<View className="flex-1 bg-white">
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1 bg-white dark:bg-neutral-950"
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 40 : 0}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1 bg-white dark:bg-neutral-950"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View className="flex-1 bg-white dark:bg-neutral-950">
|
||||
<View className="relative w-full h-[250px]">
|
||||
<Image
|
||||
source={images.signUpCar}
|
||||
alt="Car"
|
||||
alt={t("auth.signIn.carAlt")}
|
||||
className="z-0 w-full h-[250px]"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
||||
<Text className="text-2xl text-black font-JakartaSemiBold absolute bottom-5 left-5">
|
||||
Welcome 👋
|
||||
<Text className="text-2xl text-black dark:text-white font-JakartaSemiBold absolute bottom-5 left-5">
|
||||
{t("auth.signIn.welcome")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="p-5">
|
||||
<InputField
|
||||
label="Email"
|
||||
placeholder="karim@email.com"
|
||||
label={t("auth.signIn.email")}
|
||||
placeholder={t("auth.signIn.emailPlaceholder")}
|
||||
icon={icons.email}
|
||||
value={form.email}
|
||||
onChangeText={(value) =>
|
||||
@@ -75,11 +241,13 @@ const SignIn = () => {
|
||||
}))
|
||||
}
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
textContentType="username"
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
label={t("auth.signIn.password")}
|
||||
placeholder={t("auth.signIn.passwordPlaceholder")}
|
||||
icon={icons.lock}
|
||||
secureTextEntry
|
||||
value={form.password}
|
||||
@@ -89,26 +257,154 @@ const SignIn = () => {
|
||||
password: value,
|
||||
}))
|
||||
}
|
||||
autoComplete="current-password"
|
||||
textContentType="password"
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => setRemember((current) => !current)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: remember }}
|
||||
className="flex-row items-center mt-4"
|
||||
>
|
||||
<View
|
||||
className={`h-6 w-6 rounded-md items-center justify-center border-2 ${
|
||||
remember
|
||||
? "bg-primary-500 border-primary-500"
|
||||
: "bg-white dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700"
|
||||
}`}
|
||||
>
|
||||
{remember ? (
|
||||
<Text className="text-white text-xs font-JakartaBold">✓</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text className="ml-3 font-JakartaMedium text-[15px] text-black dark:text-white">
|
||||
{t("auth.signIn.keepSignedIn")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<CustomButton
|
||||
title="Sign In"
|
||||
title={busy ? t("auth.signIn.signingIn") : t("auth.signIn.signInBtn")}
|
||||
onPress={onSignInPress}
|
||||
disabled={busy}
|
||||
className="mt-6"
|
||||
/>
|
||||
|
||||
<OAuth title="Sign in with Google" />
|
||||
<TouchableOpacity onPress={openReset} className="mt-4">
|
||||
<Text className="text-primary-500 text-center font-JakartaMedium">
|
||||
{t("auth.signIn.forgotPassword")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<OAuth title={t("auth.signIn.signInGoogle")} />
|
||||
|
||||
<Link
|
||||
href="/sign-up"
|
||||
className="text-base text-center text-general-200 mt-10"
|
||||
className="text-base text-center text-general-200 dark:text-neutral-400 mt-10"
|
||||
>
|
||||
<Text>Don't have an account? </Text>
|
||||
<Text className="text-primary-500">Sign up</Text>
|
||||
<Text className="text-black dark:text-white">{t("auth.signIn.noAccount")}</Text>
|
||||
<Text className="text-primary-500">{t("auth.signIn.signUpLink")}</Text>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<ReactNativeModal
|
||||
isVisible={reset.state === "request"}
|
||||
onBackdropPress={closeReset}
|
||||
>
|
||||
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[280px]">
|
||||
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
|
||||
{t("auth.signIn.reset.title")}
|
||||
</Text>
|
||||
|
||||
<Text className="font-Jakarta mb-5 text-black dark:text-white">
|
||||
{t("auth.signIn.reset.requestBody")}
|
||||
</Text>
|
||||
|
||||
<InputField
|
||||
label={t("auth.signIn.email")}
|
||||
placeholder={t("auth.signIn.reset.emailPlaceholder")}
|
||||
icon={icons.email}
|
||||
value={reset.email}
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
textContentType="username"
|
||||
onChangeText={(email) =>
|
||||
setReset((prev) => ({ ...prev, email }))
|
||||
}
|
||||
/>
|
||||
|
||||
{reset.error ? (
|
||||
<Text className="text-rose-500 text-sm mt-1">{reset.error}</Text>
|
||||
) : null}
|
||||
|
||||
<CustomButton
|
||||
title={reset.busy ? t("auth.signIn.reset.sending") : t("auth.signIn.reset.sendCode")}
|
||||
onPress={onRequestReset}
|
||||
disabled={reset.busy}
|
||||
className="mt-5"
|
||||
/>
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
|
||||
<ReactNativeModal
|
||||
isVisible={reset.state === "reset"}
|
||||
onBackdropPress={closeReset}
|
||||
>
|
||||
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[300px]">
|
||||
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
|
||||
{t("auth.signIn.reset.newPassTitle")}
|
||||
</Text>
|
||||
|
||||
<Text className="font-Jakarta mb-5 text-black dark:text-white">
|
||||
{t("auth.signIn.reset.resetBody", { email: reset.email })}
|
||||
</Text>
|
||||
|
||||
{reset.devCode ? (
|
||||
<View className="bg-amber-50 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 rounded-xl p-3 mb-5">
|
||||
<Text className="text-sm text-amber-700 dark:text-amber-400 font-Jakarta">
|
||||
{t("auth.signIn.reset.devBanner")}
|
||||
<Text className="font-JakartaBold">{reset.devCode}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<OtpField
|
||||
value={reset.code}
|
||||
onChange={(code) =>
|
||||
setReset((prev) => ({ ...prev, code, error: "" }))
|
||||
}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label={t("auth.signIn.reset.newPassLabel")}
|
||||
icon={icons.lock}
|
||||
placeholder={t("auth.signIn.reset.newPasswordPlaceholder")}
|
||||
secureTextEntry
|
||||
value={reset.password}
|
||||
onChangeText={(password) =>
|
||||
setReset((prev) => ({ ...prev, password }))
|
||||
}
|
||||
autoComplete="new-password"
|
||||
textContentType="newPassword"
|
||||
/>
|
||||
|
||||
{reset.error ? (
|
||||
<Text className="text-rose-500 text-sm mt-1">{reset.error}</Text>
|
||||
) : null}
|
||||
|
||||
<CustomButton
|
||||
title={reset.busy ? t("auth.signIn.reset.resetting") : t("auth.signIn.reset.resetBtn")}
|
||||
onPress={onSubmitReset}
|
||||
disabled={reset.busy}
|
||||
className="mt-5 bg-emerald-500"
|
||||
/>
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+190
-67
@@ -1,18 +1,48 @@
|
||||
import { Link, router } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { Alert, Image, ScrollView, Text, View } from "react-native";
|
||||
import { useCallback, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { InputField } from "@/components/input-field";
|
||||
import { OAuth } from "@/components/oauth";
|
||||
import { OtpField } from "@/components/otp-field";
|
||||
import { icons, images } from "@/constants";
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const ROLES = [
|
||||
{
|
||||
value: "rider",
|
||||
titleKey: "auth.signUp.riderTitle",
|
||||
descKey: "auth.signUp.riderDesc",
|
||||
icon: "map" as const,
|
||||
},
|
||||
{
|
||||
value: "driver",
|
||||
titleKey: "auth.signUp.driverTitle",
|
||||
descKey: "auth.signUp.driverDesc",
|
||||
icon: "dollar" as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type Role = (typeof ROLES)[number]["value"];
|
||||
|
||||
const SignUp = () => {
|
||||
const { setSession } = useSession();
|
||||
const t = useT();
|
||||
|
||||
const [role, setRole] = useState<Role>("rider");
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
@@ -20,31 +50,35 @@ const SignUp = () => {
|
||||
password: "",
|
||||
});
|
||||
|
||||
// "verified" is a hand-off state: it hides the code modal so its onModalHide
|
||||
// can bring up the success modal, since two modals can't cross-fade.
|
||||
const [verification, setVerification] = useState({
|
||||
state: "default",
|
||||
state: "default" as "default" | "pending" | "verified" | "success",
|
||||
error: "",
|
||||
code: "",
|
||||
devCode: "",
|
||||
busy: false,
|
||||
});
|
||||
|
||||
const onSignUpPress = async () => {
|
||||
if (!form.name.trim() || !form.email.trim() || !form.password) {
|
||||
Alert.alert(
|
||||
"Missing information",
|
||||
"Please fill in your name, email and password.",
|
||||
t("auth.signUp.alertMissingTitle"),
|
||||
t("auth.signUp.alertMissingBody"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.phone.trim() && !/^[0-9\s\-()+.]+$/.test(form.phone)) {
|
||||
Alert.alert(
|
||||
"Invalid phone number",
|
||||
"Enter a valid Lebanese number, e.g. 70 123 456.",
|
||||
t("auth.signUp.alertPhoneTitle"),
|
||||
t("auth.signUp.alertPhoneBody"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchAPI("/(api)/auth/register", {
|
||||
const response = await fetchAPI("/(api)/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -52,13 +86,18 @@ const SignUp = () => {
|
||||
email: form.email,
|
||||
phone: form.phone.trim(),
|
||||
password: form.password,
|
||||
role,
|
||||
}),
|
||||
});
|
||||
|
||||
setVerification((prevVerification) => ({
|
||||
...prevVerification,
|
||||
setVerification({
|
||||
state: "pending",
|
||||
}));
|
||||
error: "",
|
||||
code: "",
|
||||
busy: false,
|
||||
devCode:
|
||||
(response as { data?: { devCode?: string } })?.data?.devCode ?? "",
|
||||
});
|
||||
|
||||
setForm((prevForm) => ({
|
||||
...prevForm,
|
||||
@@ -69,54 +108,130 @@ const SignUp = () => {
|
||||
...prevForm,
|
||||
password: "",
|
||||
}));
|
||||
Alert.alert("Error", err?.message ?? "Could not create your account.");
|
||||
Alert.alert(
|
||||
t("auth.signUp.alertErrorTitle"),
|
||||
err instanceof ApiError && err.status < 500
|
||||
? err.message
|
||||
: t("auth.signUp.alertErrorFallback"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPressVerify = async () => {
|
||||
const onPressVerify = useCallback(
|
||||
async (code: string) => {
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
setVerification((prevVerification) => ({
|
||||
...prevVerification,
|
||||
error: t("auth.signUp.errCode"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setVerification((prevVerification) =>
|
||||
// Guard the double submit that auto-verify + a button tap would cause.
|
||||
prevVerification.busy
|
||||
? prevVerification
|
||||
: { ...prevVerification, busy: true, error: "" },
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetchAPI("/(api)/auth/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: form.email, code: verification.code }),
|
||||
body: JSON.stringify({ email: form.email, code }),
|
||||
});
|
||||
|
||||
await setSession(response.data);
|
||||
setVerification((prevVerification) => ({
|
||||
...prevVerification,
|
||||
state: "success",
|
||||
state: "verified",
|
||||
busy: false,
|
||||
}));
|
||||
} catch (err: any) {
|
||||
// Stay on the code modal so the user can retry; only a real success
|
||||
// advances the flow.
|
||||
setVerification((prevVerification) => ({
|
||||
...prevVerification,
|
||||
error: err?.message?.includes("400")
|
||||
? "Invalid or expired verification code."
|
||||
: err?.message ?? "Verification failed.",
|
||||
state: "failed",
|
||||
code: "",
|
||||
busy: false,
|
||||
error:
|
||||
err instanceof ApiError && err.status < 500
|
||||
? err.message
|
||||
: t("auth.signUp.errVerify"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
},
|
||||
[form.email, setSession, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
<View className="flex-1 bg-white">
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1 bg-white dark:bg-neutral-950"
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 40 : 0}
|
||||
>
|
||||
<ScrollView
|
||||
className="flex-1 bg-white dark:bg-neutral-950"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View className="flex-1 bg-white dark:bg-neutral-950">
|
||||
<View className="relative w-full h-[250px]">
|
||||
<Image
|
||||
source={images.signUpCar}
|
||||
alt="Car"
|
||||
alt={t("auth.signUp.carAlt")}
|
||||
className="z-0 w-full h-[250px]"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
||||
<Text className="text-2xl text-black font-JakartaSemiBold absolute bottom-5 left-5">
|
||||
Create Your Account
|
||||
<Text className="text-2xl text-black dark:text-white font-JakartaSemiBold absolute bottom-5 left-5">
|
||||
{t("auth.signUp.createAccount")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="p-5">
|
||||
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
|
||||
{t("auth.signUp.howUse")}
|
||||
</Text>
|
||||
<View className="flex-row gap-3 mb-4">
|
||||
{ROLES.map((option) => {
|
||||
const selected = role === option.value;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => setRole(option.value)}
|
||||
activeOpacity={0.8}
|
||||
className={`flex-1 justify-center rounded-2xl border p-4 ${
|
||||
selected
|
||||
? "border-primary-500 bg-primary-500/10"
|
||||
: "border-neutral-100 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
source={option.value === "driver" ? icons.dollar : icons.map}
|
||||
alt={t(`auth.signUp.${option.value === "driver" ? "driverTitle" : "riderTitle"}`)}
|
||||
className="h-7 w-7 mb-2"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text
|
||||
className={`text-[15px] font-JakartaBold ${
|
||||
selected ? "text-primary-500" : "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{t(option.titleKey)}
|
||||
</Text>
|
||||
<Text className="text-xs text-neutral-400 dark:text-neutral-500 font-Jakarta mt-1">
|
||||
{t(option.descKey)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<InputField
|
||||
label="Name"
|
||||
placeholder="Karim Haddad"
|
||||
label={t("auth.signUp.name")}
|
||||
placeholder={t("auth.signUp.namePlaceholder")}
|
||||
icon={icons.person}
|
||||
value={form.name}
|
||||
onChangeText={(value) =>
|
||||
@@ -129,8 +244,8 @@ const SignUp = () => {
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Email"
|
||||
placeholder="karim@email.com"
|
||||
label={t("auth.signUp.email")}
|
||||
placeholder={t("auth.signUp.emailPlaceholder")}
|
||||
icon={icons.email}
|
||||
value={form.email}
|
||||
onChangeText={(value) =>
|
||||
@@ -143,8 +258,8 @@ const SignUp = () => {
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Phone (optional)"
|
||||
placeholder="70 123 456"
|
||||
label={t("auth.signUp.phoneOptional")}
|
||||
placeholder={t("auth.signUp.phonePlaceholder")}
|
||||
icon={icons.chat}
|
||||
value={form.phone}
|
||||
onChangeText={(value) =>
|
||||
@@ -157,8 +272,8 @@ const SignUp = () => {
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Password"
|
||||
placeholder="••••••••"
|
||||
label={t("auth.signUp.password")}
|
||||
placeholder={t("auth.signUp.passwordPlaceholder")}
|
||||
icon={icons.lock}
|
||||
secureTextEntry
|
||||
value={form.password}
|
||||
@@ -171,87 +286,94 @@ const SignUp = () => {
|
||||
/>
|
||||
|
||||
<CustomButton
|
||||
title="Sign Up"
|
||||
title={t("auth.signUp.signUpBtn")}
|
||||
onPress={onSignUpPress}
|
||||
className="mt-6"
|
||||
/>
|
||||
|
||||
<OAuth title="Sign up with Google" />
|
||||
<OAuth title={t("auth.signUp.signUpGoogle")} />
|
||||
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="text-base text-center text-general-200 mt-10"
|
||||
className="text-base text-center text-general-200 dark:text-neutral-400 mt-10"
|
||||
>
|
||||
<Text>Already have an account? </Text>
|
||||
<Text className="text-primary-500">Sign in</Text>
|
||||
<Text className="text-black dark:text-white">{t("auth.signUp.haveAccount")}</Text>
|
||||
<Text className="text-primary-500">{t("auth.signUp.signInLink")}</Text>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<ReactNativeModal
|
||||
onModalHide={() =>
|
||||
setVerification((prevVerification) => ({
|
||||
...prevVerification,
|
||||
state: "success",
|
||||
}))
|
||||
setVerification((prevVerification) =>
|
||||
prevVerification.state === "verified"
|
||||
? { ...prevVerification, state: "success" }
|
||||
: prevVerification,
|
||||
)
|
||||
}
|
||||
isVisible={verification.state === "pending"}
|
||||
>
|
||||
<View className="bg-white px-7 py-9 rounded-2xl min-h-[300px]">
|
||||
<Text className="text-2xl font-JakartaExtraBold mb-2">
|
||||
Verification
|
||||
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[300px]">
|
||||
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
|
||||
{t("auth.signUp.verify.title")}
|
||||
</Text>
|
||||
|
||||
<Text className="font-Jakarta mb-5">
|
||||
We've sent a verification code to {form.email}
|
||||
<Text className="font-Jakarta mb-5 text-black dark:text-white">
|
||||
{t("auth.signUp.verify.body", { email: form.email })}
|
||||
</Text>
|
||||
|
||||
<InputField
|
||||
label="Code"
|
||||
icon={icons.lock}
|
||||
placeholder="••••••"
|
||||
{verification.devCode ? (
|
||||
<View className="bg-amber-50 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 rounded-xl p-3 mb-5">
|
||||
<Text className="text-sm text-amber-700 dark:text-amber-400 font-Jakarta">
|
||||
{t("auth.signUp.verify.devBanner")}
|
||||
<Text className="font-JakartaBold">{verification.devCode}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<OtpField
|
||||
value={verification.code}
|
||||
maxLength={6}
|
||||
secureTextEntry
|
||||
keyboardType="numeric"
|
||||
onChangeText={(code) =>
|
||||
onChange={(code) =>
|
||||
setVerification((prevVerification) => ({
|
||||
...prevVerification,
|
||||
code,
|
||||
error: "",
|
||||
}))
|
||||
}
|
||||
onComplete={onPressVerify}
|
||||
/>
|
||||
|
||||
{verification.error && (
|
||||
{verification.error ? (
|
||||
<Text className="text-rose-500 text-sm mt-1">
|
||||
{verification.error}
|
||||
</Text>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<CustomButton
|
||||
title="Verify Email"
|
||||
onPress={onPressVerify}
|
||||
title={verification.busy ? t("auth.signUp.verify.verifying") : t("auth.signUp.verify.verifyBtn")}
|
||||
onPress={() => onPressVerify(verification.code)}
|
||||
disabled={verification.busy}
|
||||
className="mt-5 bg-emerald-500"
|
||||
/>
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
<ReactNativeModal isVisible={verification.state === "success"}>
|
||||
<View className="bg-white px-7 py-9 rounded-2xl min-h-[300px]">
|
||||
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[300px]">
|
||||
<Image
|
||||
source={images.check}
|
||||
alt="Check"
|
||||
alt={t("auth.signUp.verify.checkAlt")}
|
||||
className="w-[110px] h-[110px] mx-auto my-5"
|
||||
/>
|
||||
|
||||
<Text className="text-3xl font-JakartaBold text-center">
|
||||
Verified
|
||||
<Text className="text-3xl font-JakartaBold text-center text-black dark:text-white">
|
||||
{t("auth.signUp.verified.title")}
|
||||
</Text>
|
||||
|
||||
<Text className="text-base text-gray-400 font-Jakarta text-center mt-2">
|
||||
You've succesfully verified your account.
|
||||
<Text className="text-base text-gray-400 dark:text-neutral-500 font-Jakarta text-center mt-2">
|
||||
{t("auth.signUp.verified.body")}
|
||||
</Text>
|
||||
|
||||
<CustomButton
|
||||
title="Browse Home"
|
||||
title={t("common.browseHome")}
|
||||
onPress={() => router.push("/")}
|
||||
className="mt-5"
|
||||
/>
|
||||
@@ -259,6 +381,7 @@ const SignUp = () => {
|
||||
</ReactNativeModal>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+12
-8
@@ -6,25 +6,29 @@ import Swiper from "react-native-swiper";
|
||||
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { onboarding } from "@/constants";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
const Welcome = () => {
|
||||
const swiperRef = useRef<Swiper>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const isLastSlide = activeIndex === onboarding.length - 1;
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex h-full items-center justify-between bg-white">
|
||||
<SafeAreaView className="flex h-full items-center justify-between bg-white dark:bg-neutral-950">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(auth)/sign-up")}
|
||||
className="w-full flex justify-end items-end p-5"
|
||||
>
|
||||
<Text className="text-black text-base font-JakartaBold">Skip</Text>
|
||||
<Text className="text-black dark:text-white text-base font-JakartaBold">
|
||||
{t("onboarding.skip")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Swiper
|
||||
ref={swiperRef}
|
||||
loop={false}
|
||||
dot={<View className="w-8 h-1 mx-1 bg-[#E2E8F0] rounded-full" />}
|
||||
dot={<View className="w-8 h-1 mx-1 bg-[#E2E8F0] dark:bg-neutral-700 rounded-full" />}
|
||||
activeDot={<View className="w-8 h-1 mx-1 bg-[#0286FF] rounded-full" />}
|
||||
index={activeIndex}
|
||||
onIndexChanged={setActiveIndex}
|
||||
@@ -39,13 +43,13 @@ const Welcome = () => {
|
||||
/>
|
||||
|
||||
<View className="flex flex-row items-center justify-center w-full mt-10">
|
||||
<Text className="text-black text-3xl font-bold mx-10 text-center">
|
||||
{item.title}
|
||||
<Text className="text-black dark:text-white text-3xl font-bold mx-10 text-center">
|
||||
{t(item.titleKey)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-base font-JakartaSemiBold text-center text-[#858585] mx-10 mt-3">
|
||||
{item.description}
|
||||
<Text className="text-base font-JakartaSemiBold text-center text-[#858585] dark:text-neutral-400 mx-10 mt-3">
|
||||
{t(item.descKey)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -57,7 +61,7 @@ const Welcome = () => {
|
||||
? router.push("/(auth)/sign-up")
|
||||
: swiperRef.current?.scrollBy(1)
|
||||
}
|
||||
title={isLastSlide ? "Get Started" : "Next"}
|
||||
title={isLastSlide ? t("onboarding.getStarted") : t("onboarding.next")}
|
||||
className="w-11/12 mt-10"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Tabs } from "expo-router";
|
||||
import { Image, type ImageSourcePropType, View } from "react-native";
|
||||
|
||||
import { icons } from "@/constants";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
const TabIcon = ({
|
||||
source,
|
||||
@@ -13,7 +16,7 @@ const TabIcon = ({
|
||||
focused: boolean;
|
||||
}) => (
|
||||
<View
|
||||
className={`flex flex-row justify-center items-center rounded-full ${focused && "bg-general-300"}`}
|
||||
className={`flex flex-row justify-center items-center rounded-full ${focused && "bg-general-300 dark:bg-neutral-800"}`}
|
||||
>
|
||||
<View
|
||||
className={`rounded-full w-12 h-12 items-center justify-center ${focused && "bg-general-400"}`}
|
||||
@@ -29,15 +32,46 @@ const TabIcon = ({
|
||||
</View>
|
||||
);
|
||||
|
||||
const TabsLayout = () => (
|
||||
// Settings uses a vector glyph (MaterialCommunityIcons "cog") instead of a PNG
|
||||
// asset, so it gets its own icon renderer that matches the pill styling.
|
||||
const TabIconVector = ({
|
||||
name,
|
||||
focused,
|
||||
}: {
|
||||
name: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
|
||||
focused: boolean;
|
||||
}) => (
|
||||
<View
|
||||
className={`flex flex-row justify-center items-center rounded-full ${focused && "bg-general-300 dark:bg-neutral-800"}`}
|
||||
>
|
||||
<View
|
||||
className={`rounded-full w-12 h-12 items-center justify-center ${focused && "bg-general-400"}`}
|
||||
>
|
||||
<MaterialCommunityIcons name={name} size={28} color="white" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
const TabsLayout = () => {
|
||||
const { isDark } = useTheme();
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
initialRouteName="index"
|
||||
initialRouteName="home"
|
||||
screenOptions={{
|
||||
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: "#333",
|
||||
backgroundColor: isDark ? "#0a0a0a" : "#333",
|
||||
borderRadius: 50,
|
||||
paddingBottom: 0,
|
||||
overflow: "hidden",
|
||||
@@ -55,10 +89,10 @@ const TabsLayout = () => (
|
||||
<Tabs.Screen
|
||||
name="home"
|
||||
options={{
|
||||
title: "Home",
|
||||
title: t("tabs.home"),
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon focused={focused} source={icons.home} alt="Home" />
|
||||
<TabIcon focused={focused} source={icons.home} alt={t("tabs.home")} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -66,10 +100,10 @@ const TabsLayout = () => (
|
||||
<Tabs.Screen
|
||||
name="rides"
|
||||
options={{
|
||||
title: "Rides",
|
||||
title: t("tabs.rides"),
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon focused={focused} source={icons.list} alt="Rides" />
|
||||
<TabIcon focused={focused} source={icons.list} alt={t("tabs.rides")} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -77,10 +111,10 @@ const TabsLayout = () => (
|
||||
<Tabs.Screen
|
||||
name="chat"
|
||||
options={{
|
||||
title: "Chat",
|
||||
title: t("tabs.chat"),
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon focused={focused} source={icons.chat} alt="Chat" />
|
||||
<TabIcon focused={focused} source={icons.chat} alt={t("tabs.chat")} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -88,14 +122,26 @@ const TabsLayout = () => (
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: "Profile",
|
||||
title: t("tabs.profile"),
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon focused={focused} source={icons.profile} alt="Profile" />
|
||||
<TabIcon focused={focused} source={icons.profile} alt={t("tabs.profile")} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: t("tabs.settings"),
|
||||
headerShown: false,
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIconVector focused={focused} name="cog" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default TabsLayout;
|
||||
|
||||
@@ -1,33 +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";
|
||||
// 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 = () => {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white p-5">
|
||||
<ScrollView contentContainerStyle={{ flexGrow: 1 }}>
|
||||
<Text className="text-2xl font-JakartaBold">Chat</Text>
|
||||
|
||||
<View className="flex-1 h-fit flex justify-center items-center">
|
||||
<Image
|
||||
source={images.message}
|
||||
alt="message"
|
||||
className="w-full h-40"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
||||
<Text className="text-3xl font-JakartaBold mt-3">
|
||||
No Messages Yet
|
||||
</Text>
|
||||
|
||||
<Text className="text-base mt-2 text-center px-7">
|
||||
Start a conversation with your friends and family
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
const Chat = () => <ChatThread tabBarClearance={TAB_BAR_CLEARANCE} />;
|
||||
|
||||
export default Chat;
|
||||
|
||||
+74
-71
@@ -1,6 +1,4 @@
|
||||
import * as Location from "expo-location";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -11,25 +9,38 @@ 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";
|
||||
import { NearbySuggestions } from "@/components/nearby-suggestions";
|
||||
import { RideCard } from "@/components/ride-card";
|
||||
import { ServiceSelector } from "@/components/service-selector";
|
||||
import { icons, images } from "@/constants";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import { useUserLocation } from "@/lib/use-user-location";
|
||||
import { useLocationStore } from "@/store";
|
||||
import { useFetch } from "@/lib/fetch";
|
||||
import type { Ride } from "@/types/type";
|
||||
|
||||
const Home = () => {
|
||||
const { setUserLocation, setDestinationLocation } = useLocationStore();
|
||||
const { signOut, user } = useSession();
|
||||
const { data: recentRides, loading } = useFetch<Ride[]>(
|
||||
`/(api)/ride/${user?.id}`,
|
||||
const setDestinationLocation = useLocationStore(
|
||||
(state) => state.setDestinationLocation,
|
||||
);
|
||||
const clearDestination = useLocationStore((state) => state.clearDestination);
|
||||
const { signOut, user } = useSession();
|
||||
const { isDark } = useTheme();
|
||||
const t = useT();
|
||||
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
|
||||
|
||||
const [hasPermissions, setHasPermissions] = useState(false);
|
||||
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");
|
||||
@@ -44,46 +55,8 @@ const Home = () => {
|
||||
router.push("/(root)/find-ride");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const requestLocation = async () => {
|
||||
try {
|
||||
let { status } = await Location.requestForegroundPermissionsAsync();
|
||||
|
||||
if (status !== "granted") return setHasPermissions(false);
|
||||
|
||||
setHasPermissions(true);
|
||||
|
||||
let location = await Location.getCurrentPositionAsync();
|
||||
|
||||
let addressText = "Unknown location";
|
||||
try {
|
||||
const address = await Location.reverseGeocodeAsync({
|
||||
longitude: location.coords?.longitude,
|
||||
latitude: location.coords?.latitude,
|
||||
});
|
||||
if (address[0]) {
|
||||
addressText = `${address[0].name}, ${address[0].region}`;
|
||||
}
|
||||
} catch (geocodeErr) {
|
||||
console.log("[REVERSE_GEOCODE]: ", geocodeErr);
|
||||
}
|
||||
|
||||
setUserLocation({
|
||||
latitude: location.coords.latitude,
|
||||
longitude: location.coords.longitude,
|
||||
address: addressText,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("[LOCATION]: ", err);
|
||||
setHasPermissions(false);
|
||||
}
|
||||
};
|
||||
|
||||
requestLocation();
|
||||
}, [setUserLocation]);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="bg-general-500">
|
||||
<SafeAreaView className="bg-general-500 dark:bg-neutral-950">
|
||||
<FlatList
|
||||
data={recentRides?.slice(0, 5)}
|
||||
renderItem={({ item }) => <RideCard ride={item} />}
|
||||
@@ -92,72 +65,102 @@ const Home = () => {
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
ListEmptyComponent={() => (
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center">
|
||||
{!loading ? (
|
||||
<>
|
||||
<Image
|
||||
source={images.noResult}
|
||||
alt="No recent rides found"
|
||||
alt={t("home.noRecentAlt")}
|
||||
className="w-40 h-40"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text className="text-sm">No recent rides found.</Text>
|
||||
<Text className="text-sm text-black dark:text-white">{t("home.noRecent")}</Text>
|
||||
</>
|
||||
) : (
|
||||
<ActivityIndicator size="small" color="#000" />
|
||||
<ActivityIndicator size="small" color={isDark ? "#fff" : "#000"} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
ListHeaderComponent={() => (
|
||||
}
|
||||
// Passed as an *element*, not as `() => (...)`. VirtualizedList
|
||||
// renders a function prop as `<HeaderComponent />`, so a fresh arrow
|
||||
// function each render is a fresh element type: React unmounts the
|
||||
// whole header — MapView included — and mounts a new one. Recreating
|
||||
// the Android map surface on every re-render leaves it grey with the
|
||||
// Google logo and tiles that never finish loading.
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<View className="flex flex-row items-center justify-between my-5">
|
||||
<Text
|
||||
className="text-base font-JakartaExtraBold"
|
||||
className="text-base font-JakartaExtraBold text-black dark:text-white"
|
||||
numberOfLines={1}
|
||||
>
|
||||
Welcome{" "}
|
||||
{user?.name || user?.email} 👋
|
||||
{t("home.welcome", { name: user?.name || user?.email || "" })}
|
||||
</Text>
|
||||
|
||||
<View className="flex flex-row items-center gap-x-1">
|
||||
<TouchableOpacity
|
||||
onPress={handleSignOut}
|
||||
className="justify-center items-center w-10 h-10 rounded-full bg-white"
|
||||
className="justify-center items-center w-10 h-10 rounded-full bg-white dark:bg-neutral-900"
|
||||
>
|
||||
<Image source={icons.out} className="w-4 h-4" alt="Logout" />
|
||||
<Image source={icons.out} className="w-4 h-4" alt={t("home.logoutAlt")} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Unfinished ride or unrated trip — the way back into a ride the
|
||||
rider navigated away from. */}
|
||||
<ActiveRideBanner />
|
||||
|
||||
<GoogleTextInput
|
||||
icon={icons.search}
|
||||
containerStyles="bg-white shadow-md shadow-neutral-300"
|
||||
containerStyles="bg-white dark:bg-neutral-900 shadow-md shadow-neutral-300 dark:shadow-neutral-950/40"
|
||||
handlePress={handleDestinationPress}
|
||||
/>
|
||||
|
||||
<Text className="text-xl font-JakartaBold mt-5 mb-3">
|
||||
Your Current Location
|
||||
<Text className="text-xl font-JakartaBold mt-5 mb-3 text-black dark:text-white">
|
||||
{t("home.currentLocation")}
|
||||
</Text>
|
||||
|
||||
<View className="flex flex-row items-center bg-transparent h-[300px]">
|
||||
{hasPermissions ? (
|
||||
<Map />
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center bg-white rounded-2xl h-full">
|
||||
<Text className="text-general-200 text-center font-JakartaMedium px-5">
|
||||
Location access is off.{"\n"}Enable it in your device
|
||||
settings to see nearby drivers.
|
||||
<View className="w-full h-[300px] rounded-2xl overflow-hidden bg-white dark:bg-neutral-900">
|
||||
{locationStatus === "pending" || locationStatus === "granted" ? (
|
||||
<>
|
||||
{/* The map draws straight away on the Beirut fallback so the
|
||||
slot never sits empty while the fix is still coming. */}
|
||||
<Map routeless />
|
||||
|
||||
{locationStatus === "pending" ? (
|
||||
<View className="absolute bottom-3 self-center flex-row items-center rounded-full bg-white/95 dark:bg-neutral-900/95 px-4 py-2 shadow-md shadow-neutral-400/40 dark:shadow-neutral-950/40">
|
||||
<ActivityIndicator size="small" color="#0286ff" />
|
||||
<Text className="ml-2 text-xs font-JakartaMedium text-general-200 dark:text-neutral-400">
|
||||
{t("home.findingLocation")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<LocationNotice
|
||||
status={locationStatus}
|
||||
onRetry={retryLocation}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text className="text-xl font-JakartaBold mt-5 mb-3">
|
||||
Recent Rides
|
||||
<Text className="text-xl font-JakartaBold mt-5 mb-3 text-black dark:text-white">
|
||||
{t("home.whatNeed")}
|
||||
</Text>
|
||||
|
||||
<ServiceSelector />
|
||||
|
||||
<View className="mt-5">
|
||||
<NearbySuggestions />
|
||||
</View>
|
||||
|
||||
<Text className="text-xl font-JakartaBold mt-5 mb-3 text-black dark:text-white">
|
||||
{t("home.recentRides")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -2,49 +2,54 @@ import { Image, ScrollView, Text, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
import { InputField } from "@/components/input-field";
|
||||
import { icons } from "@/constants";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const Profile = () => {
|
||||
const { user } = useSession();
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1">
|
||||
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
|
||||
<ScrollView
|
||||
className="px-5"
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
>
|
||||
<Text className="text-2xl font-JakartaBold my-5">My Profile</Text>
|
||||
<Text className="text-2xl font-JakartaBold my-5 text-black dark:text-white">
|
||||
{t("profile.title")}
|
||||
</Text>
|
||||
|
||||
<View className="flex items-center justify-center my-5">
|
||||
<Image
|
||||
source={{ uri: user?.avatarUrl ?? undefined }}
|
||||
alt="Your Avatar"
|
||||
source={user?.avatarUrl ? { uri: user.avatarUrl } : icons.profile}
|
||||
alt={t("profile.avatarAlt")}
|
||||
style={{ width: 110, height: 110, borderRadius: 110 / 2 }}
|
||||
className=" rounded-full h-[110px] w-[110px] border-[3px] border-white shadow-sm shadow-neutral-300"
|
||||
className=" rounded-full h-[110px] w-[110px] border-[3px] border-white dark:border-neutral-800 shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col items-start justify-center bg-white rounded-lg shadow-sm shadow-neutral-300 px-5 py-3">
|
||||
<View className="flex flex-col items-start justify-center bg-white dark:bg-neutral-900 rounded-lg shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40 px-5 py-3">
|
||||
<View className="flex flex-col items-start justify-start w-full">
|
||||
<InputField
|
||||
label="First name"
|
||||
placeholder={user?.name.split(" ")[0] ?? "Your First name"}
|
||||
label={t("profile.firstName")}
|
||||
placeholder={user?.name?.split(" ")[0] || t("profile.firstNamePlaceholder")}
|
||||
containerStyles="w-full mb-4"
|
||||
inputStyles="p-3.5"
|
||||
editable={false}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Last name"
|
||||
placeholder={user?.name.split(" ").slice(1).join(" ") ?? "Your Last name"}
|
||||
label={t("profile.lastName")}
|
||||
placeholder={user?.name?.split(" ").slice(1).join(" ") || t("profile.lastNamePlaceholder")}
|
||||
containerStyles="w-full mb-4"
|
||||
inputStyles="p-3.5"
|
||||
editable={false}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label="Email"
|
||||
placeholder={user?.email ?? "Your Email address"}
|
||||
label={t("profile.email")}
|
||||
placeholder={user?.email ?? t("profile.emailPlaceholder")}
|
||||
containerStyles="w-full mb-4"
|
||||
inputStyles="p-3.5"
|
||||
editable={false}
|
||||
|
||||
+18
-16
@@ -4,17 +4,17 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { RideCard } from "@/components/ride-card";
|
||||
import { images } from "@/constants";
|
||||
import { useFetch } from "@/lib/fetch";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import type { Ride } from "@/types/type";
|
||||
|
||||
const Rides = () => {
|
||||
const { user } = useSession();
|
||||
const { data: recentRides, loading } = useFetch<Ride[]>(
|
||||
`/(api)/ride/${user?.id}`,
|
||||
);
|
||||
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
|
||||
const { isDark } = useTheme();
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<SafeAreaView>
|
||||
<SafeAreaView className="bg-general-500 dark:bg-neutral-950">
|
||||
<FlatList
|
||||
data={recentRides}
|
||||
renderItem={({ item }) => <RideCard ride={item} />}
|
||||
@@ -23,28 +23,30 @@ const Rides = () => {
|
||||
contentContainerStyle={{
|
||||
paddingBottom: 100,
|
||||
}}
|
||||
ListEmptyComponent={() => (
|
||||
ListEmptyComponent={
|
||||
<View className="flex flex-col items-center justify-center">
|
||||
{!loading ? (
|
||||
<>
|
||||
<Image
|
||||
source={images.noResult}
|
||||
alt="No recent rides found"
|
||||
alt={t("rides.noRecentAlt")}
|
||||
className="w-40 h-40"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text className="text-sm">No recent rides found.</Text>
|
||||
<Text className="text-sm text-black dark:text-white">
|
||||
{t("rides.noRecent")}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<ActivityIndicator size="small" color="#000" />
|
||||
<ActivityIndicator size="small" color={isDark ? "#fff" : "#000"} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
ListHeaderComponent={() => (
|
||||
<>
|
||||
<Text className="text-2xl font-JakartaBold my-5">All rides</Text>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
ListHeaderComponent={
|
||||
<Text className="text-2xl font-JakartaBold my-5 text-black dark:text-white">
|
||||
{t("rides.allRides")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import {
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Children, Fragment, useCallback, useState } from "react";
|
||||
|
||||
import { SettingsRow } from "@/components/settings-row";
|
||||
import {
|
||||
type Lang,
|
||||
type ThemeMode,
|
||||
useSettingsStore,
|
||||
} from "@/lib/settings";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useLocationPermission } from "@/lib/use-location-permission";
|
||||
|
||||
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<Text className="text-xs font-JakartaSemiBold uppercase tracking-wide text-general-200 dark:text-neutral-500 mt-6 mb-2 px-1">
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
|
||||
/**
|
||||
* A grouped settings card. Renders an optional muted description header, then
|
||||
* its children with an automatic divider between each row — so callers never
|
||||
* hand-thread `border-t` wrapper Views. Null/conditional children (and arrays
|
||||
* from `.map`) are flattened by `Children.toArray`, so conditionals like
|
||||
* `status !== "granted" ? <Row/> : null` and `options.map(...)` both work.
|
||||
*/
|
||||
const SettingsCard = ({
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const rows = Children.toArray(children);
|
||||
|
||||
return (
|
||||
<View className="rounded-2xl bg-white dark:bg-neutral-900 overflow-hidden shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
|
||||
{description ? (
|
||||
<View className="px-4 py-2.5">
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400">
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{rows.map((row, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? (
|
||||
<View className="border-t border-neutral-100 dark:border-neutral-800" />
|
||||
) : null}
|
||||
{row}
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Settings = () => {
|
||||
const t = useT();
|
||||
|
||||
const mode = useSettingsStore((state) => state.mode);
|
||||
const setMode = useSettingsStore((state) => state.setMode);
|
||||
const lang = useSettingsStore((state) => state.lang);
|
||||
const setLang = useSettingsStore((state) => state.setLang);
|
||||
const keepAwake = useSettingsStore((state) => state.keepAwake);
|
||||
const setKeepAwake = useSettingsStore((state) => state.setKeepAwake);
|
||||
const overlayRequested = useSettingsStore(
|
||||
(state) => state.overlayRequested,
|
||||
);
|
||||
const setOverlayRequested = useSettingsStore(
|
||||
(state) => state.setOverlayRequested,
|
||||
);
|
||||
|
||||
const { status, refresh, openSettings } = useLocationPermission();
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]),
|
||||
);
|
||||
|
||||
const [expandedSafety, setExpandedSafety] = useState<string | null>(null);
|
||||
|
||||
const locationStatusLabel =
|
||||
status === "granted"
|
||||
? t("settings.maps.statusGranted")
|
||||
: status === "denied"
|
||||
? t("settings.maps.statusDenied")
|
||||
: status === "blocked"
|
||||
? t("settings.maps.statusBlocked")
|
||||
: t("settings.maps.statusUnknown");
|
||||
|
||||
const callEmergency = useCallback(async () => {
|
||||
try {
|
||||
await Linking.openURL("tel:112");
|
||||
} catch {
|
||||
Alert.alert(
|
||||
t("settings.safety.callFailedTitle"),
|
||||
t("settings.safety.callFailedBody"),
|
||||
);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const chooseLanguage = useCallback(
|
||||
(next: Lang) => {
|
||||
const switchingToOrFromRTL = next === "ar" || lang === "ar";
|
||||
|
||||
setLang(next);
|
||||
|
||||
if (switchingToOrFromRTL) {
|
||||
Alert.alert(
|
||||
t("settings.language.rtlRestartTitle"),
|
||||
t("settings.language.rtlRestartBody"),
|
||||
);
|
||||
}
|
||||
},
|
||||
[lang, setLang, t],
|
||||
);
|
||||
|
||||
const openOverlaySettings = useCallback(async () => {
|
||||
setOverlayRequested(true);
|
||||
try {
|
||||
await Linking.openSettings();
|
||||
} catch {
|
||||
// already flagged; nothing more to do
|
||||
}
|
||||
}, [setOverlayRequested]);
|
||||
|
||||
const appearanceOptions: { mode: ThemeMode; icon: IconName }[] = [
|
||||
{ mode: "light", icon: "white-balance-sunny" },
|
||||
{ mode: "dark", icon: "weather-night" },
|
||||
{ mode: "system", icon: "theme-light-dark" },
|
||||
];
|
||||
|
||||
const languageOptions: { lang: Lang; icon: IconName }[] = [
|
||||
{ lang: "en", icon: "alpha-e-box" },
|
||||
{ lang: "ar", icon: "alpha-a-box" },
|
||||
{ lang: "fr", icon: "alpha-f-box" },
|
||||
];
|
||||
|
||||
const safetyTiles: { key: string; icon: IconName; title: string; body: string }[] = [
|
||||
{
|
||||
key: "proactive",
|
||||
icon: "shield-account",
|
||||
title: t("settings.safety.proactive.title"),
|
||||
body: t("settings.safety.proactive.body"),
|
||||
},
|
||||
{
|
||||
key: "verification",
|
||||
icon: "account-check",
|
||||
title: t("settings.safety.verification.title"),
|
||||
body: t("settings.safety.verification.body"),
|
||||
},
|
||||
{
|
||||
key: "privacy",
|
||||
icon: "lock",
|
||||
title: t("settings.safety.privacy.title"),
|
||||
body: t("settings.safety.privacy.body"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
|
||||
<ScrollView
|
||||
className="px-5"
|
||||
contentContainerStyle={{ paddingBottom: 120 }}
|
||||
>
|
||||
<Text className="text-2xl font-JakartaBold my-5 text-black dark:text-white">
|
||||
{t("settings.title")}
|
||||
</Text>
|
||||
|
||||
{/* 1. Maps & Navigation */}
|
||||
<SectionHeader title={t("settings.maps.title")} />
|
||||
<SettingsCard>
|
||||
<SettingsRow
|
||||
icon="map-marker-radius"
|
||||
title={t("settings.maps.title")}
|
||||
subtitle={t("settings.maps.description")}
|
||||
right="value"
|
||||
value={locationStatusLabel}
|
||||
/>
|
||||
{status !== "granted" ? (
|
||||
<SettingsRow
|
||||
icon="cog"
|
||||
title={t("settings.maps.openSettings")}
|
||||
right="chevron"
|
||||
onPress={openSettings}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
|
||||
{/* 2. Appearance */}
|
||||
<SectionHeader title={t("settings.appearance.title")} />
|
||||
<SettingsCard description={t("settings.appearance.description")}>
|
||||
{appearanceOptions.map((option) => (
|
||||
<SettingsRow
|
||||
key={option.mode}
|
||||
icon={option.icon}
|
||||
title={
|
||||
option.mode === "light"
|
||||
? t("settings.appearance.light")
|
||||
: option.mode === "dark"
|
||||
? t("settings.appearance.dark")
|
||||
: t("settings.appearance.system")
|
||||
}
|
||||
right="check"
|
||||
selected={mode === option.mode}
|
||||
onPress={() => setMode(option.mode)}
|
||||
/>
|
||||
))}
|
||||
</SettingsCard>
|
||||
|
||||
{/* 3. Safety */}
|
||||
<SectionHeader title={t("settings.safety.title")} />
|
||||
<SettingsCard>
|
||||
<SettingsRow
|
||||
icon="phone-in-talk"
|
||||
title={t("settings.safety.call112")}
|
||||
subtitle={t("settings.safety.call112Description")}
|
||||
right="chevron"
|
||||
danger
|
||||
onPress={callEmergency}
|
||||
/>
|
||||
{safetyTiles.map((tile) => (
|
||||
<SettingsRow
|
||||
key={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,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsCard>
|
||||
|
||||
{/* 4. Language */}
|
||||
<SectionHeader title={t("settings.language.title")} />
|
||||
<SettingsCard description={t("settings.language.description")}>
|
||||
{languageOptions.map((option) => (
|
||||
<SettingsRow
|
||||
key={option.lang}
|
||||
icon={option.icon}
|
||||
title={
|
||||
option.lang === "en"
|
||||
? t("settings.language.en")
|
||||
: option.lang === "ar"
|
||||
? t("settings.language.ar")
|
||||
: t("settings.language.fr")
|
||||
}
|
||||
right="check"
|
||||
selected={lang === option.lang}
|
||||
onPress={() => chooseLanguage(option.lang)}
|
||||
/>
|
||||
))}
|
||||
</SettingsCard>
|
||||
|
||||
{/* 5. General — keep-awake toggle + (Android) display-over-other-apps */}
|
||||
<SectionHeader title={t("settings.general.title")} />
|
||||
<SettingsCard>
|
||||
<SettingsRow
|
||||
icon="monitor"
|
||||
title={t("settings.keepAwake.title")}
|
||||
subtitle={t("settings.keepAwake.description")}
|
||||
right="switch"
|
||||
switchValue={keepAwake}
|
||||
onSwitchChange={setKeepAwake}
|
||||
/>
|
||||
{Platform.OS === "android" ? (
|
||||
<SettingsRow
|
||||
icon="application-brackets"
|
||||
title={t("settings.overlay.allow")}
|
||||
subtitle={
|
||||
overlayRequested
|
||||
? t("settings.overlay.openedHint")
|
||||
: t("settings.overlay.description")
|
||||
}
|
||||
right="chevron"
|
||||
onPress={openOverlaySettings}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
+32
-2
@@ -1,18 +1,48 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { Redirect, Stack } from "expo-router";
|
||||
|
||||
import CallWatcher from "@/components/call-watcher";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const RootLayout = () => {
|
||||
const { isLoaded, isSignedIn } = useSession();
|
||||
|
||||
// Everything under (root) is behind the session, so the check belongs here
|
||||
// rather than in each screen. app/index.tsx only guards the way in, which
|
||||
// left a session that ended *while* a screen was open with nowhere to go:
|
||||
// the screen stayed mounted and kept polling with a token the server had
|
||||
// already rejected.
|
||||
//
|
||||
// Sign-in, not welcome: someone who reaches this point had an account a
|
||||
// moment ago, and the onboarding carousel is not what they need.
|
||||
if (!isLoaded) return null;
|
||||
if (!isSignedIn) return <Redirect href="/(auth)/sign-in" />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Watches for incoming WebRTC calls on the active ride and routes the
|
||||
user to the call screen regardless of which tab is open. No UI. */}
|
||||
<CallWatcher />
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="find-ride" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="confirm-ride" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="adjust-pin" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="book-ride" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="role" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="driver-home"
|
||||
options={{ headerShown: false, gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="driver-chat" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="call"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "fullScreenModal",
|
||||
gestureEnabled: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import * as Location from "expo-location";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { PinAdjuster } from "@/components/pin-adjuster";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { addressForCoords } from "@/lib/reverse-geocode";
|
||||
import { useLocationStore } from "@/store";
|
||||
|
||||
// "Move the pin to where you actually are."
|
||||
//
|
||||
// An address from autocomplete lands on whatever the geocoder considers the
|
||||
// centre of that place — which can be the wrong side of a building, the wrong
|
||||
// end of a long street, or the middle of a junction the driver can't stop in.
|
||||
// The rider knows the doorway; this screen lets them say so, for the pickup
|
||||
// and the drop-off alike.
|
||||
//
|
||||
// Reverse geocoding is debounced rather than run on every frame of the pan:
|
||||
// the label only has to be right once the map stops.
|
||||
const GEOCODE_DEBOUNCE_MS = 450;
|
||||
|
||||
// Falls back to Beirut, matching the map's own default, so the screen always
|
||||
// has somewhere to open even before a fix arrives.
|
||||
const FALLBACK = { latitude: 33.8938, longitude: 35.5018 };
|
||||
|
||||
type Coords = { latitude: number; longitude: number };
|
||||
|
||||
const AdjustPin = () => {
|
||||
const t = useT();
|
||||
const params = useLocalSearchParams<{ mode?: string }>();
|
||||
const mode = params.mode === "destination" ? "destination" : "origin";
|
||||
|
||||
const {
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
setUserLocation,
|
||||
setDestinationLocation,
|
||||
} = useLocationStore();
|
||||
|
||||
// Open on the point being edited. A destination that hasn't been chosen yet
|
||||
// starts at the rider instead of an arbitrary city centre, because the place
|
||||
// they're going is usually near the place they are.
|
||||
const initial: Coords =
|
||||
mode === "origin"
|
||||
? {
|
||||
latitude: userLatitude ?? FALLBACK.latitude,
|
||||
longitude: userLongitude ?? FALLBACK.longitude,
|
||||
}
|
||||
: {
|
||||
latitude: destinationLatitude ?? userLatitude ?? FALLBACK.latitude,
|
||||
longitude:
|
||||
destinationLongitude ?? userLongitude ?? FALLBACK.longitude,
|
||||
};
|
||||
|
||||
const [coords, setCoords] = useState<Coords>(initial);
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
const [resolving, setResolving] = useState(true);
|
||||
const debounce = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const resolve = useCallback((next: Coords) => {
|
||||
setCoords(next);
|
||||
clearTimeout(debounce.current);
|
||||
|
||||
debounce.current = setTimeout(async () => {
|
||||
const label = await addressForCoords(next.latitude, next.longitude);
|
||||
setAddress(label);
|
||||
setResolving(false);
|
||||
}, GEOCODE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
// Label the point the screen opened on, so the card isn't blank on arrival.
|
||||
useEffect(() => {
|
||||
resolve(initial);
|
||||
return () => clearTimeout(debounce.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const confirm = () => {
|
||||
const payload = {
|
||||
latitude: coords.latitude,
|
||||
longitude: coords.longitude,
|
||||
address: address ?? t("common.yourLocation"),
|
||||
};
|
||||
|
||||
if (mode === "origin") setUserLocation(payload);
|
||||
else setDestinationLocation(payload);
|
||||
|
||||
router.back();
|
||||
};
|
||||
|
||||
// Jump back to the rider's own position — the usual reason to open this
|
||||
// screen is that the suggested pickup drifted away from where they're
|
||||
// standing.
|
||||
const recenter = async () => {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
if (status !== "granted") return;
|
||||
|
||||
const position = await Location.getLastKnownPositionAsync({
|
||||
maxAge: 60_000,
|
||||
});
|
||||
if (!position) return;
|
||||
|
||||
setResolving(true);
|
||||
resolve({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("[ADJUST_PIN_RECENTER]: ", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white dark:bg-neutral-950">
|
||||
<PinAdjuster
|
||||
initial={initial}
|
||||
onMoveStart={() => setResolving(true)}
|
||||
onSettled={resolve}
|
||||
/>
|
||||
|
||||
<SafeAreaView className="flex-1" pointerEvents="box-none">
|
||||
<View className="px-5 pt-2" pointerEvents="box-none">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
accessibilityLabel={t("common.back")}
|
||||
className="w-10 h-10 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
|
||||
>
|
||||
<MaterialCommunityIcons name="arrow-left" size={20} color="#0286ff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View className="flex-1" pointerEvents="none" />
|
||||
|
||||
<View className="px-5 pb-5" pointerEvents="box-none">
|
||||
<TouchableOpacity
|
||||
onPress={recenter}
|
||||
accessibilityLabel={t("adjustPin.recenter")}
|
||||
className="self-end mb-3 w-11 h-11 rounded-full bg-white dark:bg-neutral-900 items-center justify-center shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="crosshairs-gps"
|
||||
size={20}
|
||||
color="#0286ff"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="rounded-2xl bg-white dark:bg-neutral-900 p-5 shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40">
|
||||
<Text className="text-xs font-JakartaSemiBold uppercase tracking-wide text-general-200 dark:text-neutral-500 mb-1">
|
||||
{mode === "origin"
|
||||
? t("adjustPin.pickupLabel")
|
||||
: t("adjustPin.destinationLabel")}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center min-h-[26px] mb-1">
|
||||
{resolving ? (
|
||||
<>
|
||||
<ActivityIndicator size="small" color="#0286ff" />
|
||||
<Text className="ml-2 font-JakartaMedium text-general-200 dark:text-neutral-400">
|
||||
{t("adjustPin.locating")}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
className="font-JakartaBold text-black dark:text-white text-base flex-1"
|
||||
numberOfLines={2}
|
||||
>
|
||||
{address}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mb-4">
|
||||
{t("adjustPin.hint")}
|
||||
</Text>
|
||||
|
||||
<CustomButton
|
||||
title={
|
||||
mode === "origin"
|
||||
? t("adjustPin.confirmPickup")
|
||||
: t("adjustPin.confirmDestination")
|
||||
}
|
||||
onPress={confirm}
|
||||
disabled={resolving}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdjustPin;
|
||||
+532
-110
@@ -1,135 +1,557 @@
|
||||
import { router } from "expo-router";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
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 { Payment } from "@/components/payment";
|
||||
import { RideLayout } from "@/components/ride-layout";
|
||||
import { icons } from "@/constants";
|
||||
import { formatLBP } from "@/lib/pricing";
|
||||
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 { useDriverStore, useLocationStore } from "@/store";
|
||||
import { useLocationStore } from "@/store";
|
||||
import type { Ride, RideOffer } from "@/types/type";
|
||||
|
||||
const POLL_MS = 3000;
|
||||
|
||||
// While the request is open, offers arrive one driver at a time and the rider
|
||||
// is staring at the list waiting for them. A three-second gap between a driver
|
||||
// tapping Offer and their face appearing reads as nothing happening.
|
||||
const OPEN_POLL_MS = 1500;
|
||||
|
||||
const STATUS_KEY: Record<string, string> = {
|
||||
requested: "bookRide.status.requested",
|
||||
accepted: "bookRide.status.accepted",
|
||||
arrived: "bookRide.status.arrived",
|
||||
en_route: "bookRide.status.enRoute",
|
||||
completed: "bookRide.status.completed",
|
||||
cancelled: "bookRide.status.cancelled",
|
||||
expired: "bookRide.status.expired",
|
||||
};
|
||||
|
||||
const TERMINAL = ["completed", "cancelled", "expired"];
|
||||
|
||||
// book-ride is now the live ride-status screen. The rider lands here after
|
||||
// requesting a ride and polls its status until it completes (or they cancel).
|
||||
const BookRide = () => {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const rideId = Number(id);
|
||||
const t = useT();
|
||||
const { user } = useSession();
|
||||
const { userAddress, destinationAddress } = useLocationStore();
|
||||
const { drivers, selectedDriver } = useDriverStore();
|
||||
const setUserLocation = useLocationStore((s) => s.setUserLocation);
|
||||
const setDestinationLocation = useLocationStore(
|
||||
(s) => s.setDestinationLocation,
|
||||
);
|
||||
const clearDestination = useLocationStore((s) => s.clearDestination);
|
||||
|
||||
const driverDetails = drivers?.filter(
|
||||
(driver) => +driver.id === selectedDriver,
|
||||
)[0];
|
||||
const [ride, setRide] = useState<Ride | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
// The offer the rider tapped, held while they choose how to pay.
|
||||
const [picked, setPicked] = useState<RideOffer | null>(null);
|
||||
const [paying, setPaying] = useState(false);
|
||||
// A card order that was paid but whose selection then failed. Kept so the
|
||||
// rider can pick a different driver without paying a second time — the
|
||||
// server only consumes an order when a driver is actually assigned.
|
||||
const paidOrder = useRef<string | null>(null);
|
||||
// Server clock minus device clock, so the elapsed counter is measured on the
|
||||
// clock the request window is actually enforced against.
|
||||
const clockOffset = useRef(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
// Set once, when the ride first lands on 'completed' during this session,
|
||||
// so dismissing the sheet doesn't immediately re-open it on the next poll.
|
||||
const [ratingOpen, setRatingOpen] = useState(false);
|
||||
const [ratingHandled, setRatingHandled] = useState(false);
|
||||
|
||||
if (!driverDetails) {
|
||||
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({
|
||||
latitude: r.origin_latitude,
|
||||
longitude: r.origin_longitude,
|
||||
address: r.origin_address,
|
||||
});
|
||||
setDestinationLocation({
|
||||
latitude: r.destination_latitude,
|
||||
longitude: r.destination_longitude,
|
||||
address: r.destination_address,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("[BOOK_RIDE_LOAD]: ", err);
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setError(t("bookRide.rideNotFound"));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [rideId, setUserLocation, setDestinationLocation, ratingHandled, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// 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 || 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]);
|
||||
|
||||
// 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", reason }),
|
||||
});
|
||||
setCancelOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
console.log("[BOOK_RIDE_CANCEL]: ", err);
|
||||
Alert.alert(t("bookRide.alertErrorTitle"), t("bookRide.alertErrorBody"));
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<RideLayout title="Book Ride">
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Text className="text-base text-general-200 font-JakartaMedium text-center">
|
||||
No driver selected.{"\n"}Please go back and choose a driver first.
|
||||
</Text>
|
||||
|
||||
<CustomButton
|
||||
title="Choose a Driver"
|
||||
onPress={() => router.replace("/(root)/confirm-ride")}
|
||||
className="mt-6"
|
||||
/>
|
||||
</View>
|
||||
</RideLayout>
|
||||
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#0286ff" />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !ride) {
|
||||
return (
|
||||
<RideLayout title="Book Ride">
|
||||
<>
|
||||
<Text className="text-xl font-JakartaSemiBold mb-3">
|
||||
Ride Information
|
||||
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center px-7">
|
||||
<Text className="text-base text-general-200 dark:text-neutral-400 text-center">
|
||||
{error ?? t("bookRide.couldNotLoad")}
|
||||
</Text>
|
||||
<CustomButton
|
||||
title={t("bookRide.backHome")}
|
||||
onPress={() => router.replace("/(root)/(tabs)/home")}
|
||||
className="mt-6"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const driver = ride.driver;
|
||||
const driverId = driver.id;
|
||||
const terminal = TERMINAL.includes(ride.status);
|
||||
const driverName = [driver.first_name, driver.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const cashDue = ride.payment_status === "cash";
|
||||
const offers = (ride.offers ?? []) as RideOffer[];
|
||||
// Whole seconds the search has been running, measured on the server's clock.
|
||||
const searchSeconds = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(Date.now() + clockOffset.current - Date.parse(ride.created_at)) / 1000,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-general-500 dark:bg-neutral-950">
|
||||
<View className="h-[45%] bg-blue-500">
|
||||
<Map trackedDriver={driverId ? { ...driver, id: driverId } : null} />
|
||||
</View>
|
||||
|
||||
{/* Scrollable, because the number of things below the map isn't fixed:
|
||||
four drivers offering on a request push the fare, the cancel button
|
||||
— and the fourth driver — off the bottom of the screen, and a rider
|
||||
who can't reach an offer can't take it. */}
|
||||
<ScrollView
|
||||
className="flex-1 px-5 pt-4"
|
||||
contentContainerStyle={{ flexGrow: 1, paddingBottom: 24 }}
|
||||
>
|
||||
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
|
||||
{/* Once drivers have volunteered the screen stops being a search and
|
||||
becomes a decision, and the heading has to say which one it is —
|
||||
a rider reading "finding your driver" over a list of drivers
|
||||
doesn't know it's waiting on them. */}
|
||||
{ride.status === "requested" && offers.length > 0
|
||||
? t("bookRide.status.choosing")
|
||||
: STATUS_KEY[ride.status]
|
||||
? t(STATUS_KEY[ride.status])
|
||||
: ride.status}
|
||||
</Text>
|
||||
|
||||
<View className="flex flex-col w-full items-center justify-center mt-10">
|
||||
{/* Waiting on the first driver to volunteer. The elapsed counter is
|
||||
there because a spinner with no number on it reads as broken after
|
||||
about ten seconds — and the request legitimately sits open for a
|
||||
couple of minutes. A rider who can see it counting knows their
|
||||
request is still live. */}
|
||||
{ride.status === "requested" && offers.length === 0 ? (
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-5 mt-2 items-center">
|
||||
<ActivityIndicator size="large" color="#0286ff" />
|
||||
<Text className="text-general-200 dark:text-neutral-400 mt-3 text-center">
|
||||
{t("bookRide.matchingDriver", { service: ride.service })}
|
||||
</Text>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400 mt-2">
|
||||
{t("bookRide.searchingFor", { seconds: searchSeconds })}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Drivers who want the job. The rider picks; everyone else is let go
|
||||
the moment they do. */}
|
||||
{ride.status === "requested" && offers.length > 0 ? (
|
||||
<OfferList
|
||||
offers={offers}
|
||||
pendingOfferId={paying ? (picked?.offer_id ?? null) : null}
|
||||
busy={paying}
|
||||
onPick={setPicked}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Pickup code — the rider's half of the handshake. Shown from the
|
||||
moment a driver is assigned until the trip starts; the driver
|
||||
can't start without hearing it, which is what stops a rider from
|
||||
getting into the wrong car (and the wrong car from taking them). */}
|
||||
{ride.pickup_code ? (
|
||||
<View
|
||||
className={`rounded-2xl p-4 mt-2 items-center ${
|
||||
ride.status === "arrived"
|
||||
? "bg-emerald-500"
|
||||
: "bg-white dark:bg-neutral-900"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs font-JakartaMedium ${
|
||||
ride.status === "arrived"
|
||||
? "text-white/90"
|
||||
: "text-general-200 dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{ride.status === "arrived"
|
||||
? t("bookRide.driverHere")
|
||||
: t("bookRide.pickupCodeLabel")}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-4xl font-JakartaExtraBold tracking-[8px] mt-1 ${
|
||||
ride.status === "arrived"
|
||||
? "text-white"
|
||||
: "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{ride.pickup_code}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-xs text-center mt-1 ${
|
||||
ride.status === "arrived"
|
||||
? "text-white/90"
|
||||
: "text-general-200 dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{t("bookRide.pickupCodeHint")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Driver card — shown once the pairing is confirmed. While the ride
|
||||
is still 'matched' the confirmation card above is showing the same
|
||||
driver, and two cards for one driver reads as two drivers. */}
|
||||
{driver?.id && ride.status !== "matched" ? (
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-2">
|
||||
<View className="flex-row items-center">
|
||||
<Image
|
||||
source={{ uri: driverDetails?.profile_image_url }}
|
||||
alt="Driver Avatar"
|
||||
className="w-28 h-28 rounded-full"
|
||||
source={{ uri: driverPhotoUri(driver.profile_image_url) }}
|
||||
className="w-16 h-16 rounded-full"
|
||||
/>
|
||||
<View className="ml-4 flex-1">
|
||||
<Text className="text-lg font-JakartaSemiBold text-black dark:text-white">
|
||||
{driver.first_name} {driver.last_name}
|
||||
</Text>
|
||||
<View className="flex-row items-center mt-1">
|
||||
<Image source={icons.star} className="w-4 h-4" />
|
||||
<Text className="ml-1 text-general-200 dark:text-neutral-400">
|
||||
{driver.rating?.toFixed(1) ?? t("bookRide.ratingFallback")}
|
||||
</Text>
|
||||
{driver.car_model ? (
|
||||
<Text className="ml-3 text-general-200 dark:text-neutral-400">
|
||||
{driver.car_model}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400 capitalize mr-3">
|
||||
{driver.service ?? ride.service}
|
||||
</Text>
|
||||
{/* Call the driver — only while the ride is active. */}
|
||||
{!terminal ? (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(root)/call",
|
||||
params: { rideId: String(ride.ride_id), mode: "start" },
|
||||
})
|
||||
}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
accessibilityLabel={t("chat.call")}
|
||||
className="w-9 h-9 rounded-full bg-general-400 items-center justify-center"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="phone"
|
||||
size={18}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center gap-x-2 mt-4">
|
||||
<Image source={icons.to} className="w-4 h-4" />
|
||||
<Text
|
||||
className="font-JakartaMedium text-sm text-black dark:text-white"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{ride.origin_address}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-x-2 mt-2">
|
||||
<Image source={icons.point} className="w-4 h-4" />
|
||||
<Text
|
||||
className="font-JakartaMedium text-sm text-black dark:text-white"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{ride.destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row justify-between mt-4 pt-3 border-t border-neutral-100 dark:border-neutral-800">
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-xs">
|
||||
{ride.payment_status === "cash"
|
||||
? t("bookRide.paymentCash")
|
||||
: t("bookRide.paymentCard")}
|
||||
</Text>
|
||||
<Text className="font-JakartaBold text-emerald-600 dark:text-emerald-400">
|
||||
${(ride.fare_price / 100).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Completed summary */}
|
||||
{ride.status === "completed" ? (
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4 items-center">
|
||||
<Image source={images.check} className="w-12 h-12" />
|
||||
<Text className="text-lg font-JakartaBold mt-3 text-black dark:text-white">
|
||||
{t("bookRide.fare", { fare: (ride.fare_price / 100).toFixed(2) })}
|
||||
</Text>
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-1">
|
||||
{t("bookRide.tripTime", { time: formatTime(ride.ride_time) })}
|
||||
</Text>
|
||||
{/* A cash ride the driver hasn't marked collected is money still
|
||||
owed — say so rather than showing a clean "all done". */}
|
||||
{cashDue ? (
|
||||
<Text className="text-amber-600 dark:text-amber-400 text-sm mt-2 text-center">
|
||||
{t("bookRide.cashDue", {
|
||||
amount: (ride.fare_price / 100).toFixed(2),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{ride.my_rating ? (
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-sm mt-2">
|
||||
{t("bookRide.youRated", { n: ride.my_rating })}
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => setRatingOpen(true)}
|
||||
className="mt-3"
|
||||
>
|
||||
<Text className="font-JakartaBold text-primary-500">
|
||||
{t("bookRide.rateDriver")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Cancelled / expired */}
|
||||
{ride.status === "cancelled" || ride.status === "expired" ? (
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mt-4 items-center">
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-center">
|
||||
{ride.status === "expired"
|
||||
? t("bookRide.noDriversFound")
|
||||
: ride.cancelled_by === "driver"
|
||||
? t("bookRide.cancelledByDriver")
|
||||
: t("bookRide.rideCancelled")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="mt-auto pt-6">
|
||||
{terminal ? (
|
||||
<CustomButton
|
||||
title={t("bookRide.backHome")}
|
||||
onPress={() => router.replace("/(root)/(tabs)/home")}
|
||||
/>
|
||||
) : ride.status === "en_route" ? (
|
||||
// Once the trip is under way there is nothing to cancel — the
|
||||
// rider is in the car. Ending it early is the driver's action.
|
||||
<Text className="text-center text-general-200 dark:text-neutral-400 text-sm pb-3">
|
||||
{t("bookRide.enRouteNotice")}
|
||||
</Text>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => setCancelOpen(true)}
|
||||
disabled={cancelling}
|
||||
className="rounded-full py-3 bg-white dark:bg-neutral-900 items-center border border-rose-300 dark:border-rose-900"
|
||||
>
|
||||
<Text className="font-JakartaBold text-rose-500">
|
||||
{cancelling
|
||||
? t("bookRide.cancelling")
|
||||
: t("bookRide.cancelRide")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<PaymentChoiceSheet
|
||||
visible={picked !== null}
|
||||
driverName={
|
||||
picked
|
||||
? [picked.first_name, picked.last_name].filter(Boolean).join(" ")
|
||||
: null
|
||||
}
|
||||
fareCents={ride.fare_price}
|
||||
submitting={paying}
|
||||
onPay={(method) => void pay(method)}
|
||||
onCancel={() => setPicked(null)}
|
||||
/>
|
||||
|
||||
<View className="flex flex-row items-center justify-center mt-5 space-x-2">
|
||||
<Text className="text-lg font-JakartaSemiBold">
|
||||
{driverDetails?.title}
|
||||
</Text>
|
||||
|
||||
<View className="flex flex-row items-center space-x-0.5">
|
||||
<Image
|
||||
source={icons.star}
|
||||
alt="Star"
|
||||
className="w-5 h-5"
|
||||
resizeMode="contain"
|
||||
<CancelSheet
|
||||
visible={cancelOpen}
|
||||
audience="rider"
|
||||
submitting={cancelling}
|
||||
onCancel={() => setCancelOpen(false)}
|
||||
onConfirm={(reason) => void cancel(reason)}
|
||||
/>
|
||||
|
||||
<Text className="text-lg font-JakartaRegular">
|
||||
{driverDetails?.rating}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col w-full items-start justify-center py-3 px-5 rounded-3xl bg-general-600 mt-5">
|
||||
<View className="flex flex-row items-center justify-between w-full border-b border-white py-3">
|
||||
<Text className="text-lg font-JakartaRegular">Ride Price</Text>
|
||||
|
||||
<View className="flex flex-col items-end">
|
||||
<Text className="text-lg font-JakartaRegular text-[#0CC25F]">
|
||||
${driverDetails?.price}
|
||||
</Text>
|
||||
|
||||
<Text className="text-xs font-JakartaRegular text-general-200">
|
||||
≈ {formatLBP(parseFloat(driverDetails?.price ?? "0"))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between w-full border-b border-white py-3">
|
||||
<Text className="text-lg font-JakartaRegular">Pickup Time</Text>
|
||||
|
||||
<Text className="text-lg font-JakartaRegular">
|
||||
{formatTime(driverDetails?.time!)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-between w-full py-3">
|
||||
<Text className="text-lg font-JakartaRegular">Car Seats</Text>
|
||||
|
||||
<Text className="text-lg font-JakartaRegular">
|
||||
{driverDetails?.car_seats}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col w-full items-start justify-center mt-5">
|
||||
<View className="flex flex-row items-center justify-start mt-3 border-t border-b border-general-700 w-full py-3">
|
||||
<Image source={icons.to} alt="To" className="w-6 h-6" />
|
||||
|
||||
<Text className="text-lg font-JakartaRegular ml-2">
|
||||
{userAddress}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-start border-b border-general-700 w-full py-3">
|
||||
<Image source={icons.point} alt="Point" className="w-6 h-6" />
|
||||
|
||||
<Text className="text-lg font-JakartaRegular ml-2">
|
||||
{destinationAddress}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Payment
|
||||
fullName={user?.name ?? ""}
|
||||
email={user?.email ?? ""}
|
||||
amount={driverDetails?.price ?? "0"}
|
||||
driverId={driverDetails?.id}
|
||||
rideTime={driverDetails?.time ?? 0}
|
||||
<RatingSheet
|
||||
visible={ratingOpen}
|
||||
rideId={rideId}
|
||||
audience="rider"
|
||||
subjectName={driverName || null}
|
||||
subjectAvatar={driver.profile_image_url}
|
||||
onDone={() => {
|
||||
setRatingOpen(false);
|
||||
setRatingHandled(true);
|
||||
void load();
|
||||
}}
|
||||
onSkip={() => {
|
||||
setRatingOpen(false);
|
||||
setRatingHandled(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</RideLayout>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, Text, TouchableOpacity, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { RTCView } from "react-native-webrtc";
|
||||
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useCall } from "@/lib/use-call";
|
||||
import type { ChatActiveRide } from "@/types/type";
|
||||
|
||||
// In-app WebRTC audio call screen. Two entry modes:
|
||||
// mode=start — caller opened this from the chat header; we place the call.
|
||||
// mode=incoming — CallWatcher detected a ringing call; we attach and wait
|
||||
// for the user to Accept/Decline.
|
||||
// Either way the authoritative ride/role/peer come from GET /(api)/chat/active
|
||||
// (so a stale nav param never dials the wrong ride).
|
||||
|
||||
const Call = () => {
|
||||
const t = useT();
|
||||
const params = useLocalSearchParams<{
|
||||
rideId?: string;
|
||||
role?: "rider" | "driver";
|
||||
mode?: "start" | "incoming";
|
||||
}>();
|
||||
|
||||
const [active, setActive] = useState<ChatActiveRide | null>(null);
|
||||
const [resolving, setResolving] = useState(true);
|
||||
|
||||
const call = useCall();
|
||||
const startedRef = useRef(false);
|
||||
|
||||
// Resolve the active ride + peer once, then kick off the right flow.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetchAPI("/(api)/chat/active");
|
||||
const a = (res.data ?? null) as ChatActiveRide | null;
|
||||
if (cancelled) return;
|
||||
setActive(a);
|
||||
if (!a) return;
|
||||
|
||||
if (startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
const peerName = a.peer?.name ?? "";
|
||||
if (params.mode === "start") {
|
||||
void call.startCall(a.ride_id, a.role, peerName);
|
||||
} else {
|
||||
call.watch(a.ride_id, a.role, peerName);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[CALL_SCREEN_RESOLVE]: ", err);
|
||||
} finally {
|
||||
if (!cancelled) setResolving(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Surface a mic-permission denial and back out.
|
||||
useEffect(() => {
|
||||
if (call.micError) {
|
||||
Alert.alert(t("call.micDeniedTitle"), t("call.micDeniedBody"), [
|
||||
{ text: "OK", onPress: () => router.back() },
|
||||
]);
|
||||
}
|
||||
}, [call.micError, t]);
|
||||
|
||||
// When the call reaches a terminal state, show the label briefly, then
|
||||
// leave the screen so the user returns to where they came from.
|
||||
useEffect(() => {
|
||||
if (call.status !== "ended") return;
|
||||
const timer = setTimeout(() => router.back(), 1200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [call.status]);
|
||||
|
||||
const peerName = active?.peer?.name ?? call.peerName ?? "";
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
void call.endCall();
|
||||
}, [call]);
|
||||
const handleAccept = useCallback(() => {
|
||||
void call.answerCall();
|
||||
}, [call]);
|
||||
const handleDecline = useCallback(() => {
|
||||
void call.declineCall();
|
||||
}, [call]);
|
||||
|
||||
if (resolving) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
|
||||
<Text className="text-general-200 dark:text-neutral-400">
|
||||
{t("call.connecting")}
|
||||
</Text>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center px-7">
|
||||
<Text className="text-base text-center text-general-200 dark:text-neutral-400">
|
||||
{t("call.unavailable")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="mt-6 px-6 py-3 rounded-full bg-general-400"
|
||||
>
|
||||
<Text className="text-white font-JakartaBold">
|
||||
{t("call.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-between py-10">
|
||||
{/* Audio sink — hidden; keeps the native audio pipeline attached even
|
||||
though this is an audio-only call (RTCView is the stream sink). */}
|
||||
{call.remoteStream ? (
|
||||
<RTCView
|
||||
streamURL={call.remoteStream.toURL()}
|
||||
className="w-1 h-1 opacity-0"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Peer identity + status */}
|
||||
<View className="items-center mt-16">
|
||||
<View className="w-28 h-28 rounded-full bg-general-400 items-center justify-center mb-6">
|
||||
<Text className="text-4xl font-JakartaBold text-white">
|
||||
{(peerName.trim()[0] ?? "?").toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
|
||||
{peerName}
|
||||
</Text>
|
||||
<Text className="text-base mt-1 text-general-200 dark:text-neutral-400">
|
||||
{call.status === "incoming"
|
||||
? t("call.incoming")
|
||||
: call.status === "outgoing" || call.status === "connecting"
|
||||
? t("call.connectingWith", { name: peerName })
|
||||
: call.status === "in-call"
|
||||
? t("call.inCall")
|
||||
: call.status === "ended"
|
||||
? t("call.ended")
|
||||
: t("call.connecting")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Controls vary by state */}
|
||||
<View className="flex-row items-center justify-center mb-10">
|
||||
{call.status === "incoming" ? (
|
||||
<>
|
||||
<CallButton
|
||||
icon="phone-hangup"
|
||||
color="#ef4444"
|
||||
label={t("call.decline")}
|
||||
onPress={handleDecline}
|
||||
/>
|
||||
<CallButton
|
||||
icon="phone"
|
||||
color="#22c55e"
|
||||
label={t("call.accept")}
|
||||
onPress={handleAccept}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CallButton
|
||||
icon={call.muted ? "microphone-off" : "microphone"}
|
||||
color={call.muted ? "#ef4444" : "#6b7280"}
|
||||
label={call.muted ? t("call.unmute") : t("call.mute")}
|
||||
onPress={call.toggleMute}
|
||||
/>
|
||||
<CallButton
|
||||
icon="phone-hangup"
|
||||
color="#ef4444"
|
||||
label={t("call.end")}
|
||||
onPress={handleEnd}
|
||||
/>
|
||||
<CallButton
|
||||
icon={call.speakerOn ? "volume-high" : "volume-off"}
|
||||
color={call.speakerOn ? "#0286ff" : "#6b7280"}
|
||||
label={call.speakerOn ? t("call.speaker") : t("call.speakerOff")}
|
||||
onPress={call.toggleSpeaker}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const CallButton = ({
|
||||
icon,
|
||||
color,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
icon: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
|
||||
color: string;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
}) => (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
className="items-center mx-6"
|
||||
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||
>
|
||||
<View
|
||||
className="w-16 h-16 rounded-full items-center justify-center"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<MaterialCommunityIcons name={icon} size={28} color="white" />
|
||||
</View>
|
||||
<Text className="text-xs mt-2 text-general-200 dark:text-neutral-400">
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
export default Call;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { router } from "expo-router";
|
||||
import { FlatList, Text, View } from "react-native";
|
||||
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { DriverCard } from "@/components/driver-card";
|
||||
import { RideLayout } from "@/components/ride-layout";
|
||||
import { useDriverStore } from "@/store";
|
||||
|
||||
const ConfirmRide = () => {
|
||||
const { drivers, selectedDriver, setSelectedDriver } = useDriverStore();
|
||||
|
||||
return (
|
||||
<RideLayout title="Choose a Driver" snapPoints={["65%", "85%"]}>
|
||||
<FlatList
|
||||
data={drivers}
|
||||
renderItem={({ item }) => (
|
||||
<DriverCard
|
||||
item={item}
|
||||
selected={selectedDriver ?? 0}
|
||||
setSelected={() => setSelectedDriver(item.id)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={() => (
|
||||
<Text className="text-center text-general-200 font-JakartaMedium mt-10">
|
||||
No drivers available on this route right now.{"\n"}Please try
|
||||
another destination.
|
||||
</Text>
|
||||
)}
|
||||
ListFooterComponent={() => (
|
||||
<View className="mx-5 mt-10">
|
||||
<CustomButton
|
||||
title="Select Ride"
|
||||
onPress={() => router.push("/(root)/book-ride")}
|
||||
disabled={selectedDriver === null}
|
||||
className={selectedDriver === null ? "opacity-50" : ""}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</RideLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmRide;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ChatThread } from "@/components/chat-thread";
|
||||
|
||||
// Standalone chat screen for the driver side. Reuses the same ChatThread as
|
||||
// the rider's (tabs) Chat screen, but outside the rider's (tabs) navigator —
|
||||
// routing a driver into "/(root)/(tabs)/chat" would mount the rider's tab bar
|
||||
// (Home/Rides/Chat/Profile/Settings) around them, exposing rider-only screens
|
||||
// and clashing visually with the composer at the bottom. No tab bar here, so
|
||||
// no extra clearance is needed.
|
||||
const DriverChat = () => <ChatThread />;
|
||||
|
||||
export default DriverChat;
|
||||
+1962
-17
File diff suppressed because it is too large
Load Diff
+294
-15
@@ -1,12 +1,131 @@
|
||||
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 { useLocationStore } from "@/store";
|
||||
import { router } from "expo-router";
|
||||
import { Text, View } from "react-native";
|
||||
import { SERVICES, type ServiceId } from "@/constants/services";
|
||||
import { ApiError } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { calculateTripFare } from "@/lib/map";
|
||||
import { formatLBP } from "@/lib/pricing";
|
||||
import { createRideRequest } from "@/lib/request-ride";
|
||||
import { useServiceAvailability } from "@/lib/use-service-availability";
|
||||
import { formatTime } from "@/lib/utils";
|
||||
import { useLocationStore, useServiceStore } from "@/store";
|
||||
|
||||
/**
|
||||
* "Set it on the map" for one of the two points.
|
||||
*
|
||||
* An autocomplete result lands on whatever the geocoder calls the centre of a
|
||||
* place, which is regularly the wrong side of a building or the wrong end of a
|
||||
* long street — and a driver sent to the wrong side of a divided road can't
|
||||
* simply turn around. This is the escape hatch: the rider drags the map to the
|
||||
* exact doorway.
|
||||
*/
|
||||
const AdjustOnMap = ({ mode }: { mode: "origin" | "destination" }) => {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push({ pathname: "/(root)/adjust-pin", params: { mode } })
|
||||
}
|
||||
className="flex-row items-center gap-x-2 mt-2 self-start px-1 py-1.5"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="map-marker-radius"
|
||||
size={16}
|
||||
color="#0286ff"
|
||||
/>
|
||||
<Text className="text-sm font-JakartaBold text-primary-500">
|
||||
{t("findRide.adjustOnMap")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Which service the request goes out on, with live availability.
|
||||
*
|
||||
* It lives on this screen because this is now the last screen before drivers
|
||||
* are contacted — the request is broadcast on tap, so the choice of who to
|
||||
* broadcast it to has to be made here, next to the button that sends it.
|
||||
*/
|
||||
const ServiceRow = ({
|
||||
service,
|
||||
counts,
|
||||
onSelect,
|
||||
}: {
|
||||
service: ServiceId;
|
||||
counts: Record<ServiceId, number>;
|
||||
onSelect: (id: ServiceId) => void;
|
||||
}) => {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<View className="flex-row gap-2">
|
||||
{SERVICES.map((item) => {
|
||||
const active = item.id === service;
|
||||
const available = counts[item.id] ?? 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
onPress={() => onSelect(item.id)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: active }}
|
||||
className={`flex-1 items-center rounded-2xl border py-2.5 ${
|
||||
active
|
||||
? "border-primary-500 bg-primary-500/10"
|
||||
: "border-neutral-100 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={item.icon}
|
||||
size={20}
|
||||
color={active ? "#0286ff" : "#858585"}
|
||||
/>
|
||||
<Text
|
||||
className={`text-[11px] mt-1 font-JakartaMedium ${
|
||||
active
|
||||
? "text-primary-500"
|
||||
: "text-general-200 dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Text>
|
||||
{/* The count is the honest version of an empty map: it says
|
||||
whether asking this service is worth doing before the rider
|
||||
sends a request nobody will answer. */}
|
||||
<Text
|
||||
className={`text-[10px] ${
|
||||
available > 0
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-general-200 dark:text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{available > 0 ? t("findRide.nAvailable", { n: available }) : "—"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const FindRide = () => {
|
||||
const t = useT();
|
||||
const {
|
||||
userAddress,
|
||||
destinationAddress,
|
||||
@@ -17,44 +136,204 @@ const FindRide = () => {
|
||||
setDestinationLocation,
|
||||
setUserLocation,
|
||||
} = useLocationStore();
|
||||
const { service, setService } = useServiceStore();
|
||||
|
||||
const canFind =
|
||||
const [estimate, setEstimate] = useState<{
|
||||
fare: string;
|
||||
durationSeconds: number;
|
||||
} | null>(null);
|
||||
const [estimating, setEstimating] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const hasRoute =
|
||||
!!userLatitude &&
|
||||
!!userLongitude &&
|
||||
!!destinationLatitude &&
|
||||
!!destinationLongitude;
|
||||
|
||||
const { counts } = useServiceAvailability(userLatitude, userLongitude);
|
||||
|
||||
// The fare is quoted before the request goes out, not after: it is what the
|
||||
// drivers deciding whether to take the job are shown, so it has to exist by
|
||||
// the time the request does. Recomputed when the route or service changes.
|
||||
useEffect(() => {
|
||||
if (!hasRoute) {
|
||||
setEstimate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setEstimating(true);
|
||||
|
||||
void calculateTripFare({
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
service,
|
||||
})
|
||||
.then((trip) => {
|
||||
if (cancelled) return;
|
||||
setEstimate(
|
||||
trip
|
||||
? { fare: trip.fare, durationSeconds: trip.durationSeconds }
|
||||
: null,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setEstimating(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
hasRoute,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
service,
|
||||
]);
|
||||
|
||||
const findNow = async () => {
|
||||
if (!hasRoute || !estimate) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const ride = await createRideRequest({
|
||||
service,
|
||||
origin: {
|
||||
address: userAddress ?? "",
|
||||
latitude: userLatitude!,
|
||||
longitude: userLongitude!,
|
||||
},
|
||||
destination: {
|
||||
address: destinationAddress ?? "",
|
||||
latitude: destinationLatitude!,
|
||||
longitude: destinationLongitude!,
|
||||
},
|
||||
rideTimeSeconds: estimate.durationSeconds,
|
||||
fareCents: Math.round(parseFloat(estimate.fare) * 100),
|
||||
});
|
||||
|
||||
router.replace(`/(root)/book-ride?id=${ride.ride_id}`);
|
||||
} catch (err) {
|
||||
console.log("[FIND_RIDE]: ", err);
|
||||
|
||||
// The rider already has a ride in flight. Booking a second one isn't
|
||||
// what they want — they want the one they lost track of, so take them
|
||||
// to it instead of showing an error they can't act on.
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.status === 409 &&
|
||||
err.body?.code === "RIDE_IN_PROGRESS"
|
||||
) {
|
||||
const inProgressId = String(err.body.ride_id);
|
||||
Alert.alert(
|
||||
t("confirmRide.alertInProgressTitle"),
|
||||
t("confirmRide.alertInProgressBody"),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("confirmRide.viewRide"),
|
||||
onPress: () =>
|
||||
router.replace(`/(root)/book-ride?id=${inProgressId}`),
|
||||
},
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
t("confirmRide.alertErrorTitle"),
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("confirmRide.alertErrorFallback"),
|
||||
);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<RideLayout title="Ride" snapPoints={["85%"]}>
|
||||
<RideLayout title={t("findRide.title")} snapPoints={["85%"]}>
|
||||
<View className="my-3">
|
||||
<Text className="text-lg font-JakartaSemiBold mb-3">From</Text>
|
||||
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
|
||||
{t("findRide.from")}
|
||||
</Text>
|
||||
|
||||
<GoogleTextInput
|
||||
icon={icons.target}
|
||||
initialLocation={userAddress ?? ""}
|
||||
containerStyles="bg-neutral-100"
|
||||
textInputBackgroundColor="#F5F5F5"
|
||||
containerStyles="bg-neutral-100 dark:bg-neutral-800"
|
||||
handlePress={setUserLocation}
|
||||
/>
|
||||
|
||||
<AdjustOnMap mode="origin" />
|
||||
</View>
|
||||
|
||||
<View className="my-3">
|
||||
<Text className="text-lg font-JakartaSemiBold mb-3">To</Text>
|
||||
<Text className="text-lg font-JakartaSemiBold mb-3 text-black dark:text-white">
|
||||
{t("findRide.to")}
|
||||
</Text>
|
||||
|
||||
<GoogleTextInput
|
||||
icon={icons.map}
|
||||
initialLocation={destinationAddress ?? ""}
|
||||
containerStyles="bg-neutral-100"
|
||||
textInputBackgroundColor="transparent"
|
||||
containerStyles="bg-neutral-100 dark:bg-neutral-800"
|
||||
handlePress={setDestinationLocation}
|
||||
/>
|
||||
|
||||
<AdjustOnMap mode="destination" />
|
||||
</View>
|
||||
|
||||
<Text className="text-sm font-JakartaSemiBold mb-2 mt-1 text-black dark:text-white">
|
||||
{t("findRide.service")}
|
||||
</Text>
|
||||
<ServiceRow service={service} counts={counts} onSelect={setService} />
|
||||
|
||||
{/* The quote, shown before the request goes out rather than on a screen
|
||||
after it. This is the number the rider agrees to and the number every
|
||||
driver who sees the request is offered, so it belongs next to the
|
||||
button that sends it. */}
|
||||
<View className="flex-row items-center justify-between rounded-2xl bg-general-500 dark:bg-neutral-950 px-4 py-3 mt-4">
|
||||
<View>
|
||||
<Text className="text-xs font-JakartaMedium text-general-200 dark:text-neutral-400">
|
||||
{t("findRide.estimatedFare")}
|
||||
</Text>
|
||||
<Text className="text-[11px] text-general-200 dark:text-neutral-400 mt-0.5">
|
||||
{estimate
|
||||
? t("confirmRide.tripTime", {
|
||||
time: formatTime(estimate.durationSeconds / 60),
|
||||
})
|
||||
: t("findRide.setBothPoints")}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="items-end">
|
||||
<Text className="text-2xl font-JakartaExtraBold text-black dark:text-white">
|
||||
{estimating ? "…" : estimate ? `$${estimate.fare}` : "—"}
|
||||
</Text>
|
||||
{estimate ? (
|
||||
<Text className="text-[11px] text-general-200 dark:text-neutral-400">
|
||||
{t("confirmRide.lbpEstimate", {
|
||||
lbp: formatLBP(parseFloat(estimate.fare)),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="text-[11px] text-center text-general-200 dark:text-neutral-400 mt-3">
|
||||
{t("findRide.payLaterHint")}
|
||||
</Text>
|
||||
|
||||
<CustomButton
|
||||
title="Find now"
|
||||
onPress={() => router.push("/(root)/confirm-ride")}
|
||||
disabled={!canFind}
|
||||
className={`mt-5 ${!canFind ? "opacity-50" : ""}`}
|
||||
Touchable={TouchableOpacity}
|
||||
title={sending ? t("findRide.sending") : t("findRide.findNow")}
|
||||
onPress={() => void findNow()}
|
||||
disabled={!hasRoute || !estimate || estimating || sending}
|
||||
className={`mt-3 ${!hasRoute || !estimate || estimating || sending ? "opacity-50" : ""}`}
|
||||
/>
|
||||
</RideLayout>
|
||||
);
|
||||
|
||||
+16
-12
@@ -4,10 +4,12 @@ import { Alert, Text, TouchableOpacity, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const RoleSelection = () => {
|
||||
const { setUserRole } = useSession();
|
||||
const t = useT();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const chooseRole = async (role: "rider" | "driver") => {
|
||||
@@ -31,20 +33,20 @@ const RoleSelection = () => {
|
||||
);
|
||||
} catch (err) {
|
||||
console.log("[ROLE_SELECT]: ", err);
|
||||
Alert.alert("Error", "Could not save your choice. Please try again.");
|
||||
Alert.alert(t("auth.role.alertErrorTitle"), t("auth.role.alertErrorBody"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white justify-center px-7">
|
||||
<Text className="text-3xl font-JakartaExtraBold text-center">
|
||||
How will you use Waseel?
|
||||
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 justify-center px-7">
|
||||
<Text className="text-3xl font-JakartaExtraBold text-center text-black dark:text-white">
|
||||
{t("auth.role.title")}
|
||||
</Text>
|
||||
|
||||
<Text className="text-base text-general-200 font-Jakarta text-center mt-3 mb-10">
|
||||
You can change this later by contacting support.
|
||||
<Text className="text-base text-general-200 dark:text-neutral-400 font-Jakarta text-center mt-3 mb-10">
|
||||
{t("auth.role.subtitle")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -54,22 +56,24 @@ const RoleSelection = () => {
|
||||
>
|
||||
<Text className="text-5xl mb-3">🧍</Text>
|
||||
<Text className="text-2xl font-JakartaBold text-white">
|
||||
I'm a Rider
|
||||
{t("auth.role.riderTitle")}
|
||||
</Text>
|
||||
<Text className="text-sm font-Jakarta text-white/80 text-center mt-2">
|
||||
Book rides and get around Lebanon
|
||||
{t("auth.role.riderDesc")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => chooseRole("driver")}
|
||||
disabled={saving}
|
||||
className="bg-general-600 rounded-2xl p-7 items-center"
|
||||
className="bg-general-600 dark:bg-primary-500/20 border border-primary-500 rounded-2xl p-7 items-center"
|
||||
>
|
||||
<Text className="text-5xl mb-3">🚗</Text>
|
||||
<Text className="text-2xl font-JakartaBold">I'm a Driver</Text>
|
||||
<Text className="text-sm font-Jakarta text-general-200 text-center mt-2">
|
||||
Give rides and earn money
|
||||
<Text className="text-2xl font-JakartaBold text-black dark:text-white">
|
||||
{t("auth.role.driverTitle")}
|
||||
</Text>
|
||||
<Text className="text-sm font-Jakarta text-general-200 dark:text-neutral-400 text-center mt-2">
|
||||
{t("auth.role.driverDesc")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</SafeAreaView>
|
||||
|
||||
+19
-5
@@ -1,17 +1,27 @@
|
||||
import { useFonts } from "expo-font";
|
||||
import { Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useEffect } from "react";
|
||||
import { LogBox } from "react-native";
|
||||
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();
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
// 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({
|
||||
@@ -35,15 +45,19 @@ const RootLayout = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsProvider>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<SessionProvider>
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(root)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
|
||||
<StatusBar style="dark" />
|
||||
</SessionProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</SettingsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ const App = () => {
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<View className="flex-1 items-center justify-center bg-white dark:bg-neutral-950">
|
||||
<ActivityIndicator size="large" color="#0286FF" />
|
||||
</View>
|
||||
);
|
||||
@@ -36,7 +36,7 @@ const App = () => {
|
||||
// Still loading the user's role from the database.
|
||||
if (role === undefined) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<View className="flex-1 items-center justify-center bg-white dark:bg-neutral-950">
|
||||
<ActivityIndicator size="large" color="#0286FF" />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { RatingSheet } from "@/components/rating-sheet";
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
// Home-screen banner for unfinished business. Two things can be unfinished
|
||||
// after the rider leaves the tracking screen:
|
||||
//
|
||||
// * a ride still in flight — before this, killing the app mid-ride stranded
|
||||
// the rider with no route back to their driver, since home only lists
|
||||
// completed history;
|
||||
// * a finished ride they never rated — the prompt is easy to miss when the
|
||||
// app is backgrounded the moment the door closes.
|
||||
//
|
||||
// Both are recoverable from one poll, so they share one banner.
|
||||
|
||||
const POLL_MS = 15000;
|
||||
|
||||
type ActiveRide = {
|
||||
ride_id: number;
|
||||
status: string;
|
||||
service: string;
|
||||
destination_address: string;
|
||||
driver_name: string | null;
|
||||
};
|
||||
|
||||
type PendingRating = {
|
||||
ride_id: number;
|
||||
destination_address: string;
|
||||
driver_name: string | null;
|
||||
driver_avatar: string | null;
|
||||
};
|
||||
|
||||
const STATUS_KEY: Record<string, string> = {
|
||||
requested: "bookRide.status.requested",
|
||||
accepted: "bookRide.status.accepted",
|
||||
arrived: "bookRide.status.arrived",
|
||||
en_route: "bookRide.status.enRoute",
|
||||
};
|
||||
|
||||
export const ActiveRideBanner = () => {
|
||||
const t = useT();
|
||||
const [active, setActive] = useState<ActiveRide | null>(null);
|
||||
const [pending, setPending] = useState<PendingRating | null>(null);
|
||||
const [ratingOpen, setRatingOpen] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<number[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetchAPI("/(api)/ride/active");
|
||||
setActive(res.data?.active ?? null);
|
||||
setPending(res.data?.pending_rating ?? null);
|
||||
} catch (err) {
|
||||
// A signed-out or offline home screen simply shows no banner.
|
||||
console.log("[ACTIVE_RIDE_BANNER]: ", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => void load(), POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
if (active) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(root)/book-ride",
|
||||
params: { id: String(active.ride_id) },
|
||||
})
|
||||
}
|
||||
className="bg-primary-500 rounded-2xl p-4 mb-4 flex-row items-center"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white/80 text-xs font-JakartaMedium">
|
||||
{STATUS_KEY[active.status]
|
||||
? t(STATUS_KEY[active.status])
|
||||
: active.status}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-white font-JakartaBold mt-0.5"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{active.driver_name
|
||||
? t("home.activeRideWithDriver", { name: active.driver_name })
|
||||
: active.destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (pending && !dismissed.includes(pending.ride_id)) {
|
||||
return (
|
||||
<>
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-4 mb-4 flex-row items-center">
|
||||
<View className="flex-1">
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-xs font-JakartaMedium">
|
||||
{t("home.rateLastRide")}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-black dark:text-white font-JakartaBold mt-0.5"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{pending.destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setRatingOpen(true)}
|
||||
className="bg-primary-500 rounded-full px-4 py-2 ml-3"
|
||||
>
|
||||
<Text className="text-white font-JakartaBold text-xs">
|
||||
{t("home.rate")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<RatingSheet
|
||||
visible={ratingOpen}
|
||||
rideId={pending.ride_id}
|
||||
audience="rider"
|
||||
subjectName={pending.driver_name}
|
||||
subjectAvatar={pending.driver_avatar}
|
||||
onDone={() => {
|
||||
setRatingOpen(false);
|
||||
setDismissed((prev) => [...prev, pending.ride_id]);
|
||||
void load();
|
||||
}}
|
||||
onSkip={() => {
|
||||
setRatingOpen(false);
|
||||
setDismissed((prev) => [...prev, pending.ride_id]);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import type { CallRecord, ChatActiveRide } from "@/types/type";
|
||||
|
||||
// Listens for an incoming WebRTC call (a 'ringing' call row this user did not
|
||||
// place) and routes the user to the call screen — regardless of which tab is
|
||||
// open. Rendered once at the root layout level; emits no UI.
|
||||
//
|
||||
// It only polls while an active ride exists (the only window in which a call
|
||||
// can happen). To avoid re-navigating on every poll, it remembers the call id
|
||||
// it already handed off to the call screen and resets once that call goes
|
||||
// terminal.
|
||||
|
||||
const ACTIVE_POLL_MS = 5000;
|
||||
const CALL_POLL_MS = 3000;
|
||||
|
||||
const CallWatcher = () => {
|
||||
// The ride we're watching for an incoming call on.
|
||||
const rideIdRef = useRef<number | null>(null);
|
||||
// The call id we've already navigated to, so we don't re-push the screen.
|
||||
const handledCallIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// Refresh which ride (if any) is active for this user, then poll its call
|
||||
// row. Both run on intervals; the call poll no-ops until a rideId is known.
|
||||
const activeTimer = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetchAPI("/(api)/chat/active");
|
||||
const active = (res.data ?? null) as ChatActiveRide | null;
|
||||
if (cancelled) return;
|
||||
rideIdRef.current = active?.ride_id ?? null;
|
||||
} catch (err) {
|
||||
console.log("[CALL_WATCHER_ACTIVE]: ", err);
|
||||
}
|
||||
}, ACTIVE_POLL_MS);
|
||||
|
||||
const callTimer = setInterval(async () => {
|
||||
const rideId = rideIdRef.current;
|
||||
if (rideId === null) return;
|
||||
try {
|
||||
const res = await fetchAPI(`/(api)/ride/${rideId}/call`);
|
||||
const call = (res.data ?? null) as CallRecord | null;
|
||||
if (cancelled || !call) return;
|
||||
|
||||
// A terminal call clears the handled marker so the next incoming call
|
||||
// can navigate again.
|
||||
if (
|
||||
call.status === "ended" ||
|
||||
call.status === "declined" ||
|
||||
call.status === "missed"
|
||||
) {
|
||||
if (handledCallIdRef.current === call.id) {
|
||||
handledCallIdRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// An incoming ringing call we didn't place: hand off to the call
|
||||
// screen, once per call id.
|
||||
if (call.status === "ringing" && !call.is_caller) {
|
||||
if (handledCallIdRef.current === call.id) return;
|
||||
handledCallIdRef.current = call.id;
|
||||
router.push({
|
||||
pathname: "/(root)/call",
|
||||
params: { rideId: String(rideId), mode: "incoming" },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[CALL_WATCHER_CALL]: ", err);
|
||||
}
|
||||
}, CALL_POLL_MS);
|
||||
|
||||
// Kick the active poll immediately so an incoming call on a freshly
|
||||
// matched ride is noticed without waiting for the first interval.
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetchAPI("/(api)/chat/active");
|
||||
if (cancelled) return;
|
||||
rideIdRef.current =
|
||||
((res.data ?? null) as ChatActiveRide | null)?.ride_id ?? null;
|
||||
} catch {
|
||||
// ignore — the interval will retry
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(activeTimer);
|
||||
clearInterval(callTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CallWatcher;
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
// Cancelling asks *why* before it asks "are you sure". The reason codes are
|
||||
// fixed (lib/ride-lifecycle CANCELLATION_REASONS) rather than free text, so
|
||||
// the admin portal can count them — "driver never showed" and "I changed my
|
||||
// mind" are the same cancellation in the ledger otherwise, and only one of
|
||||
// them is a problem worth chasing.
|
||||
|
||||
const RIDER_REASONS = [
|
||||
"wait_too_long",
|
||||
"driver_no_show",
|
||||
"unreachable",
|
||||
"wrong_address",
|
||||
"changed_mind",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
const DRIVER_REASONS = [
|
||||
"rider_no_show",
|
||||
"unreachable",
|
||||
"wrong_address",
|
||||
"vehicle_issue",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
audience: "rider" | "driver";
|
||||
submitting?: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
};
|
||||
|
||||
export const CancelSheet = ({
|
||||
visible,
|
||||
audience,
|
||||
submitting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: Props) => {
|
||||
const t = useT();
|
||||
const [reason, setReason] = useState<string | null>(null);
|
||||
const reasons = audience === "rider" ? RIDER_REASONS : DRIVER_REASONS;
|
||||
|
||||
return (
|
||||
<ReactNativeModal isVisible={visible} onBackdropPress={onCancel}>
|
||||
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
|
||||
<Text className="text-xl font-JakartaBold text-black dark:text-white">
|
||||
{t("cancelSheet.title")}
|
||||
</Text>
|
||||
<Text className="text-sm text-general-200 dark:text-neutral-400 mt-1 mb-4">
|
||||
{audience === "rider"
|
||||
? t("cancelSheet.subtitleRider")
|
||||
: t("cancelSheet.subtitleDriver")}
|
||||
</Text>
|
||||
|
||||
{reasons.map((code) => {
|
||||
const selected = reason === code;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={code}
|
||||
onPress={() => setReason(code)}
|
||||
className={`rounded-2xl border px-4 py-3 mb-2 ${
|
||||
selected
|
||||
? "border-primary-500 bg-primary-500/10"
|
||||
: "border-neutral-200 dark:border-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-JakartaMedium ${
|
||||
selected ? "text-primary-500" : "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{t(`cancelSheet.reasons.${code}`)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => reason && onConfirm(reason)}
|
||||
disabled={!reason || submitting}
|
||||
className={`rounded-full py-3 items-center mt-3 bg-rose-500 ${
|
||||
!reason || submitting ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<Text className="font-JakartaBold text-white">
|
||||
{submitting
|
||||
? t("cancelSheet.cancelling")
|
||||
: t("cancelSheet.confirm")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={onCancel} className="py-3 mt-1">
|
||||
<Text className="text-center font-JakartaMedium text-general-200 dark:text-neutral-400">
|
||||
{t("cancelSheet.keepRide")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Image,
|
||||
Keyboard,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import {
|
||||
SafeAreaView,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
|
||||
import { images } from "@/constants";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { ensureMicPermission } from "@/lib/use-call";
|
||||
import { useChat } from "@/lib/use-chat";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import type { ChatActiveRide, Message } from "@/types/type";
|
||||
|
||||
const initials = (name: string): string => {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return "?";
|
||||
return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
|
||||
};
|
||||
|
||||
type ChatThreadProps = {
|
||||
/**
|
||||
* Extra clearance (px) the composer needs below the safe area — nonzero
|
||||
* when this screen sits under the rider's floating tab bar (position:
|
||||
* "absolute", ~78px tall + 20px margin), which doesn't reserve layout
|
||||
* space of its own and would otherwise sit on top of the composer. Pass 0
|
||||
* for a standalone screen (no tab bar underneath, e.g. the driver's).
|
||||
*/
|
||||
tabBarClearance?: number;
|
||||
};
|
||||
|
||||
// Ride-scoped chat thread: header with the peer + call button, message list,
|
||||
// and composer. Shared by the rider's (tabs) Chat screen and the driver's
|
||||
// standalone chat screen — both resolve the same conversation via
|
||||
// GET /(api)/chat/active, which returns the correct peer for either role.
|
||||
export const ChatThread = ({ tabBarClearance = 0 }: ChatThreadProps) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [active, setActive] = useState<ChatActiveRide | null>(null);
|
||||
const [resolving, setResolving] = useState(true);
|
||||
|
||||
// Resolve which conversation (if any) is open for the signed-in user. Re-run
|
||||
// whenever the screen is focused so a just-matched ride appears immediately.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setResolving(true);
|
||||
try {
|
||||
const res = await fetchAPI("/(api)/chat/active");
|
||||
if (!cancelled) setActive((res.data ?? null) as ChatActiveRide);
|
||||
} catch (err) {
|
||||
console.log("[CHAT_ACTIVE]: ", err);
|
||||
if (!cancelled) setActive(null);
|
||||
} finally {
|
||||
if (!cancelled) setResolving(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []),
|
||||
);
|
||||
|
||||
const rideId = active?.ride_id ?? null;
|
||||
const role = active?.role ?? null;
|
||||
const { messages, loading, sending, sendMessage } = useChat(rideId, role);
|
||||
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const peer = active?.peer ?? null;
|
||||
const peerName = peer?.name ?? "";
|
||||
|
||||
// Prime the mic permission as soon as a conversation (and its Call button)
|
||||
// is on screen, so the OS prompt lands here — not mid-handshake after the
|
||||
// user has already tapped Call and navigated to the call screen.
|
||||
const hasPeer = Boolean(peer);
|
||||
useEffect(() => {
|
||||
if (hasPeer) void ensureMicPermission();
|
||||
}, [hasPeer]);
|
||||
|
||||
const openCall = useCallback(() => {
|
||||
if (!active) return;
|
||||
router.push({
|
||||
pathname: "/(root)/call",
|
||||
params: {
|
||||
rideId: String(active.ride_id),
|
||||
role: active.role,
|
||||
mode: "start",
|
||||
},
|
||||
});
|
||||
}, [active]);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const text = draft.trim();
|
||||
if (!text || sending) return;
|
||||
setDraft("");
|
||||
void sendMessage(text);
|
||||
Keyboard.dismiss();
|
||||
}, [draft, sending, sendMessage]);
|
||||
|
||||
const renderBubble = useCallback(
|
||||
({ item }: { item: Message }) => {
|
||||
const mine = item.sender_type === role;
|
||||
return (
|
||||
<View
|
||||
className={`flex-row ${mine ? "justify-end" : "justify-start"} my-1`}
|
||||
>
|
||||
<View
|
||||
className={`max-w-[78%] rounded-2xl px-4 py-2.5 ${
|
||||
mine ? "bg-general-400" : "bg-neutral-100 dark:bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-[15px] ${
|
||||
mine ? "text-white" : "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{item.body}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[role],
|
||||
);
|
||||
|
||||
const emptyConversation = useMemo(
|
||||
() => (
|
||||
<View className="flex-1 h-fit flex justify-center items-center">
|
||||
<Image
|
||||
source={images.message}
|
||||
alt={t("chat.messageAlt")}
|
||||
className="w-full h-40"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text className="text-3xl font-JakartaBold mt-3 text-black dark:text-white">
|
||||
{t("chat.noMessages")}
|
||||
</Text>
|
||||
<Text className="text-base mt-2 text-center px-7 text-general-200 dark:text-neutral-400">
|
||||
{t("chat.startConversation")}
|
||||
</Text>
|
||||
</View>
|
||||
),
|
||||
[t],
|
||||
);
|
||||
|
||||
if (resolving) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-white dark:bg-neutral-950 items-center justify-center">
|
||||
<ActivityIndicator size="large" color={isDark ? "#fff" : "#0286ff"} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
className="flex-1 bg-white dark:bg-neutral-950"
|
||||
edges={["top"]}
|
||||
>
|
||||
{/* Conversation header — only when a ride is matched */}
|
||||
{active && peer ? (
|
||||
<View className="flex-row items-center px-4 py-3 border-b border-neutral-100 dark:border-neutral-800">
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/(root)/book-ride",
|
||||
params: { id: String(active.ride_id) },
|
||||
})
|
||||
}
|
||||
className="flex-row items-center flex-1"
|
||||
>
|
||||
{peer.avatar ? (
|
||||
<Image
|
||||
source={{ uri: driverPhotoUri(peer.avatar) }}
|
||||
className="w-10 h-10 rounded-full bg-neutral-200 dark:bg-neutral-700"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-10 h-10 rounded-full bg-general-400 items-center justify-center">
|
||||
<Text className="text-white font-JakartaBold">
|
||||
{initials(peerName)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="ml-3">
|
||||
<Text className="text-base font-JakartaBold text-black dark:text-white">
|
||||
{peerName}
|
||||
</Text>
|
||||
{peer.car_model ? (
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{peer.car_model}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={openCall}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
accessibilityLabel={t("chat.call")}
|
||||
className="w-10 h-10 rounded-full bg-general-300 dark:bg-neutral-800 items-center justify-center"
|
||||
>
|
||||
<MaterialCommunityIcons name="phone" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{active && peer ? (
|
||||
<>
|
||||
{loading && messages.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={isDark ? "#fff" : "#0286ff"}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={messages}
|
||||
keyExtractor={(m) => String(m.id)}
|
||||
renderItem={renderBubble}
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
}}
|
||||
onScrollBeginDrag={Keyboard.dismiss}
|
||||
keyboardShouldPersistTaps="never"
|
||||
ListEmptyComponent={emptyConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<View
|
||||
className="flex-row items-center px-3 py-2 border-t border-neutral-100 dark:border-neutral-800"
|
||||
style={{ paddingBottom: insets.bottom + 8 + tabBarClearance }}
|
||||
>
|
||||
<TextInput
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder={t("chat.inputPlaceholder")}
|
||||
placeholderTextColor={isDark ? "#737373" : "#9ca3af"}
|
||||
className="flex-1 min-h-[44px] max-h-28 rounded-full bg-neutral-100 dark:bg-neutral-800 px-4 py-2.5 text-[15px] text-black dark:text-white"
|
||||
multiline
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={submit}
|
||||
disabled={sending || !draft.trim()}
|
||||
accessibilityLabel={t("chat.send")}
|
||||
className="w-11 h-11 ml-2 rounded-full bg-general-400 items-center justify-center disabled:opacity-40"
|
||||
>
|
||||
<MaterialCommunityIcons name="send" size={20} color="white" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<View className="flex-1 px-5">{emptyConversation}</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ const getBgVariantStyle = (variant: ButtonProps["bgVariant"]) => {
|
||||
case "success":
|
||||
return "bg-emerald-500";
|
||||
case "outline":
|
||||
return "bg-transparent-500 border-neutral-300 border-[0.5px]";
|
||||
return "bg-transparent border-neutral-300 dark:border-neutral-700 border-[0.5px]";
|
||||
default:
|
||||
return "bg-[#0286ff]";
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const getBgVariantStyle = (variant: ButtonProps["bgVariant"]) => {
|
||||
const getTextVariantStyle = (variant: ButtonProps["textVariant"]) => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return "text-black";
|
||||
return "text-black dark:text-white";
|
||||
case "secondary":
|
||||
return "text-gray-100";
|
||||
case "danger":
|
||||
@@ -40,11 +40,16 @@ export const CustomButton = ({
|
||||
iconLeft: IconLeft,
|
||||
iconRight: IconRight,
|
||||
className,
|
||||
// Which touchable the button is built on. React Native's own works
|
||||
// everywhere except inside a @gorhom/bottom-sheet on Android, where the
|
||||
// sheet's gesture handler eats the first press — the button only fires on
|
||||
// the second tap. Screens hosted in a sheet pass the sheet's touchable.
|
||||
Touchable = TouchableOpacity,
|
||||
...props
|
||||
}: ButtonProps) => (
|
||||
<TouchableOpacity
|
||||
<Touchable
|
||||
onPress={onPress}
|
||||
className={`w-full rounded-full p-3 flex flex-row justify-center items-center shadow-md shadow-neutral-400/70 ${getBgVariantStyle(bgVariant)} ${className}`}
|
||||
className={`w-full rounded-full p-3 flex flex-row justify-center items-center shadow-md shadow-neutral-400/70 dark:shadow-neutral-950/70 ${getBgVariantStyle(bgVariant)} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{IconLeft && <IconLeft />}
|
||||
@@ -54,5 +59,5 @@ export const CustomButton = ({
|
||||
</Text>
|
||||
|
||||
{IconRight && <IconRight />}
|
||||
</TouchableOpacity>
|
||||
</Touchable>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type * as ImagePicker from "expo-image-picker";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { alertPermissionDenied } from "@/lib/capture-permission";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { loadImagePicker } from "@/lib/image-picker";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
/** The three documents a Lebanese driver is vetted against. */
|
||||
export type DocumentType = "license" | "id" | "vehicle_reg";
|
||||
|
||||
/**
|
||||
* What a scan can fill in. Every field is optional and independent: a licence
|
||||
* whose number reads cleanly but whose expiry is smudged yields just the
|
||||
* number. Mirrors ExtractedFields on the server — deliberately redeclared here
|
||||
* so the client bundle doesn't pull in lib/document-ocr.ts, which is Node-only.
|
||||
*/
|
||||
export type ScannedFields = {
|
||||
license_number?: string;
|
||||
license_expiry?: string;
|
||||
national_id?: string;
|
||||
plate_number?: string;
|
||||
car_model?: string;
|
||||
};
|
||||
|
||||
type ScanResponse = {
|
||||
data: {
|
||||
doc_type: DocumentType;
|
||||
document: string;
|
||||
fields: ScannedFields;
|
||||
code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Photographs one document, sends it for OCR, and reports back both the stored
|
||||
* scan's name (which goes with the profile submission) and whatever fields
|
||||
* were read off it.
|
||||
*
|
||||
* The component never writes to the form itself — it hands the values up, and
|
||||
* the form decides what to do with them. That separation is what lets a driver
|
||||
* correct a misread field and not have the next scan silently stamp over it.
|
||||
* A failed read is not an error state here: the scan is still stored for the
|
||||
* reviewer, and the driver types the details in by hand as before.
|
||||
*/
|
||||
export const DocumentScanner = ({
|
||||
docType,
|
||||
label,
|
||||
hint,
|
||||
optional = false,
|
||||
onFile = false,
|
||||
onScanned,
|
||||
}: {
|
||||
docType: DocumentType;
|
||||
label: string;
|
||||
hint: string;
|
||||
optional?: boolean;
|
||||
/** A scan of this document is already stored — resubmitting may not need a new one. */
|
||||
onFile?: boolean;
|
||||
onScanned: (document: string, fields: ScannedFields) => void;
|
||||
}) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const upload = async (asset: ImagePicker.ImagePickerAsset) => {
|
||||
if (!asset.base64) {
|
||||
Alert.alert(t("driver.scan.errorTitle"), t("driver.scan.errorBody"));
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setStatus(null);
|
||||
setFailed(false);
|
||||
|
||||
try {
|
||||
const { data } = (await fetchAPI("/(api)/driver/scan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ doc_type: docType, image_base64: asset.base64 }),
|
||||
})) as ScanResponse;
|
||||
|
||||
setPreview(asset.uri);
|
||||
onScanned(data.document, data.fields);
|
||||
|
||||
const filled = Object.values(data.fields).filter(Boolean).length;
|
||||
|
||||
// Three outcomes worth telling apart: OCR read something, OCR ran and
|
||||
// found nothing usable, or OCR never ran. All three keep the scan; only
|
||||
// the wording changes, because in every case the driver's next move is
|
||||
// to check the fields below.
|
||||
setStatus(
|
||||
filled > 0
|
||||
? t("driver.scan.filled", undefined, filled)
|
||||
: data.code === "OCR_UNAVAILABLE"
|
||||
? t("driver.scan.savedUnreadable")
|
||||
: t("driver.scan.savedNoFields"),
|
||||
);
|
||||
} catch (err) {
|
||||
console.log("[DOCUMENT_SCAN]: ", err);
|
||||
|
||||
const code =
|
||||
err instanceof ApiError
|
||||
? (err.body?.code as string | undefined)
|
||||
: undefined;
|
||||
|
||||
Alert.alert(
|
||||
t("driver.scan.errorTitle"),
|
||||
code === "IMAGE_TOO_LARGE"
|
||||
? t("driver.scan.errorTooLarge")
|
||||
: code === "SCAN_RATE_LIMIT"
|
||||
? t("driver.scan.errorRateLimit")
|
||||
: code === "UNSUPPORTED_IMAGE"
|
||||
? t("driver.scan.errorUnsupported")
|
||||
: t("driver.scan.errorBody"),
|
||||
);
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const capture = async (source: "camera" | "library") => {
|
||||
if (busy) return;
|
||||
|
||||
// Loaded on demand: on a binary built before expo-image-picker was added
|
||||
// the native module is missing, and importing it at the top of this file
|
||||
// would take the whole app down instead of just this button.
|
||||
const picker = loadImagePicker();
|
||||
if (!picker) {
|
||||
Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask only for the permission the tapped button actually needs — a driver
|
||||
// who refuses the camera can still pick an existing photo of their papers.
|
||||
let permission: ImagePicker.PermissionResponse;
|
||||
|
||||
try {
|
||||
permission =
|
||||
source === "camera"
|
||||
? await picker.requestCameraPermissionsAsync()
|
||||
: await picker.requestMediaLibraryPermissionsAsync();
|
||||
} catch (error) {
|
||||
console.log("[DOCUMENT_SCAN_PERMISSION]: ", error);
|
||||
Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
alertPermissionDenied(permission, {
|
||||
title: t("driver.scan.permissionTitle"),
|
||||
message:
|
||||
source === "camera"
|
||||
? t("driver.scan.permissionCamera")
|
||||
: t("driver.scan.permissionLibrary"),
|
||||
blocked:
|
||||
source === "camera"
|
||||
? t("driver.scan.permissionCameraBlocked")
|
||||
: t("driver.scan.permissionLibraryBlocked"),
|
||||
openSettings: t("common.openSettings"),
|
||||
cancel: t("common.cancel"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// `quality: 0.6` keeps a phone photo comfortably under the upload cap
|
||||
// while staying sharp enough to read small print; no cropping step,
|
||||
// because OCR wants the whole card and an edited crop routinely loses the
|
||||
// line the expiry date sits on.
|
||||
const options: ImagePicker.ImagePickerOptions = {
|
||||
mediaTypes: picker.MediaTypeOptions.Images,
|
||||
quality: 0.6,
|
||||
base64: true,
|
||||
exif: false,
|
||||
};
|
||||
|
||||
let result: ImagePicker.ImagePickerResult;
|
||||
|
||||
try {
|
||||
result =
|
||||
source === "camera"
|
||||
? await picker.launchCameraAsync(options)
|
||||
: await picker.launchImageLibraryAsync(options);
|
||||
} catch (error) {
|
||||
console.log("[DOCUMENT_SCAN_CAPTURE]: ", error);
|
||||
Alert.alert(t("driver.scan.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.canceled || !result.assets[0]) return;
|
||||
|
||||
await upload(result.assets[0]);
|
||||
};
|
||||
|
||||
const scanned = preview !== null;
|
||||
|
||||
return (
|
||||
<View className="bg-neutral-100 dark:bg-neutral-900 rounded-2xl p-4 mb-4">
|
||||
<View className="flex-row items-start justify-between mb-1">
|
||||
<Text className="text-sm font-JakartaBold text-black dark:text-white flex-1 pr-2">
|
||||
{label}
|
||||
</Text>
|
||||
{optional ? (
|
||||
<Text className="text-[11px] font-JakartaSemiBold text-general-200 dark:text-neutral-500 uppercase">
|
||||
{t("driver.scan.optional")}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mb-3">
|
||||
{hint}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center">
|
||||
{scanned ? (
|
||||
<Image
|
||||
source={{ uri: preview }}
|
||||
className="w-16 h-16 rounded-xl mr-3"
|
||||
resizeMode="cover"
|
||||
alt={label}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View className="flex-1 flex-row gap-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => void capture("camera")}
|
||||
disabled={busy}
|
||||
className="flex-1 flex-row items-center justify-center rounded-full bg-primary-500 py-3 px-2"
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<>
|
||||
<MaterialCommunityIcons
|
||||
name="camera-outline"
|
||||
size={16}
|
||||
color="#ffffff"
|
||||
/>
|
||||
<Text className="text-white font-JakartaBold text-xs ml-1.5">
|
||||
{scanned ? t("driver.scan.retake") : t("driver.scan.take")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => void capture("library")}
|
||||
disabled={busy}
|
||||
className="flex-1 flex-row items-center justify-center rounded-full border border-neutral-300 dark:border-neutral-700 py-3 px-2"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="image-outline"
|
||||
size={16}
|
||||
color={isDark ? "#e5e5e5" : "#333333"}
|
||||
/>
|
||||
<Text className="text-black dark:text-white font-JakartaBold text-xs ml-1.5">
|
||||
{t("driver.scan.choose")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{busy ? (
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mt-3">
|
||||
{t("driver.scan.reading")}
|
||||
</Text>
|
||||
) : status ? (
|
||||
<View className="flex-row items-center mt-3">
|
||||
<MaterialCommunityIcons
|
||||
name="check-circle-outline"
|
||||
size={14}
|
||||
color="#10b981"
|
||||
/>
|
||||
<Text className="text-xs font-JakartaSemiBold text-emerald-600 dark:text-emerald-400 ml-1.5 flex-1">
|
||||
{status}
|
||||
</Text>
|
||||
</View>
|
||||
) : failed ? (
|
||||
<View className="flex-row items-center mt-3">
|
||||
<MaterialCommunityIcons
|
||||
name="alert-outline"
|
||||
size={14}
|
||||
color="#f43f5e"
|
||||
/>
|
||||
<Text className="text-xs font-JakartaSemiBold text-rose-500 ml-1.5 flex-1">
|
||||
{t("driver.scan.errorRetry")}
|
||||
</Text>
|
||||
</View>
|
||||
) : onFile ? (
|
||||
// Resubmitting after a rejection: the reviewer already has a scan, so
|
||||
// say so rather than making the driver wonder whether it was lost.
|
||||
<View className="flex-row items-center mt-3">
|
||||
<MaterialCommunityIcons
|
||||
name="paperclip"
|
||||
size={14}
|
||||
color={isDark ? "#9ca3af" : "#858585"}
|
||||
/>
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 ml-1.5 flex-1">
|
||||
{t("driver.scan.alreadyOnFile")}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
+28
-14
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
|
||||
@@ -13,56 +15,68 @@ export const DriverCard = ({
|
||||
<TouchableOpacity
|
||||
onPress={setSelected}
|
||||
className={`${
|
||||
selected === item.id ? "bg-general-600" : "bg-white"
|
||||
selected === item.id
|
||||
? "bg-general-600 dark:bg-primary-500/20"
|
||||
: "bg-white dark:bg-neutral-900"
|
||||
} flex flex-row items-center justify-between py-5 px-3 rounded-xl`}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: item.profile_image_url }}
|
||||
alt="Driver Avatar"
|
||||
source={{ uri: driverPhotoUri(item.profile_image_url) }}
|
||||
alt={tr("components.driverCard.avatarAlt")}
|
||||
className="w-14 h-14 rounded-full"
|
||||
/>
|
||||
|
||||
<View className="flex-1 flex flex-col items-start justify-center mx-3">
|
||||
<View className="flex flex-row items-center justify-start mb-1">
|
||||
<Text className="text-lg font-JakartaRegular">
|
||||
<Text className="text-lg font-JakartaRegular text-black dark:text-white">
|
||||
{item.title ?? `${item.first_name} ${item.last_name}`}
|
||||
</Text>
|
||||
|
||||
<View className="flex flex-row items-center space-x-1 ml-2">
|
||||
<Image source={icons.star} alt="Star" className="w-3.5 h-3.5" />
|
||||
<Text className="text-sm font-JakartaRegular">{item.rating}</Text>
|
||||
<Image
|
||||
source={icons.star}
|
||||
alt={tr("components.driverCard.starAlt")}
|
||||
className="w-3.5 h-3.5"
|
||||
/>
|
||||
<Text className="text-sm font-JakartaRegular text-black dark:text-white">
|
||||
{item.rating}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center justify-start">
|
||||
<View className="flex flex-row items-center">
|
||||
<Image source={icons.dollar} alt="Dollar" className="w-4 h-4" />
|
||||
<Text className="text-sm font-JakartaRegular ml-1">
|
||||
<Image
|
||||
source={icons.dollar}
|
||||
alt={tr("components.driverCard.dollarAlt")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<Text className="text-sm font-JakartaRegular ml-1 text-black dark:text-white">
|
||||
${item.price}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-sm font-JakartaRegular text-general-800 mx-1">
|
||||
<Text className="text-sm font-JakartaRegular text-general-800 dark:text-neutral-400 mx-1">
|
||||
|
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-JakartaRegular text-general-800">
|
||||
<Text className="text-sm font-JakartaRegular text-general-800 dark:text-neutral-400">
|
||||
{formatTime(parseInt(`${item.time}`))}
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-JakartaRegular text-general-800 mx-1">
|
||||
<Text className="text-sm font-JakartaRegular text-general-800 dark:text-neutral-400 mx-1">
|
||||
|
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-JakartaRegular text-general-800">
|
||||
{item.car_seats} seats
|
||||
<Text className="text-sm font-JakartaRegular text-general-800 dark:text-neutral-400">
|
||||
{tr("components.driverCard.seats", {}, item.car_seats)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Image
|
||||
source={{ uri: item.car_image_url }}
|
||||
alt="Car"
|
||||
alt={tr("components.driverCard.carAlt")}
|
||||
className="h-14 w-14"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Image,
|
||||
Keyboard,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "react-native";
|
||||
|
||||
import { icons } from "@/constants";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import type { GoogleInputProps } from "@/types/type";
|
||||
|
||||
const googleApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!;
|
||||
@@ -69,10 +71,15 @@ export const GoogleTextInput = ({
|
||||
textInputBackgroundColor,
|
||||
handlePress,
|
||||
}: GoogleInputProps) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
const [query, setQuery] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const inputBg = textInputBackgroundColor || (isDark ? "#1a1a1a" : "white");
|
||||
const inputShadow = isDark ? "#000000" : "#d4d4d4";
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
@@ -97,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);
|
||||
@@ -118,14 +129,14 @@ export const GoogleTextInput = ({
|
||||
<View
|
||||
className="flex flex-row items-center rounded-full px-4 mt-1"
|
||||
style={{
|
||||
backgroundColor: textInputBackgroundColor || "white",
|
||||
shadowColor: "#d4d4d4",
|
||||
backgroundColor: inputBg,
|
||||
shadowColor: inputShadow,
|
||||
}}
|
||||
>
|
||||
<View className="justify-center items-center w-6 h-6">
|
||||
<Image
|
||||
source={icon ? icon : icons.search}
|
||||
alt="Search"
|
||||
alt={t("components.googleTextInput.searchAlt")}
|
||||
className="w-6 h-6"
|
||||
resizeMode="contain"
|
||||
/>
|
||||
@@ -134,35 +145,36 @@ export const GoogleTextInput = ({
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={initialLocation ?? "Where do you want to go?"}
|
||||
placeholderTextColor="gray"
|
||||
className="flex-1 p-3 text-base font-JakartaSemiBold"
|
||||
placeholder={initialLocation ?? t("components.googleTextInput.placeholder")}
|
||||
placeholderTextColor="#a3a3a3"
|
||||
className="flex-1 p-3 text-base font-JakartaSemiBold text-black dark:text-white"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Rendered as plain rows, not a FlatList. Places never returns more
|
||||
than a handful of predictions, so there is nothing to virtualise —
|
||||
and a list that scrolls inside the home feed (or inside the ride
|
||||
sheet) fights its parent for the gesture and swallows taps meant
|
||||
for a suggestion. */}
|
||||
{suggestions.length > 0 && (
|
||||
<View
|
||||
className="rounded-xl mt-1"
|
||||
style={{
|
||||
backgroundColor: textInputBackgroundColor || "white",
|
||||
shadowColor: "#d4d4d4",
|
||||
backgroundColor: inputBg,
|
||||
shadowColor: inputShadow,
|
||||
}}
|
||||
>
|
||||
<FlatList
|
||||
data={suggestions}
|
||||
keyExtractor={(item) => item.placeId}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
renderItem={({ item }) => (
|
||||
{suggestions.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.placeId}
|
||||
onPress={() => onSelect(item)}
|
||||
className="p-3 border-b border-general-700"
|
||||
className="p-3 border-b border-general-700 dark:border-neutral-700"
|
||||
>
|
||||
<Text className="text-base font-JakartaRegular">
|
||||
<Text className="text-base font-JakartaRegular text-black dark:text-white">
|
||||
{item.text}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { tr } from "@/lib/i18n";
|
||||
import type { InputFieldProps } from "@/types/type";
|
||||
|
||||
export const InputField = ({
|
||||
@@ -25,26 +26,29 @@ export const InputField = ({
|
||||
<KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||
<View className="my-2 w-full">
|
||||
<Text className={`text-lg font-JakartaSemiBold mb-3 ${labelStyles}`}>
|
||||
<Text
|
||||
className={`text-lg font-JakartaSemiBold mb-3 text-black dark:text-white ${labelStyles}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
className={`flex flex-row justify-start items-center relative bg-neutral-100 rounded-full border border-neutral-100 focus:border-primary-500 ${containerStyles}`}
|
||||
className={`flex flex-row justify-start items-center relative bg-neutral-100 dark:bg-neutral-800 rounded-full border border-neutral-100 dark:border-neutral-800 focus:border-primary-500 ${containerStyles}`}
|
||||
>
|
||||
{icon && (
|
||||
<Image
|
||||
source={icon}
|
||||
alt={`${label} icon`}
|
||||
alt={tr("components.inputField.labelIconAlt", { label })}
|
||||
className={`h-6 w-6 ml-4 mt-1 ${iconStyles}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
className={`rounded-full p-4 font-JakartaSemiBold text-[15px] flex-1 text-left ${inputStyles}`}
|
||||
className={`rounded-full p-4 font-JakartaSemiBold text-[15px] flex-1 text-left text-black dark:text-white ${inputStyles}`}
|
||||
secureTextEntry={secureTextEntry}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
placeholderTextColor="#a3a3a3"
|
||||
selectionColor="#0286ff"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Linking, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { tr } from "@/lib/i18n";
|
||||
import type { LocationStatus } from "@/lib/use-user-location";
|
||||
|
||||
const COPY: Record<string, { titleKey: string; bodyKey: string; actionKey: string }> = {
|
||||
denied: {
|
||||
titleKey: "components.locationNotice.denied.title",
|
||||
bodyKey: "components.locationNotice.denied.body",
|
||||
actionKey: "components.locationNotice.denied.action",
|
||||
},
|
||||
"services-off": {
|
||||
titleKey: "components.locationNotice.servicesOff.title",
|
||||
bodyKey: "components.locationNotice.servicesOff.body",
|
||||
actionKey: "components.locationNotice.servicesOff.action",
|
||||
},
|
||||
unavailable: {
|
||||
titleKey: "components.locationNotice.unavailable.title",
|
||||
bodyKey: "components.locationNotice.unavailable.body",
|
||||
actionKey: "components.locationNotice.unavailable.action",
|
||||
},
|
||||
};
|
||||
|
||||
/** Fills the map slot when there's no position to draw. */
|
||||
export const LocationNotice = ({
|
||||
status,
|
||||
onRetry,
|
||||
}: {
|
||||
status: LocationStatus;
|
||||
onRetry: () => void;
|
||||
}) => {
|
||||
const copy = COPY[status];
|
||||
|
||||
if (!copy) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
<Text className="text-base font-JakartaBold text-black dark:text-white text-center">
|
||||
{tr(copy.titleKey)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm font-Jakarta text-general-200 dark:text-neutral-400 text-center mt-2">
|
||||
{tr(copy.bodyKey)}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() =>
|
||||
status === "denied" ? void Linking.openSettings() : onRetry()
|
||||
}
|
||||
activeOpacity={0.8}
|
||||
className="mt-5 rounded-full bg-primary-500 px-6 py-3"
|
||||
>
|
||||
<Text className="text-white font-JakartaBold text-sm">
|
||||
{tr(copy.actionKey)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
+381
-45
@@ -1,38 +1,369 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import MapView, { Marker, PROVIDER_DEFAULT } from "react-native-maps";
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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,
|
||||
calculateRegion,
|
||||
generateMarkersFromData,
|
||||
} from "@/lib/map";
|
||||
import { useDriverStore, useLocationStore } from "@/store";
|
||||
import type { Driver, MarkerData } from "@/types/type";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
import { useNearbyDrivers } from "@/lib/use-nearby-drivers";
|
||||
import { useDriverStore, useLocationStore, useServiceStore } from "@/store";
|
||||
import type { MarkerData } from "@/types/type";
|
||||
|
||||
export const Map = () => {
|
||||
const { data: drivers, loading, error } = useFetch<Driver[]>("/(api)/driver");
|
||||
// react-native-maps sizes itself from a real style object, so give it explicit
|
||||
// dimensions rather than relying on percentage classNames resolving to 0.
|
||||
const styles = StyleSheet.create({
|
||||
map: { ...StyleSheet.absoluteFillObject, borderRadius: 16 },
|
||||
markerBubble: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#111827",
|
||||
borderWidth: 2,
|
||||
borderColor: "#ffffff",
|
||||
// A flat dot on a light map is hard to pick out; a soft shadow lifts it.
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 3,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
elevation: 4,
|
||||
},
|
||||
markerBubbleSelected: {
|
||||
backgroundColor: "#0286ff",
|
||||
},
|
||||
// Wraps bubble + arrow so the arrow can orbit the bubble by rotating the
|
||||
// whole frame, while the vehicle glyph inside stays upright and readable.
|
||||
markerFrame: {
|
||||
width: 54,
|
||||
height: 54,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
headingArrow: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// How long a marker takes to slide to its new position.
|
||||
//
|
||||
// Deliberately the poll interval, not less: each update is the car's position
|
||||
// as of that moment, so spreading the movement across the whole gap until the
|
||||
// next one is what makes a series of samples read as continuous travel. A
|
||||
// shorter duration would animate quickly and then sit frozen, which looks
|
||||
// worse than not animating at all.
|
||||
const MARKER_GLIDE_MS = 5000;
|
||||
|
||||
// Below this the GPS heading is mostly noise — a stationary phone reports
|
||||
// wildly varying directions — so the arrow is hidden and the car is simply
|
||||
// drawn as parked.
|
||||
const MOVING_KPH = 5;
|
||||
|
||||
// A driver pin drawn as the vehicle they actually drive.
|
||||
//
|
||||
// Every driver used to get the same car marker, so a moto rider watching a
|
||||
// motorbike approach saw a car on their map — and the four services were
|
||||
// indistinguishable at a glance. The glyphs come from the same SERVICES table
|
||||
// the service picker uses, so a pin and its tile always agree.
|
||||
const glyphFor = (service?: string | null) =>
|
||||
(SERVICES.find((s) => s.id === service) ?? SERVICES[0]).icon;
|
||||
|
||||
const ServiceMarker = ({
|
||||
marker,
|
||||
selected,
|
||||
}: {
|
||||
marker: MarkerData;
|
||||
selected: boolean;
|
||||
}) => {
|
||||
// Android renders a custom marker view by snapshotting it, and a snapshot
|
||||
// taken before layout is blank. Track changes briefly so the first real
|
||||
// frame is captured, then stop — leaving it on re-snapshots every marker on
|
||||
// every frame, which makes a map full of drivers crawl.
|
||||
const [tracksViewChanges, setTracksViewChanges] = useState(true);
|
||||
|
||||
const heading = marker.heading ?? null;
|
||||
const moving = (marker.speed_kph ?? 0) >= MOVING_KPH;
|
||||
const showArrow = moving && heading !== null;
|
||||
|
||||
// The marker's own coordinate, animated rather than assigned.
|
||||
//
|
||||
// Positions arrive every few seconds; setting them directly teleports each
|
||||
// car across the gap it covered since the last update. Holding the
|
||||
// coordinate in an AnimatedRegion and easing to each new fix turns the same
|
||||
// samples into visible travel — which is the whole point of showing other
|
||||
// drivers at all.
|
||||
const coordinate = useRef(
|
||||
new AnimatedRegion({
|
||||
latitude: marker.latitude,
|
||||
longitude: marker.longitude,
|
||||
latitudeDelta: 0,
|
||||
longitudeDelta: 0,
|
||||
}),
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
// `timing` is not on the public typings for AnimatedRegion in this
|
||||
// version, though it exists at runtime; the cast keeps the call honest
|
||||
// without loosening the rest of the component.
|
||||
(
|
||||
coordinate as unknown as {
|
||||
timing: (config: Record<string, unknown>) => {
|
||||
start: () => void;
|
||||
};
|
||||
}
|
||||
)
|
||||
.timing({
|
||||
latitude: marker.latitude,
|
||||
longitude: marker.longitude,
|
||||
latitudeDelta: 0,
|
||||
longitudeDelta: 0,
|
||||
duration: MARKER_GLIDE_MS,
|
||||
// AnimatedRegion drives a native prop that the native driver can't
|
||||
// handle, so this animation runs on the JS thread by necessity.
|
||||
useNativeDriver: false,
|
||||
})
|
||||
.start();
|
||||
}, [coordinate, marker.latitude, marker.longitude]);
|
||||
|
||||
useEffect(() => {
|
||||
setTracksViewChanges(true);
|
||||
const timer = setTimeout(() => setTracksViewChanges(false), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [selected, marker.service, showArrow, heading]);
|
||||
|
||||
// react-native-maps accepts an AnimatedRegion here at runtime — it is what
|
||||
// every animated-marker example passes — but types the prop as an animated
|
||||
// LatLng, so the two don't line up. Cast at the boundary rather than
|
||||
// loosening the component's own types.
|
||||
const animatedCoordinate = coordinate as unknown as React.ComponentProps<
|
||||
typeof MarkerAnimated
|
||||
>["coordinate"];
|
||||
|
||||
return (
|
||||
<MarkerAnimated
|
||||
coordinate={animatedCoordinate}
|
||||
title={marker.title}
|
||||
anchor={{ x: 0.5, y: 0.5 }}
|
||||
tracksViewChanges={tracksViewChanges}
|
||||
>
|
||||
<View style={styles.markerFrame}>
|
||||
{/* Rotating the frame swings the arrow around the bubble to point the
|
||||
way the car is travelling, while the bubble itself — and the
|
||||
vehicle glyph in it — stays upright and legible. */}
|
||||
{showArrow ? (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{ transform: [{ rotate: `${heading}deg` }] },
|
||||
styles.markerFrame,
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="navigation"
|
||||
size={14}
|
||||
color={selected ? "#0286ff" : "#111827"}
|
||||
style={styles.headingArrow}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.markerBubble,
|
||||
selected ? styles.markerBubbleSelected : null,
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={glyphFor(marker.service)}
|
||||
size={18}
|
||||
color="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</MarkerAnimated>
|
||||
);
|
||||
};
|
||||
|
||||
// "mutedStandard" is an Apple Maps type. Android's MapManager looks the value
|
||||
// up in a fixed table and unboxes the result into an int, so an unrecognised
|
||||
// name is a null Integer -> NullPointerException, and the map never draws.
|
||||
const MAP_TYPE = Platform.OS === "ios" ? "mutedStandard" : "standard";
|
||||
|
||||
// showsPointsOfInterest is iOS-only; on Android the same muting is done with a
|
||||
// style array, so both platforms get the same clean base map.
|
||||
const MUTED_POI_STYLE = [
|
||||
{
|
||||
featureType: "poi",
|
||||
elementType: "labels",
|
||||
stylers: [{ visibility: "off" }],
|
||||
},
|
||||
{
|
||||
featureType: "transit",
|
||||
elementType: "labels.icon",
|
||||
stylers: [{ visibility: "off" }],
|
||||
},
|
||||
];
|
||||
|
||||
// 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,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
} = useLocationStore();
|
||||
const { service } = useServiceStore();
|
||||
const { selectedDriver, setDrivers } = useDriverStore();
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const region = calculateRegion({
|
||||
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 { drivers } = useNearbyDrivers(service, lat, lng);
|
||||
|
||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||
const mapRef = useRef<MapView>(null);
|
||||
|
||||
// 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,
|
||||
destinationLongitude,
|
||||
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
|
||||
// out to fit a destination the rider picks later. Animate on every real
|
||||
// change instead. Keyed on the coordinates so the repeated setUserLocation
|
||||
// from reverse geocoding (same coords, new address) doesn't yank the camera
|
||||
// back while the rider is panning.
|
||||
const regionKey = `${region.latitude},${region.longitude},${region.latitudeDelta},${region.longitudeDelta}`;
|
||||
const lastRegionKey = useRef(regionKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastRegionKey.current === regionKey) return;
|
||||
|
||||
lastRegionKey.current = regionKey;
|
||||
mapRef.current?.animateToRegion(region, 500);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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;
|
||||
|
||||
@@ -45,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,
|
||||
@@ -55,61 +387,64 @@ export const Map = () => {
|
||||
userLongitude,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
}).then((drivers) => {
|
||||
setDrivers(drivers as MarkerData[]);
|
||||
service,
|
||||
}).then((driversWithTimes) => {
|
||||
setDrivers((driversWithTimes as MarkerData[]) ?? []);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
trackingMode,
|
||||
markers,
|
||||
destinationLatitude,
|
||||
destinationLongitude,
|
||||
userLatitude,
|
||||
userLongitude,
|
||||
setDrivers,
|
||||
service,
|
||||
]);
|
||||
|
||||
if (loading || !userLatitude || !userLongitude) {
|
||||
return (
|
||||
<View className="flex justify-between items-center w-full">
|
||||
<ActivityIndicator size="small" color="#000" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View className="flex justify-between items-center w-full">
|
||||
<Text>Error: {error}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// The map itself never waits on the driver list or the location fix: drivers
|
||||
// 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".
|
||||
|
||||
return (
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
provider={PROVIDER_DEFAULT}
|
||||
className="w-full h-full rounded-2xl"
|
||||
tintColor="black"
|
||||
mapType="mutedStandard"
|
||||
style={styles.map}
|
||||
tintColor={isDark ? "white" : "black"}
|
||||
mapType={MAP_TYPE}
|
||||
customMapStyle={MUTED_POI_STYLE}
|
||||
showsPointsOfInterest={false}
|
||||
initialRegion={region}
|
||||
showsUserLocation
|
||||
userInterfaceStyle="light"
|
||||
userInterfaceStyle={isDark ? "dark" : "light"}
|
||||
>
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
<ServiceMarker
|
||||
key={marker.id}
|
||||
coordinate={{
|
||||
latitude: marker.latitude,
|
||||
longitude: marker.longitude,
|
||||
}}
|
||||
title={marker.title}
|
||||
image={
|
||||
selectedDriver === marker.id ? icons.selectedMarker : icons.marker
|
||||
}
|
||||
marker={marker}
|
||||
selected={Boolean(trackedDriver) || selectedDriver === marker.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{destinationLatitude && destinationLongitude && (
|
||||
{destinationOverride ? (
|
||||
<Marker
|
||||
key="pickup"
|
||||
coordinate={{
|
||||
latitude: destinationOverride.latitude,
|
||||
longitude: destinationOverride.longitude,
|
||||
}}
|
||||
title={destinationOverride.label ?? tr("components.map.destination")}
|
||||
image={icons.pin}
|
||||
/>
|
||||
) : (
|
||||
!routeless &&
|
||||
userLatitude &&
|
||||
userLongitude &&
|
||||
destinationLatitude &&
|
||||
destinationLongitude && (
|
||||
<>
|
||||
<Marker
|
||||
key="destination"
|
||||
@@ -117,7 +452,7 @@ export const Map = () => {
|
||||
latitude: destinationLatitude,
|
||||
longitude: destinationLongitude,
|
||||
}}
|
||||
title="Destination"
|
||||
title={tr("components.map.destination")}
|
||||
image={icons.pin}
|
||||
/>
|
||||
|
||||
@@ -135,6 +470,7 @@ export const Map = () => {
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</MapView>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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 (
|
||||
<View className="w-full h-full rounded-2xl bg-general-100 flex items-center justify-center">
|
||||
<Text className="text-general-200 text-center font-JakartaMedium">
|
||||
Map is not available on web.{"\n"}Run on Android/iOS for the full
|
||||
experience.
|
||||
{t("components.map.webUnavailable")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScrollView, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { POI_CATEGORIES, searchNearby } from "@/lib/places";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useLocationStore } from "@/store";
|
||||
import type { NearbyPlace } from "@/types/type";
|
||||
|
||||
// Four quick destination chips: nearest mall / hospital / pharmacy / restaurant
|
||||
// around the rider. Tapping one sets it as the destination and opens find-ride.
|
||||
// Each chip resolves independently, so a category with no result nearby just
|
||||
// shows "none nearby" instead of breaking the whole row.
|
||||
type ChipState =
|
||||
| { status: "loading" }
|
||||
| { status: "empty" }
|
||||
| { status: "ready"; place: NearbyPlace };
|
||||
|
||||
export const NearbySuggestions = () => {
|
||||
const { userLatitude, userLongitude, setDestinationLocation } =
|
||||
useLocationStore();
|
||||
const t = useT();
|
||||
const [chips, setChips] = useState<Record<string, ChipState>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (userLatitude == null || userLongitude == null) return;
|
||||
|
||||
let cancelled = false;
|
||||
setChips({});
|
||||
|
||||
// Resolve all four categories in parallel.
|
||||
POI_CATEGORIES.forEach(async (category) => {
|
||||
setChips((prev) => ({ ...prev, [category.id]: { status: "loading" } }));
|
||||
const place = await searchNearby(category.googleType, {
|
||||
latitude: userLatitude,
|
||||
longitude: userLongitude,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setChips((prev) => ({
|
||||
...prev,
|
||||
[category.id]: place ? { status: "ready", place } : { status: "empty" },
|
||||
}));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userLatitude, userLongitude]);
|
||||
|
||||
const select = (place: NearbyPlace) => {
|
||||
setDestinationLocation({
|
||||
latitude: place.latitude,
|
||||
longitude: place.longitude,
|
||||
address: place.name,
|
||||
});
|
||||
router.push("/(root)/find-ride");
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-base font-JakartaSemiBold mb-3 text-black dark:text-white">
|
||||
{t("pois.nearbyTitle")}
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 10, paddingBottom: 4 }}
|
||||
>
|
||||
{POI_CATEGORIES.map((category) => {
|
||||
const state = chips[category.id];
|
||||
const ready = state?.status === "ready" ? state.place : null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={category.id}
|
||||
disabled={!ready}
|
||||
onPress={() => ready && select(ready)}
|
||||
activeOpacity={0.8}
|
||||
className={`flex-row items-center rounded-2xl border px-3 py-2.5 ${
|
||||
ready
|
||||
? "border-primary-500 bg-primary-500/10"
|
||||
: "border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
}`}
|
||||
style={{ minWidth: 150 }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={category.icon as never}
|
||||
size={20}
|
||||
color={ready ? "#0286ff" : "#a3a3a3"}
|
||||
/>
|
||||
|
||||
<View className="ml-2 flex-1">
|
||||
<Text
|
||||
className={`text-xs font-JakartaBold ${
|
||||
ready ? "text-primary-500" : "text-neutral-400"
|
||||
}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{t(category.labelKey)}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-[11px] text-general-200 dark:text-neutral-400"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{!state || state.status === "loading"
|
||||
? t("pois.searching")
|
||||
: state.status === "empty"
|
||||
? t("pois.noneNearby")
|
||||
: state.place.routeDistanceMeters != null
|
||||
? t("pois.routeAway", {
|
||||
km:
|
||||
Math.round(
|
||||
state.place.routeDistanceMeters / 100,
|
||||
) / 10,
|
||||
min: Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
(state.place.routeDurationSeconds ?? 0) / 60,
|
||||
),
|
||||
),
|
||||
})
|
||||
: state.place.distanceMeters != null
|
||||
? t("pois.kmAway", {
|
||||
km:
|
||||
Math.round(state.place.distanceMeters / 100) /
|
||||
10,
|
||||
})
|
||||
: state.place.name}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
+38
-12
@@ -5,6 +5,7 @@ import { Image, Text, View, Alert } from "react-native";
|
||||
|
||||
import { icons } from "@/constants";
|
||||
import { googleAuth } from "@/lib/auth";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
import { CustomButton } from "./custom-button";
|
||||
@@ -14,12 +15,37 @@ type OAuthProps = {
|
||||
};
|
||||
|
||||
export const OAuth = ({ title }: OAuthProps) => {
|
||||
const clientId = process.env.EXPO_PUBLIC_GOOGLE_AUTH_WEB_CLIENT_ID;
|
||||
const iosClientId = process.env.EXPO_PUBLIC_GOOGLE_AUTH_IOS_CLIENT_ID;
|
||||
const androidClientId =
|
||||
process.env.EXPO_PUBLIC_GOOGLE_AUTH_ANDROID_CLIENT_ID;
|
||||
|
||||
const isConfigured = Boolean(
|
||||
clientId && (androidClientId || iosClientId),
|
||||
);
|
||||
|
||||
if (!isConfigured) return null;
|
||||
|
||||
return <GoogleOAuth title={title} clientId={clientId!} iosClientId={iosClientId} androidClientId={androidClientId} />;
|
||||
};
|
||||
|
||||
function GoogleOAuth({
|
||||
title,
|
||||
clientId,
|
||||
iosClientId,
|
||||
androidClientId,
|
||||
}: OAuthProps & {
|
||||
clientId: string;
|
||||
iosClientId?: string;
|
||||
androidClientId?: string;
|
||||
}) {
|
||||
const { setSession } = useSession();
|
||||
const t = useT();
|
||||
|
||||
const [request, response, promptAsync] = Google.useIdTokenAuthRequest({
|
||||
clientId: process.env.EXPO_PUBLIC_GOOGLE_AUTH_WEB_CLIENT_ID,
|
||||
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_AUTH_IOS_CLIENT_ID,
|
||||
androidClientId: process.env.EXPO_PUBLIC_GOOGLE_AUTH_ANDROID_CLIENT_ID,
|
||||
clientId,
|
||||
iosClientId,
|
||||
androidClientId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,7 +54,7 @@ export const OAuth = ({ title }: OAuthProps) => {
|
||||
const idToken = response.params?.id_token;
|
||||
|
||||
if (!idToken) {
|
||||
Alert.alert("Google sign-in failed", "No token returned. Try again.");
|
||||
Alert.alert(t("components.oauth.alertFailTitle"), t("components.oauth.alertFailNoToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,12 +65,12 @@ export const OAuth = ({ title }: OAuthProps) => {
|
||||
} catch (err: any) {
|
||||
console.error("OAuth error", err);
|
||||
Alert.alert(
|
||||
"Google sign-in failed",
|
||||
err?.message || "Please try again.",
|
||||
t("components.oauth.alertFailTitle"),
|
||||
err?.message || t("components.oauth.alertFailFallback"),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [response, setSession]);
|
||||
}, [response, setSession, t]);
|
||||
|
||||
const handleGoogleOAuth = useCallback(() => {
|
||||
void promptAsync();
|
||||
@@ -53,11 +79,11 @@ export const OAuth = ({ title }: OAuthProps) => {
|
||||
return (
|
||||
<View>
|
||||
<View className="flex flex-row justify-center items-center mt-4 gap-x-3">
|
||||
<View className="flex-1 h-px bg-general-100" />
|
||||
<View className="flex-1 h-px bg-general-100 dark:bg-neutral-700" />
|
||||
|
||||
<Text className="text-lg">Or</Text>
|
||||
<Text className="text-lg text-black dark:text-white">{t("components.oauth.or")}</Text>
|
||||
|
||||
<View className="flex-1 h-px bg-general-100" />
|
||||
<View className="flex-1 h-px bg-general-100 dark:bg-neutral-700" />
|
||||
</View>
|
||||
|
||||
<CustomButton
|
||||
@@ -66,7 +92,7 @@ export const OAuth = ({ title }: OAuthProps) => {
|
||||
iconLeft={() => (
|
||||
<Image
|
||||
source={icons.google}
|
||||
alt="Google logo"
|
||||
alt={t("components.oauth.googleLogoAlt")}
|
||||
resizeMode="contain"
|
||||
className="h-5 w-5 mx-2"
|
||||
/>
|
||||
@@ -78,4 +104,4 @@ export const OAuth = ({ title }: OAuthProps) => {
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { SERVICES } from "@/constants/services";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import type { RideOffer } from "@/types/type";
|
||||
|
||||
// The drivers who have volunteered for a request, and the rider's choice
|
||||
// between them.
|
||||
//
|
||||
// Dispatch broadcasts the job and this is what comes back: several drivers,
|
||||
// none of them assigned, each waiting to be picked. So every row has to carry
|
||||
// what a person actually decides on — how far away they are, how they're
|
||||
// rated, what they drive — and picking one has to be a single deliberate tap,
|
||||
// because that tap is what commits the rider and releases everyone else.
|
||||
|
||||
// Rough road-speed assumption for turning a straight-line distance into
|
||||
// minutes. A per-offer Directions call would be more accurate and would also
|
||||
// mean one billed request per driver per poll; this is honest to within a
|
||||
// couple of minutes in city traffic, which is the precision a rider comparing
|
||||
// three drivers is actually using.
|
||||
const URBAN_KMH = 22;
|
||||
// Streets aren't straight. Multiplying the great-circle distance gets closer
|
||||
// to the distance a car really drives.
|
||||
const ROAD_FACTOR = 1.3;
|
||||
|
||||
const etaMinutes = (meters: number | null): number | null => {
|
||||
if (meters === null || !Number.isFinite(meters)) return null;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(((meters * ROAD_FACTOR) / 1000 / URBAN_KMH) * 60),
|
||||
);
|
||||
};
|
||||
|
||||
const distanceLabel = (meters: number | null): string | null => {
|
||||
if (meters === null || !Number.isFinite(meters)) return null;
|
||||
return meters < 1000
|
||||
? `${Math.round(meters / 50) * 50} m`
|
||||
: `${(meters / 1000).toFixed(1)} km`;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
offers: RideOffer[];
|
||||
/** Offer currently being taken, so only that row shows a spinner. */
|
||||
pendingOfferId: number | null;
|
||||
busy: boolean;
|
||||
onPick: (offer: RideOffer) => void;
|
||||
};
|
||||
|
||||
export const OfferList = ({ offers, pendingOfferId, busy, onPick }: Props) => {
|
||||
const t = useT();
|
||||
|
||||
// What the rider is getting into. A driver who never filled in their car
|
||||
// model would otherwise leave the vehicle line blank on the one screen where
|
||||
// the rider is choosing between cars, so the service they drive for stands
|
||||
// in — "Car · 4 seats" is thin, but it isn't nothing.
|
||||
const vehicle = (offer: RideOffer): string => {
|
||||
const service = SERVICES.find((s) => s.id === offer.service);
|
||||
const label = offer.car_model ?? (service ? t(service.labelKey) : null);
|
||||
const seats = offer.car_seats
|
||||
? t("bookRide.offers.seats", undefined, offer.car_seats)
|
||||
: null;
|
||||
|
||||
return [label, seats].filter(Boolean).join(" · ");
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="mt-2">
|
||||
<View className="flex-row items-center justify-between mb-2">
|
||||
<Text className="text-base font-JakartaBold text-black dark:text-white">
|
||||
{t("bookRide.offers.title")}
|
||||
</Text>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{t("bookRide.offers.count", undefined, offers.length)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{offers.map((offer) => {
|
||||
const name = [offer.first_name, offer.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const distance = offer.pickup_distance_m ?? null;
|
||||
const eta = etaMinutes(distance);
|
||||
const taking = pendingOfferId === offer.offer_id;
|
||||
// The face the rider is choosing between. This is the screen the
|
||||
// driver's photo exists for, so it leads the row.
|
||||
const photo = driverPhotoUri(offer.profile_image_url);
|
||||
|
||||
return (
|
||||
<View
|
||||
key={offer.offer_id}
|
||||
className="bg-white dark:bg-neutral-900 rounded-2xl p-3 mb-2 flex-row items-center"
|
||||
>
|
||||
{photo ? (
|
||||
<Image
|
||||
source={{ uri: photo }}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-12 h-12 rounded-full bg-neutral-200 dark:bg-neutral-800 items-center justify-center">
|
||||
<MaterialCommunityIcons
|
||||
name="account"
|
||||
size={22}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="ml-3 flex-1">
|
||||
<Text
|
||||
className="font-JakartaSemiBold text-black dark:text-white"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{name || t("bookRide.match.driverFallback")}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row items-center gap-x-2 mt-0.5">
|
||||
<View className="flex-row items-center gap-x-1">
|
||||
<MaterialCommunityIcons
|
||||
name="star"
|
||||
size={13}
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{offer.rating != null
|
||||
? Number(offer.rating).toFixed(1)
|
||||
: t("bookRide.ratingFallback")}
|
||||
</Text>
|
||||
</View>
|
||||
{vehicle(offer) ? (
|
||||
<Text
|
||||
className="text-xs text-general-200 dark:text-neutral-400 flex-1"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{vehicle(offer)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{eta !== null ? (
|
||||
<Text className="text-xs font-JakartaMedium text-primary-500 mt-0.5">
|
||||
{t("bookRide.offers.away", {
|
||||
eta,
|
||||
distance: distanceLabel(distance) ?? "",
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onPick(offer)}
|
||||
disabled={busy}
|
||||
className={`rounded-full px-5 py-2.5 ml-2 ${
|
||||
busy && !taking ? "bg-emerald-500/40" : "bg-emerald-500"
|
||||
}`}
|
||||
>
|
||||
{taking ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<Text className="text-white font-JakartaBold text-xs">
|
||||
{t("bookRide.offers.pick")}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppState, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { InputField } from "@/components/input-field";
|
||||
import { icons } from "@/constants";
|
||||
import { tr } from "@/lib/i18n";
|
||||
|
||||
// `\b` won't match between two digits, so a longer run like an order number
|
||||
// never yields a false positive.
|
||||
const CODE_PATTERN = /\b\d{6}\b/;
|
||||
|
||||
/** Pulls the 6-digit code out of whatever the user copied from the email. */
|
||||
export const extractCode = (raw: string | null | undefined): string | null =>
|
||||
raw ? (CODE_PATTERN.exec(raw)?.[0] ?? null) : null;
|
||||
|
||||
type OtpFieldProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
/** Fired once the field holds a complete 6-digit code. */
|
||||
onComplete?: (code: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Code entry for the emailed verification/reset codes.
|
||||
*
|
||||
* Three ways in, cheapest first:
|
||||
* 1. iOS surfaces the code above the keyboard once Mail has it —
|
||||
* `textContentType="oneTimeCode"` is what opts the field into that.
|
||||
* 2. Gmail's notification carries a "Copy code" action (Android) and the
|
||||
* code is one long-press away on any platform: coming back to the app
|
||||
* with a code on the clipboard raises the paste chip below.
|
||||
* 3. Typing it.
|
||||
*/
|
||||
export const OtpField = ({
|
||||
label = tr("components.otp.code"),
|
||||
value,
|
||||
onChange,
|
||||
onComplete,
|
||||
}: OtpFieldProps) => {
|
||||
const [pasteReady, setPasteReady] = useState(false);
|
||||
const completedFor = useRef<string | null>(null);
|
||||
|
||||
// `hasStringAsync` inspects the clipboard without reading it, so it never
|
||||
// trips the iOS paste prompt — that only fires on the explicit tap below.
|
||||
const refreshPasteChip = useCallback(async () => {
|
||||
try {
|
||||
setPasteReady(await Clipboard.hasStringAsync());
|
||||
} catch {
|
||||
setPasteReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPasteChip();
|
||||
|
||||
// The user leaves for Gmail and comes back with the code copied.
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") void refreshPasteChip();
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [refreshPasteChip]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string) => {
|
||||
// Paste of a whole line ("123456 is your Waseel…") still lands the code.
|
||||
const digits =
|
||||
next.length > 6
|
||||
? (extractCode(next) ?? next.replace(/\D/g, "").slice(0, 6))
|
||||
: next.replace(/\D/g, "");
|
||||
|
||||
onChange(digits);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const onPastePress = useCallback(async () => {
|
||||
try {
|
||||
const code = extractCode(await Clipboard.getStringAsync());
|
||||
|
||||
if (code) {
|
||||
onChange(code);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the hint below.
|
||||
}
|
||||
|
||||
setPasteReady(false);
|
||||
}, [onChange]);
|
||||
|
||||
// Auto-submit on a complete code, but only once per distinct code so a
|
||||
// rejected code isn't resubmitted on every re-render.
|
||||
useEffect(() => {
|
||||
if (value.length !== 6 || !onComplete) return;
|
||||
if (completedFor.current === value) return;
|
||||
|
||||
completedFor.current = value;
|
||||
onComplete(value);
|
||||
}, [value, onComplete]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<InputField
|
||||
label={label}
|
||||
icon={icons.lock}
|
||||
placeholder={tr("components.otp.codePlaceholder")}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
// iOS reads codes out of Mail; Android's autofill only covers SMS, so
|
||||
// there the paste chip is the fast path.
|
||||
textContentType="oneTimeCode"
|
||||
autoComplete="one-time-code"
|
||||
importantForAutofill="yes"
|
||||
inputStyles="tracking-[8px] text-lg"
|
||||
/>
|
||||
|
||||
{pasteReady && value.length < 6 ? (
|
||||
<TouchableOpacity
|
||||
onPress={onPastePress}
|
||||
activeOpacity={0.7}
|
||||
className="self-start mt-2 rounded-full bg-primary-500/10 px-4 py-2"
|
||||
>
|
||||
<Text className="text-primary-500 font-JakartaSemiBold text-sm">
|
||||
{tr("components.otp.pasteCode")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
// How the rider pays, asked at the moment it becomes a real question: after
|
||||
// they have chosen a driver, not before they know one exists.
|
||||
//
|
||||
// The card path opens the gateway's hosted page and can take the better part
|
||||
// of a minute, during which the driver they picked could be taken by someone
|
||||
// else — so the sheet says what happens either way rather than dropping the
|
||||
// rider into a browser with no warning.
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
driverName: string | null;
|
||||
fareCents: number;
|
||||
submitting: boolean;
|
||||
onPay: (method: "cash" | "card") => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export const PaymentChoiceSheet = ({
|
||||
visible,
|
||||
driverName,
|
||||
fareCents,
|
||||
submitting,
|
||||
onPay,
|
||||
onCancel,
|
||||
}: Props) => {
|
||||
const t = useT();
|
||||
const fare = (fareCents / 100).toFixed(2);
|
||||
|
||||
return (
|
||||
<ReactNativeModal
|
||||
isVisible={visible}
|
||||
onBackdropPress={submitting ? undefined : onCancel}
|
||||
>
|
||||
<View className="bg-white dark:bg-neutral-900 rounded-2xl p-5">
|
||||
<Text className="text-lg font-JakartaBold text-black dark:text-white">
|
||||
{driverName
|
||||
? t("bookRide.payment.titleNamed", { name: driverName })
|
||||
: t("bookRide.payment.title")}
|
||||
</Text>
|
||||
<Text className="text-sm text-general-200 dark:text-neutral-400 mt-1">
|
||||
{t("bookRide.payment.subtitle", { fare })}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onPay("cash")}
|
||||
disabled={submitting}
|
||||
className="flex-row items-center gap-x-3 rounded-2xl border border-neutral-200 dark:border-neutral-700 px-4 py-4 mt-4"
|
||||
>
|
||||
<MaterialCommunityIcons name="cash" size={22} color="#10b981" />
|
||||
<View className="flex-1">
|
||||
<Text className="font-JakartaBold text-black dark:text-white">
|
||||
{t("bookRide.payment.cash")}
|
||||
</Text>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{t("bookRide.payment.cashHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={20}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onPay("card")}
|
||||
disabled={submitting}
|
||||
className="flex-row items-center gap-x-3 rounded-2xl border border-neutral-200 dark:border-neutral-700 px-4 py-4 mt-2"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="credit-card-outline"
|
||||
size={22}
|
||||
color="#0286ff"
|
||||
/>
|
||||
<View className="flex-1">
|
||||
<Text className="font-JakartaBold text-black dark:text-white">
|
||||
{t("bookRide.payment.card")}
|
||||
</Text>
|
||||
<Text className="text-xs text-general-200 dark:text-neutral-400">
|
||||
{t("bookRide.payment.cardHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={20}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onCancel}
|
||||
disabled={submitting}
|
||||
className="items-center py-3 mt-2"
|
||||
>
|
||||
<Text className="font-JakartaBold text-general-200 dark:text-neutral-400">
|
||||
{submitting ? t("bookRide.payment.working") : t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
);
|
||||
};
|
||||
+76
-42
@@ -5,7 +5,8 @@ import { Alert, Image, Text, TouchableOpacity, View } from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { images } from "@/constants";
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { formatLBP } from "@/lib/pricing";
|
||||
import { useLocationStore } from "@/store";
|
||||
import type { PaymentProps } from "@/types/type";
|
||||
@@ -32,8 +33,11 @@ export const Payment = ({
|
||||
const [method, setMethod] = useState<PaymentMethod>("cash");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const t = useT();
|
||||
|
||||
const recordRide = async (paymentStatus: string) => {
|
||||
const fareCents = Math.round(parseFloat(amount) * 100); // in cents
|
||||
|
||||
const recordRide = async (paymentMethod: PaymentMethod, orderId?: string) => {
|
||||
await fetchAPI("/(api)/ride/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -47,8 +51,9 @@ export const Payment = ({
|
||||
destination_latitude: destinationLatitude,
|
||||
destination_longitude: destinationLongitude,
|
||||
ride_time: rideTime.toFixed(0),
|
||||
fare_price: Math.round(parseFloat(amount) * 100), // in cents
|
||||
payment_status: paymentStatus,
|
||||
fare_price: fareCents,
|
||||
payment_method: paymentMethod,
|
||||
...(orderId ? { payment_order_id: orderId } : {}),
|
||||
driver_id: driverId,
|
||||
}),
|
||||
});
|
||||
@@ -63,8 +68,8 @@ export const Payment = ({
|
||||
} catch (err) {
|
||||
console.log("[PAYMENT]: ", err);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Something went wrong while booking your ride. Please try again.",
|
||||
t("components.payment.alertErrorTitle"),
|
||||
t("components.payment.alertErrorBody"),
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
@@ -75,8 +80,10 @@ export const Payment = ({
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
// 1. Create an Areeba checkout session on our server.
|
||||
const { orderId, checkoutUrl, successIndicator, error } = await fetchAPI(
|
||||
// 1. Create an Areeba checkout session on our server. The server stores
|
||||
// the ride intent and the successIndicator; the client only gets an
|
||||
// orderId + checkoutUrl.
|
||||
const { orderId, checkoutUrl, error } = await fetchAPI(
|
||||
"/(api)/(areeba)/create",
|
||||
{
|
||||
method: "POST",
|
||||
@@ -86,7 +93,15 @@ export const Payment = ({
|
||||
body: JSON.stringify({
|
||||
name: fullName || email,
|
||||
email,
|
||||
amount,
|
||||
fare_cents: fareCents,
|
||||
driver_id: driverId,
|
||||
origin_address: userAddress,
|
||||
destination_address: destinationAddress,
|
||||
origin_latitude: userLatitude,
|
||||
origin_longitude: userLongitude,
|
||||
destination_latitude: destinationLatitude,
|
||||
destination_longitude: destinationLongitude,
|
||||
ride_time: rideTime.toFixed(0),
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -107,30 +122,44 @@ export const Payment = ({
|
||||
) as string;
|
||||
}
|
||||
|
||||
// 3. Verify the payment server-side before recording the ride.
|
||||
// 3. Verify the payment server-side. The server compares
|
||||
// resultIndicator against the stored successIndicator, reconciles
|
||||
// the captured amount, and marks the order paid.
|
||||
const verification = await fetchAPI("/(api)/(areeba)/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ orderId, resultIndicator, successIndicator }),
|
||||
body: JSON.stringify({ orderId, resultIndicator }),
|
||||
});
|
||||
|
||||
if (verification.success) {
|
||||
await recordRide("paid");
|
||||
// 4. Record the ride, consuming the paid order atomically. The client
|
||||
// never sets payment_status itself.
|
||||
await recordRide("card", orderId);
|
||||
setSuccess(true);
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Payment not completed",
|
||||
"Your payment was cancelled or could not be verified. Please try again.",
|
||||
t("components.payment.alertPaymentNotCompletedTitle"),
|
||||
t("components.payment.alertPaymentNotCompletedBody"),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[PAYMENT]: ", err);
|
||||
// Verification failures (cancelled, not captured, amount/intent mismatch)
|
||||
// come back as 400s. fetchAPI throws ApiError on non-2xx, so without this
|
||||
// branch every cancellation lands in the generic "something went wrong".
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Something went wrong while processing your payment. Please try again.",
|
||||
t("components.payment.alertPaymentNotCompletedTitle"),
|
||||
t("components.payment.alertPaymentNotCompletedBody"),
|
||||
);
|
||||
} else {
|
||||
Alert.alert(
|
||||
t("components.payment.alertProcessingTitle"),
|
||||
t("components.payment.alertProcessingBody"),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@@ -139,15 +168,19 @@ export const Payment = ({
|
||||
const confirm = () =>
|
||||
method === "cash"
|
||||
? payWithCash()
|
||||
: Alert.alert("Pay by card", `Your card will be charged $${amount}.`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Continue", onPress: () => void payWithCard() },
|
||||
]);
|
||||
: Alert.alert(
|
||||
t("components.payment.alertPayCardTitle"),
|
||||
t("components.payment.alertPayCardBody", { amount }),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: t("common.continue"), onPress: () => void payWithCard() },
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text className="text-lg font-JakartaSemiBold mt-4 mb-2">
|
||||
Payment Method
|
||||
<Text className="text-lg font-JakartaSemiBold mt-4 mb-2 text-black dark:text-white">
|
||||
{t("components.payment.paymentMethod")}
|
||||
</Text>
|
||||
|
||||
<View className="flex flex-row gap-x-3">
|
||||
@@ -155,16 +188,16 @@ export const Payment = ({
|
||||
onPress={() => setMethod("cash")}
|
||||
className={`flex-1 items-center py-3 rounded-xl border ${
|
||||
method === "cash"
|
||||
? "bg-general-600 border-primary-500"
|
||||
: "bg-white border-general-700"
|
||||
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
|
||||
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-JakartaMedium ${
|
||||
method === "cash" ? "text-white" : "text-black"
|
||||
method === "cash" ? "text-white" : "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
💵 Cash
|
||||
{t("components.payment.cash")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -172,16 +205,16 @@ export const Payment = ({
|
||||
onPress={() => setMethod("card")}
|
||||
className={`flex-1 items-center py-3 rounded-xl border ${
|
||||
method === "card"
|
||||
? "bg-general-600 border-primary-500"
|
||||
: "bg-white border-general-700"
|
||||
? "bg-general-600 dark:bg-primary-500/20 border-primary-500"
|
||||
: "bg-white dark:bg-neutral-900 border-general-700 dark:border-neutral-700"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`font-JakartaMedium ${
|
||||
method === "card" ? "text-white" : "text-black"
|
||||
method === "card" ? "text-white" : "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
💳 Card
|
||||
{t("components.payment.card")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -189,10 +222,10 @@ export const Payment = ({
|
||||
<CustomButton
|
||||
title={
|
||||
processing
|
||||
? "Processing..."
|
||||
? t("components.payment.processing")
|
||||
: method === "cash"
|
||||
? "Book ride · Pay cash to driver"
|
||||
: "Confirm & Pay by Card"
|
||||
? t("components.payment.bookCash")
|
||||
: t("components.payment.confirmCard")
|
||||
}
|
||||
className="my-2 mt-4"
|
||||
onPress={confirm}
|
||||
@@ -203,23 +236,24 @@ export const Payment = ({
|
||||
isVisible={success}
|
||||
onBackdropPress={() => setSuccess(false)}
|
||||
>
|
||||
<View className="flex flex-col items-center justify-center bg-white p-7 rounded-2xl">
|
||||
<Image source={images.check} alt="Check" className="w-28 h-28 mt-5" />
|
||||
<View className="flex flex-col items-center justify-center bg-white dark:bg-neutral-900 p-7 rounded-2xl">
|
||||
<Image source={images.check} alt={t("components.payment.checkAlt")} className="w-28 h-28 mt-5" />
|
||||
|
||||
<Text className="text-2xl text-center font-JakartaBold mt-5">
|
||||
Ride Booked!
|
||||
<Text className="text-2xl text-center font-JakartaBold mt-5 text-black dark:text-white">
|
||||
{t("components.payment.rideBooked")}
|
||||
</Text>
|
||||
|
||||
<Text className="text-base text-general-200 text-JakartaMedium text-center mt-3">
|
||||
Thank you for your booking.{"\n"} Your reservation has been placed.
|
||||
{"\n"}
|
||||
<Text className="text-base text-general-200 dark:text-neutral-400 text-JakartaMedium text-center mt-3">
|
||||
{t("components.payment.successBody")}
|
||||
{method === "cash"
|
||||
? `Please have ${formatLBP(parseFloat(amount))} ready.`
|
||||
? t("components.payment.cashInstruction", {
|
||||
lbp: formatLBP(parseFloat(amount)),
|
||||
})
|
||||
: null}
|
||||
</Text>
|
||||
|
||||
<CustomButton
|
||||
title="Back Home"
|
||||
title={t("components.payment.backHome")}
|
||||
onPress={() => {
|
||||
setSuccess(false);
|
||||
router.push("/(root)/(tabs)/home");
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
// The driver's half of the pickup handshake: they ask the rider for the code
|
||||
// on the rider's screen and type it here to start the trip. The code is never
|
||||
// sent to the driver's device, so a wrong entry is a real mismatch — either
|
||||
// the wrong passenger got in, or the driver is at the wrong car.
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
submitting?: boolean;
|
||||
/** Set when the server rejected the last attempt. */
|
||||
error?: string | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (code: string) => void;
|
||||
};
|
||||
|
||||
export const PickupCodeSheet = ({
|
||||
visible,
|
||||
submitting,
|
||||
error,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: Props) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
return (
|
||||
<ReactNativeModal
|
||||
isVisible={visible}
|
||||
onBackdropPress={onCancel}
|
||||
avoidKeyboard
|
||||
>
|
||||
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
|
||||
<Text className="text-xl font-JakartaBold text-center text-black dark:text-white">
|
||||
{t("pickupCode.title")}
|
||||
</Text>
|
||||
<Text className="text-sm text-general-200 dark:text-neutral-400 text-center mt-1">
|
||||
{t("pickupCode.subtitle")}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
value={code}
|
||||
onChangeText={(v) => setCode(v.replace(/\D/g, "").slice(0, 4))}
|
||||
keyboardType="number-pad"
|
||||
maxLength={4}
|
||||
autoFocus
|
||||
placeholder="0000"
|
||||
placeholderTextColor={isDark ? "#525252" : "#d4d4d4"}
|
||||
className="bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white rounded-2xl py-4 my-5 text-center text-3xl font-JakartaExtraBold tracking-[10px]"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-rose-500 text-sm text-center mb-3">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<CustomButton
|
||||
title={submitting ? "…" : t("pickupCode.startTrip")}
|
||||
bgVariant="success"
|
||||
onPress={() => onSubmit(code)}
|
||||
disabled={code.length < 4 || submitting}
|
||||
className={code.length < 4 ? "opacity-50" : ""}
|
||||
/>
|
||||
|
||||
<TouchableOpacity onPress={onCancel} className="py-3 mt-1">
|
||||
<Text className="text-center font-JakartaMedium text-general-200 dark:text-neutral-400">
|
||||
{t("common.cancel")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useRef } from "react";
|
||||
import { Image, StyleSheet, View } from "react-native";
|
||||
import MapView, { PROVIDER_DEFAULT, type Region } from "react-native-maps";
|
||||
|
||||
import { icons } from "@/constants";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
// Fine-tuning a pickup or drop-off point.
|
||||
//
|
||||
// The pin does NOT move — the map moves under it. Dragging a marker means
|
||||
// fighting for a few pixels with the same thumb that pans the map, and on a
|
||||
// phone the marker spends most of the gesture hidden under the finger holding
|
||||
// it. Anchoring the pin to the centre of the screen and sliding the map
|
||||
// underneath makes the target the one thing always visible, which is why every
|
||||
// ride-hailing app converged on it.
|
||||
//
|
||||
// The component is deliberately dumb: it reports the centre when the map
|
||||
// settles and nothing else. Reverse geocoding, debouncing and confirmation all
|
||||
// live on the screen, so this stays reusable for the origin and the
|
||||
// destination alike.
|
||||
|
||||
export type PinAdjusterProps = {
|
||||
initial: { latitude: number; longitude: number };
|
||||
/** Fired when the map stops moving, with the coordinate under the pin. */
|
||||
onSettled: (coords: { latitude: number; longitude: number }) => void;
|
||||
/** Fired as soon as a drag starts, to clear a now-stale address label. */
|
||||
onMoveStart?: () => void;
|
||||
};
|
||||
|
||||
// Tight enough that the rider is choosing a doorway, not a district.
|
||||
const ZOOM_DELTA = 0.004;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
map: StyleSheet.absoluteFillObject,
|
||||
// Sits above the map and ignores touches, so panning still reaches the map.
|
||||
pinLayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
pin: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
// The pin's point is at its bottom edge, but the coordinate we report is
|
||||
// the centre of the screen — so lift it by its own height to put the tip,
|
||||
// not the middle of the graphic, on the spot being chosen.
|
||||
marginBottom: 36,
|
||||
},
|
||||
// A small ground marker under the tip: without it, on a busy map, it is
|
||||
// genuinely hard to tell which pixel the pin is pointing at.
|
||||
dot: {
|
||||
position: "absolute",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: "rgba(2,134,255,0.9)",
|
||||
borderWidth: 1,
|
||||
borderColor: "#ffffff",
|
||||
},
|
||||
});
|
||||
|
||||
export const PinAdjuster = ({
|
||||
initial,
|
||||
onSettled,
|
||||
onMoveStart,
|
||||
}: PinAdjusterProps) => {
|
||||
const { isDark } = useTheme();
|
||||
const mapRef = useRef<MapView>(null);
|
||||
|
||||
const region: Region = {
|
||||
latitude: initial.latitude,
|
||||
longitude: initial.longitude,
|
||||
latitudeDelta: ZOOM_DELTA,
|
||||
longitudeDelta: ZOOM_DELTA,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
provider={PROVIDER_DEFAULT}
|
||||
style={styles.map}
|
||||
initialRegion={region}
|
||||
showsUserLocation
|
||||
showsMyLocationButton={false}
|
||||
userInterfaceStyle={isDark ? "dark" : "light"}
|
||||
onPanDrag={onMoveStart}
|
||||
onRegionChangeComplete={(next) =>
|
||||
onSettled({ latitude: next.latitude, longitude: next.longitude })
|
||||
}
|
||||
/>
|
||||
|
||||
<View style={styles.pinLayer} pointerEvents="none">
|
||||
<Image source={icons.pin} style={styles.pin} resizeMode="contain" />
|
||||
<View style={styles.dot} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { useT } from "@/lib/i18n";
|
||||
import type { PinAdjusterProps } from "@/components/pin-adjuster";
|
||||
|
||||
// react-native-maps does not support web, same as components/map.web.tsx.
|
||||
// The screen around this still works — the rider just can't drag a pin — so
|
||||
// the stub reports nothing and leaves whatever coordinate they arrived with
|
||||
// intact, rather than blocking the flow on a platform used only for testing.
|
||||
export const PinAdjuster = (_props: PinAdjusterProps) => {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-general-100 dark:bg-neutral-900">
|
||||
<Text className="text-general-200 dark:text-neutral-400 text-center font-JakartaMedium px-8">
|
||||
{t("components.map.webUnavailable")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import type * as ImagePicker from "expo-image-picker";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Image,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
import { alertPermissionDenied } from "@/lib/capture-permission";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||
import { loadImagePicker } from "@/lib/image-picker";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
type PhotoResponse = { data: { photo: string; attached: boolean } };
|
||||
|
||||
/**
|
||||
* The driver's own photo — the one a rider sees against their name in the list
|
||||
* of offers, and checks the arriving driver against.
|
||||
*
|
||||
* Deliberately not the document scanner: this photo is never read by OCR, it
|
||||
* is cropped square because it is rendered in a circle everywhere, and it
|
||||
* opens the front camera because it is a picture of a person rather than a
|
||||
* piece of paper.
|
||||
*
|
||||
* Uploading attaches it immediately for a driver who already has a profile, so
|
||||
* replacing a bad photo is one tap. During onboarding there is no profile row
|
||||
* yet, so the caller keeps the returned name and sends it with the submission.
|
||||
*/
|
||||
export const ProfilePhotoPicker = ({
|
||||
current,
|
||||
onUploaded,
|
||||
}: {
|
||||
/** The photo already on the profile, if any. */
|
||||
current?: string | null;
|
||||
onUploaded: (photo: string) => void;
|
||||
}) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// A just-taken photo wins over what the server has, so the driver sees the
|
||||
// result of their own tap rather than the picture it replaced.
|
||||
const shown = preview ?? driverPhotoUri(current) ?? null;
|
||||
|
||||
const upload = async (asset: ImagePicker.ImagePickerAsset) => {
|
||||
if (!asset.base64) {
|
||||
Alert.alert(t("driver.photo.errorTitle"), t("driver.photo.errorBody"));
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data } = (await fetchAPI("/(api)/driver/photo", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_base64: asset.base64 }),
|
||||
})) as PhotoResponse;
|
||||
|
||||
setPreview(asset.uri);
|
||||
onUploaded(data.photo);
|
||||
} catch (err) {
|
||||
console.log("[DRIVER_PHOTO]: ", err);
|
||||
|
||||
const code =
|
||||
err instanceof ApiError
|
||||
? (err.body?.code as string | undefined)
|
||||
: undefined;
|
||||
|
||||
Alert.alert(
|
||||
t("driver.photo.errorTitle"),
|
||||
code === "IMAGE_TOO_LARGE"
|
||||
? t("driver.photo.errorTooLarge")
|
||||
: code === "PHOTO_RATE_LIMIT"
|
||||
? t("driver.photo.errorRateLimit")
|
||||
: code === "UNSUPPORTED_IMAGE"
|
||||
? t("driver.photo.errorUnsupported")
|
||||
: t("driver.photo.errorBody"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Camera only — deliberately no gallery option.
|
||||
//
|
||||
// This photo is the rider's check that the person who pulled up is the
|
||||
// person the app sent them, so it has to be a picture of whoever is holding
|
||||
// the phone right now. Letting it come from the gallery would let a driver
|
||||
// register with someone else's face, or a photo of a photo, and nothing
|
||||
// downstream could tell the difference. It is not proof of identity — a
|
||||
// determined faker can point the camera at a printout — but it removes the
|
||||
// effortless version of that, and it keeps the photo current.
|
||||
const capture = async () => {
|
||||
if (busy) return;
|
||||
|
||||
// Loaded on demand — see lib/image-picker. On a binary built before
|
||||
// expo-image-picker was added this is the difference between one button
|
||||
// not working and the app not starting.
|
||||
const picker = loadImagePicker();
|
||||
if (!picker) {
|
||||
Alert.alert(t("driver.photo.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything that touches the picker is wrapped: the availability check
|
||||
// above should make a missing native module impossible, but a driver must
|
||||
// never be shown a raw "Cannot find native module" either way.
|
||||
let result: ImagePicker.ImagePickerResult;
|
||||
|
||||
try {
|
||||
const permission = await picker.requestCameraPermissionsAsync();
|
||||
|
||||
if (!permission.granted) {
|
||||
alertPermissionDenied(permission, {
|
||||
title: t("driver.photo.permissionTitle"),
|
||||
message: t("driver.photo.permissionCamera"),
|
||||
blocked: t("driver.photo.permissionCameraBlocked"),
|
||||
openSettings: t("common.openSettings"),
|
||||
cancel: t("common.cancel"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No crop step: one tap, done. Every surface renders this in a circle
|
||||
// with a centre crop anyway, and a selfie is already centred on the face.
|
||||
result = await picker.launchCameraAsync({
|
||||
mediaTypes: picker.MediaTypeOptions.Images,
|
||||
quality: 0.7,
|
||||
base64: true,
|
||||
exif: false,
|
||||
cameraType: picker.CameraType.front,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("[DRIVER_PHOTO_CAMERA]: ", error);
|
||||
Alert.alert(t("driver.photo.errorTitle"), t("driver.captureUnavailable"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.canceled || !result.assets[0]) return;
|
||||
|
||||
await upload(result.assets[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="items-center mb-6">
|
||||
<TouchableOpacity
|
||||
onPress={() => void capture()}
|
||||
disabled={busy}
|
||||
className="w-28 h-28 rounded-full bg-neutral-100 dark:bg-neutral-900 items-center justify-center overflow-hidden border-2 border-primary-500"
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color="#0286ff" />
|
||||
) : shown ? (
|
||||
<Image
|
||||
source={{ uri: shown }}
|
||||
className="w-28 h-28"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<MaterialCommunityIcons
|
||||
name="camera-plus-outline"
|
||||
size={30}
|
||||
color={isDark ? "#9ca3af" : "#858585"}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-sm font-JakartaBold text-black dark:text-white mt-3">
|
||||
{t("driver.photo.title")}
|
||||
</Text>
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 text-center mt-1 px-6">
|
||||
{t("driver.photo.hint")}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => void capture()}
|
||||
disabled={busy}
|
||||
className="flex-row items-center rounded-full bg-primary-500 py-2.5 px-5 mt-3"
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="camera-outline"
|
||||
size={15}
|
||||
color="#ffffff"
|
||||
/>
|
||||
<Text className="text-white font-JakartaBold text-xs ml-1.5">
|
||||
{shown ? t("driver.photo.retake") : t("driver.photo.take")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import { Image, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import ReactNativeModal from "react-native-modal";
|
||||
|
||||
import { CustomButton } from "@/components/custom-button";
|
||||
import { driverPhotoUri } from "@/lib/driver-photo";
|
||||
import { fetchAPI } from "@/lib/fetch";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
// The post-trip rating prompt, shared by both apps: a rider rates their driver
|
||||
// and a driver rates their rider through the same endpoint, which infers who
|
||||
// is rating from the caller's role on the ride. Both sides get the same sheet
|
||||
// so the two directions can't drift apart.
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
rideId: number;
|
||||
/** Who is being rated — only used for the copy. */
|
||||
subjectName?: string | null;
|
||||
subjectAvatar?: string | null;
|
||||
/** Rider-facing copy differs from driver-facing copy. */
|
||||
audience: "rider" | "driver";
|
||||
onDone: () => void;
|
||||
/** Called on "not now"; omit to make the rating unskippable. */
|
||||
onSkip?: () => void;
|
||||
};
|
||||
|
||||
export const RatingSheet = ({
|
||||
visible,
|
||||
rideId,
|
||||
subjectName,
|
||||
subjectAvatar,
|
||||
audience,
|
||||
onDone,
|
||||
onSkip,
|
||||
}: Props) => {
|
||||
const t = useT();
|
||||
const { isDark } = useTheme();
|
||||
const [stars, setStars] = useState(0);
|
||||
const [comment, setComment] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async () => {
|
||||
if (stars < 1) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fetchAPI(`/(api)/ride/${rideId}/rate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rating: stars,
|
||||
comment: comment.trim() || null,
|
||||
}),
|
||||
});
|
||||
onDone();
|
||||
} catch (err) {
|
||||
console.log("[RATE_RIDE]: ", err);
|
||||
setError(t("rating.error"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactNativeModal isVisible={visible} onBackdropPress={onSkip}>
|
||||
<View className="bg-white dark:bg-neutral-900 p-6 rounded-2xl">
|
||||
{subjectAvatar ? (
|
||||
<Image
|
||||
source={{ uri: driverPhotoUri(subjectAvatar) }}
|
||||
className="w-16 h-16 rounded-full self-center mb-3"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Text className="text-xl font-JakartaBold text-center text-black dark:text-white">
|
||||
{audience === "rider"
|
||||
? t("rating.rateDriverTitle", { name: subjectName ?? "" })
|
||||
: t("rating.rateRiderTitle", { name: subjectName ?? "" })}
|
||||
</Text>
|
||||
<Text className="text-sm text-general-200 dark:text-neutral-400 text-center mt-1">
|
||||
{t("rating.subtitle")}
|
||||
</Text>
|
||||
|
||||
<View className="flex-row justify-center gap-x-2 my-5">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<TouchableOpacity
|
||||
key={value}
|
||||
onPress={() => setStars(value)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
|
||||
accessibilityLabel={t("rating.starLabel", { n: value })}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={value <= stars ? "star" : "star-outline"}
|
||||
size={38}
|
||||
color={
|
||||
value <= stars ? "#f5b301" : isDark ? "#525252" : "#d4d4d4"
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
value={comment}
|
||||
onChangeText={setComment}
|
||||
placeholder={t("rating.commentPlaceholder")}
|
||||
placeholderTextColor={isDark ? "#737373" : "#858585"}
|
||||
multiline
|
||||
maxLength={500}
|
||||
className="bg-neutral-100 dark:bg-neutral-800 text-black dark:text-white rounded-2xl px-4 py-3 font-Jakarta text-[15px] min-h-[72px]"
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-rose-500 text-sm text-center mt-3">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<CustomButton
|
||||
title={submitting ? t("common.saving") : t("rating.submit")}
|
||||
onPress={submit}
|
||||
disabled={submitting || stars < 1}
|
||||
className={`mt-5 ${stars < 1 ? "opacity-50" : ""}`}
|
||||
/>
|
||||
|
||||
{onSkip ? (
|
||||
<TouchableOpacity onPress={onSkip} className="py-3 mt-1">
|
||||
<Text className="text-center font-JakartaMedium text-general-200 dark:text-neutral-400">
|
||||
{t("rating.notNow")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</ReactNativeModal>
|
||||
);
|
||||
};
|
||||
+115
-27
@@ -1,9 +1,33 @@
|
||||
import { Image, Text, View } from "react-native";
|
||||
|
||||
import { icons } from "@/constants";
|
||||
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,
|
||||
@@ -14,25 +38,68 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
|
||||
ride_time,
|
||||
driver,
|
||||
payment_status,
|
||||
status,
|
||||
cancelled_by,
|
||||
cancellation_reason,
|
||||
} = ride;
|
||||
|
||||
const outcome = OUTCOME[status as keyof typeof OUTCOME] ?? null;
|
||||
const didNotHappen = status === "cancelled" || status === "expired";
|
||||
|
||||
// A cancelled or expired ride never had a driver assigned in most cases, and
|
||||
// the LEFT JOIN hands back an object of nulls — which rendered as an empty
|
||||
// gap where a name should be.
|
||||
const driverName = [driver?.first_name, driver?.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-center bg-white rounded-lg shadow-sm shadow-neutral-300 mb-3">
|
||||
<View className="flex flex-row items-center justify-center bg-white dark:bg-neutral-900 rounded-lg shadow-sm shadow-neutral-300 dark:shadow-neutral-950/40 mb-3">
|
||||
<View className="flex flex-col items-center justify-center p-3">
|
||||
{outcome ? (
|
||||
<View className="flex flex-row items-center justify-between w-full mb-3">
|
||||
<View className={`rounded-full px-3 py-1 ${outcome.chip}`}>
|
||||
<Text className={`text-xs font-JakartaBold ${outcome.text}`}>
|
||||
{tr(outcome.labelKey)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Who ended it, and why — the two things a rider looking back at
|
||||
a cancelled trip actually wants to know. */}
|
||||
{didNotHappen && cancelled_by ? (
|
||||
<Text
|
||||
className="text-[11px] font-JakartaMedium text-gray-500 dark:text-neutral-400 flex-1 text-right ml-2"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{cancelled_by === "system"
|
||||
? tr("components.rideCard.cancelledBySystem")
|
||||
: tr(
|
||||
cancelled_by === "driver"
|
||||
? "components.rideCard.cancelledByDriver"
|
||||
: "components.rideCard.cancelledByYou",
|
||||
)}
|
||||
{cancellation_reason
|
||||
? ` · ${tr(`cancelSheet.reasons.${cancellation_reason}`)}`
|
||||
: ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<Image
|
||||
source={{
|
||||
uri: `https://maps.geoapify.com/v1/staticmap?style=osm-bright&width=600&height=400¢er=lonlat:${destination_longitude},${destination_latitude}&zoom=14&apiKey=${process.env.EXPO_PUBLIC_GEOAPIFY_API_KEY}`,
|
||||
}}
|
||||
alt="Map"
|
||||
alt={tr("components.rideCard.mapAlt")}
|
||||
className="w-[80px] h-[90px] rounded-lg"
|
||||
/>
|
||||
|
||||
<View className="flex flex-col mx-5 gap-y-5 flex-1">
|
||||
<View className="flex flex-row items-center gap-x-2">
|
||||
<Image source={icons.to} alt="Origin" className="w-5 h-5" />
|
||||
<Image source={icons.to} alt={tr("components.rideCard.originAlt")} className="w-5 h-5" />
|
||||
|
||||
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
|
||||
{origin_address}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -40,71 +107,92 @@ export const RideCard = ({ ride }: { ride: Ride }) => {
|
||||
<View className="flex flex-row items-center gap-x-2">
|
||||
<Image
|
||||
source={icons.point}
|
||||
alt="Destination"
|
||||
alt={tr("components.rideCard.destinationAlt")}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
|
||||
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||
<Text className="font-JakartaMedium text-black dark:text-white" numberOfLines={1}>
|
||||
{destination_address}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col w-full mt-5 bg-general-500 rounded-lg p-3 items-start justify-center">
|
||||
<View className="flex flex-col w-full mt-5 bg-general-500 dark:bg-neutral-800 rounded-lg p-3 items-start justify-center">
|
||||
<View className="flex flex-row items-center w-full justify-between mb-5">
|
||||
<Text className="font-JakartaMedium text-gray-500 text-xs">
|
||||
Date & Time
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
{tr("components.rideCard.dateTime")}
|
||||
</Text>
|
||||
|
||||
<Text className="font-JakartaMedium text-gray-500 text-xs">
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
{formatDate(created_at)}, {formatTime(ride_time)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center w-full justify-between mb-5">
|
||||
<Text className="font-JakartaMedium text-gray-500 text-xs">
|
||||
Driver
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
{tr("components.rideCard.driver")}
|
||||
</Text>
|
||||
|
||||
<Text className="font-JakartaMedium text-gray-500 text-xs">
|
||||
{driver.first_name} {driver.last_name}
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
{driverName || tr("components.rideCard.noDriver")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center w-full justify-between mb-5">
|
||||
<Text className="font-JakartaMedium text-gray-500 text-xs">
|
||||
Car Seats
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
{tr("components.rideCard.carSeats")}
|
||||
</Text>
|
||||
|
||||
<Text className="font-JakartaMedium text-gray-500 text-xs">
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
{driver.car_seats}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center w-full justify-between mb-5">
|
||||
<Text className="font-JakartaMedium text-gray-500 text-xs">
|
||||
Fare
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
{tr("components.rideCard.fare")}
|
||||
</Text>
|
||||
|
||||
<Text className="font-JakartaMedium text-gray-500 text-xs">
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
${(ride.fare_price / 100).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row items-center w-full justify-between mb-5">
|
||||
<Text className="font-JakartaMedium text-gray-500 text-xs">
|
||||
Payment Status
|
||||
<Text className="font-JakartaMedium text-gray-500 dark:text-neutral-400 text-xs">
|
||||
{tr("components.rideCard.paymentStatus")}
|
||||
</Text>
|
||||
|
||||
{/* A ride that never happened has no payment worth reporting as
|
||||
successful. A cash ride simply wasn't collected; a card ride
|
||||
that was charged before cancellation is called out as owed a
|
||||
refund rather than shown as a cheerful green "Paid". */}
|
||||
<Text
|
||||
className={`font-JakartaMedium capitalize text-xs ${payment_status === "paid" ? "text-emerald-500" : "text-gray-500"}`}
|
||||
className={`font-JakartaMedium capitalize text-xs ${
|
||||
didNotHappen
|
||||
? payment_status === "paid"
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-gray-500 dark:text-neutral-400"
|
||||
: payment_status === "paid" ||
|
||||
payment_status === "cash_collected"
|
||||
? "text-emerald-500 dark:text-emerald-400"
|
||||
: "text-gray-500 dark:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{payment_status === "cash"
|
||||
? "Cash · Pay to driver"
|
||||
{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"
|
||||
? "Paid by card"
|
||||
: payment_status}
|
||||
? tr("components.rideCard.paymentPaid")
|
||||
: tr("components.rideCard.paymentOther", {
|
||||
status: payment_status,
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
+31
-13
@@ -1,10 +1,13 @@
|
||||
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";
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
import { Map } from "./map";
|
||||
|
||||
@@ -14,29 +17,33 @@ type RideLayoutProps = {
|
||||
};
|
||||
|
||||
export const RideLayout = ({
|
||||
title = "Go Back",
|
||||
title,
|
||||
snapPoints,
|
||||
children,
|
||||
}: PropsWithChildren<RideLayoutProps>) => {
|
||||
const bottomSheetRef = useRef<BottomSheet>(null);
|
||||
const { isDark } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView>
|
||||
<View className="flex-1 bg-white">
|
||||
<View className="flex flex-col h-screen bg-blue-500">
|
||||
<View className="flex-1 bg-white dark:bg-neutral-950">
|
||||
<View className="flex flex-col h-screen bg-blue-500 dark:bg-neutral-900">
|
||||
<View className="flex flex-row absolute z-10 top-16 items-center justify-start px-5">
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<View className="w-10 h-10 bg-white rounded-full items-center justify-center">
|
||||
<View className="w-10 h-10 bg-white dark:bg-neutral-900 rounded-full items-center justify-center">
|
||||
<Image
|
||||
source={icons.backArrow}
|
||||
alt="Back arrow"
|
||||
alt={tr("components.rideLayout.backArrowAlt")}
|
||||
resizeMode="contain"
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className="text-xl font-JakartaSemiBold ml-5">{title}</Text>
|
||||
<Text className="text-xl font-JakartaSemiBold ml-5 text-black dark:text-white">
|
||||
{title ?? tr("components.rideLayout.goBack")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Map />
|
||||
@@ -47,15 +54,26 @@ export const RideLayout = ({
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints ?? ["40%", "85%"]}
|
||||
index={0}
|
||||
>
|
||||
<BottomSheetView
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
backgroundStyle={{ backgroundColor: isDark ? "#0a0a0a" : "#ffffff" }}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: isDark ? "#525252" : "#d4d4d4",
|
||||
}}
|
||||
>
|
||||
{/* Scrollable rather than a plain view: the keyboard takes half the
|
||||
screen while the rider is typing an address, and everything below
|
||||
the field it covers — the fare, "Find now" — was simply out of
|
||||
reach until they dismissed it. The bottom inset keeps the button
|
||||
clear of the Android gesture bar. */}
|
||||
<BottomSheetScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{
|
||||
padding: 20,
|
||||
paddingBottom: 20 + insets.bottom,
|
||||
}}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{children}
|
||||
</BottomSheetView>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { SERVICES } from "@/constants/services";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useServiceAvailability } from "@/lib/use-service-availability";
|
||||
import { useLocationStore, useServiceStore } from "@/store";
|
||||
|
||||
/**
|
||||
* Service picker: Car / Moto / Courier / My Car.
|
||||
*
|
||||
* Four equal tiles across the row rather than a scroller — with only four
|
||||
* 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 (
|
||||
<View>
|
||||
<View className="flex-row gap-2">
|
||||
{SERVICES.map((item) => {
|
||||
const active = item.id === service;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
onPress={() => setService(item.id)}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: active }}
|
||||
className={`flex-1 items-center rounded-2xl border py-3 ${
|
||||
active
|
||||
? "border-primary-500 bg-primary-500/10"
|
||||
: "border-neutral-100 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={item.icon}
|
||||
size={24}
|
||||
color={active ? "#0286ff" : "#858585"}
|
||||
/>
|
||||
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`mt-1.5 text-xs font-JakartaBold ${
|
||||
active ? "text-primary-500" : "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</Text>
|
||||
|
||||
{/* Availability. Hidden until the first count lands so the tiles
|
||||
don't flash "none nearby" while the request is still out. */}
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className={`mt-0.5 text-[10px] font-JakartaMedium ${
|
||||
loading
|
||||
? "text-transparent"
|
||||
: counts[item.id] > 0
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-general-200 dark:text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{counts[item.id] > 0
|
||||
? t("services.nearbyCount", { n: counts[item.id] })
|
||||
: t("services.noneNearby")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{selected ? (
|
||||
<Text className="mt-3 text-sm font-Jakarta text-general-200 dark:text-neutral-400">
|
||||
{t(selected.taglineKey)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||
import { Switch, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
import { useTheme } from "@/lib/theme";
|
||||
|
||||
type IconName = React.ComponentProps<typeof MaterialCommunityIcons>["name"];
|
||||
|
||||
type RightKind = "chevron" | "switch" | "value" | "check" | "none";
|
||||
|
||||
type SettingsRowProps = {
|
||||
icon: IconName;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
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;
|
||||
onPress?: () => void;
|
||||
/** Red accent — used for the emergency-call row. */
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
/** A single row in the Settings screen. Born dark-aware. */
|
||||
export const SettingsRow = ({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
right = "none",
|
||||
value,
|
||||
selected,
|
||||
switchValue,
|
||||
onSwitchChange,
|
||||
onPress,
|
||||
danger = false,
|
||||
}: SettingsRowProps) => {
|
||||
const { isDark } = useTheme();
|
||||
const interactive = right === "chevron" || right === "value" || right === "check";
|
||||
|
||||
const content = (
|
||||
<View className="flex-row items-center py-3.5">
|
||||
<View
|
||||
className={`w-10 h-10 rounded-full items-center justify-center mr-3.5 ${
|
||||
danger
|
||||
? "bg-rose-500/15"
|
||||
: "bg-neutral-100 dark:bg-neutral-800"
|
||||
}`}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={icon}
|
||||
size={20}
|
||||
color={danger ? "#e11d48" : isDark ? "#e5e5e5" : "#404040"}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-[15px] font-JakartaSemiBold ${
|
||||
danger ? "text-rose-600 dark:text-rose-400" : "text-black dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{subtitle ? (
|
||||
<Text className="text-xs font-Jakarta text-general-200 dark:text-neutral-400 mt-0.5">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{right === "switch" ? (
|
||||
<Switch
|
||||
value={switchValue}
|
||||
onValueChange={onSwitchChange}
|
||||
trackColor={{ false: "#d4d4d4", true: "#0286ff" }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{right === "value" ? (
|
||||
<Text className="text-sm font-JakartaMedium text-general-200 dark:text-neutral-400 mr-1">
|
||||
{value}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{right === "check" && selected ? (
|
||||
<MaterialCommunityIcons name="check" size={22} color="#0286ff" />
|
||||
) : null}
|
||||
|
||||
{right === "chevron" ? (
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={22}
|
||||
color={isDark ? "#737373" : "#a3a3a3"}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (right === "switch" || !interactive || !onPress) {
|
||||
return <View className="px-4">{content}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.6}
|
||||
className="px-4"
|
||||
>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
// Dispatch timings and distances shared by the server engine (lib/dispatch.ts)
|
||||
// and both clients. They live here rather than in lib/dispatch.ts because that
|
||||
// module imports the database driver and can't be pulled into the bundle.
|
||||
|
||||
/**
|
||||
* How long a request stays open for drivers to offer on before it gives up.
|
||||
*
|
||||
* This is the number both sides watch: the rider sees it as "we're still
|
||||
* looking", the driver as how long they have to decide before the job is off
|
||||
* the board. Long enough that a driver finishing a drop-off can still take it,
|
||||
* short enough that a rider standing on a corner at 4am gets an answer instead
|
||||
* of a spinner.
|
||||
*/
|
||||
export const REQUEST_TTL_SECONDS = 150;
|
||||
|
||||
/**
|
||||
* How far a request is broadcast from the pickup point.
|
||||
*
|
||||
* A request is shown to every eligible driver inside this radius rather than
|
||||
* to the nearest one at a time — the rider picks from whoever volunteers, so
|
||||
* dispatch's job is to put the job in front of enough people to give them a
|
||||
* real choice. Matches the default radius riders see drivers over on the map,
|
||||
* so a rider who can see a car can be offered by that car.
|
||||
*/
|
||||
export const BROADCAST_RADIUS_M = 8000;
|
||||
|
||||
/**
|
||||
* Android notification channel for incoming ride requests. Created on the
|
||||
* client with max importance, sound and vibration, and named here so the
|
||||
* server sends to the same channel the client registered — a mismatch
|
||||
* silently downgrades the notification to the default channel and it stops
|
||||
* making noise.
|
||||
*/
|
||||
export const OFFER_CHANNEL_ID = "ride-offers";
|
||||
|
||||
/**
|
||||
* How old a driver's last position ping may be before they're treated as gone,
|
||||
* whatever their `online` flag says. Every rider-facing query and the dispatch
|
||||
* broadcast share this, so a driver can never be visible on the map but
|
||||
* unreachable by a request, or vice versa.
|
||||
*
|
||||
* The heartbeat fires every 5s, so this is ~24 missed beats of slack. That
|
||||
* sounds generous until you watch a real phone: Android throttles JS timers
|
||||
* hard once the app leaves the foreground, and gaps of 20-30s were measured on
|
||||
* a device that was awake and on screen. At 60s those gaps flickered drivers
|
||||
* in and out of every rider's map.
|
||||
*/
|
||||
export const DRIVER_STALE_SECONDS = 120;
|
||||
+6
-9
@@ -76,23 +76,20 @@ export const icons = {
|
||||
export const onboarding = [
|
||||
{
|
||||
id: 1,
|
||||
title: "The perfect ride is just a tap away!",
|
||||
description:
|
||||
"Your journey begins with Waseel. Find your ideal ride effortlessly.",
|
||||
titleKey: "onboarding.slide1.title",
|
||||
descKey: "onboarding.slide1.desc",
|
||||
image: images.onboarding1,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Best car in your hands with Waseel",
|
||||
description:
|
||||
"Discover the convenience of finding your perfect ride with Waseel",
|
||||
titleKey: "onboarding.slide2.title",
|
||||
descKey: "onboarding.slide2.desc",
|
||||
image: images.onboarding2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Your ride, your way. Let's go!",
|
||||
description:
|
||||
"Enter your destination, sit back, and let us take care of the rest.",
|
||||
titleKey: "onboarding.slide3.title",
|
||||
descKey: "onboarding.slide3.desc",
|
||||
image: images.onboarding3,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// The services offered on the home screen.
|
||||
//
|
||||
// Labels and taglines are translation keys (resolved via `t()` at the call
|
||||
// site) so each language carries its own names. The Arabic names are Levantine
|
||||
// rather than formal MSA — "موتور" is what a motorcycle taxi is actually called
|
||||
// in Lebanon, where "دراجة نارية" reads like a textbook translation.
|
||||
|
||||
export type ServiceId = "car" | "moto" | "courier" | "chauffeur";
|
||||
|
||||
export type Service = {
|
||||
id: ServiceId;
|
||||
/** MaterialCommunityIcons glyph name. */
|
||||
icon: "car" | "motorbike" | "package-variant-closed" | "steering";
|
||||
/** i18n key for the short label (kept short so four tiles fit a phone width). */
|
||||
labelKey: string;
|
||||
/** i18n key for the tagline shown under the row once the service is selected. */
|
||||
taglineKey: string;
|
||||
/** Multiplier applied to the base fare for this service (car = 1.0). */
|
||||
fareMultiplier: number;
|
||||
};
|
||||
|
||||
export const SERVICES: Service[] = [
|
||||
{
|
||||
id: "car",
|
||||
icon: "car",
|
||||
labelKey: "services.car.label",
|
||||
taglineKey: "services.car.tagline",
|
||||
fareMultiplier: 1.0,
|
||||
},
|
||||
{
|
||||
id: "moto",
|
||||
icon: "motorbike",
|
||||
labelKey: "services.moto.label",
|
||||
taglineKey: "services.moto.tagline",
|
||||
fareMultiplier: 0.7,
|
||||
},
|
||||
{
|
||||
id: "courier",
|
||||
icon: "package-variant-closed",
|
||||
labelKey: "services.courier.label",
|
||||
taglineKey: "services.courier.tagline",
|
||||
fareMultiplier: 0.85,
|
||||
},
|
||||
{
|
||||
id: "chauffeur",
|
||||
icon: "steering",
|
||||
labelKey: "services.chauffeur.label",
|
||||
taglineKey: "services.chauffeur.tagline",
|
||||
fareMultiplier: 1.5,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SERVICE: ServiceId = "car";
|
||||
@@ -130,6 +130,13 @@ tr:last-child td {
|
||||
.badge.paid { color: var(--success); border-color: var(--success); }
|
||||
.badge.unpaid { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* Driver vetting states. Pending has to catch the eye — it is a queue someone
|
||||
has to work through — while approved stays quiet, being the resting state. */
|
||||
.badge.pending { color: #f5a524; border-color: #f5a524; }
|
||||
.badge.approved { color: var(--success); border-color: var(--success); }
|
||||
.badge.rejected { color: var(--danger); border-color: var(--danger); }
|
||||
.badge.suspended { color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
button,
|
||||
select,
|
||||
input {
|
||||
@@ -176,6 +183,25 @@ select {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card .sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.card.warn .value {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.pager {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
margin: auto;
|
||||
width: 340px;
|
||||
|
||||
@@ -48,3 +48,29 @@ export const api = async <T>(
|
||||
|
||||
return body as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a binary response — a driver's document scan — as an object URL.
|
||||
*
|
||||
* Scans are served from an authenticated route, and an `<img src>` cannot
|
||||
* carry the bearer token, so the bytes are fetched here and handed to the
|
||||
* image as a blob URL instead. The caller owns the returned URL and must
|
||||
* revokeObjectURL it, or the blob is pinned in memory for the tab's life.
|
||||
*/
|
||||
export const apiObjectUrl = async (path: string): Promise<string> => {
|
||||
const headers = new Headers();
|
||||
if (authToken) headers.set("Authorization", `Bearer ${authToken}`);
|
||||
|
||||
const res = await fetch(`${API_URL}${path}`, { headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
throw new ApiError("Session expired. Please sign in again.", 401);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`Could not load document (${res.status})`, res.status);
|
||||
}
|
||||
|
||||
return URL.createObjectURL(await res.blob());
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -9,10 +9,38 @@ type Driver = {
|
||||
car_image_url: string | null;
|
||||
car_seats: number;
|
||||
rating: string;
|
||||
service: string;
|
||||
online: boolean;
|
||||
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: "",
|
||||
@@ -26,11 +54,31 @@ export default function Drivers() {
|
||||
const [drivers, setDrivers] = useState<Driver[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<Driver | "new" | null>(null);
|
||||
const [balances, setBalances] = useState<Record<number, Balance>>({});
|
||||
|
||||
// Vetting a driver against their scans. Separate from the edit form: this is
|
||||
// a decision about whether someone may carry passengers, not a field update.
|
||||
const [reviewing, setReviewing] = useState<Driver | null>(null);
|
||||
|
||||
// Opening the picker rather than settling outright. A driver handing over
|
||||
// part of what they owe is normal, and settling the whole balance because
|
||||
// the button only offered all-or-nothing would put the ledger out of step
|
||||
// with the cash actually received.
|
||||
const [settleFor, setSettleFor] = useState<{
|
||||
driver: Driver;
|
||||
side: "platform_fee" | "driver_payout";
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api<{ data: Driver[] }>("/admin/drivers");
|
||||
const [res, ledger] = await Promise.all([
|
||||
api<{ data: Driver[] }>("/admin/drivers"),
|
||||
api<{ data: Balance[] }>("/admin/settle"),
|
||||
]);
|
||||
setDrivers(res.data);
|
||||
setBalances(
|
||||
Object.fromEntries(ledger.data.map((b) => [b.driver_id, b])),
|
||||
);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -63,10 +111,15 @@ export default function Drivers() {
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Service</th>
|
||||
<th>Seats</th>
|
||||
<th>Rating</th>
|
||||
<th>Vetting</th>
|
||||
<th>Online</th>
|
||||
<th>Rides</th>
|
||||
<th>Revenue</th>
|
||||
<th>Owes company</th>
|
||||
<th>Owed to driver</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -77,12 +130,58 @@ export default function Drivers() {
|
||||
<td>
|
||||
{d.first_name} {d.last_name}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`tag tag-${d.service}`}>{d.service}</span>
|
||||
</td>
|
||||
<td>{d.car_seats}</td>
|
||||
<td>{d.rating}</td>
|
||||
<td>
|
||||
<span className={`badge ${d.approval_status}`}>
|
||||
{d.approval_status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{d.online ? "● online" : "○ off"}</td>
|
||||
<td>{d.total_rides}</td>
|
||||
<td>{d.revenue.toLocaleString()}</td>
|
||||
<td>
|
||||
{balances[d.id]?.owes_company_cents ? (
|
||||
<strong>{money(balances[d.id].owes_company_cents)}</strong>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{balances[d.id]?.owed_to_driver_cents ? (
|
||||
<strong>{money(balances[d.id].owed_to_driver_cents)}</strong>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
{balances[d.id]?.owes_company_cents ? (
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() =>
|
||||
setSettleFor({ driver: d, side: "platform_fee" })
|
||||
}
|
||||
>
|
||||
Collect
|
||||
</button>
|
||||
) : null}
|
||||
{balances[d.id]?.owed_to_driver_cents ? (
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() =>
|
||||
setSettleFor({ driver: d, side: "driver_payout" })
|
||||
}
|
||||
>
|
||||
Pay out
|
||||
</button>
|
||||
) : null}
|
||||
<button className="secondary" onClick={() => setReviewing(d)}>
|
||||
Review
|
||||
</button>
|
||||
<button className="secondary" onClick={() => setEditing(d)}>
|
||||
Edit
|
||||
</button>
|
||||
@@ -96,6 +195,29 @@ export default function Drivers() {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{settleFor && (
|
||||
<SettlePicker
|
||||
driver={settleFor.driver}
|
||||
side={settleFor.side}
|
||||
onClose={() => setSettleFor(null)}
|
||||
onSettled={() => {
|
||||
setSettleFor(null);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{reviewing && (
|
||||
<VettingPanel
|
||||
driver={reviewing}
|
||||
onClose={() => setReviewing(null)}
|
||||
onDecided={() => {
|
||||
setReviewing(null);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<DriverForm
|
||||
initial={editing === "new" ? null : editing}
|
||||
@@ -110,6 +232,261 @@ export default function Drivers() {
|
||||
);
|
||||
}
|
||||
|
||||
// One document scan, fetched with the operator's token and rendered from a
|
||||
// blob URL — the route is authenticated, so a bare <img src> would 401.
|
||||
function DocumentScan({ name, label }: { name: string; label: string }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let url: string | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
apiObjectUrl(`/driver/documents?name=${encodeURIComponent(name)}`)
|
||||
.then((objectUrl) => {
|
||||
url = objectUrl;
|
||||
// The panel may have closed while the fetch was in flight; revoke
|
||||
// rather than setting state on an unmounted component.
|
||||
if (cancelled) URL.revokeObjectURL(objectUrl);
|
||||
else setSrc(objectUrl);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
<figure style={{ margin: 0 }}>
|
||||
<figcaption className="muted" style={{ fontSize: 12, marginBottom: 4 }}>
|
||||
{label}
|
||||
</figcaption>
|
||||
{error ? (
|
||||
<div className="error">{error}</div>
|
||||
) : src ? (
|
||||
// Opens full size in a tab: small print on a licence is unreadable at
|
||||
// thumbnail size, and reading it is the whole point of this panel.
|
||||
<a href={src} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={src}
|
||||
alt={label}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxHeight: 220,
|
||||
objectFit: "contain",
|
||||
background: "#00000010",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="muted">Loading…</div>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
// The driver's profile photo. Unlike a document scan this route is public, so
|
||||
// the browser can load it straight from a <src> — a stored name is resolved
|
||||
// through the API, while an external URL an owner typed in is used as-is.
|
||||
function DriverPhoto({ name }: { name: string }) {
|
||||
const src = /^https?:/i.test(name)
|
||||
? name
|
||||
: `${import.meta.env.VITE_API_URL ?? ""}/driver/photo?name=${encodeURIComponent(name)}`;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt="Driver profile photo"
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
background: "#00000010",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check what a driver typed against the documents they photographed, then
|
||||
* approve or reject.
|
||||
*
|
||||
* The scans exist precisely because the typed numbers arrive from OCR and OCR
|
||||
* is fallible — so the two are shown side by side and the decision rests on
|
||||
* the document, not on the field. Rejecting requires a reason, which is what
|
||||
* the driver sees in the app and corrects against.
|
||||
*/
|
||||
function VettingPanel({
|
||||
driver,
|
||||
onClose,
|
||||
onDecided,
|
||||
}: {
|
||||
driver: Driver;
|
||||
onClose: () => void;
|
||||
onDecided: () => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState(driver.rejection_reason ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const decide = async (
|
||||
approval_status: "approved" | "rejected" | "suspended",
|
||||
) => {
|
||||
if (approval_status !== "approved" && !reason.trim()) {
|
||||
setError("Give the driver a reason they can act on.");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api(`/admin/drivers/${driver.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
approval_status,
|
||||
rejection_reason:
|
||||
approval_status === "approved" ? undefined : reason.trim(),
|
||||
}),
|
||||
});
|
||||
onDecided();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scans: [string | null, string][] = [
|
||||
[driver.license_image_url, "Driving licence"],
|
||||
[driver.id_image_url, "ID card"],
|
||||
[driver.vehicle_reg_image_url, "Vehicle registration"],
|
||||
];
|
||||
|
||||
const present = scans.filter(([name]) => name);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>
|
||||
Vetting — {driver.first_name} {driver.last_name} (#{driver.id})
|
||||
</h3>
|
||||
|
||||
{/* The photo riders will actually see. It is checked here rather than
|
||||
left to chance because it is the one part of the profile shown to
|
||||
every passenger before they get into the car. */}
|
||||
{driver.profile_image_url && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<DriverPhoto name={driver.profile_image_url} />
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
Shown to riders choosing a driver
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Status: <strong>{driver.approval_status}</strong>
|
||||
{driver.submitted_at
|
||||
? ` · submitted ${new Date(driver.submitted_at).toLocaleString()}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Licence number</td>
|
||||
<td>
|
||||
{driver.license_number ?? <span className="muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Licence expiry</td>
|
||||
<td>
|
||||
{driver.license_expiry ? (
|
||||
driver.license_expiry.slice(0, 10)
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>National ID</td>
|
||||
<td>{driver.national_id ?? <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Plate</td>
|
||||
<td>{driver.plate_number ?? <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Car</td>
|
||||
<td>{driver.car_model ?? <span className="muted">—</span>}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{present.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
|
||||
gap: 12,
|
||||
margin: "12px 0",
|
||||
}}
|
||||
>
|
||||
{present.map(([name, label]) => (
|
||||
<DocumentScan key={name} name={name as string} label={label} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="muted" style={{ margin: "12px 0" }}>
|
||||
No scans on file — this profile predates document capture.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
placeholder="Reason (required to reject or suspend)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<div className="row-actions">
|
||||
<button disabled={busy} onClick={() => decide("approved")}>
|
||||
{busy ? "Saving…" : "Approve"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={busy}
|
||||
onClick={() => decide("rejected")}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
{driver.approval_status === "approved" && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
disabled={busy}
|
||||
onClick={() => decide("suspended")}
|
||||
>
|
||||
Suspend
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DriverForm({
|
||||
initial,
|
||||
onClose,
|
||||
@@ -197,3 +574,206 @@ function DriverForm({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pick exactly which rides a payment covers.
|
||||
//
|
||||
// Settling is an assertion about the real world — that cash was handed over,
|
||||
// or a transfer was made — so the operator has to be able to say precisely
|
||||
// which trips it accounts for. Everything is selected by default, because
|
||||
// settling the whole balance is still the common case; unticking is the
|
||||
// exception, not the workflow.
|
||||
type UnsettledRide = {
|
||||
ride_id: number;
|
||||
amount_cents: number;
|
||||
fare_price: number;
|
||||
origin_address: string;
|
||||
destination_address: string;
|
||||
completed_at: string;
|
||||
};
|
||||
|
||||
function SettlePicker({
|
||||
driver,
|
||||
side,
|
||||
onClose,
|
||||
onSettled,
|
||||
}: {
|
||||
driver: Driver;
|
||||
side: "platform_fee" | "driver_payout";
|
||||
onClose: () => void;
|
||||
onSettled: () => void;
|
||||
}) {
|
||||
const [rides, setRides] = useState<UnsettledRide[]>([]);
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set());
|
||||
const [note, setNote] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const collecting = side === "platform_fee";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api<{ data: { rides: UnsettledRide[] } }>(
|
||||
`/admin/settle?driver_id=${driver.id}&side=${side}`,
|
||||
);
|
||||
if (cancelled) return;
|
||||
setRides(res.data.rides);
|
||||
setPicked(new Set(res.data.rides.map((r) => r.ride_id)));
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [driver.id, side]);
|
||||
|
||||
const toggle = (rideId: number) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(rideId)) next.delete(rideId);
|
||||
else next.add(rideId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const allPicked = rides.length > 0 && picked.size === rides.length;
|
||||
const total = rides
|
||||
.filter((r) => picked.has(r.ride_id))
|
||||
.reduce((sum, r) => sum + r.amount_cents, 0);
|
||||
|
||||
const submit = async () => {
|
||||
if (picked.size === 0) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api("/admin/settle", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
side,
|
||||
// Ride ids, not driver_id: the server settles exactly these and
|
||||
// leaves the rest of the balance outstanding.
|
||||
ride_ids: [...picked],
|
||||
note: note.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
onSettled();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>
|
||||
{collecting ? "Collect commission from" : "Pay out"}{" "}
|
||||
{driver.first_name} {driver.last_name}
|
||||
</h3>
|
||||
<p className="muted" style={{ marginTop: -6 }}>
|
||||
{collecting
|
||||
? "Cash rides where this driver still owes the platform fee."
|
||||
: "Card rides where the platform still owes this driver."}
|
||||
</p>
|
||||
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">Loading rides…</p>
|
||||
) : rides.length === 0 ? (
|
||||
<p className="muted">Nothing outstanding.</p>
|
||||
) : (
|
||||
<>
|
||||
<label style={{ display: "block", margin: "8px 0" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPicked}
|
||||
onChange={() =>
|
||||
setPicked(
|
||||
allPicked
|
||||
? new Set()
|
||||
: new Set(rides.map((r) => r.ride_id)),
|
||||
)
|
||||
}
|
||||
/>{" "}
|
||||
Select all ({rides.length})
|
||||
</label>
|
||||
|
||||
<div style={{ maxHeight: 260, overflowY: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Ride</th>
|
||||
<th>Route</th>
|
||||
<th>Fare</th>
|
||||
<th>{collecting ? "Commission" : "Payout"}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rides.map((r) => (
|
||||
<tr key={r.ride_id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked.has(r.ride_id)}
|
||||
onChange={() => toggle(r.ride_id)}
|
||||
/>
|
||||
</td>
|
||||
<td>#{r.ride_id}</td>
|
||||
<td style={{ fontSize: 11 }}>
|
||||
{r.origin_address} → {r.destination_address}
|
||||
<div className="muted">
|
||||
{new Date(r.completed_at).toLocaleDateString()}
|
||||
</div>
|
||||
</td>
|
||||
<td>{money(r.fare_price)}</td>
|
||||
<td>
|
||||
<strong>{money(r.amount_cents)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<input
|
||||
placeholder="Reference (transfer id, receipt no., 'cash in office')"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
|
||||
<p>
|
||||
<strong>
|
||||
{picked.size} of {rides.length} ride
|
||||
{rides.length === 1 ? "" : "s"} · {money(total)}
|
||||
</strong>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="row-actions">
|
||||
<button className="secondary" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || picked.size === 0}
|
||||
title={
|
||||
picked.size === 0 ? "Select at least one ride" : undefined
|
||||
}
|
||||
>
|
||||
{busy
|
||||
? "Recording…"
|
||||
: collecting
|
||||
? `Mark ${money(total)} collected`
|
||||
: `Mark ${money(total)} paid`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+183
-15
@@ -8,43 +8,128 @@ 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 = {
|
||||
data: Ride[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString();
|
||||
|
||||
// Money is stored in cents; the table shows currency units.
|
||||
const money = (cents: number | null | undefined) =>
|
||||
cents == null ? "—" : (cents / 100).toFixed(2);
|
||||
|
||||
// A cancelled or expired ride earns nobody anything, so the split columns show
|
||||
// a dash rather than a zero — "no money changed hands here" and "the fee
|
||||
// happened to be zero" are different facts.
|
||||
const happened = (r: Ride) => r.status === "completed";
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
completed: "paid",
|
||||
cancelled: "unpaid",
|
||||
expired: "unpaid",
|
||||
};
|
||||
|
||||
export default function Rides() {
|
||||
const [rides, setRides] = useState<Ride[]>([]);
|
||||
const [meta, setMeta] = useState({ total: 0, page: 1, pages: 1 });
|
||||
const [status, setStatus] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (status: string) => {
|
||||
const load = useCallback(async (status: string, q: string, page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api<{ data: Ride[] }>(
|
||||
`/admin/rides${status ? `?status=${encodeURIComponent(status)}` : ""}`,
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
if (q) params.set("q", q);
|
||||
if (page > 1) params.set("page", String(page));
|
||||
const res = await api<RidesResponse>(
|
||||
`/admin/rides${params.size ? `?${params}` : ""}`,
|
||||
);
|
||||
setRides(res.data);
|
||||
setMeta({ total: res.total, page: res.page, pages: res.pages });
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(status);
|
||||
}, [load, status]);
|
||||
load(status, query, page);
|
||||
}, [load, status, page]);
|
||||
|
||||
const search = () => {
|
||||
setPage(1);
|
||||
load(status, query, 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Rides & payments</h2>
|
||||
<div className="toolbar">
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All payments</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="unpaid">Unpaid</option>
|
||||
<input
|
||||
placeholder="Search email, driver or address…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && search()}
|
||||
/>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
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. */}
|
||||
<option value="">All rides</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="expired">No driver found</option>
|
||||
<option value="paid">Paid by card</option>
|
||||
<option value="cash">Cash owed</option>
|
||||
<option value="cash_collected">Cash collected</option>
|
||||
</select>
|
||||
<button className="secondary" onClick={search}>
|
||||
Search
|
||||
</button>
|
||||
<span className="muted">
|
||||
{fmt(meta.total)} ride{meta.total === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
{!loading && !error && rides.length === 0 && (
|
||||
<div className="muted">No rides match the current filters.</div>
|
||||
)}
|
||||
|
||||
{rides.length > 0 && (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -54,35 +139,118 @@ export default function Rides() {
|
||||
<th>Driver</th>
|
||||
<th>Time (min)</th>
|
||||
<th>Fare</th>
|
||||
<th>Driver gets</th>
|
||||
<th>Company gets</th>
|
||||
<th>Ride</th>
|
||||
<th>Payment</th>
|
||||
<th>Settled</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className={loading ? "loading" : ""}>
|
||||
{rides.map((r) => (
|
||||
<tr key={r.ride_id}>
|
||||
<tr key={r.ride_id} style={loading ? { opacity: 0.5 } : undefined}>
|
||||
<td>{r.ride_id}</td>
|
||||
<td>
|
||||
{r.origin_address} → {r.destination_address}
|
||||
</td>
|
||||
<td>{r.user_email}</td>
|
||||
<td>{r.driver.name}</td>
|
||||
<td>{r.driver?.name ?? <span className="muted">no driver</span>}</td>
|
||||
<td>{r.ride_time}</td>
|
||||
<td>{r.fare_price.toLocaleString()}</td>
|
||||
<td>{money(r.fare_price)}</td>
|
||||
<td>{happened(r) ? money(r.driver_payout_cents) : "—"}</td>
|
||||
<td>{happened(r) ? money(r.platform_fee_cents) : "—"}</td>
|
||||
<td>
|
||||
<span className={`badge ${STATUS_CLASS[r.status] ?? ""}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
{r.cancellation_reason ? (
|
||||
<div className="muted" style={{ fontSize: 11 }}>
|
||||
{r.cancelled_by}: {r.cancellation_reason.replace(/_/g, " ")}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
{/* A ride that never happened has no payment to report as
|
||||
pending — it owes nobody anything. */}
|
||||
{happened(r) ? (
|
||||
<span
|
||||
className={`badge ${
|
||||
r.payment_status.toLowerCase() === "paid" ? "paid" : "unpaid"
|
||||
["paid", "cash_collected"].includes(
|
||||
r.payment_status.toLowerCase(),
|
||||
)
|
||||
? "paid"
|
||||
: "unpaid"
|
||||
}`}
|
||||
>
|
||||
{r.payment_status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="muted">not charged</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{/* Two directions: cash rides leave the company waiting on
|
||||
its fee, card rides leave the driver waiting on their
|
||||
payout. A ride that produced no money owes nobody. */}
|
||||
{!happened(r) ||
|
||||
!["paid", "cash_collected"].includes(
|
||||
r.payment_status.toLowerCase(),
|
||||
) ? (
|
||||
<span className="muted">—</span>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
r.platform_fee_settled_at ? "" : "muted"
|
||||
}
|
||||
style={{ fontSize: 11 }}
|
||||
title="Company's commission"
|
||||
>
|
||||
{r.platform_fee_settled_at
|
||||
? "company paid"
|
||||
: "company awaiting"}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
r.driver_payout_settled_at ? "" : "muted"
|
||||
}
|
||||
style={{ fontSize: 11 }}
|
||||
title="Driver's payout"
|
||||
>
|
||||
{r.driver_payout_settled_at
|
||||
? "driver paid"
|
||||
: "driver awaiting"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(r.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div className="toolbar pager">
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={meta.page <= 1 || loading}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="muted">
|
||||
Page {meta.page} of {meta.pages}
|
||||
</span>
|
||||
<button
|
||||
className="secondary"
|
||||
disabled={meta.page >= meta.pages || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,40 @@ import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
|
||||
type Stats = {
|
||||
totals: { users: number; drivers: number; rides: number; revenue: number };
|
||||
trend: { day: string; rides: number; revenue: number }[];
|
||||
topDrivers: { driver_id: number; name: string; rides: number; revenue: number }[];
|
||||
totals: {
|
||||
users: number;
|
||||
drivers: number;
|
||||
rides: number;
|
||||
completed_rides: number;
|
||||
cancelled_rides: number;
|
||||
gross_fares: number;
|
||||
driver_payouts: number;
|
||||
company_revenue: number;
|
||||
company_collected: number;
|
||||
company_outstanding: number;
|
||||
driver_outstanding: number;
|
||||
rides_today: number;
|
||||
avg_fare: number;
|
||||
pending_count: number;
|
||||
pending_revenue: number;
|
||||
new_users_7d: number;
|
||||
};
|
||||
trend: { day: string; rides: number; revenue: number; payouts: number }[];
|
||||
topDrivers: {
|
||||
driver_id: number;
|
||||
name: string;
|
||||
rides: number;
|
||||
earnings: number;
|
||||
company_revenue: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString();
|
||||
|
||||
export default function Stats() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [metric, setMetric] = useState<"rides" | "revenue" | "payouts">("rides");
|
||||
|
||||
useEffect(() => {
|
||||
api<{ data: Stats }>("/admin/stats")
|
||||
@@ -18,9 +44,10 @@ export default function Stats() {
|
||||
}, []);
|
||||
|
||||
if (error) return <div className="error">{error}</div>;
|
||||
if (!stats) return <div>Loading…</div>;
|
||||
if (!stats) return <div className="muted">Loading…</div>;
|
||||
|
||||
const maxRides = Math.max(1, ...stats.trend.map((d) => d.rides));
|
||||
const t = stats.totals;
|
||||
const max = Math.max(1, ...stats.trend.map((d) => d[metric]));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -28,30 +55,72 @@ export default function Stats() {
|
||||
<div className="cards">
|
||||
<div className="card">
|
||||
<div className="label">Users</div>
|
||||
<div className="value">{stats.totals.users}</div>
|
||||
<div className="value">{fmt(t.users)}</div>
|
||||
<div className="sub">+{fmt(t.new_users_7d)} this week</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="label">Drivers</div>
|
||||
<div className="value">{stats.totals.drivers}</div>
|
||||
<div className="value">{fmt(t.drivers)}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="label">Rides</div>
|
||||
<div className="value">{stats.totals.rides}</div>
|
||||
<div className="value">{fmt(t.rides)}</div>
|
||||
<div className="sub">
|
||||
{fmt(t.completed_rides)} completed · {fmt(t.cancelled_rides)} cancelled
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="label">Revenue (paid)</div>
|
||||
<div className="value">{stats.totals.revenue.toLocaleString()}</div>
|
||||
<div className="label">Gross fares</div>
|
||||
<div className="value">{fmt(t.gross_fares)}</div>
|
||||
<div className="sub">what riders paid · avg {fmt(t.avg_fare)}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="label">Company revenue</div>
|
||||
<div className="value">{fmt(t.company_revenue)}</div>
|
||||
<div className="sub">
|
||||
{fmt(t.company_collected)} collected
|
||||
</div>
|
||||
</div>
|
||||
{/* Commission drivers took in cash and haven't handed over yet. This
|
||||
is the number to chase at the end of a shift. */}
|
||||
<div className={`card ${t.company_outstanding > 0 ? "warn" : ""}`}>
|
||||
<div className="label">Commission to collect</div>
|
||||
<div className="value">{fmt(t.company_outstanding)}</div>
|
||||
<div className="sub">held by drivers from cash rides</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="label">Payouts owed</div>
|
||||
<div className="value">{fmt(t.driver_outstanding)}</div>
|
||||
<div className="sub">of {fmt(t.driver_payouts)} total earned</div>
|
||||
</div>
|
||||
{/* Completed rides whose money never landed. Cancelled rides are no
|
||||
longer counted here — they never owed anything. */}
|
||||
<div className={`card ${t.pending_count > 0 ? "warn" : ""}`}>
|
||||
<div className="label">Uncollected</div>
|
||||
<div className="value">{fmt(t.pending_count)}</div>
|
||||
<div className="sub">{fmt(t.pending_revenue)} on completed rides</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Rides — last 14 days</h2>
|
||||
<h2>Last 14 days</h2>
|
||||
<div className="toolbar">
|
||||
<select value={metric} onChange={(e) =>
|
||||
setMetric(e.target.value as "rides" | "revenue" | "payouts")
|
||||
}>
|
||||
<option value="rides">Rides</option>
|
||||
<option value="revenue">Company revenue</option>
|
||||
<option value="payouts">Driver payouts</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="chart" style={{ marginBottom: 40 }}>
|
||||
{stats.trend.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
className="bar"
|
||||
style={{ height: `${(d.rides / maxRides) * 100}%` }}
|
||||
title={`${d.day}: ${d.rides} rides`}
|
||||
style={{ height: `${Math.max(1, (d[metric] / max) * 100)}%` }}
|
||||
title={`${d.day}: ${
|
||||
metric === "rides" ? `${d.rides} rides` : fmt(d[metric])
|
||||
}`}
|
||||
>
|
||||
<span>{d.day.slice(5)}</span>
|
||||
</div>
|
||||
@@ -64,15 +133,17 @@ export default function Stats() {
|
||||
<tr>
|
||||
<th>Driver</th>
|
||||
<th>Rides</th>
|
||||
<th>Revenue</th>
|
||||
<th>Driver earned</th>
|
||||
<th>Company earned</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.topDrivers.map((d) => (
|
||||
<tr key={d.driver_id}>
|
||||
<td>{d.name}</td>
|
||||
<td>{d.rides}</td>
|
||||
<td>{d.revenue.toLocaleString()}</td>
|
||||
<td>{fmt(d.rides)}</td>
|
||||
<td>{fmt(d.earnings)}</td>
|
||||
<td>{fmt(d.company_revenue)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -58,6 +58,23 @@ export default function Users() {
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (u: User) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete ${u.name} (${u.email})? This also removes their rides and cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api(`/admin/users/${u.id}`, { method: "DELETE" });
|
||||
await load(query);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Users</h2>
|
||||
@@ -110,10 +127,13 @@ export default function Users() {
|
||||
</td>
|
||||
<td>{u.rides}</td>
|
||||
<td>{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<td className="row-actions">
|
||||
<button className="secondary" onClick={() => toggleVerified(u)}>
|
||||
{u.email_verified ? "Unverify" : "Verify"}
|
||||
</button>
|
||||
<button className="danger" onClick={() => remove(u)}>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Vendored
+12
-5
@@ -19,11 +19,12 @@ declare global {
|
||||
EXPO_PUBLIC_GOOGLE_AUTH_IOS_CLIENT_ID: string;
|
||||
EXPO_PUBLIC_GOOGLE_AUTH_ANDROID_CLIENT_ID: string;
|
||||
|
||||
// gmail api
|
||||
GMAIL_CLIENT_ID: string;
|
||||
GMAIL_CLIENT_SECRET: string;
|
||||
GMAIL_REFRESH_TOKEN: string;
|
||||
GMAIL_FROM: string;
|
||||
// gmail smtp
|
||||
SMTP_HOST: string;
|
||||
SMTP_PORT: string;
|
||||
SMTP_USER: string;
|
||||
SMTP_PASS: string;
|
||||
SMTP_FROM: string;
|
||||
|
||||
// geoapify api key
|
||||
EXPO_PUBLIC_GEOAPIFY_API_KEY: string;
|
||||
@@ -36,6 +37,12 @@ declare global {
|
||||
AREEBA_MERCHANT_ID: string;
|
||||
AREEBA_API_PASSWORD: string;
|
||||
AREEBA_API_VERSION: string;
|
||||
|
||||
// in-app WebRTC audio calls (STUN for dev; TURN for production NAT)
|
||||
EXPO_PUBLIC_STUN_URL: string;
|
||||
EXPO_PUBLIC_TURN_URL: string;
|
||||
EXPO_PUBLIC_TURN_USERNAME: string;
|
||||
EXPO_PUBLIC_TURN_CREDENTIAL: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# شروط استخدام Waseel.Courier
|
||||
|
||||
آخر تحديث: 24 يوليو 2026
|
||||
|
||||
> **ملاحظة قانونية:** هذه الوثيقة مشتقّة من شروط استخدام inDrive.Courier وتحويلها إلى شروط Waseel. يجب مراجعتها من مستشار قانوني قبل النشر.
|
||||
|
||||
مرحبًا بكم في Waseel.Courier!
|
||||
|
||||
تحكم شروط الاستخدام هذه ("الشروط") استخدامك لخدمة Waseel.Courier للأجهزة المحمولة ومواقع الويب والمنتجات والمحتوى والميزات والمنصة (يُشار إليها مجتمعةً باسم - "المنصة").
|
||||
|
||||
تعتبر شروط استخدام خدمة Waseel.Courier هذه جزءًا لا يتجزأ من شروط الاستخدام العامة. وباستخدامك لخدمة Waseel.Courier، فإنك تعبر صراحةً عن موافقتك الكاملة على هذه الشروط وشروط الاستخدام العامة. وفي حال وجود أي تعارض أو تناقض بين أحكام هذه الشروط وشروط الاستخدام العامة، تُطبَّق أحكام هذه الشروط.
|
||||
|
||||
عندما تؤكد قبولك لهذه الشروط أو تستخدم المنصة بطريقة أخرى، فإنك تدخل في عقد معنا. وتعتمد الشركة التي تتعاقد معها على المكان الذي تعيش فيه.
|
||||
|
||||
قد يخضع استخدامك للمنصة بصفتك عامل توصيل لشروط استخدام Waseel المحددة. وتوضح سياسة الخصوصية لدينا ممارسات الخصوصية الخاصة بنا بالتفصيل.
|
||||
|
||||
## 1. نموذج عمل Waseel.Courier
|
||||
|
||||
1.1. تربط منصتنا بين مزودي خدمات التوصيل المستقلين من الأطراف الثالثة ("عمال التوصيل") وعملائهم ("المرسلين") مع بعضهم البعض. عندما يطلب المرسلون تسليم طرد، يعرضون أسعارهم المقابلة لخدمات عمال التوصيل. ويمكن لعمال التوصيل الذين يرون الطلب إما الموافقة على السعر المعروض أو تقديم عرض مقابل.
|
||||
|
||||
1.2. وللمرسلين الحرية في اختيار عامل التوصيل من قائمة عمال التوصيل الذين أبدوا اهتمامهم بقبول الطلب. وتُبرم اتفاقية منفصلة بين عامل التوصيل والمرسل عندما يؤكد المرسل عملية توصيل الطرد.
|
||||
|
||||
1.3. يجب على المرسل دفع السعر المتفق عليه لعامل التوصيل من خلال المنصة. يشمل هذا السعر المتفق عليه جميع الرسوم المرتبطة بتوصيل الطرد (الرسوم والمبالغ والجبايات والضرائب وما إلى ذلك). ولا تتدخل Waseel ولا تؤثر بأي شكل على التسويات بين عامل التوصيل والمرسل.
|
||||
|
||||
## 2. مسؤولية المرسل
|
||||
|
||||
2.1. يتحمل المرسل المسؤولية عن تقديم معلومات التوصيل بشكل دقيق وكامل، بما في ذلك العنوان الصحيح للمستلم ومعلومات الاتصال وأي تعليمات خاصة ضرورية لتسليم الطرد بنجاح.
|
||||
|
||||
2.2. يتعهد المرسل بضمان وصول عامل التوصيل إلى مكان التسليم دون عوائق، كما يضمن وجوده أو وجود طرف ثالث قادر على استلام الطرد في مكان عنوان التسليم المحدد.
|
||||
|
||||
2.3. يتحمل المرسل المسؤولية عن ضمان تغليف الطرد بشكل صحيح وتأمينه للنقل. كما تقع عليه وحده المسؤولية الكاملة عن أي ضرر أو تأخير ناتج عن التغليف غير المناسب أو معلومات التوصيل غير الصحيحة التي قدمها.
|
||||
|
||||
2.4. يجب على المرسل ألا يرسل طردًا يزيد وزنه عن 20 كغ للتوصيل بالسيارة، ولا يزيد عن 10 كغ للتوصيل عبر أنواع التوصيل الأخرى (عامل توصيل ماشيًا، عامل توصيل على دراجة هوائية، عامل توصيل على دراجة نارية).
|
||||
|
||||
2.5. يقر المرسل ويتعهد بأن لديه الحق القانوني في امتلاك وإرسال العناصر المضمنة في الطرد. كما يتحمل المرسل المسؤولية عن ضمان أن البضائع المشحونة قد اشُترت بشكل قانوني ولا تنتهك أي قوانين أو لوائح أو قيود معمول بها.
|
||||
|
||||
2.6. يقر المرسل بأنه يتحمل وحده المسؤولية الكاملة عن أي عواقب أو مطالبات قانونية قد تنشأ فيما يتعلق بإرسال البضائع دون إثبات الملكية الصحيح أو التصريح ذي الصلة.
|
||||
|
||||
## 3. مسؤولية عامل التوصيل
|
||||
|
||||
3.1. يكون عامل التوصيل مسؤولاً عن النقل الآمن للطرد ونقله في المواعيد المحددة من نقطة الاستلام إلى نقطة التسليم. ويتعين عليه اتخاذ جميع الاحتياطات اللازمة لمنع أي فقدان للطرد أو تلفه أثناء نقله. وفي حال حدوث أي فقدان للطرد أو تضرره أثناء وجوده في حوزته، يتحمل عامل التوصيل المسؤولية المترتبة عن ذلك، مع مراعاة أي قيود أو استثناءات للمسؤولية المنصوص عليها في هذه الشروط أو القانون المعمول به.
|
||||
|
||||
3.2. يقر كلا الطرفين بأن مسؤولية كل طرف عن التسليم تقتصر على التزامات كل منهما المنصوص عليها أعلاه، ولن يتحمل أي طرف مسؤولية أي إخفاق أو تأخير في التسليم بسبب ظروف خارجة عن سيطرتهما المعقولة، بما في ذلك على سبيل المثال لا الحصر الكوارث الطبيعية أو الإجراءات الحكومية أو أي أحداث قوة قاهرة أخرى.
|
||||
|
||||
3.3. في حال فشل عملية التسليم بسبب عدم وجود المستلم في مكان التسليم أو معلومات التسليم غير الصحيحة المقدمة من المرسل، أو لأي سبب آخر خارج عن السيطرة المعقولة لعامل التوصيل، لن تكون Waseel مسؤولة عن تخزين الطرد. وفي مثل هذه الحالات يجب أن يدرك المرسل والمستلم أنه يتعين عليهما اتخاذ تدابير بديلة لتخزين الطرد أو التعامل معه.
|
||||
|
||||
3.4. بقبول طرد من المرسل، يحق لعامل التوصيل، ولكنه غير ملزم بـ:
|
||||
|
||||
3.4.1. قراءة محتوياته بتمعّن؛
|
||||
|
||||
3.4.2. الطلب من المرسل بيان محتويات الطرد وختم الطرد بوجوده؛
|
||||
|
||||
3.4.3. رفض قبول الطرد إذا رفض المرسل بيان محتوياته، أو إذا بدت المحتويات مشبوهة أو غير قانونية.
|
||||
|
||||
## 4. البضائع المحظورة
|
||||
|
||||
4.1. عند استخدام المنصة، فأنت مسؤول عن التأكد من أن الطرد الذي يتم تسليمه ليس سلعة محظورة.
|
||||
|
||||
4.2. البضائع المحظورة، بما في ذلك على سبيل المثال لا الحصر:
|
||||
|
||||
4.2.1. نوصي بألا تتجاوز قيمة أي طرد يتم إرساله عبر المنصة قيمةً يحددها Waseel ويُعلن عنها في التطبيق. باستخدامك للمنصة، فإنك تقر بأن Waseel ليست مسؤولة عن أي خسارة أو تلف أو مشاكل تتعلق بتسليم الطرد الخاص بك، بغض النظر عن قيمته؛
|
||||
|
||||
4.2.2. الأدوية المخدرة، الأدوية التي تصرف بوصفة طبية، المؤثرات العقلية، المواد شديدة الفعالية، المواد السامة، المواد المشعة، المواد المتفجرة؛
|
||||
|
||||
4.2.3. المواد السامة والكاوية والقابلة للاشتعال وغيرها من المواد الخطرة، بما في ذلك تلك المواد المضغوطة؛
|
||||
|
||||
4.2.4. الأسلحة النارية أو الأسلحة التي تعمل بالهواء المضغوط أو الأسلحة الغازية أو الأسلحة البيضاء وأجزائها والذخيرة والألعاب النارية والمشاعل والخراطيش؛
|
||||
|
||||
4.2.5. العملات الأجنبية والأوراق النقدية؛
|
||||
|
||||
4.2.6. الأشياء ذات القيمة العالية مثل المجوهرات والمعادن الثمينة والأحجار الكريمة والمنتجات التي تحتوي عليها؛
|
||||
|
||||
4.2.7. الأشياء والمواد التي قد تشكل بطبيعتها أو بسبب تغليفها خطراً على الأشخاص أو تسبب تلوثاً أو تفسد (تتلف) البضائع الأخرى أو تضر الأشخاص أو الأشياء من حولها؛
|
||||
|
||||
4.2.8. البشر والأنواع الخاضعة للرقابة والحيوانات والنباتات والمواد البيولوجية؛
|
||||
|
||||
4.2.9. المواد التي تتطلب مركبات مجهزة خصيصاً لنقلها، بما في ذلك المواد الغذائية؛
|
||||
|
||||
4.2.10. السوائل الموضوعة في حاويات غير مخصصة لها؛
|
||||
|
||||
4.2.11. المواد الهشة غير المغلفة بمواد وطريقة خاصة؛
|
||||
|
||||
4.2.12. جميع البضائع المشحونة التي يحظرها القانون؛
|
||||
|
||||
4.2.13. البضائع غير القانونية أو المسروقة أو المنتجات المقرصنة أو السلع المقلدة؛
|
||||
|
||||
4.2.14. النفايات الخطرة مثل البطاريات؛
|
||||
|
||||
4.2.15. المواد المتفجرة ومكوناتها؛
|
||||
|
||||
4.2.16. المشروبات الكحولية؛
|
||||
|
||||
4.2.17. منتجات التبغ والسجائر الإلكترونية؛
|
||||
|
||||
4.2.18. المواد الإباحية أو غير اللائقة.
|
||||
|
||||
## الاتصال بخدمة Waseel.Courier
|
||||
|
||||
يمكنك التواصل معنا عبر دعم المستخدم داخل التطبيق أو من خلال قنوات الدعم الرسمية لـ Waseel.
|
||||
@@ -0,0 +1,186 @@
|
||||
# شروط الاستخدام العامة — Waseel
|
||||
|
||||
آخر تحديث بتاريخ 24 يوليو 2026
|
||||
|
||||
> **ملاحظة قانونية:** هذه الوثيقة مشتقّة من شروط استخدام inDrive (نموذج غير منصوص على ملكيته) وتحويلها إلى شروط Waseel. يجب مراجعتها من مستشار قانوني قبل النشر. تم اقتطاع النص المصدر في قسم 12 (المسؤولية) — الأقسام التالية غير مكتملة.
|
||||
|
||||
مرحبًا بكم في Waseel!
|
||||
|
||||
تحكم شروط الاستخدام هذه ("الشروط") استخدامك لتطبيقات Waseel للأجهزة المحمولة ومواقع الويب والمنتجات والمحتوى والميزات والمنصة (يُشار إليها مجتمعةً باسم - "المنصة").
|
||||
|
||||
عندما تؤكد قبولك لهذه الشروط أو عندما تستخدم المنصة بأي شكلٍ من الأشكال، فإنك تُبرم عقدًا معنا. وتعتمد الشركة التي تتعاقد معها على المكان الذي تقيم فيه.
|
||||
|
||||
وقد تنطبق شروط تكميلية على فئة معيّنة من Waseel. وتوضح سياسة الخصوصية لدينا ممارسات الخصوصية الخاصة بنا بالتفصيل. وتعد سياسة الامتثال الخاصة بنا (نظام إدارة السلامة) جزءًا من هذه الشروط. وعندما تقبل هذه الشروط، فأنت تقبلها أيضًا.
|
||||
|
||||
## 1. استقلالية السائقين واختيارات الركاب
|
||||
|
||||
### نموذج أعمالنا
|
||||
تربط منصتنا مزودي خدمات النقل المستقلين من الأطراف الثالثة ("السائقين") وعملائهم ("الركاب") مع بعضهم البعض. وعندما يحجز الركاب رحلة، يعرضون السعر المناسب لهم مقابل خدمات السائق. ويمكن للسائقين الذين يرون الطلب إما الموافقة على السعر المعروض أو تقديم عرضهم الخاص.
|
||||
|
||||
وللراكب الحرية في اختيار السائق من قائمة السائقين الذين أبدوا اهتمامًا بقبول الطلب. وتُبرم اتفاقية منفصلة بين السائق والراكب عندما يؤكد الراكب الرحلة.
|
||||
|
||||
يجب على الراكب دفع السعر المتفق عليه للسائق من خلال المنصة. ويشمل هذا السعر المتفق عليه جميع الرسوم المرتبطة بالرحلة (الرسوم والمبالغ والجبايات والضرائب وما إلى ذلك). لا تتدخل Waseel ولا تؤثر بأي شكل على التسويات بين السائق والراكب.
|
||||
|
||||
### حالة Waseel
|
||||
Waseel هي شركة تقنية لا تقدم خدمات النقل أو الخدمات اللوجستية أو خدمات البريد السريع أو أي خدمات أخرى ذات صلة ("الخدمات"). فهذه الخدمات يقدمها سائقون مستقلون باستخدام منصتنا. وأي قرار لعرض الخدمات أو قبولها هو قرار مستقل يُتخذ وفقًا لتقدير كل مستخدم وعلى مسؤوليته الخاصة. ولا تقوم Waseel بتوجيه السائقين أو فرض تعليمات عليهم بشكلٍ عام أو في تقديمهم للخدمات. ولا يشكل أي جهد نبذله لتحسين تجربتك عند استخدام منصتنا أي علاقة عمل أو وكالة مع أي مستخدم.
|
||||
|
||||
لا تلغي هذه الشروط أو تؤثر بأي شكلٍ على قابلية إنفاذ أي اتفاقيات قد يبرمها الركاب مع السائقين فيما يتعلق بالخدمات المقدمة.
|
||||
|
||||
### الرسوم والمدفوعات
|
||||
قد تفرض Waseel على السائقين رسوم ترخيص مقابل استخدام المنصة. ويجوز لنا تغيير مبلغ رسوم الترخيص من وقت لآخر. وسيشكل استمرار استخدامك للمنصة موافقتك الضمنية على الرسوم المحدّثة.
|
||||
|
||||
تُفرض رسوم الترخيص على الطلبات المكتملة فقط. وتُسجل المدفوعات في حسابك الشخصي، ويمكنك العثور على المبلغ الحالي لرسوم الترخيص في حسابك.
|
||||
|
||||
يُحجز مبلغ رسوم الترخيص للطلب المكتمل في حسابك بمجرد تأكيد الطلب (أي عند قبولك عرض الراكب، أو عند الاتفاق على سعر الرحلة مع الراكب من خلال المنصة)، ويُحصّل عند إتمام الطلب. وتُرد المبالغ المحجوزة إلى حسابك إذا لم يتم إتمام الطلب، بما في ذلك في حال إلغاء الراكب للطلب أو عدم حضوره. قد تستغرق عمليات رد الأموال في هذه الحالات ما يصل إلى 30 يومًا بعد إلغاء الطلب ومراجعته من قبل Waseel.
|
||||
|
||||
إذا بقي لديك أموال غير مستخدمة في حسابك بعد مراسلتنا لحذف تطبيق Waseel وإلغاء الشروط، فيرجى إرسال نسخة من طلبك المكتوب لاسترداد الأموال غير المستخدمة إلى فريق الدعم لدينا، بالإضافة إلى تفاصيل الحساب المصرفي الذي تريد رد المبلغ إليه.
|
||||
|
||||
لا ترد Waseel المبالغ المستحقة نقدًا. وسيتم رد المبلغ المستحق في غضون 10 أيام عمل من استلام طلبك الكتابي لاسترداد الأموال غير المستخدمة.
|
||||
|
||||
وفي حال أصبح رصيد حساب السائق في تطبيق Waseel سالبًا، فيجب على السائق سداد كامل مبلغ الدين في غضون يومين تقويميين. وسيكون وصول السائق إلى تطبيق Waseel محدودًا حتى يتم تعبئة الرصيد بمقدار الدين، وخلال هذه الفترة لن يتمكن السائق من رؤية طلبات الرحلات من الركاب أو التفاوض معهم.
|
||||
|
||||
### عمليات رد المبالغ المدفوعة والمدفوعات المعكوسة
|
||||
تحدث "عملية رد المبالغ المدفوعة" أو "المدفوعات المعكوسة" عندما يتم عكس مبلغ مدفوع مرتبط بالمنصة أو استرداده أو الاعتراض عليه — مثل إيداع رصيد في حسابك أو دفعة عولجت من خلال المنصة — من قبل البنك أو جهة إصدار البطاقة أو معالج الدفع، بما في ذلك الحالات التي يتم فيها الإبلاغ عن معاملة على أنها غير معترف بها أو غير مصرح بها أو احتيالية.
|
||||
|
||||
في حال استرداد مبلغ أودع في حسابك، أو طُبّق على رسوم الترخيص أو أي مبلغ آخر مستحق الدفع، فإنك تفوضنا بتخفيض أو عكس أو خصم المبلغ المقابل من رصيد حسابك. بقبولك هذه الشروط، توافق على هذه التعديلات، ويجوز لنا إجراؤها دون إشعار منفصل مسبق.
|
||||
|
||||
تقع على عاتق السائقين مسؤولية تحصيل ودفع جميع الضرائب المطبقة المرتبطة بالخدمات المقدمة من خلال المنصة. ولن تتحمل Waseel أي مسؤولية فيما يتعلق بأي معاملات بين الركاب والسائقين يحدث فيها مخالفات ضريبية.
|
||||
|
||||
يجوز لـ Waseel وفقًا لتقديرها الخاص وفي أي وقت تراه مناسبًا تقديم عروض ترويجية وخصومات وبرامج إحالة وبرامج ولاء (يشار إليها — "العروض") بميزات مختلفة لأي راكب أو سائق. وقد تؤثر هذه العروض أو تُطبَّق على مدفوعات الخدمة أو تكلفة الرحلة، مما يقلل المبالغ المحددة، وتُقدم في شكل مكافآت.
|
||||
|
||||
تكون هذه المكافآت صالحة للاستخدام فقط داخل تطبيق Waseel، ولا يمكن تحويلها أو استبدالها بمبالغ نقدية. ويجوز استخدام المكافآت لدفع رسوم الترخيص المترتبة على طلبات السائقين.
|
||||
|
||||
يمكن الحصول على مزيد من المعلومات ذات الصلة بالعروض في إشعار داخل التطبيق أو بأي وسيلة اتصال أخرى مذكورة في هذه الشروط. وفي الوقت نفسه، تحتفظ Waseel بالحق في حجز أو خصم المكافآت أو المزايا الأخرى التي تم الحصول عليها من خلال العروض إذا خلصت أو اعتقدت أن استخدام العرض أو الحصول على المكافآت تم عن طريق الخطأ أو بالاحتيال أو بشكل غير قانوني أو ينتهك العروض السارية أو هذه الشروط. كما تحتفظ Waseel بالحق في إنهاء أو إيقاف أو تعديل أو إلغاء أي عرض في أي وقت ووفقًا لتقديرها الخاص دون إشعار المستخدم.
|
||||
|
||||
## 2. حساب Waseel الخاص بك
|
||||
|
||||
### تسجيل الحساب
|
||||
للوصول إلى وظائف منصتنا والبدء في استخدامها، يجب عليك إنشاء حساب لدينا. للتسجيل على هذه المنصة، يجب ألا يقل عمرك عن 18 عامًا أو تكون بلغت سن الرشد القانوني في بلدك (أيهما أكبر)، وتتوفر لديك الصلاحية اللازمة لإبرام عقد معنا واستخدام المنصة. وعند إنشاء الحساب، يجب عليك تقديم معلومات دقيقة وحديثة عن نفسك.
|
||||
|
||||
لمنع الاحتيال وضمان أمانك والامتثال لقوانين ولوائح مكافحة غسل الأموال والعقوبات (حسب مقتضى الحال)، سنطلب منك معلومات في وقت فتح حسابك للتحقق من هويتك. وقد نطلب منك أيضًا تحديث معلوماتك وتأكيدها من وقت لآخر.
|
||||
|
||||
نوفر أنواعًا مختلفة من الحسابات اعتمادًا على ما إذا كنت تستخدم المنصة بصفتك راكبًا أو سائقًا. ولإنشاء حساب سائق، يجب أن تزودنا بمعلومات إضافية وتجتاز عملية التحقق.
|
||||
|
||||
إذا كنت تستخدم المنصة في بلد آخر، فإنك توافق على الالتزام بشروط Waseel الخاصة بذلك البلد.
|
||||
|
||||
### الحفاظ على حسابك نشطًا
|
||||
يجب عليك تحديث بياناتك على الفور في حال تغييرها. إذا غيّرت رقم هاتفك المحمول، فيرجى إخبارنا في أقرب وقت ممكن. وإذا لم تعد تستخدم رقمك، فقد يمنح مشغّل الهاتف المحمول لديك للتعميل إليه إلى شخص جديد يمكنه الوصول إلى حسابك إذا استخدم المنصة.
|
||||
|
||||
لا يجوز لك السماح للآخرين باستخدام حسابك. ويتعين عليك الحفاظ على أمان الوصول إلى جهازك وسرية معلومات تسجيل الدخول الخاصة بك. وستتحمل المسؤولية عن جميع الأنشطة التي تحدث في حسابك. إذا شككت في أن أي طرف ثالث يعرف كلمة المرور الخاصة بك أو يمكنه الوصول إلى حسابك، فيرجى إخبارنا من خلال التواصل معنا عبر دعم المستخدم.
|
||||
|
||||
### حذف الحساب
|
||||
يمكنك حذف حسابك في أي وقت تريد. يمكنك القيام بذلك من خلال إعدادات التطبيق أو عبر الاتصال بخدمة دعم المستخدم. قد لا تتمكن في بعض الحالات من حذف حسابك، أو قد نحتفظ ببعض المعلومات لأغراض قانونية، مثل منع الاحتيال وضمان سلامة مستخدمينا أو الامتثال للالتزامات القانونية أو إدارة أو حل أي مطالبات أو نزاعات معلقة. يرجى الرجوع إلى سياسة الخصوصية الخاصة بنا لفهم كيفية معالجتنا لمعلوماتك بعد حذف الحساب.
|
||||
|
||||
قد تحذف الحسابات التي تظل غير نشطة لفترة تتجاوز 3 سنوات. كما نحتفظ بالحق في حذف حسابك أو تعليق الوصول إليه (راجع قسم "حقوق Waseel").
|
||||
|
||||
## 3. سلامتك
|
||||
تُعد صحة مستخدمي Waseel وسلامتهم على رأس أولوياتنا. نعمل خطوات معقولة لضمان أن تظل المنصة بيئة آمنة لمستخدمينا. على سبيل المثال، نتحقق من مستندات جميع السائقين قبل السماح لهم بتقديم خدماتهم. بالإضافة إلى ذلك، قد نجري فحوصات عشوائية للتحقق من استخدام حساب السائق من قبل السائق المسجل، وأن السائق يستخدم السيارة المرتبطة بحساب السائق. كما قد نطلب من الركاب اجتياز فحص حيوي أو التحقق من هويتهم من خلال تقديم رقم هوية صادر عن جهة حكومية.
|
||||
|
||||
وعلى الرغم من قصارى جهدنا، فإننا نقر بمحدودية قدرة المنصة على الإنترنت في ضمان الأمان في وضع عدم الاتصال بالإنترنت. وليس لدينا أي تحكم في جودة أو سلامة النقل الناتجة عن تقديم الخدمات.
|
||||
|
||||
كما لا يمكننا ضمان أن يكون كل راكب أو سائق هو ما يدّعون. يرجى مراجعة صور السائق أو الراكب التي تراها على المنصة للتأكد من أنها نفس الشخص الذي تراه شخصيًا. لكن إذا لاحظت أن صورة السائق الذي وصل إليك تختلف عن صورة السائق في تطبيق Waseel، فيرجى إبلاغنا وسنتحقق ونعمل على ذلك.
|
||||
|
||||
نحثك على الانتباه والحذر عند التعامل مع المستخدمين الآخرين. أنت تستخدم خدمات السائق وتوفرها على مسؤوليتك الخاصة.
|
||||
|
||||
وفي حال وجود خطر مشتبه على الصحة أو السلامة، يرجى إبلاغ دعم المستخدم فورًا أو استخدام "زر SOS".
|
||||
|
||||
يحتوي زر SOS على خيارين:
|
||||
- يتيح لك الاتصال بالشرطة في بلدك.
|
||||
- يتيح لك مشاركة معلومات رحلتك مع رقم من جهات الاتصال الخاصة بك.
|
||||
|
||||
نعمل باستمرار على تحسين وتعديل أنظمة التحقق من المستخدم ووظائف تطبيقنا.
|
||||
|
||||
## 4. التزامات السائق
|
||||
من خلال تقديم الخدمات كسائق، فإنك تقر وتضمن وتوافق على:
|
||||
- لديك رخصة قيادة سارية وجميع التصاريح اللازمة لتقديم الخدمات، وأنت لائق صحياً لتقديمها؛
|
||||
- تمتلك أو لديك الحق القانوني في قيادة السيارة التي تستخدمها لتقديم الخدمات؛ ويجب أن تكون هذه السيارة في حالة عمل جيدة وتفي بالمعايير والمتطلبات القانونية ومعايير السلامة؛
|
||||
- ستقدم الخدمات فقط باستخدام السيارة التي أبلغتها في Waseel؛
|
||||
- لن تسمح لأي شخص بمرافقتك في السيارة أثناء تقديم الخدمات؛
|
||||
- لن تقدم الخدمات وأنت تحت تأثير الإرهاق أو الكحول أو المخدرات، أو تشترك بطريقة أخرى في سلوك غير آمن أو غير قانوني؛
|
||||
- لن تقوم بالتمييز بين الركاب؛
|
||||
- لن تطلب أي مدفوعات إضافية بالإضافة إلى السعر المتفق عليه مع الراكب من خلال المنصة؛
|
||||
- ستكون مسؤولاً عن حساب جميع الضرائب المطبقة التي تنص عليها التشريعات في بلدك؛
|
||||
- ستمتثل لطلباتنا المقبولة لتقديم المعلومات فيما يتعلق بالخدمات واستخدامك للمنصة؛
|
||||
- لن تستخدم المعلومات التي حصلت عليها من خلال المنصة لأي غرض لا يتعلق باستخدام أو توفير خدمات السائق؛
|
||||
- ستمتثل لمتطلبات جميع قوانين مكافحة غسل الأموال والعقوبات والفساد والرشوة ومكافحة التجارة غير المشروعة ومكافحة تمويل الإرهاب السارية؛
|
||||
- ستمتثل لسياسات Waseel المطبقة في بلدك.
|
||||
|
||||
## 5. التواصل بين السائق والراكب
|
||||
يجب أن تعامل مستخدمي Waseel الآخرين باحترام. ولا يجوز لك التواصل مع مستخدمين آخرين إلا للأغراض المتعلقة بتقديم الخدمات. كما يجب عليك عدم الكشف عن أي معلومات اتصال غير ضرورية. ويجب قطع الاتصال بعد اكتمال تقديم الخدمة، إلا إذا كان ذلك يتعلق بإعادة عنصر مفقود. وقد يُعتبر أي اتصال آخر مضايقة وقد يؤدي إلى تعليق حسابك أو إنهائه.
|
||||
|
||||
نمكن المستخدمين من التواصل على المنصة، مثال عبر التعليقات أو الدردشة داخل التطبيق أو المكالمات داخل التطبيق (قد يختلف توفر هذه الميزات حسب موقعك). ولدينا الحق في مراقبة وتسجيل اتصالاتك مع المستخدمين الآخرين للتحقق من الامتثال لهذه الشروط.
|
||||
|
||||
## 6. الاتصالات في Waseel
|
||||
قد نرسل لك إشعارات فورية حول حسابك أو الخدمات التي تقدمها، وتحديثات حول Waseel والمنصة، وطلبات للمراجعات، واتصالات تسويقية. وقد نتواصل معك عبر البريد الإلكتروني والرسائل القصيرة والهاتف والإشعارات الفورية. أما بالنسبة لأنواع الاتصالات التي تتطلب موافقتك، فسنلتزم بالقوانين المحلية ونمنحك خيار إلغاء الاشتراك.
|
||||
|
||||
## 7. ما لا يمكنك فعله على المنصة
|
||||
يمنع عليك استخدام المنصة من أجل:
|
||||
- ممارسة أي أعمال غير قانونية؛
|
||||
- ممارسة أي أعمال تنتهك هذه الشروط أو أي قواعد أخرى للمنصة وسياسات Waseel؛
|
||||
- استخدام المنصة لأي غرض لا تغطيه هذه الشروط؛
|
||||
- نقل أو بيع حسابك أو كلمة المرور أو هويتك إلى أي طرف آخر؛
|
||||
- انتحال شخصية شخص آخر أو إخفاء هويتك أو استخدام أو محاولة استخدام حساب مستخدم آخر؛
|
||||
- حث الآخرين على ممارسة أنشطة غير قانونية أو خطيرة؛
|
||||
- مضايقة الآخرين أو تهديدهم أو التحرش بهم؛
|
||||
- تحميل أي محتوى على المنصة غير دقيق أو غير مناسب أو ينتهك حقوق أي شخص (مثل الملكية الفكرية أو الخصوصية أو حقوق الشخصية) أو غير قانوني بطريقة أخرى؛
|
||||
- تقويض تشغيل المنصة أو أمنها، ومحاولة الوصول غير المصرح به إلى المنصة أو الأنظمة أو الشبكات المرتبطة بها؛
|
||||
- استخراج أي بيانات أو محتوى من المنصة؛
|
||||
- إنشاء مسؤولية عن Waseel أو جعلنا خاضعين للتنظيم كشركة نقل أو مزود خدمة سيارات أجرة.
|
||||
|
||||
### مكافحة الاحتيال
|
||||
يُحظر على المستخدمين الانخراط في أي نشاط يهدف إلى التحايل أو تجاوز أو التلاعب بوظائف المنصة أو عملياتها أو رسومها الطبيعية، ويشمل ذلك على سبيل المثال لا الحصر:
|
||||
|
||||
**التلاعب بميزات المنصة:** يُمنع استغلال أو اختراق أو التلاعب بميزات المنصة أو وظائفها أو خوارزمياتها بهدف تشويه تجربة المستخدم المقصودة أو نموذج أعمال المنصة.
|
||||
|
||||
**استخدام التطبيقات والتعديلات الخارجية غير المصرح بها:** لا يجوز استخدام أي تطبيقات خارجية أو برامج أو أدوات غير مصرح بها من شأنها تعديل أو التدخل في أو تغيير الوظائف الطبيعية للمنصة.
|
||||
|
||||
**التواطؤ:** يُحظر التآمر مع مستخدمين آخرين أو أطراف ثالثة لدفع حصان قواعد المنصة، مثل الاتفاق على إلغاء الطلبات أو تقديم معلومات كاذبة أو مضللة.
|
||||
|
||||
**إساءة استخدام المدفوعات أو عمليات رد المبالغ أو الاسترداد:** يمنع إساءة استخدام آليات الدفع أو الإيداع أو عمليات رد المبالغ أو الاسترداد، بما في ذلك الإبلاغ عن معاملة مشروعة على أنها غير معترف بها أو احتيالية، أو إجراء ردود المبالغ بسوء نية للحصول على الخدمات أو الأموال دون دفع.
|
||||
|
||||
يؤدي أي انتهاك لأحكام مكافحة الاحتيال إلى فرض عقوبات تشمل الحظر المؤقت أو الدائم أو إجراءات أخرى، بناءً على جسامة المخالفة.
|
||||
|
||||
ونحتفظ، وفقًا لتقديرنا المعقول، بالحق في فرض رسوم ترخيص على أي طلب منفّذ فعليًا لم تُدفع رسومه الترخيصية المستحقة نتيجة لأي تلاعب أو تواطؤ أو انتهاك آخر لشروط المنصة. كما نحتفظ، وفقًا لتقديرنا المعقول، بالحق في تخفيض أو عكس أو خصم المبالغ من رصيد حسابك بما يتوافق مع "عمليات رد المبالغ المدفوعة والمدفوعات المعكوسة" في القسم 1.
|
||||
|
||||
يهدف هذا النظام إلى ضمان بيئة عادلة وشفافة لجميع المستخدمين والحفاظ على نزاهة العمليات التجارية للمنصة.
|
||||
|
||||
## 8. حقوق Waseel
|
||||
لدينا الحق في التحقيق في أي انتهاك مزعوم لهذه الشروط. وعند القيام بذلك، يجوز لنا تعليق وصولك إلى بعض أو كل ميزات المنصة، والتصرف بشكل معقول وموضوعي، اعتمادًا على خطورة الانتهاك المزعوم.
|
||||
|
||||
ثم بعد ذلك، قد نقرر تعليق حسابك مؤقتًا أو بشكل دائم أو إنهائه أو فرض قيود على وصولك إلى ميزات المنصة في الحالات التالية:
|
||||
- أن نحدد، بعمق وموضوعية وبشكل لا لقولي، أنك تقوم بانتهاك مادي أو متكرر لهذه الشروط أو قواعد وسياسات Waseel الأخرى؛
|
||||
- لدينا أسباب للاعتقاد بشكل لا لبس فيه أنك على وشك انتهاك هذه الشروط بشكل خطير؛
|
||||
- نحن مطالبون قانونًا بذلك؛
|
||||
- مطلوب بشكل معقول للاستجابة لمشكلة تقنية أو أمنية أو تتعلق بالخصوصية.
|
||||
|
||||
إذا علقنا حسابك في وقت سابق لانتهاك هذه الشروط، ثم عدت إلى استخدام منصتنا مرة أخرى (مثل فتح حساب آخر)، فيحق لنا تعليق أو إنهاء جميع هذه الحسابات.
|
||||
|
||||
إذا كنت تعتقد أننا ارتكبنا خطأ في تعليق حسابك أو إنهائه، يمكنك استئناف ذلك عبر خدمة دعم المستخدم.
|
||||
|
||||
## 9. المحتوى الخاص بك
|
||||
أنت مسؤول عن المعلومات والملفات والصور (يُشار إليها مجتمعةً — "المحتوى") التي تنشرها على المنصة. يجب عليك التأكد من أن المحتوى الخاص بك لا ينتهك القوانين أو حقوق أي شخص آخر. لسنا ملزمين بمراجعة محتوى المستخدم ولا نتحمل أي مسؤولية عنه. يجوز لنا إزالة أو تقييد الوصول إلى أي محتوى نعتقد أنه ينتهك هذه الشروط أو يسبب ضررًا لـ Waseel أو مستخدمينا أو الأطراف الثالثة.
|
||||
|
||||
نحن لا نملك المحتوى الخاص بك. ومن خلال إتاحة المحتوى على المنصة، تمنح Waseel ترخيصًا دائمًا وغير قابل للإلغاء وعالميًا وخاليًا من حقوق الملكية وغير حصري لاستخدام المحتوى الخاص بك، بما في ذلك إعادة إنتاج أو اقتباس أو إنشاء أعمال مشتقة منه وتنفيذه وإتاحته للجمهور، لأغراض تشغيل المنصة وتطويرها وتوفيرها.
|
||||
|
||||
## 10. الملكية الفكرية
|
||||
تحتوي المنصة على محتوى (مثل التصميمات والصور والأصوات والنصوص وقواعد البيانات ورموز الحاسوب والعلامات التجارية وغيرها من العناصر المماثلة) مملوكة أو مرخصة من قبل Waseel وهي محمية بموجب حقوق النشر والعلامات التجارية وبراءات الاختراع والأسرار التجارية وغيرها من القوانين. تمتلك Waseel والمرخص لهم جميع الحقوق وحقوق الملكية والمصالح، بما في ذلك حقوق الملكية الفكرية ذات الصلة في المنصة (البرنامج أو التطبيق أو كليهما) والخدمة وأي اقتراحات وأفكار وطلبات للتحسين أو المراجعات أو التوصيات أو المعلومات الأخرى التي تقدمها.
|
||||
|
||||
تمنحك Waseel ترخيصًا محدودًا وغير حصري وغير قابل للتحويل أو التنازل وقابل للإلغاء من أجل: (أ) الوصول إلى المنصة واستخدامها على جهازك الشخصي لغرض وحيد هو استخدام المنصة؛ (ب) الوصول وعرض أي محتوى أو مواد متاحة من خلال المنصة، في كل حالة لاستخدامك الشخصي غير التجاري فقط. جميع الحقوق غير الممنوحة لك هنا محفوظة لـ Waseel أو لمرخصها.
|
||||
|
||||
لا يجوز لك، أو لا تسمح لأي طرف آخر بـ: (أ) تعديل أو إعادة إنتاج أو إنشاء أعمال مشتقة من المنصة؛ (ب) إجراء هندسة عكسية أو إلغاء تجميع أو تفكيك أو محاولة اكتشاف أو تغيير الكود المصدري للمنصة لإنشاء منتج أو خدمة منافسة؛ (ج) تأطير أو ربط أو عكس أي جزء من المنصة على أي خادم آخر أو جهاز متصل؛ (د) نشر أو توزيع أو إعادة إنتاج أي مواد محمية بحقوق النشر أو العلامات أو معلومات مملة لـ Waseel بأي شكل دون موافقة مسبقة.
|
||||
|
||||
## 11. التعويض
|
||||
توافق على الدفاع عن Waseel والشركات التابعة لها ومسؤوليها وموظفيها ووكلائها وتعويضها وحمايتها من أي وجميع المطالبات والطلبات والأضرار والمسؤوليات والنفقات (بما في ذلك أتعاب المحاماة المعقولة) الناشئة عن أو فيما يتعلق بـ: (أ) استخدامك للمنصة أو الخدمات؛ (ب) انتهاكك أو إخلالك بأي من هذه الشروط أو أي قانون أو لائحة سارية أو حقوق أي طرف ثالث.
|
||||
|
||||
## 12. المسؤولية
|
||||
### مسؤولية Waseel
|
||||
دون تقييد القوانين واللوائح السارية، يُستبعد بموجب هذا، وإلى أقصى حد تسمح به القوانين، أي إقرارات وضمانات، صريحة أو ضمنية أو قانونية، بما في ذلك أي ضمان ضمني للقابلية للتسويق أو الملاءمة لغرض معين أو عدم انتهاك حقوق الآخرين.
|
||||
|
||||
في حدود ما تسمح به القوانين السارية، لن تكون Waseel بأي حال مسؤولة تجاهك أو تجاه أي شخص عن أي أضرار أو خسائر مباشرة أو غير مباشرة أو تأديبية أو اقتصادية أو مستقبلية أو خاصة أو نموذجية أو عراضية أو تابعة أو غيرها من الأضرار أو الخسائر من أي نوع كانت (بما في ذلك دون حصر الإصابة الشخصية والاضطراب العاطفي وفقدان البيانات أو السلع أو الإيرادات أو الأرباح أو الاستخدام أو أي منفعة اقتصادية أخرى)، سواء نشأت عن العقد أو الضرر (بما في ذلك الإهمال) أو نشأت عن المنصة أو ارتبطت بها بأي شكل، بما في ذلك دون حصر استخدام المنصة أو عدم القدرة على استخدامها، أو أي تعويل منك على اكتمال أو دقة أو وجود أي إعلان، أو نتيجة لأي علاقة أو معاملة بينك وبين أي سائق، حتى لو حُذرت Waseel مسبقًا من احتمال حدوث مثل هذا الضرر.
|
||||
|
||||
### خدمات الطرف الثالث
|
||||
لا توجد أي علاقة مشروع مشترك أو شراكة أو توظيف أو وكالة بين Waseel وأي من مستخدمينا. وإلى الحد الأقصى الذي يسمح به القانون...
|
||||
|
||||
---
|
||||
|
||||
*نهاية النص المُقدَّم. النص المصدر اقتُطع في هذا الموضع — الأقسام المتبقية (استكمال حدود المسؤولية، حل النزاعات، القانون الحاكم، التعديلات على الشروط، الإتصال، تاريخ السريان، إلخ) لم تُقدَّم ويلزم إكمالها.*
|
||||
+43
-5
@@ -1,20 +1,58 @@
|
||||
import { sql } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/jwt";
|
||||
|
||||
export const corsHeaders: Record<string, string> = {
|
||||
"Access-Control-Allow-Origin": process.env.ADMIN_CORS_ORIGIN ?? "*",
|
||||
// The owner API is cross-origin only for the admin dashboard, so the allowed
|
||||
// origin has to be named explicitly. An unset ADMIN_CORS_ORIGIN used to fall
|
||||
// back to "*", which meant a missing env var silently opened every owner
|
||||
// endpoint to every website the owner happened to have open. Fail closed
|
||||
// instead: with nothing configured we send no allow-origin header at all and
|
||||
// the browser blocks the call, which is a loud, obvious failure to fix.
|
||||
//
|
||||
// A comma-separated list is accepted so dev (localhost) and production can be
|
||||
// configured at once; the header echoes back whichever entry matched, since
|
||||
// "Access-Control-Allow-Origin" only ever takes a single value.
|
||||
const allowedOrigins = (): string[] =>
|
||||
(process.env.ADMIN_CORS_ORIGIN ?? "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export const corsHeaders = (req: Request): Record<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
Vary: "Origin",
|
||||
};
|
||||
|
||||
const allowed = allowedOrigins();
|
||||
if (allowed.length === 0) return headers;
|
||||
|
||||
// A wildcard is still honoured when it is configured deliberately — the
|
||||
// change is that it is no longer what you get by forgetting to configure it.
|
||||
if (allowed.includes("*")) {
|
||||
headers["Access-Control-Allow-Origin"] = "*";
|
||||
return headers;
|
||||
}
|
||||
|
||||
const origin = req.headers.get("origin");
|
||||
if (origin && allowed.includes(origin)) {
|
||||
headers["Access-Control-Allow-Origin"] = origin;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export const withCors = (response: Response): Response => {
|
||||
for (const [key, value] of Object.entries(corsHeaders)) {
|
||||
// Takes the request first so the origin it echoes is never accidentally
|
||||
// omitted — a call site that forgets it won't compile.
|
||||
export const withCors = (req: Request, response: Response): Response => {
|
||||
for (const [key, value] of Object.entries(corsHeaders(req))) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
export const preflight = (): Response => withCors(new Response(null, { status: 204 }));
|
||||
export const preflight = (req: Request): Response =>
|
||||
withCors(req, new Response(null, { status: 204 }));
|
||||
|
||||
// Returns the authenticated owner or a ready-to-return error Response.
|
||||
export const requireOwner = async (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user