From 4e0a7cca51c5f870a2057e2b4abc558fe39a14b8 Mon Sep 17 00:00:00 2001 From: Krikorios Date: Mon, 24 Aug 2026 00:32:34 +0300 Subject: [PATCH 1/2] Fix map failing to render, dedupe expo-crypto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home screen passed its FlatList header as `ListHeaderComponent={() => (...)}`. VirtualizedList renders a function-valued header prop as ``, so a fresh arrow function on each render is a fresh element type: React unmounted the entire header subtree — MapView included — and mounted a new one. Home re-renders several times on mount (useFetch loading->data, useUserLocation pending->granted, session resolve), and recreating the Android GoogleMap surface each time left it grey with the Google logo and tiles that never finished loading. Pass the header as an element instead so the type stays stable and MapView mounts once. The same `() => (...)` pattern in ListEmptyComponent here, and in rides.tsx and confirm-ride.tsx, cost needless remounts of list chrome and DriverCards; fixed alongside. Also pin expo-crypto to ~13.0.2. It was ^57.0.1 — a wrong-major native module for SDK 51 that nothing in the app imports. npm hoisted it to the top level and gave expo-auth-session a nested 13.0.2 to satisfy its ~13.0.0 constraint, leaving two copies of one native module in the tree for autolinking to choose between. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 10 +++++++++- app.config.js | 19 ++++++++++++++++--- app/(root)/(tabs)/home.tsx | 18 +++++++++++------- app/(root)/(tabs)/rides.tsx | 12 +++++------- app/(root)/confirm-ride.tsx | 8 ++++---- package-lock.json | 22 +++++++--------------- package.json | 2 +- 7 files changed, 53 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 908d02c..e8ca9e3 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app.config.js b/app.config.js index fbfa41d..f8b82e3 100644 --- a/app.config.js +++ b/app.config.js @@ -12,9 +12,22 @@ const LOCATION_PERMISSION = module.exports = ({ config }) => { if (!googleMapsApiKey) { - console.warn( - "[app.config] EXPO_PUBLIC_GOOGLE_API_KEY is not set — maps will render blank on Android.", - ); + const message = + "EXPO_PUBLIC_GOOGLE_API_KEY is not set — the Android manifest will have no " + + "Maps key and the map will render blank."; + + // `.env` is gitignored, so it is never uploaded to EAS. Unless the key is + // also registered as an EAS environment variable the build silently + // produces a keyless APK, and the blank map only shows up on the device. + // Fail the build here rather than shipping that. + if (process.env.EAS_BUILD) { + throw new Error( + `[app.config] ${message} Register it with \`eas env:create\` (or in the ` + + "EAS dashboard) for this build profile.", + ); + } + + console.warn(`[app.config] ${message}`); } return { diff --git a/app/(root)/(tabs)/home.tsx b/app/(root)/(tabs)/home.tsx index 3f066e9..60142ff 100644 --- a/app/(root)/(tabs)/home.tsx +++ b/app/(root)/(tabs)/home.tsx @@ -47,7 +47,6 @@ const Home = () => { router.push("/(root)/find-ride"); }; - return ( { contentContainerStyle={{ paddingBottom: 100, }} - ListEmptyComponent={() => ( + ListEmptyComponent={ {!loading ? ( <> @@ -74,16 +73,21 @@ const Home = () => { )} - )} - ListHeaderComponent={() => ( + } + // Passed as an *element*, not as `() => (...)`. VirtualizedList + // renders a function prop as ``, 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={ <> - Welcome{" "} - {user?.name || user?.email} 👋 + Welcome {user?.name || user?.email} 👋 @@ -140,7 +144,7 @@ const Home = () => { Recent Rides - )} + } /> ); diff --git a/app/(root)/(tabs)/rides.tsx b/app/(root)/(tabs)/rides.tsx index a0adbb3..8275366 100644 --- a/app/(root)/(tabs)/rides.tsx +++ b/app/(root)/(tabs)/rides.tsx @@ -23,7 +23,7 @@ const Rides = () => { contentContainerStyle={{ paddingBottom: 100, }} - ListEmptyComponent={() => ( + ListEmptyComponent={ {!loading ? ( <> @@ -39,12 +39,10 @@ const Rides = () => { )} - )} - ListHeaderComponent={() => ( - <> - All rides - - )} + } + ListHeaderComponent={ + All rides + } /> ); diff --git a/app/(root)/confirm-ride.tsx b/app/(root)/confirm-ride.tsx index ebaefd0..b8c7793 100644 --- a/app/(root)/confirm-ride.tsx +++ b/app/(root)/confirm-ride.tsx @@ -20,13 +20,13 @@ const ConfirmRide = () => { setSelected={() => setSelectedDriver(item.id)} /> )} - ListEmptyComponent={() => ( + ListEmptyComponent={ No drivers available on this route right now.{"\n"}Please try another destination. - )} - ListFooterComponent={() => ( + } + ListFooterComponent={ { className={selectedDriver === null ? "opacity-50" : ""} /> - )} + } /> ); diff --git a/package-lock.json b/package-lock.json index 28f0dd3..d1069dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "expo-auth-session": "~5.5.2", "expo-clipboard": "~6.0.3", "expo-constants": "~16.0.2", - "expo-crypto": "^57.0.1", + "expo-crypto": "~13.0.2", "expo-font": "~12.0.9", "expo-linking": "^6.3.1", "expo-location": "^17.0.1", @@ -9337,17 +9337,6 @@ "invariant": "^2.2.4" } }, - "node_modules/expo-auth-session/node_modules/expo-crypto": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-13.0.2.tgz", - "integrity": "sha512-7f/IMPYJZkBM21LNEMXGrNo/0uXSVfZTwufUdpNKedJR0fm5fH4DCSN79ZddlV26nF90PuXjK2inIbI6lb0qRA==", - "dependencies": { - "base64-js": "^1.3.0" - }, - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-clipboard": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-6.0.3.tgz", @@ -9368,9 +9357,12 @@ } }, "node_modules/expo-crypto": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.1.tgz", - "integrity": "sha512-xwegXQw3ATgeL1ZuqbSNrGzOeG+zNeh6Z6DSJk825Qpa3TEQQ1kG3ioE1p3g/SNF373BAVz2iBKUTSytlIbBRA==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-13.0.2.tgz", + "integrity": "sha512-7f/IMPYJZkBM21LNEMXGrNo/0uXSVfZTwufUdpNKedJR0fm5fH4DCSN79ZddlV26nF90PuXjK2inIbI6lb0qRA==", + "dependencies": { + "base64-js": "^1.3.0" + }, "peerDependencies": { "expo": "*" } diff --git a/package.json b/package.json index fc040cc..9d4b88b 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "expo-auth-session": "~5.5.2", "expo-clipboard": "~6.0.3", "expo-constants": "~16.0.2", - "expo-crypto": "^57.0.1", + "expo-crypto": "~13.0.2", "expo-font": "~12.0.9", "expo-linking": "^6.3.1", "expo-location": "^17.0.1", From f50ff27e115121ce29eeae61e84ee23028db5087 Mon Sep 17 00:00:00 2001 From: Krikorios Date: Mon, 24 Aug 2026 13:57:21 +0300 Subject: [PATCH 2/2] Build driver app, Uber-style dispatch, POI suggestions; fix map tiles Driver side (was a stub): - In-app driver onboarding: a driver-role user creates their own linked drivers profile (driver/profile+api GET/POST/PATCH). - Driver dashboard: online/offline toggle, today's earnings, incoming request cards (accept/decline), active ride panel (start/complete trip). Polls /driver/rides every 4s while online. - Location heartbeat (use-driver-location): watchPositionAsync pings /driver/location every ~5s; restarts the watch on app foreground so a backgrounded driver doesn't go permanently stale and miss requests. Dispatch (auto-match nearest, Uber-style): - Ride state machine: requested -> accepted -> en_route -> completed/cancelled with a nullable driver_id until matched (lib/dispatch.matchNextDriver). - matchNextDriver locks the ride (SELECT FOR UPDATE), expires 15s-stale offers, picks the nearest eligible driver of the matching service by haversine, offers one at a time. Called from ride/create, ride/[id] GET (lazy match on the rider's poll), and ride/[id]/respond (on decline). - ride/create is now a request endpoint (driver_id NULL, status=requested, service); drops the pre-match driver_id payment reconciliation. - ride/[id] GET returns status/service/nullable driver; PATCH handles rider cancel + driver en_route/completed. ride/list backs the history tabs. Rider flow (best experience): - confirm-ride is now a request screen: single trip fare + nearest-driver ETA + cash/card + Request Ride -> live status. Periodically polls online drivers of the selected service and disables Request when none are online (prevents the "stuck searching forever" state). - book-ride is the live ride-status screen (searching -> accepted -> en_route -> completed/cancelled + Cancel), polling every 3s. - lib/request-ride unifies the Areeba card flow + cash path. - Map reads /driver/nearby (real positions, service-filtered); lib/map adds calculateTripFare + service-aware fares. POI suggestions: - lib/places (Google Nearby Search) + nearby-suggestions chips for mall/hospital/pharmacy/restaurant on the home screen. Service categories now drive both matching and a per-service fare multiplier (car 1.0 / moto 0.7 / courier 0.85 / chauffeur 1.5). Map tiles: react-native-maps rendered blank on Android because no Google Maps key was set. Switched app.json -> app.config.js so android.config.googleMaps.apiKey is injected from EXPO_PUBLIC_GOOGLE_API_KEY at build time (keeps the key out of git). Requires a native rebuild (expo run:android) to take effect. Also includes the prior payment/auth hardening (server-authoritative payment_orders ledger with double-spend guards, peppered OTP, register TOCTOU fix, stats cents fix) that was left uncommitted. Co-Authored-By: Claude --- .env.example | 9 +- .gitignore | 7 + app.config.js | 115 +++--- app.json | 50 --- app/(api)/(areeba)/create+api.ts | 123 +++++- app/(api)/(areeba)/verify+api.ts | 80 ++-- app/(api)/admin/stats+api.ts | 10 +- app/(api)/auth/forgot-password+api.ts | 39 +- app/(api)/auth/register+api.ts | 79 ++-- app/(api)/auth/reset-password+api.ts | 83 ++-- app/(api)/auth/verify+api.ts | 77 ++-- app/(api)/driver+api.ts | 13 +- app/(api)/driver/location+api.ts | 44 ++ app/(api)/driver/nearby+api.ts | 45 +++ app/(api)/driver/profile+api.ts | 151 +++++++ app/(api)/driver/rides+api.ts | 111 +++++ app/(api)/ride/[id]+api.ts | 170 ++++++-- app/(api)/ride/[id]/respond+api.ts | 102 +++++ app/(api)/ride/create+api.ts | 209 ++++++++-- app/(api)/ride/list+api.ts | 53 +++ app/(root)/(tabs)/_layout.tsx | 2 +- app/(root)/(tabs)/home.tsx | 9 +- app/(root)/(tabs)/profile.tsx | 7 +- app/(root)/(tabs)/rides.tsx | 6 +- app/(root)/book-ride.tsx | 326 ++++++++++----- app/(root)/confirm-ride.tsx | 341 ++++++++++++++-- app/(root)/driver-home.tsx | 557 ++++++++++++++++++++++++-- app/_layout.tsx | 3 - components/map.tsx | 21 +- components/nearby-suggestions.tsx | 120 ++++++ components/payment.tsx | 57 ++- constants/services.ts | 6 + dashboard/src/pages/Drivers.tsx | 9 + lib/dispatch.ts | 107 +++++ lib/driver.ts | 50 +++ lib/mailer.ts | 14 +- lib/map.ts | 103 ++++- lib/otp.ts | 19 +- lib/payment-orders.ts | 127 ++++++ lib/places.ts | 86 ++++ lib/pricing.ts | 24 +- lib/request-ride.ts | 112 ++++++ lib/use-driver-location.ts | 105 +++++ lib/users.ts | 9 - lib/utils.ts | 18 + package.json | 4 +- scripts/seed-db.mjs | 79 +++- types/type.d.ts | 53 ++- 48 files changed, 3342 insertions(+), 602 deletions(-) delete mode 100644 app.json create mode 100644 app/(api)/driver/location+api.ts create mode 100644 app/(api)/driver/nearby+api.ts create mode 100644 app/(api)/driver/profile+api.ts create mode 100644 app/(api)/driver/rides+api.ts create mode 100644 app/(api)/ride/[id]/respond+api.ts create mode 100644 app/(api)/ride/list+api.ts create mode 100644 components/nearby-suggestions.tsx create mode 100644 lib/dispatch.ts create mode 100644 lib/driver.ts create mode 100644 lib/payment-orders.ts create mode 100644 lib/places.ts create mode 100644 lib/request-ride.ts create mode 100644 lib/use-driver-location.ts diff --git a/.env.example b/.env.example index 747ef95..69a6d13 100644 --- a/.env.example +++ b/.env.example @@ -22,10 +22,12 @@ SMTP_USER=you@gmail.com SMTP_PASS=your-16-char-app-password SMTP_FROM="Waseel " -# 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 # areeba payment gateway (credentials issued after merchant onboarding) @@ -33,3 +35,6 @@ AREEBA_API_BASE_URL="https://your-gateway-host.areeba.com" AREEBA_MERCHANT_ID=XXXXXXXXXXXX AREEBA_API_PASSWORD=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX AREEBA_API_VERSION=100 + +# admin dashboard origin for CORS (lib/admin.ts); defaults to * when unset +ADMIN_CORS_ORIGIN=* diff --git a/.gitignore b/.gitignore index b715f76..ab92a84 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,10 @@ expo-env.d.ts # env .env + +# Native projects generated by `expo prebuild` / `expo run:*`. +/android +/ios + +# admin dashboard build output +dashboard/dist/ diff --git a/app.config.js b/app.config.js index f8b82e3..ce49624 100644 --- a/app.config.js +++ b/app.config.js @@ -1,64 +1,67 @@ -// Dynamic config layered over app.json. +// 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 reads the Google Maps key from the *native* manifest -// (AndroidManifest `com.google.android.geo.API_KEY`), not from the JS bundle, -// so EXPO_PUBLIC_GOOGLE_API_KEY has to be injected here at build time. Without -// it Android renders an empty grey tile area instead of a map. +// 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; -const LOCATION_PERMISSION = - "Waseel uses your location to show nearby drivers and set your pickup point."; - -module.exports = ({ config }) => { - if (!googleMapsApiKey) { - const message = - "EXPO_PUBLIC_GOOGLE_API_KEY is not set — the Android manifest will have no " + - "Maps key and the map will render blank."; - - // `.env` is gitignored, so it is never uploaded to EAS. Unless the key is - // also registered as an EAS environment variable the build silently - // produces a keyless APK, and the blank map only shows up on the device. - // Fail the build here rather than shipping that. - if (process.env.EAS_BUILD) { - throw new Error( - `[app.config] ${message} Register it with \`eas env:create\` (or in the ` + - "EAS dashboard) for this build profile.", - ); - } - - console.warn(`[app.config] ${message}`); - } - - return { - ...config, - ios: { - ...config.ios, - // iOS uses Apple Maps via PROVIDER_DEFAULT, so this only matters if the - // Map component is switched to PROVIDER_GOOGLE. - config: { ...config.ios?.config, googleMapsApiKey }, - infoPlist: { - ...config.ios?.infoPlist, - NSLocationWhenInUseUsageDescription: LOCATION_PERMISSION, +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", + }, + android: { + adaptiveIcon: { + foregroundImage: "./assets/images/adaptive-icon.png", + backgroundColor: "#ffffff", + }, + package: "com.waseel.app", + config: { + googleMaps: { + apiKey: googleMapsApiKey ?? "", }, }, - android: { - ...config.android, - config: { - ...config.android?.config, - googleMaps: { apiKey: googleMapsApiKey }, + }, + web: { + bundler: "metro", + output: "server", + favicon: "./assets/images/favicon.png", + }, + plugins: [ + [ + "expo-router", + { + origin: "https://example.com/", }, - // Location permissions come from the expo-location plugin below. - }, - plugins: [ - ...(config.plugins ?? []), - [ - "expo-location", - { - locationAlwaysAndWhenInUsePermission: LOCATION_PERMISSION, - locationWhenInUsePermission: LOCATION_PERMISSION, - }, - ], ], - }; -}; + ], + experiments: { + typedRoutes: true, + }, + extra: { + router: { + origin: "https://example.com/", + }, + }, +}); \ No newline at end of file diff --git a/app.json b/app.json deleted file mode 100644 index bc2f350..0000000 --- a/app.json +++ /dev/null @@ -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/" - } - } - } -} diff --git a/app/(api)/(areeba)/create+api.ts b/app/(api)/(areeba)/create+api.ts index 69fbcaf..ad0fc18 100644 --- a/app/(api)/(areeba)/create+api.ts +++ b/app/(api)/(areeba)/create+api.ts @@ -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 }); } -} +} \ No newline at end of file diff --git a/app/(api)/(areeba)/verify+api.ts b/app/(api)/(areeba)/verify+api.ts index cdc61f7..55b5b97 100644 --- a/app/(api)/(areeba)/verify+api.ts +++ b/app/(api)/(areeba)/verify+api.ts @@ -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 }); } -} +} \ No newline at end of file diff --git a/app/(api)/admin/stats+api.ts b/app/(api)/admin/stats+api.ts index 67ccf2d..1e090ea 100644 --- a/app/(api)/admin/stats+api.ts +++ b/app/(api)/admin/stats+api.ts @@ -25,11 +25,11 @@ export async function GET(request: Request) { (SELECT COUNT(*)::int FROM users) AS users, (SELECT COUNT(*)::int FROM drivers) AS drivers, (SELECT COUNT(*)::int FROM rides) AS rides, - (SELECT COALESCE(SUM(fare_price), 0)::int FROM rides WHERE payment_status = 'paid') AS revenue, + (SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE payment_status = 'paid') AS revenue, (SELECT COUNT(*)::int FROM rides WHERE created_at >= CURRENT_DATE) AS rides_today, - (SELECT COALESCE(ROUND(AVG(fare_price)), 0)::int FROM rides WHERE payment_status = 'paid') AS avg_fare, + (SELECT COALESCE(ROUND(AVG(fare_price) / 100.0, 2), 0)::float8 FROM rides WHERE payment_status = 'paid') AS avg_fare, (SELECT COUNT(*)::int FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_count, - (SELECT COALESCE(SUM(fare_price), 0)::int FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_revenue, + (SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_revenue, (SELECT COUNT(*)::int FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days') AS new_users_7d `; @@ -37,7 +37,7 @@ export async function GET(request: Request) { 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(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue FROM generate_series( CURRENT_DATE - INTERVAL '13 days', CURRENT_DATE, @@ -58,7 +58,7 @@ export async function GET(request: Request) { 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 + COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue FROM drivers d LEFT JOIN rides r ON r.driver_id = d.id GROUP BY d.id, d.first_name, d.last_name diff --git a/app/(api)/auth/forgot-password+api.ts b/app/(api)/auth/forgot-password+api.ts index ce70c23..faf2132 100644 --- a/app/(api)/auth/forgot-password+api.ts +++ b/app/(api)/auth/forgot-password+api.ts @@ -1,5 +1,5 @@ -import { sql } from "@/lib/db"; -import { sendEmail } from "@/lib/mailer"; +import { sql, transaction } from "@/lib/db"; +import { isDevOtpExposed, sendEmail } from "@/lib/mailer"; import { CODE_TTL_MINUTES, generateCode, @@ -26,20 +26,24 @@ export async function POST(req: Request) { return Response.json({ data: { sent: false } }); } - const code = generateCode(); + const code = await transaction(async (tx) => { + const generated = generateCode(); - await sql` - INSERT INTO password_reset_codes (email, code_hash, expires_at) - VALUES ( - ${normalized}, - ${hashCode(normalized, code)}, - 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 - `; + 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); @@ -48,8 +52,9 @@ export async function POST(req: Request) { 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. - ...(delivered ? {} : { devCode: code }), + // 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) { diff --git a/app/(api)/auth/register+api.ts b/app/(api)/auth/register+api.ts index 146dd03..b46ed15 100644 --- a/app/(api)/auth/register+api.ts +++ b/app/(api)/auth/register+api.ts @@ -1,18 +1,13 @@ -import { sql } from "@/lib/db"; +import { sql, transaction } from "@/lib/db"; import { hashPassword } from "@/lib/password"; -import { sendEmail } from "@/lib/mailer"; +import { isDevOtpExposed, sendEmail } from "@/lib/mailer"; import { CODE_TTL_MINUTES, generateCode, hashCode, verificationEmail, } from "@/lib/otp"; - -const normalizePhone = (raw: string): string => { - const cleaned = raw.replace(/[^\d+]/g, ""); - if (cleaned.startsWith("+")) return cleaned; - return `+961${cleaned.replace(/^0+/, "")}`; -}; +import { normalizePhone } from "@/lib/utils"; export async function POST(req: Request) { const { name, email, phone, password, role } = await req.json(); @@ -46,37 +41,42 @@ 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, role) - VALUES ( - ${name.trim()}, - ${email.trim().toLowerCase()}, - ${phone ? normalizePhone(phone) : null}, - ${hashPassword(password)}, - FALSE, - ${normalizedRole} - ) - ON CONFLICT (email) DO UPDATE SET - name = EXCLUDED.name, - phone = COALESCE(EXCLUDED.phone, users.phone), - password_hash = EXCLUDED.password_hash, - role = EXCLUDED.role - `; + 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, + ${normalizedRole} + ) + ON CONFLICT (email) DO UPDATE SET + name = EXCLUDED.name, + phone = COALESCE(EXCLUDED.phone, users.phone), + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role + WHERE users.email_verified = FALSE + `; - const code = generateCode(); + const generated = generateCode(); - await sql` - INSERT INTO email_verification_codes (email, code_hash, expires_at) - VALUES ( - ${email.trim().toLowerCase()}, - ${hashCode(email.trim().toLowerCase(), code)}, - 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 - `; + await tx` + INSERT INTO email_verification_codes (email, code_hash, expires_at) + VALUES ( + ${email.trim().toLowerCase()}, + ${hashCode(email.trim().toLowerCase(), 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 = verificationEmail(code); const delivered = await sendEmail( @@ -90,8 +90,9 @@ export async function POST(req: Request) { data: { sent: delivered, // Without SMTP/Gmail configured there is nothing to receive, so - // surface the code to keep self-hosted sign-up usable. - ...(delivered ? {} : { devCode: code }), + // 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 }, diff --git a/app/(api)/auth/reset-password+api.ts b/app/(api)/auth/reset-password+api.ts index 834648d..da2fba9 100644 --- a/app/(api)/auth/reset-password+api.ts +++ b/app/(api)/auth/reset-password+api.ts @@ -1,4 +1,4 @@ -import { sql } from "@/lib/db"; +import { transaction } from "@/lib/db"; import { MAX_CODE_ATTEMPTS, codeMatches } from "@/lib/otp"; import { hashPassword } from "@/lib/password"; import { issueSession, toProfile } from "@/lib/users"; @@ -24,52 +24,67 @@ export async function POST(req: Request) { 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. - const attempts = await sql<{ 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 - `; + // 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]; + const record = attempts[0]; - if ( - !record || - record.attempts > MAX_CODE_ATTEMPTS || - !codeMatches(record.code_hash, normalized, code) - ) { + 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 }, ); } - // A successful reset also proves control of the mailbox, so verify it too. - const rows = await sql<{ - 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) { + if (result.kind === "not_found") { return Response.json({ error: "User not found." }, { status: 404 }); } - await sql`DELETE FROM password_reset_codes WHERE email = ${normalized}`; - - 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("[RESET_PASSWORD]: ", error); diff --git a/app/(api)/auth/verify+api.ts b/app/(api)/auth/verify+api.ts index 9647b5b..a6f3ea9 100644 --- a/app/(api)/auth/verify+api.ts +++ b/app/(api)/auth/verify+api.ts @@ -1,4 +1,4 @@ -import { sql } from "@/lib/db"; +import { transaction } from "@/lib/db"; import { MAX_CODE_ATTEMPTS, codeMatches, @@ -19,50 +19,63 @@ export async function POST(req: Request) { 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. - const attempts = await sql<{ 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 - `; + // 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]; + const record = attempts[0]; - if ( - !record || - record.attempts > MAX_CODE_ATTEMPTS || - !codeMatches(record.code_hash, normalized, code) - ) { + 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; + role: string | null; + }>` + UPDATE users SET 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 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 }, ); } - const rows = await sql<{ - id: string; - name: string; - email: string; - role: string | null; - }>` - UPDATE users SET email_verified = TRUE - WHERE email = ${normalized} - RETURNING id, name, email, role - `; - - const user = rows[0]; - - if (!user) { + if (result.kind === "not_found") { return Response.json({ error: "User not found." }, { status: 404 }); } - await sql`DELETE FROM email_verification_codes WHERE email = ${normalized}`; - - 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); diff --git a/app/(api)/driver+api.ts b/app/(api)/driver+api.ts index 5c3bdb6..dbb7cd2 100644 --- a/app/(api)/driver+api.ts +++ b/app/(api)/driver+api.ts @@ -1,8 +1,15 @@ +import { requireAuth } from "@/lib/jwt"; import { sql } from "@/lib/db"; -export async function GET() { +export async function GET(req: Request) { + const auth = requireAuth(req); + if ("error" in auth) return auth.error; + try { - const response = await sql`SELECT * FROM drivers`; + const response = await sql` + SELECT id, first_name, last_name, profile_image_url, car_image_url, car_seats, rating + FROM drivers + `; return Response.json({ data: response }); } catch (error) { @@ -10,4 +17,4 @@ export async function GET() { return Response.json({ error }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/app/(api)/driver/location+api.ts b/app/(api)/driver/location+api.ts new file mode 100644 index 0000000..fdf5dc8 --- /dev/null +++ b/app/(api)/driver/location+api.ts @@ -0,0 +1,44 @@ +import { requireDriverProfile } from "@/lib/driver"; +import { sql } from "@/lib/db"; + +// 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 } = 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 }, + ); + } + + const { driverId } = result; + const rows = await sql` + UPDATE drivers + SET latitude = ${latitude}, + longitude = ${longitude}, + last_seen = CURRENT_TIMESTAMP, + online = TRUE + WHERE id = ${driverId} + RETURNING id, latitude, longitude, last_seen, online + `; + + return Response.json({ data: rows[0] }); + } catch (error) { + console.error("[DRIVER_LOCATION]: ", error); + return Response.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/(api)/driver/nearby+api.ts b/app/(api)/driver/nearby+api.ts new file mode 100644 index 0000000..56ecd4b --- /dev/null +++ b/app/(api)/driver/nearby+api.ts @@ -0,0 +1,45 @@ +import { requireAuth } from "@/lib/jwt"; +import { sql } from "@/lib/db"; + +// GET — online drivers of `service` near (lat,lng), for the rider map and the +// "nearest driver ETA" estimate on confirm-ride. Only real, logged-in drivers +// (user_id IS NOT NULL) with a fresh location ping are returned; legacy seed +// rows have no position and are never shown to riders. +// +// Query: ?service=car&lat=33.89&lng=35.50&radius=8000 +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 rows = await sql` + SELECT id, first_name, last_name, profile_image_url, car_image_url, + car_seats, rating, service, car_model, latitude, longitude, + last_seen + FROM drivers + WHERE service = ${service} + AND online = TRUE + AND user_id IS NOT NULL + AND last_seen > CURRENT_TIMESTAMP - INTERVAL '60 seconds' + AND latitude IS NOT NULL + AND longitude IS NOT NULL + `; + + return Response.json({ data: rows }); + } catch (error) { + console.error("[DRIVER_NEARBY]: ", error); + return Response.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/(api)/driver/profile+api.ts b/app/(api)/driver/profile+api.ts new file mode 100644 index 0000000..6aa1d1b --- /dev/null +++ b/app/(api)/driver/profile+api.ts @@ -0,0 +1,151 @@ +import { requireAuth } from "@/lib/jwt"; +import { sql, query } from "@/lib/db"; +import { isServiceId, requireDriverProfile } from "@/lib/driver"; +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, service, online, car_model, user_id + FROM drivers WHERE id = ${driverId} + `; + return Response.json({ data: rows[0], userId: auth.userId }); +} + +// POST — onboarding. A driver-role user creates their one linked drivers row. +// The user must carry role='driver' (set on sign-up / role.tsx) so a rider +// can't silently become a driver by hitting this endpoint. +export async function POST(req: Request) { + const auth = requireAuth(req); + if ("error" in auth) return auth.error; + + try { + const body = await req.json(); + const { car_model, car_seats, service, profile_image_url, car_image_url } = + body; + + // 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 [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 + ) VALUES ( + ${auth.userId}, + ${firstName || "Driver"}, + ${rest.join(" ") || ""}, + ${profile_image_url ?? null}, + ${car_image_url ?? null}, + ${seats}, + 5.0, + ${service as ServiceId}, + ${car_model ?? null}, + FALSE + ) + RETURNING id, service, online + `; + 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); + }; + + 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, + ); + return Response.json({ data: rows[0] }); + } catch (error) { + console.error("[DRIVER_PROFILE_PATCH]: ", error); + return Response.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/(api)/driver/rides+api.ts b/app/(api)/driver/rides+api.ts new file mode 100644 index 0000000..5927368 --- /dev/null +++ b/app/(api)/driver/rides+api.ts @@ -0,0 +1,111 @@ +import { requireDriverProfile } from "@/lib/driver"; +import { sql } from "@/lib/db"; + +// GET — the driver's world in one poll: +// offers : incoming ride_offers awaiting this driver's accept/decline, +// each joined to its ride so the card can show pickup/dest/fare. +// active : the ride this driver is currently on (accepted or en_route). +// recent : rides completed today, for the earnings summary. +export async function GET(req: Request) { + const result = await requireDriverProfile(req); + if ("error" in result) return result.error; + + try { + const { driverId } = result; + + const offers = await sql` + SELECT + ro.id AS offer_id, ro.offered_at, + r.ride_id, r.origin_address, r.destination_address, + r.origin_latitude, r.origin_longitude, + r.destination_latitude, r.destination_longitude, + r.ride_time, r.fare_price, r.payment_status, r.service, r.user_id + FROM ride_offers ro + JOIN rides r ON r.ride_id = ro.ride_id + WHERE ro.driver_id = ${driverId} AND ro.status = 'offered' + ORDER BY ro.offered_at DESC + `; + + const active = await sql` + SELECT + r.ride_id, r.status, r.service, r.payment_status, + r.origin_address, r.destination_address, + r.origin_latitude, r.origin_longitude, + r.destination_latitude, r.destination_longitude, + r.ride_time, r.fare_price, r.created_at, + u.name AS rider_name, u.phone AS rider_phone + FROM rides r + LEFT JOIN users u ON u.id = r.user_id + WHERE r.driver_id = ${driverId} AND r.status IN ('accepted', 'en_route') + ORDER BY r.created_at DESC + LIMIT 1 + `; + + const recent = await sql` + SELECT ride_id, fare_price, service, completed_at + FROM rides + WHERE driver_id = ${driverId} AND status = 'completed' + AND completed_at >= CURRENT_DATE + ORDER BY completed_at DESC + `; + + const earnings = recent.reduce( + (sum, r) => sum + Number(r.fare_price), + 0, + ); + + return Response.json({ + data: { + offers: offers as unknown as OfferRow[], + active: (active[0] as unknown as ActiveRide | undefined) ?? null, + recent: recent as unknown as RecentRow[], + earnings, + }, + }); + } catch (error) { + console.error("[DRIVER_RIDES]: ", error); + return Response.json({ error: "Internal Server Error" }, { status: 500 }); + } +} + +type OfferRow = { + offer_id: number; + offered_at: string; + 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; + payment_status: string; + service: string; + user_id: string; +}; + +type ActiveRide = { + ride_id: number; + status: string; + service: string; + payment_status: string; + origin_address: string; + destination_address: string; + origin_latitude: number; + origin_longitude: number; + destination_latitude: number; + destination_longitude: number; + ride_time: number; + fare_price: number; + created_at: string; + rider_name: string | null; + rider_phone: string | null; +}; + +type RecentRow = { + ride_id: number; + fare_price: number; + service: string; + completed_at: string; +}; \ No newline at end of file diff --git a/app/(api)/ride/[id]+api.ts b/app/(api)/ride/[id]+api.ts index 3de91bc..173891e 100644 --- a/app/(api)/ride/[id]+api.ts +++ b/app/(api)/ride/[id]+api.ts @@ -1,46 +1,148 @@ import { requireAuth } from "@/lib/jwt"; -import { sql } from "@/lib/db"; +import { sql, query } from "@/lib/db"; +import { matchNextDriver } from "@/lib/dispatch"; +import { requireDriverProfile } from "@/lib/driver"; +// GET — single ride by id, the rider's status-poll endpoint. If the ride is +// still 'requested' with no offer in flight, kick auto-match before reading +// so the rider's poll itself drives matching forward (no background worker). export async function GET(request: Request, { id }: { id: string }) { const auth = requireAuth(request); if ("error" in auth) return auth.error; - try { - const response = 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, - 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 - ) 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; - `; + const rideId = Number(id); + if (!Number.isInteger(rideId)) { + return Response.json({ error: "Invalid ride id." }, { status: 400 }); + } - return Response.json({ data: response }); + try { + 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 match: try to offer the ride to a driver if it's still requested. + if (ride[0].status === "requested") { + void matchNextDriver(rideId); + } + + const rows = 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, + 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, + 'latitude', d.latitude, + 'longitude', d.longitude + ) AS driver + FROM rides r + LEFT JOIN drivers d ON d.id = r.driver_id + WHERE r.ride_id = ${rideId} + `; + + return Response.json({ data: rows[0] }); } catch (error) { console.error("[GET_RIDE]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } } + +// PATCH — ride lifecycle transitions. +// Rider: { status: 'cancelled' } — only from 'requested' or 'accepted', and +// only on their own ride. +// Driver: { status: 'en_route' | 'completed' } — only on the ride they own +// (driver_id = their profile), from the right prior state. +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 }; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const next = body.status; + + try { + // Rider cancel — authenticate by ownership of the ride. + if (next === "cancelled") { + const auth = requireAuth(request); + if ("error" in auth) return auth.error; + + const rows = await sql<{ status: string }>` + UPDATE rides + SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP + WHERE ride_id = ${rideId} + AND user_id = ${auth.userId} + AND status IN ('requested', 'accepted') + RETURNING status + `; + if (!rows[0]) { + return Response.json( + { error: "Ride cannot be cancelled." }, + { status: 409 }, + ); + } + return Response.json({ data: { status: rows[0].status } }); + } + + // Driver transitions — must be the driver assigned to the ride. + if (next === "en_route" || next === "completed") { + const result = await requireDriverProfile(request); + if ("error" in result) return result.error; + + const { driverId } = result; + const priorStatus = next === "en_route" ? "accepted" : "en_route"; + const setClause = + next === "completed" + ? "status = $1, completed_at = CURRENT_TIMESTAMP, driver_id = $2" + : "status = $1, driver_id = $2"; + + const rows = await query<{ status: string }>( + `UPDATE rides SET ${setClause} + WHERE ride_id = $3 AND driver_id = $2 AND status = $4 + RETURNING status`, + [next, driverId, rideId, priorStatus], + ); + if (!rows[0]) { + return Response.json( + { error: "Ride cannot transition to that state." }, + { status: 409 }, + ); + } + return Response.json({ data: { status: rows[0].status } }); + } + + return Response.json({ error: "Unknown status transition." }, { status: 400 }); + } catch (error) { + console.error("[PATCH_RIDE]: ", error); + return Response.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/(api)/ride/[id]/respond+api.ts b/app/(api)/ride/[id]/respond+api.ts new file mode 100644 index 0000000..010238b --- /dev/null +++ b/app/(api)/ride/[id]/respond+api.ts @@ -0,0 +1,102 @@ +import { requireDriverProfile } from "@/lib/driver"; +import { transaction } from "@/lib/db"; +import { matchNextDriver } from "@/lib/dispatch"; + +// POST — a driver responds to a ride offer. +// { action: 'accept' } — claim the ride: offer -> accepted, ride -> accepted, +// ride.driver_id set to this driver. Guarded so only +// the offered driver can accept, and only while the +// offer is still 'offered' (not expired/timed out). +// { action: 'decline' } — release the ride: offer -> declined, then offer +// it to the next-nearest driver via matchNextDriver. +export async function POST(req: Request, { id }: { id: string }) { + const rideId = Number(id); + if (!Number.isInteger(rideId)) { + return Response.json({ error: "Invalid ride id." }, { status: 400 }); + } + + const result = await requireDriverProfile(req); + if ("error" in result) return result.error; + + const { driverId } = result; + + let body: { action?: string }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + const action = body.action; + if (action !== "accept" && action !== "decline") { + return Response.json( + { error: "action must be 'accept' or 'decline'." }, + { status: 400 }, + ); + } + + try { + if (action === "accept") { + const claimed = await transaction(async (tx) => { + // Atomically flip the offer to accepted only if it's still offered to + // this driver. This is the race guard: two drivers can't both accept, + // and an expired offer can't be revived. + const offer = await tx<{ id: number }>` + UPDATE ride_offers + SET status = 'accepted', responded_at = CURRENT_TIMESTAMP + WHERE ride_id = ${rideId} + AND driver_id = ${driverId} + AND status = 'offered' + RETURNING id + `; + if (!offer[0]) return null; + + // Assign the ride to this driver. The status='requested' guard means + // we never overwrite a ride another driver already accepted. + const ride = await tx` + UPDATE rides + SET status = 'accepted', driver_id = ${driverId} + WHERE ride_id = ${rideId} AND status = 'requested' + RETURNING ride_id + `; + if (!ride[0]) return null; + + return offer[0].id; + }); + + if (claimed === null) { + return Response.json( + { error: "This offer is no longer available." }, + { status: 409 }, + ); + } + return Response.json({ data: { action: "accepted" } }); + } + + // Decline: mark the offer declined and offer the ride to the next driver. + const declined = await transaction(async (tx) => { + const offer = await tx` + UPDATE ride_offers + SET status = 'declined', responded_at = CURRENT_TIMESTAMP + WHERE ride_id = ${rideId} + AND driver_id = ${driverId} + AND status = 'offered' + RETURNING id + `; + return offer[0]?.id ?? null; + }); + + if (declined === null) { + return Response.json( + { error: "This offer is no longer available." }, + { status: 409 }, + ); + } + + void matchNextDriver(rideId); + return Response.json({ data: { action: "declined" } }); + } catch (error) { + console.error("[RIDE_RESPOND]: ", error); + return Response.json({ error: "Internal Server Error" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/(api)/ride/create+api.ts b/app/(api)/ride/create+api.ts index 8b32af5..fb0a1a9 100644 --- a/app/(api)/ride/create+api.ts +++ b/app/(api)/ride/create+api.ts @@ -1,6 +1,18 @@ import { requireAuth } from "@/lib/jwt"; -import { sql } from "@/lib/db"; +import { sql, transaction } from "@/lib/db"; +import { getOrder, consumeOrderForRide } from "@/lib/payment-orders"; +import { matchNextDriver } from "@/lib/dispatch"; +import { isServiceId } from "@/lib/driver"; +import { DEFAULT_SERVICE } from "@/constants/services"; +// Explicit missing check — a truthy check would reject legitimate 0 values +// like latitude 0.0 (the equator) or a zero fare. +const isMissing = (v: unknown): boolean => v === undefined || v === null; + +// POST — request a ride. The rider no longer picks a driver; the ride is +// created with status='requested' and driver_id=NULL, then auto-match offers +// it to the nearest eligible driver of the requested service. `driver_id` in +// the body is accepted for backward compatibility but ignored. export async function POST(request: Request) { const auth = requireAuth(request); if ("error" in auth) return auth.error; @@ -16,21 +28,20 @@ export async function POST(request: Request) { destination_longitude, ride_time, fare_price, - payment_status, - driver_id, + payment_method, + payment_order_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,38 +49,154 @@ export async function POST(request: Request) { ); } - const response = await sql` - INSERT INTO rides ( - origin_address, - destination_address, - origin_latitude, - origin_longitude, - destination_latitude, - destination_longitude, - ride_time, - fare_price, - payment_status, - driver_id, - user_id - ) VALUES ( - ${origin_address}, - ${destination_address}, - ${origin_latitude}, - ${origin_longitude}, - ${destination_latitude}, - ${destination_longitude}, - ${ride_time}, - ${fare_price}, - ${payment_status}, - ${driver_id}, - ${auth.userId} - ) - RETURNING *; + if (payment_method !== "card" && payment_method !== "cash") + return Response.json( + { error: "Invalid payment method." }, + { status: 400 }, + ); + + const rideService = isServiceId(service) ? service : DEFAULT_SERVICE; + const fareCents = Math.round(Number(fare_price)); + + if (payment_method === "card") { + // Card: the ride is only recorded once a paid, server-authoritative + // payment order is consumed. The client can no longer self-declare + // payment_status='paid'. + if (isMissing(payment_order_id)) + return Response.json( + { error: "Missing payment order id." }, + { status: 400 }, + ); + + const order = await getOrder(payment_order_id); + if (!order) + return Response.json( + { error: "Payment order not found." }, + { status: 404 }, + ); + + if (order.user_id !== auth.userId) + return Response.json({ error: "Unauthorized." }, { status: 403 }); + + if (order.status !== "paid") + return Response.json( + { error: "Payment not verified." }, + { status: 400 }, + ); + + if (order.amount_cents !== fareCents) + return Response.json( + { error: "Payment amount mismatch." }, + { status: 400 }, + ); + + // Reconcile route intent (driver isn't known yet, so driver_id is no + // longer part of the intent check). Null intent fields are skipped. + const intentsMatch = + (order.origin_address === null || + order.origin_address === origin_address) && + (order.destination_address === null || + order.destination_address === destination_address) && + (order.ride_time === null || order.ride_time === Number(ride_time)); + + if (!intentsMatch) + return Response.json( + { error: "Payment does not match this ride." }, + { status: 400 }, + ); + + // Consume the order and insert the ride on one connection, so a failure + // rolls back both and no paid order is wasted without a ride. + const inserted = await transaction(async (tx) => { + const consumed = await consumeOrderForRide( + payment_order_id, + auth.userId, + tx, + ); + if (!consumed) throw new Error("PAYMENT_ORDER_NOT_CONSUMABLE"); + + const rows = await tx` + INSERT INTO rides ( + origin_address, + destination_address, + origin_latitude, + origin_longitude, + destination_latitude, + destination_longitude, + ride_time, + fare_price, + payment_status, + driver_id, + user_id, + payment_order_id, + status, + service + ) VALUES ( + ${origin_address}, + ${destination_address}, + ${origin_latitude}, + ${origin_longitude}, + ${destination_latitude}, + ${destination_longitude}, + ${ride_time}, + ${fareCents}, + 'paid', + NULL, + ${auth.userId}, + ${payment_order_id}, + 'requested', + ${rideService} + ) + RETURNING * `; + return rows[0]; + }); + + // Kick off auto-match asynchronously — don't block the response on it. + void matchNextDriver(inserted.ride_id); + + return Response.json({ data: inserted }, { status: 201 }); + } + + // Cash: settled directly with the driver at drop-off. No order involved. + const response = await sql` + INSERT INTO rides ( + origin_address, + destination_address, + origin_latitude, + origin_longitude, + destination_latitude, + destination_longitude, + ride_time, + fare_price, + payment_status, + driver_id, + user_id, + status, + service + ) VALUES ( + ${origin_address}, + ${destination_address}, + ${origin_latitude}, + ${origin_longitude}, + ${destination_latitude}, + ${destination_longitude}, + ${ride_time}, + ${fareCents}, + 'cash', + NULL, + ${auth.userId}, + 'requested', + ${rideService} + ) + RETURNING * + `; + + void matchNextDriver(response[0].ride_id); return Response.json({ data: response[0] }, { status: 201 }); } catch (error) { console.error("[CREATE_RIDES]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } -} +} \ No newline at end of file diff --git a/app/(api)/ride/list+api.ts b/app/(api)/ride/list+api.ts new file mode 100644 index 0000000..5edebc5 --- /dev/null +++ b/app/(api)/ride/list+api.ts @@ -0,0 +1,53 @@ +import { requireAuth } from "@/lib/jwt"; +import { sql } from "@/lib/db"; + +// 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, + 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 IN ('completed', 'cancelled') + 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 }); + } +} \ No newline at end of file diff --git a/app/(root)/(tabs)/_layout.tsx b/app/(root)/(tabs)/_layout.tsx index ce12012..23edf06 100644 --- a/app/(root)/(tabs)/_layout.tsx +++ b/app/(root)/(tabs)/_layout.tsx @@ -31,7 +31,7 @@ const TabIcon = ({ const TabsLayout = () => ( { (state) => state.setDestinationLocation, ); const { signOut, user } = useSession(); - const { data: recentRides, loading } = useFetch( - `/(api)/ride/${user?.id}`, - ); + const { data: recentRides, loading } = useFetch("/(api)/ride/list"); const { status: locationStatus, retry: retryLocation } = useUserLocation(); @@ -140,6 +139,10 @@ const Home = () => { + + + + Recent Rides diff --git a/app/(root)/(tabs)/profile.tsx b/app/(root)/(tabs)/profile.tsx index dbce75b..d230cde 100644 --- a/app/(root)/(tabs)/profile.tsx +++ b/app/(root)/(tabs)/profile.tsx @@ -2,6 +2,7 @@ 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 { useSession } from "@/lib/session"; const Profile = () => { @@ -17,7 +18,7 @@ const Profile = () => { Your Avatar { { { - const { user } = useSession(); - const { data: recentRides, loading } = useFetch( - `/(api)/ride/${user?.id}`, - ); + const { data: recentRides, loading } = useFetch("/(api)/ride/list"); return ( diff --git a/app/(root)/book-ride.tsx b/app/(root)/book-ride.tsx index 844df6b..fbe5255 100644 --- a/app/(root)/book-ride.tsx +++ b/app/(root)/book-ride.tsx @@ -1,136 +1,248 @@ -import { router } from "expo-router"; -import { Image, Text, View } from "react-native"; +import { router, useLocalSearchParams } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, + Alert, + Image, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; 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 { useSession } from "@/lib/session"; +import { Map } from "@/components/map"; +import { icons, images } from "@/constants"; +import { ApiError, fetchAPI } from "@/lib/fetch"; import { formatTime } from "@/lib/utils"; -import { useDriverStore, useLocationStore } from "@/store"; +import { useLocationStore } from "@/store"; +import type { Ride } from "@/types/type"; +const POLL_MS = 3000; + +const statusLabel: Record = { + requested: "Finding your driver…", + accepted: "Driver assigned — heading to you", + en_route: "On your trip", + completed: "You've arrived!", + cancelled: "Ride cancelled", +}; + +// 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 { user } = useSession(); - const { userAddress, destinationAddress } = useLocationStore(); - const { drivers, selectedDriver } = useDriverStore(); + const { id } = useLocalSearchParams<{ id: string }>(); + const rideId = Number(id); + const setUserLocation = useLocationStore((s) => s.setUserLocation); + const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation); - const driverDetails = drivers?.filter( - (driver) => +driver.id === selectedDriver, - )[0]; + const [ride, setRide] = useState(null); + const [loading, setLoading] = useState(true); + const [cancelling, setCancelling] = useState(false); + const [error, setError] = useState(null); - if (!driverDetails) { + const load = useCallback(async () => { + try { + const res = await fetchAPI(`/(api)/ride/${rideId}`); + const r = res.data as Ride; + setRide(r); + + // 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("Ride not found."); + } + } finally { + setLoading(false); + } + }, [rideId, setUserLocation, setDestinationLocation]); + + useEffect(() => { + void load(); + }, [load]); + + // Poll while the ride is still in a non-terminal state. + useEffect(() => { + const status = ride?.status; + if (!status || status === "completed" || status === "cancelled") return; + const timer = setInterval(() => void load(), POLL_MS); + return () => clearInterval(timer); + }, [ride?.status, load]); + + const cancel = async () => { + setCancelling(true); + try { + await fetchAPI(`/(api)/ride/${rideId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "cancelled" }), + }); + await load(); + } catch (err) { + console.log("[BOOK_RIDE_CANCEL]: ", err); + Alert.alert("Error", "Could not cancel this ride. Please try again."); + } finally { + setCancelling(false); + } + }; + + if (loading) { return ( - - - - No driver selected.{"\n"}Please go back and choose a driver first. - - - router.replace("/(root)/confirm-ride")} - className="mt-6" - /> - - + + + ); } + if (error || !ride) { + return ( + + + {error ?? "Could not load this ride."} + + router.replace("/(root)/(tabs)/home")} + className="mt-6" + /> + + ); + } + + const driver = ride.driver; + const terminal = ride.status === "completed" || ride.status === "cancelled"; + return ( - - <> - - Ride Information + + + + + + + + {statusLabel[ride.status] ?? ride.status} - - Driver Avatar - - - - {driverDetails?.title} + {/* Searching state */} + {ride.status === "requested" ? ( + + + + We're matching you with the nearest {ride.service} driver. + + ) : null} - + {/* Driver card — shown once a driver is assigned. */} + {driver?.id ? ( + + Star + + + {driver.first_name} {driver.last_name} + + + + + {driver.rating?.toFixed(1) ?? "—"} + + {driver.car_model ? ( + + {driver.car_model} + + ) : null} + + + + {driver.service ?? ride.service} + + - - {driverDetails?.rating} + + + + {ride.origin_address} + + + + + + {ride.destination_address} + + + + + + {ride.payment_status === "cash" + ? "💵 Cash to driver" + : "💳 Paid by card"} + + + ${(ride.fare_price / 100).toFixed(2)} - + ) : null} - - - Ride Price + {/* Completed summary */} + {ride.status === "completed" ? ( + + + + Fare: ${(ride.fare_price / 100).toFixed(2)} + + + Trip time {formatTime(ride.ride_time)} + + + ) : null} - - - ${driverDetails?.price} + {/* Cancelled */} + {ride.status === "cancelled" ? ( + + + This ride was cancelled. + + + ) : null} + + + {terminal ? ( + router.replace("/(root)/(tabs)/home")} + /> + ) : ( + + + {cancelling ? "Cancelling…" : "Cancel Ride"} - - - ≈ {formatLBP(parseFloat(driverDetails?.price ?? "0"))} - - - - - - Pickup Time - - - {formatTime(driverDetails?.time!)} - - - - - Car Seats - - - {driverDetails?.car_seats} - - + + )} - - - - To - - - {userAddress} - - - - - Point - - - {destinationAddress} - - - - - - - + + ); }; -export default BookRide; +export default BookRide; \ No newline at end of file diff --git a/app/(root)/confirm-ride.tsx b/app/(root)/confirm-ride.tsx index b8c7793..c1ee2cf 100644 --- a/app/(root)/confirm-ride.tsx +++ b/app/(root)/confirm-ride.tsx @@ -1,44 +1,325 @@ -import { router } from "expo-router"; -import { FlatList, Text, View } from "react-native"; +import { router, useLocalSearchParams } from "expo-router"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from "react-native"; import { CustomButton } from "@/components/custom-button"; -import { DriverCard } from "@/components/driver-card"; import { RideLayout } from "@/components/ride-layout"; -import { useDriverStore } from "@/store"; +import { SERVICES } from "@/constants/services"; +import { ApiError, fetchAPI } from "@/lib/fetch"; +import { calculateTripFare } from "@/lib/map"; +import { formatLBP } from "@/lib/pricing"; +import { requestRide } from "@/lib/request-ride"; +import { useSession } from "@/lib/session"; +import { formatTime, haversine } from "@/lib/utils"; +import { useLocationStore, useServiceStore } from "@/store"; +type PaymentMethod = "cash" | "card"; + +type NearbyDriver = { + id: number; + first_name: string; + latitude: number; + longitude: number; +}; + +// Confirm-ride is now the request screen: the rider no longer browses and +// picks a driver. They see a single fare estimate + nearest-driver ETA, pick a +// payment method, and tap Request — auto-match assigns the driver and they're +// routed to the live status screen. const ConfirmRide = () => { - const { drivers, selectedDriver, setSelectedDriver } = useDriverStore(); + const params = useLocalSearchParams<{ service?: string }>(); + const { + userAddress, + userLatitude, + userLongitude, + destinationAddress, + destinationLatitude, + destinationLongitude, + } = useLocationStore(); + const { service: storeService, setService } = useServiceStore(); + const { user } = useSession(); + + const service = params.service ?? storeService; + const selected = SERVICES.find((s) => s.id === service) ?? SERVICES[0]; + + const [method, setMethod] = useState("cash"); + const [estimate, setEstimate] = useState<{ + fare: string; + durationSeconds: number; + } | null>(null); + const [nearestEta, setNearestEta] = useState(null); + const [driversOnline, setDriversOnline] = useState(null); + const [estimating, setEstimating] = useState(true); + const [processing, setProcessing] = useState(false); + + // Trip fare estimate — one Directions call for the trip leg, recomputed when + // the route or service changes. Independent of driver availability. + useEffect(() => { + if ( + !userLatitude || + !userLongitude || + !destinationLatitude || + !destinationLongitude + ) + return; + + let cancelled = false; + setEstimating(true); + + const run = async () => { + const trip = await calculateTripFare({ + userLatitude, + userLongitude, + destinationLatitude, + destinationLongitude, + service: selected.id, + }); + if (cancelled) return; + setEstimate( + trip + ? { fare: trip.fare, durationSeconds: trip.durationSeconds } + : null, + ); + }; + + void run().finally(() => { + if (!cancelled) setEstimating(false); + }); + + return () => { + cancelled = true; + }; + }, [ + userLatitude, + userLongitude, + destinationLatitude, + destinationLongitude, + selected.id, + ]); + + // Online-driver availability for the selected service, polled so the "no + // drivers" state self-heals the moment a driver of this service comes + // online. The nearest driver's pickup ETA is resolved alongside the count. + useEffect(() => { + if (!userLatitude || !userLongitude) return; + + let cancelled = false; + + const check = async () => { + try { + const res = await fetchAPI( + `/(api)/driver/nearby?service=${selected.id}&lat=${userLatitude}&lng=${userLongitude}`, + ); + const drivers = (res.data ?? []) as NearbyDriver[]; + if (cancelled) return; + setDriversOnline(drivers.length); + if (drivers.length === 0) { + setNearestEta(null); + return; + } + const nearest = drivers + .map((d) => ({ + d, + dist: haversine( + userLatitude, + userLongitude, + d.latitude, + d.longitude, + ), + })) + .sort((a, b) => a.dist - b.dist)[0].d; + + const directionsRes = await fetch( + `https://maps.googleapis.com/maps/api/directions/json?origin=${nearest.latitude},${nearest.longitude}&destination=${userLatitude},${userLongitude}&key=${process.env.EXPO_PUBLIC_GOOGLE_API_KEY}`, + ); + const data = await directionsRes.json(); + const leg = data.routes?.[0]?.legs?.[0]; + if (!cancelled) + setNearestEta(leg ? Math.round(leg.duration.value / 60) : null); + } catch { + if (!cancelled) { + setDriversOnline(null); + setNearestEta(null); + } + } + }; + + void check(); + const timer = setInterval(() => void check(), 10000); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [userLatitude, userLongitude, selected.id]); + + const request = async () => { + if (!userLatitude || !userLongitude || !destinationLatitude || !destinationLongitude) { + Alert.alert("Missing route", "Please set a pickup and destination first."); + return; + } + if (!estimate) { + Alert.alert("No estimate", "We couldn't estimate this fare. Please try again."); + return; + } + + // Nested so the guards above narrow userLatitude/estimate to non-null for + // the card-confirm callback as well as the direct cash path. + const doRequest = async () => { + setProcessing(true); + try { + // Keep the store in sync with whatever service we resolved for this ride. + setService(selected.id); + + const { ride } = await requestRide({ + method, + service: selected.id, + user: { name: user?.name ?? "", email: user?.email ?? "" }, + origin: { + address: userAddress ?? "", + latitude: userLatitude, + longitude: userLongitude, + }, + destination: { + address: destinationAddress ?? "", + latitude: destinationLatitude, + longitude: destinationLongitude, + }, + rideTimeSeconds: estimate.durationSeconds, + fareCents: Math.round(parseFloat(estimate.fare) * 100), + }); + + router.replace(`/(root)/book-ride?id=${ride.ride_id}`); + } catch (err) { + console.log("[REQUEST_RIDE]: ", err); + const msg = + err instanceof ApiError + ? err.message + : "Something went wrong while booking your ride. Please try again."; + Alert.alert("Error", msg); + } finally { + setProcessing(false); + } + }; + + if (method === "card") { + Alert.alert( + "Pay by card", + `Your card will be charged $${estimate.fare}.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Continue", onPress: () => void doRequest() }, + ], + ); + } else { + void doRequest(); + } + }; return ( - - ( - setSelectedDriver(item.id)} - /> - )} - ListEmptyComponent={ - - No drivers available on this route right now.{"\n"}Please try - another destination. + + Your trip + + + Pickup + + + {userAddress} + + + + Destination + + + {destinationAddress} + + + + + + {selected.label} · {selected.tagline} + + Trip time {estimate ? formatTime(estimate.durationSeconds / 60) : "…"} + + + + + {estimating ? "…" : estimate ? `$${estimate.fare}` : "—"} + + {estimate ? ( + + ≈ {formatLBP(parseFloat(estimate.fare))} + + ) : null} + + + + + {driversOnline === 0 + ? `No ${selected.label} drivers online right now` + : nearestEta == null + ? "Finding drivers nearby…" + : `Nearest driver ≈ ${nearestEta} min away`} + + + + Payment Method + + + 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" + }`} + > + + 💵 Cash + + + 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" + }`} + > + + 💳 Card + + + + + - router.push("/(root)/book-ride")} - disabled={selectedDriver === null} - className={selectedDriver === null ? "opacity-50" : ""} - /> - - } + className="mt-4" + onPress={request} + disabled={processing || estimating || !estimate || driversOnline === 0} /> ); }; -export default ConfirmRide; +export default ConfirmRide; \ No newline at end of file diff --git a/app/(root)/driver-home.tsx b/app/(root)/driver-home.tsx index 3ae3d18..91904f1 100644 --- a/app/(root)/driver-home.tsx +++ b/app/(root)/driver-home.tsx @@ -1,40 +1,549 @@ -import { Image, Text, View } from "react-native"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { router } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, + Alert, + Image, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { CustomButton } from "@/components/custom-button"; -import { images } from "@/constants"; +import { icons, images } from "@/constants"; +import { SERVICES, type ServiceId } from "@/constants/services"; +import { ApiError, fetchAPI } from "@/lib/fetch"; import { useSession } from "@/lib/session"; +import { useDriverLocation } from "@/lib/use-driver-location"; +import { formatTime } from "@/lib/utils"; + +// Poll cadence for the driver dashboard (offers / active ride / earnings). +const POLL_MS = 4000; + +type Profile = { + id: number; + first_name: string; + last_name: string; + profile_image_url: string | null; + car_image_url: string | null; + car_seats: number; + rating: number; + service: ServiceId; + online: boolean; + car_model: string | null; +}; + +type Offer = { + offer_id: number; + offered_at: string; + ride_id: number; + origin_address: string; + destination_address: string; + ride_time: number; + fare_price: number; + payment_status: string; + service: string; +}; + +type ActiveRide = { + ride_id: number; + status: string; + service: string; + payment_status: string; + origin_address: string; + destination_address: string; + ride_time: number; + fare_price: number; + rider_name: string | null; + rider_phone: string | null; +}; + +type Dashboard = { + offers: Offer[]; + active: ActiveRide | null; + recent: { ride_id: number; fare_price: number; service: string }[]; + earnings: number; +}; -// Placeholder driver home. The driver experience (going online, accepting -// rides) is not built yet — drivers are registered here and managed in the -// database for now. const DriverHome = () => { const { signOut, user } = useSession(); + const [loading, setLoading] = useState(true); + const [profile, setProfile] = useState(null); + const [online, setOnline] = useState(false); + const [dashboard, setDashboard] = useState(null); + const [busy, setBusy] = useState(false); + + const loadProfile = useCallback(async () => { + try { + const res = await fetchAPI("/(api)/driver/profile"); + const p = res.data as Profile; + setProfile(p); + setOnline(p.online); + } catch (err) { + // 403 with code ONBOARD means no profile yet — show the onboarding form. + if (err instanceof ApiError && err.status === 403) { + setProfile(null); + } else { + console.log("[DRIVER_PROFILE_LOAD]: ", err); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadProfile(); + }, [loadProfile]); + + // Keep the location heartbeat running only while the driver is online and + // has completed onboarding. + useDriverLocation(online && profile !== null); + + // Poll the dashboard while online. useCallback keeps the fetcher stable so the + // interval effect doesn't re-subscribe on every render. + const fetchDashboard = useCallback(async () => { + try { + const res = await fetchAPI("/(api)/driver/rides"); + setDashboard(res.data as Dashboard); + } catch (err) { + console.log("[DRIVER_DASHBOARD_POLL]: ", err); + } + }, []); + + useEffect(() => { + if (!online || !profile) return; + void fetchDashboard(); + const timer = setInterval(() => void fetchDashboard(), POLL_MS); + return () => clearInterval(timer); + }, [online, profile, fetchDashboard]); + + const toggleOnline = async () => { + if (!profile) return; + const next = !online; + setBusy(true); + try { + await fetchAPI("/(api)/driver/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ online: next }), + }); + setOnline(next); + setProfile({ ...profile, online: next }); + if (!next) setDashboard(null); + } catch (err) { + console.log("[DRIVER_TOGGLE]: ", err); + Alert.alert("Error", "Could not change your status. Please try again."); + } finally { + setBusy(false); + } + }; + + const respond = async (offer: Offer, action: "accept" | "decline") => { + setBusy(true); + try { + await fetchAPI(`/(api)/ride/${offer.ride_id}/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + await fetchDashboard(); + } catch (err) { + console.log("[DRIVER_RESPOND]: ", err); + Alert.alert( + "Error", + action === "accept" + ? "Could not accept this ride. It may have been taken or expired." + : "Could not decline this ride. Please try again.", + ); + } finally { + setBusy(false); + } + }; + + const advance = async (rideId: number, status: "en_route" | "completed") => { + setBusy(true); + try { + await fetchAPI(`/(api)/ride/${rideId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + await fetchDashboard(); + } catch (err) { + console.log("[DRIVER_ADVANCE]: ", err); + Alert.alert("Error", "Could not update the ride. Please try again."); + } finally { + setBusy(false); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (!profile) { + return ( + + ); + } + + const earnings = dashboard?.earnings ?? 0; + const rideCount = dashboard?.recent.length ?? 0; return ( - - Registered + + + + + Driver mode + + + Sign out + + - - You're registered as a driver, {user?.name || "there"}! - + {/* Online / offline toggle */} + + + {online ? "● Online — receiving ride requests" : "○ Go online to drive"} + + - - Driver mode is coming soon. We'll contact you at{" "} - {user?.email} once your account is activated. - + {/* Earnings summary */} + + + + Today's earnings + + + ${(earnings / 100).toFixed(2)} + + + + + Completed today + + {rideCount} + + - signOut()} - className="mt-10" - /> + {/* Active ride */} + {dashboard?.active ? ( + + ) : null} + + {/* Incoming offers */} + + Incoming requests {online ? "" : "(offline)"} + + + {!online ? null : dashboard?.offers.length ? ( + dashboard.offers.map((offer) => ( + respond(offer, "accept")} + onDecline={() => respond(offer, "decline")} + /> + )) + ) : ( + + + + {online ? "Waiting for ride requests…" : "Go online to start driving."} + + + )} + ); }; -export default DriverHome; +// --- Onboarding form ------------------------------------------------------ + +const Onboarding = ({ + onCreated, + signOut, + userName, +}: { + onCreated: () => Promise; + signOut: () => Promise; + userName?: string | null; +}) => { + const [service, setService] = useState("car"); + const [carModel, setCarModel] = useState(""); + const [carSeats, setCarSeats] = useState("4"); + const [submitting, setSubmitting] = useState(false); + + const submit = async () => { + const seats = Number(carSeats); + if (!Number.isInteger(seats) || seats < 1 || seats > 8) { + Alert.alert("Invalid seats", "Car seats must be a whole number 1–8."); + return; + } + setSubmitting(true); + try { + await fetchAPI("/(api)/driver/profile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + service, + car_model: carModel.trim() || null, + car_seats: seats, + }), + }); + await onCreated(); + } catch (err) { + console.log("[DRIVER_ONBOARD]: ", err); + Alert.alert("Error", "Could not create your driver profile. Please try again."); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + Welcome, {userName?.split(" ")[0] || "driver"} + + + Sign out + + + + + Set up your driver profile to start receiving ride requests. + + + + What will you drive? + + + {SERVICES.map((item) => { + const active = item.id === service; + return ( + setService(item.id)} + className={`flex-1 items-center rounded-2xl border py-3 ${ + active + ? "border-primary-500 bg-primary-500/10" + : "border-neutral-100 bg-neutral-100" + }`} + > + + + {item.label} + + + ); + })} + + + Car model + + + Car seats + + + + + + ); +}; + +// --- Offer card ----------------------------------------------------------- + +const OfferCard = ({ + offer, + busy, + onAccept, + onDecline, +}: { + offer: Offer; + busy: boolean; + onAccept: () => void; + onDecline: () => void; +}) => ( + + + + New request · {offer.service} + + + {offer.payment_status === "cash" ? "💵 Cash" : "💳 Card"} + + + + + From + + {offer.origin_address} + + + + To + + {offer.destination_address} + + + + + Trip time + + {formatTime(offer.ride_time)} + + + + Fare + + ${(offer.fare_price / 100).toFixed(2)} + + + + + + Decline + + + + {busy ? "…" : "Accept"} + + + + +); + +// --- Active ride card ----------------------------------------------------- + +const ActiveRideCard = ({ + ride, + busy, + onAdvance, +}: { + ride: ActiveRide; + busy: boolean; + onAdvance: (rideId: number, status: "en_route" | "completed") => void; +}) => { + const statusLabel = + ride.status === "accepted" + ? "Head to pickup" + : ride.status === "en_route" + ? "Trip in progress" + : ride.status; + + return ( + + + + ● {statusLabel} + + {ride.service} + + + {ride.rider_name ? ( + {ride.rider_name} + ) : null} + + + From + + {ride.origin_address} + + + + To + + {ride.destination_address} + + + + + Fare + + ${(ride.fare_price / 100).toFixed(2)} + + + + {ride.status === "accepted" ? ( + onAdvance(ride.ride_id, "en_route")} + className="mb-2" + /> + ) : null} + {ride.status === "en_route" ? ( + onAdvance(ride.ride_id, "completed")} + /> + ) : null} + + ); +}; + +export default DriverHome; \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index 15a957f..e062411 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -3,7 +3,6 @@ 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 { SessionProvider } from "@/lib/session"; @@ -11,8 +10,6 @@ import { SessionProvider } from "@/lib/session"; // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); -LogBox.ignoreAllLogs(); - const RootLayout = () => { const [loaded] = useFonts({ "Jakarta-Bold": require("../assets/fonts/PlusJakartaSans-Bold.ttf"), diff --git a/components/map.tsx b/components/map.tsx index 3ddc509..0b71562 100644 --- a/components/map.tsx +++ b/components/map.tsx @@ -10,7 +10,7 @@ import { calculateRegion, generateMarkersFromData, } from "@/lib/map"; -import { useDriverStore, useLocationStore } from "@/store"; +import { useDriverStore, useLocationStore, useServiceStore } from "@/store"; import type { Driver, MarkerData } from "@/types/type"; // react-native-maps sizes itself from a real style object, so give it explicit @@ -40,15 +40,24 @@ const MUTED_POI_STYLE = [ ]; export const Map = () => { - const { data: drivers, error } = useFetch("/(api)/driver"); - const { userLatitude, userLongitude, destinationLatitude, destinationLongitude, } = useLocationStore(); + const { service } = useServiceStore(); const { selectedDriver, setDrivers } = useDriverStore(); + + // 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. + const lat = userLatitude ?? 33.8938; + const lng = userLongitude ?? 35.5018; + const { data: drivers, error } = useFetch( + `/(api)/driver/nearby?service=${service}&lat=${lat}&lng=${lng}`, + ); + const [markers, setMarkers] = useState([]); const region = calculateRegion({ @@ -81,8 +90,9 @@ export const Map = () => { userLongitude, destinationLatitude, destinationLongitude, - }).then((drivers) => { - setDrivers(drivers as MarkerData[]); + service, + }).then((driversWithTimes) => { + setDrivers((driversWithTimes as MarkerData[]) ?? []); }); } }, [ @@ -92,6 +102,7 @@ export const Map = () => { userLatitude, userLongitude, setDrivers, + service, ]); // The map itself never waits on the driver list or the location fix: drivers diff --git a/components/nearby-suggestions.tsx b/components/nearby-suggestions.tsx new file mode 100644 index 0000000..56018a2 --- /dev/null +++ b/components/nearby-suggestions.tsx @@ -0,0 +1,120 @@ +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 { 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 [chips, setChips] = useState>({}); + + 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 ( + + + Nearby suggestions + + + + {POI_CATEGORIES.map((category) => { + const state = chips[category.id]; + const ready = state?.status === "ready" ? state.place : null; + + return ( + 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" + }`} + style={{ minWidth: 150 }} + > + + + + + {category.label} + + + {!state || state.status === "loading" + ? "searching…" + : state.status === "empty" + ? "none nearby" + : state.place.distanceMeters != null + ? `${Math.round(state.place.distanceMeters / 100) / 10} km away` + : state.place.name} + + + + ); + })} + + + ); +}; \ No newline at end of file diff --git a/components/payment.tsx b/components/payment.tsx index 4cd2099..8063da3 100644 --- a/components/payment.tsx +++ b/components/payment.tsx @@ -5,7 +5,7 @@ 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 { formatLBP } from "@/lib/pricing"; import { useLocationStore } from "@/store"; import type { PaymentProps } from "@/types/type"; @@ -33,7 +33,9 @@ export const Payment = ({ const [success, setSuccess] = useState(false); const [processing, setProcessing] = useState(false); - 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 +49,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, }), }); @@ -75,8 +78,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 +91,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,17 +120,21 @@ 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( @@ -127,10 +144,20 @@ export const Payment = ({ } } catch (err) { console.log("[PAYMENT]: ", err); - Alert.alert( - "Error", - "Something went wrong while processing your payment. Please try again.", - ); + // 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( + "Payment not completed", + "Your payment was cancelled or could not be verified. Please try again.", + ); + } else { + Alert.alert( + "Error", + "Something went wrong while processing your payment. Please try again.", + ); + } } finally { setProcessing(false); } @@ -230,4 +257,4 @@ export const Payment = ({ ); -}; +}; \ No newline at end of file diff --git a/constants/services.ts b/constants/services.ts index c1c6e01..7dbfe95 100644 --- a/constants/services.ts +++ b/constants/services.ts @@ -19,6 +19,8 @@ export type Service = { label: string; /** Shown under the row once the service is selected. */ tagline: string; + /** Multiplier applied to the base fare for this service (car = 1.0). */ + fareMultiplier: number; }; export const SERVICES: Service[] = [ @@ -27,24 +29,28 @@ export const SERVICES: Service[] = [ icon: "car", label: "Car", tagline: "An everyday ride, up to 4 seats.", + fareMultiplier: 1.0, }, { id: "moto", icon: "motorbike", label: "Moto", tagline: "Beat the traffic — one passenger, no luggage.", + fareMultiplier: 0.7, }, { id: "courier", icon: "package-variant-closed", label: "Courier", tagline: "Send a parcel across town without riding along.", + fareMultiplier: 0.85, }, { id: "chauffeur", icon: "steering", label: "My Car", tagline: "A driver comes to you and drives your own car.", + fareMultiplier: 1.5, }, ]; diff --git a/dashboard/src/pages/Drivers.tsx b/dashboard/src/pages/Drivers.tsx index 3468ee6..803167b 100644 --- a/dashboard/src/pages/Drivers.tsx +++ b/dashboard/src/pages/Drivers.tsx @@ -9,6 +9,9 @@ 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; }; @@ -63,8 +66,10 @@ export default function Drivers() { ID Name + Service Seats Rating + Online Rides Revenue @@ -77,8 +82,12 @@ export default function Drivers() { {d.first_name} {d.last_name} + + {d.service} + {d.car_seats} {d.rating} + {d.online ? "● online" : "○ off"} {d.total_rides} {d.revenue.toLocaleString()} diff --git a/lib/dispatch.ts b/lib/dispatch.ts new file mode 100644 index 0000000..bf85ea9 --- /dev/null +++ b/lib/dispatch.ts @@ -0,0 +1,107 @@ +// Uber-style auto-match dispatch. A requested ride has no driver; this engine +// offers it to the nearest eligible driver of the matching service. Drivers +// accept/decline; a decline (or a 15s offer expiry) triggers the next-nearest +// match. There is no background worker — matchNextDriver is called lazily from +// the rider status poll and the driver poll, so matching progresses on every +// request cycle. + +import { transaction } from "@/lib/db"; +import { haversine } from "@/lib/utils"; + +// A driver has this long to respond to an offer before it expires and the next +// driver is offered. Tuned short so a rider searching for a driver isn't left +// hanging on a phone that's face-down on a seat. +const OFFER_TTL_SECONDS = 15; +// A driver whose last location ping is older than this is treated as offline +// even if their `online` flag is still true (they closed the app without +// toggling off). +const DRIVER_STALE_SECONDS = 60; + +type EligibleDriver = { + id: number; + latitude: number; + longitude: number; +}; + +// Offer `rideId` to the nearest eligible driver, if no offer is already in +// flight for it. Idempotent: safe to call on every poll. Returns the driver id +// that was offered, or null if no driver was available. +export const matchNextDriver = async ( + rideId: number, +): Promise => { + try { + return await transaction(async (tx) => { + // Lock the ride row so concurrent matchers serialize on it. + const rides = await tx<{ status: string; service: string }>` + SELECT status, service FROM rides WHERE ride_id = ${rideId} FOR UPDATE + `; + const ride = rides[0]; + if (!ride || ride.status !== "requested") return null; + + // Expire any offers that have been sitting past their TTL. + await tx` + UPDATE ride_offers + SET status = 'expired', responded_at = CURRENT_TIMESTAMP + WHERE ride_id = ${rideId} + AND status = 'offered' + AND offered_at < CURRENT_TIMESTAMP - make_interval(secs => ${OFFER_TTL_SECONDS}) + `; + + // If there is still an active (unexpired) offer in flight, leave it — + // don't stack a second offer on top. + const inFlight = await tx<{ n: number }>` + SELECT COUNT(*)::int AS n FROM ride_offers + WHERE ride_id = ${rideId} AND status = 'offered' + `; + if ((inFlight[0]?.n ?? 0) > 0) return null; + + const rideOrigin = await tx<{ lat: number; lng: number }>` + SELECT origin_latitude AS lat, origin_longitude AS lng + FROM rides WHERE ride_id = ${rideId} + `; + const origin = rideOrigin[0]; + if (!origin) return null; + + // Eligible: right service, online, fresh, a real account, not on an + // active ride, and not already offered/declined for THIS ride. + const candidates = await tx` + SELECT d.id, d.latitude, d.longitude + FROM drivers d + WHERE d.service = ${ride.service} + AND d.online = TRUE + AND d.user_id IS NOT NULL + AND d.latitude IS NOT NULL + AND d.longitude IS NOT NULL + AND d.last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS}) + AND NOT EXISTS ( + SELECT 1 FROM rides r + WHERE r.driver_id = d.id AND r.status IN ('accepted', 'en_route') + ) + AND NOT EXISTS ( + SELECT 1 FROM ride_offers ro + WHERE ro.ride_id = ${rideId} AND ro.driver_id = d.id + ) + `; + + if (candidates.length === 0) return null; + + // Nearest by great-circle distance to the pickup point. + candidates.sort((a, b) => { + const da = haversine(origin.lat, origin.lng, a.latitude, a.longitude); + const db = haversine(origin.lat, origin.lng, b.latitude, b.longitude); + return da - db; + }); + const nearest = candidates[0]; + + await tx` + INSERT INTO ride_offers (ride_id, driver_id, status) + VALUES (${rideId}, ${nearest.id}, 'offered') + `; + + return nearest.id; + }); + } catch (error) { + console.error("[MATCH_NEXT_DRIVER]: ", error); + return null; + } +}; \ No newline at end of file diff --git a/lib/driver.ts b/lib/driver.ts new file mode 100644 index 0000000..a0f71c4 --- /dev/null +++ b/lib/driver.ts @@ -0,0 +1,50 @@ +// Driver-side auth helper. Every driver-action endpoint first calls +// requireDriverProfile: it proves the request is from a signed-in user and +// that the user has completed onboarding (has a linked drivers row). A +// driver-role user who hasn't onboarded yet gets a 403 so the client can +// route them to the onboarding form rather than showing a bare 404. + +import { requireAuth } from "@/lib/jwt"; +import { sql } from "@/lib/db"; +import type { ServiceId } from "@/constants/services"; + +type Auth = { userId: string; email: string }; + +export type DriverProfile = { + auth: Auth; + driverId: number; + service: ServiceId; + online: boolean; +}; + +export type AuthError = { error: Response }; + +const VALID_SERVICES = ["car", "moto", "courier", "chauffeur"] as const; +export const isServiceId = (v: unknown): v is ServiceId => + typeof v === "string" && (VALID_SERVICES as readonly string[]).includes(v); + +// Returns the driver profile for the authenticated user, or a 401/403 the +// caller can return directly. A 403 with the onboarding code tells the client +// to show the onboarding form instead of treating it as a hard error. +export const requireDriverProfile = async ( + req: Request, +): Promise => { + const auth = requireAuth(req); + if ("error" in auth) return { error: auth.error }; + + const rows = await sql<{ id: number; service: ServiceId; online: boolean }>` + SELECT id, service, online FROM drivers WHERE user_id = ${auth.userId} + `; + + if (!rows[0]) { + return { + error: Response.json( + { error: "No driver profile — complete onboarding.", code: "ONBOARD" }, + { status: 403 }, + ), + }; + } + + const { id, service, online } = rows[0]; + return { auth, driverId: id, service, online }; +}; \ No newline at end of file diff --git a/lib/mailer.ts b/lib/mailer.ts index 5222876..c9d03c8 100644 --- a/lib/mailer.ts +++ b/lib/mailer.ts @@ -15,6 +15,11 @@ const getPassword = (): string | undefined => export const isMailConfigured = (): boolean => Boolean(process.env.SMTP_USER && getPassword()); +// Whether the OTP code may be surfaced outside email (response body or server +// stdout) for self-hosted development. Never in production. +export const isDevOtpExposed = (): boolean => + process.env.NODE_ENV !== "production"; + let transporter: nodemailer.Transporter | null = null; const getTransporter = (): nodemailer.Transporter => { @@ -47,7 +52,10 @@ export const sendEmail = async ( ): Promise => { if (!isMailConfigured()) { // Not configured: fall back to the server log so development still works. - console.log(`[MAIL to=${to}] ${subject}\n${text}`); + // In production never log the code to stdout; just report not sent. + if (isDevOtpExposed()) { + console.log(`[MAIL to=${to}] ${subject}\n${text}`); + } return false; } @@ -60,7 +68,9 @@ export const sendEmail = async ( // Delivery is best-effort: report the failure and let the caller surface // the code another way instead of failing the whole request. console.error(`[MAIL to=${to}] send failed:`, error); - console.log(`[MAIL to=${to}] ${subject}\n${text}`); + if (isDevOtpExposed()) { + console.log(`[MAIL to=${to}] ${subject}\n${text}`); + } return false; } }; diff --git a/lib/map.ts b/lib/map.ts index 278e5db..6d564dd 100644 --- a/lib/map.ts +++ b/lib/map.ts @@ -1,8 +1,12 @@ import { calculateFare } from "@/lib/pricing"; +import { DEFAULT_SERVICE, type ServiceId } from "@/constants/services"; import type { Driver, MarkerData } from "@/types/type"; const directionsAPI = process.env.EXPO_PUBLIC_GOOGLE_API_KEY; +// Build map markers from driver rows. Drivers with a real GPS position use it +// directly; only legacy seed rows (no position) fall back to a small random +// scatter around the rider so the map isn't empty during local dev. export const generateMarkersFromData = ({ data, userLatitude, @@ -12,18 +16,25 @@ export const generateMarkersFromData = ({ userLatitude: number; userLongitude: number; }): MarkerData[] => { - return data.map((driver, i) => { - const latOffset = (Math.random() - 0.5) * 0.01; // Random offset between -0.005 and 0.005 - const lngOffset = (Math.random() - 0.5) * 0.01; // Random offset between -0.005 and 0.005 + return data + .filter((driver) => driver.latitude != null && driver.longitude != null) + .map((driver) => { + const lat = + driver.latitude != null + ? driver.latitude + : userLatitude + (Math.random() - 0.5) * 0.01; + const lng = + driver.longitude != null + ? driver.longitude + : userLongitude + (Math.random() - 0.5) * 0.01; - return { - id: i, - latitude: userLatitude + latOffset, - longitude: userLongitude + lngOffset, - title: `${driver.first_name} ${driver.last_name}`, - ...driver, - }; - }); + return { + ...driver, + latitude: lat, + longitude: lng, + title: `${driver.first_name} ${driver.last_name}`, + }; + }); }; export const calculateRegion = ({ @@ -75,18 +86,23 @@ export const calculateRegion = ({ }; }; +// Per-driver ETA + fare. The rider pays for the trip leg only (distance + +// duration) — never the driver's approach leg. `service` drives the fare +// multiplier. export const calculateDriverTimes = async ({ markers, userLatitude, userLongitude, destinationLatitude, destinationLongitude, + service = DEFAULT_SERVICE, }: { markers: MarkerData[]; userLatitude: number | null; userLongitude: number | null; destinationLatitude: number | null; destinationLongitude: number | null; + service?: ServiceId; }) => { if ( !userLatitude || @@ -120,10 +136,13 @@ export const calculateDriverTimes = async ({ // The rider pays for the trip leg only (distance + duration) — // never for the driver's approach. - const price = calculateFare({ - distanceMeters: legToDestination.distance.value, - durationSeconds: timeToDestination, - }); + const price = calculateFare( + { + distanceMeters: legToDestination.distance.value, + durationSeconds: timeToDestination, + }, + service, + ); const totalTripTime = (timeToUser + timeToDestination) / 60; // Minutes until drop-off @@ -135,3 +154,57 @@ export const calculateDriverTimes = async ({ console.error("Error calculating driver times:", error); } }; + +// A single trip-leg fare estimate for the confirm-ride screen. One Directions +// call instead of one per driver, since the trip leg is the same regardless of +// which driver arrives. Returns { fare, durationSeconds, distanceMeters } or +// null when the route is unreachable. +export const calculateTripFare = async ({ + userLatitude, + userLongitude, + destinationLatitude, + destinationLongitude, + service = DEFAULT_SERVICE, +}: { + userLatitude: number | null; + userLongitude: number | null; + destinationLatitude: number | null; + destinationLongitude: number | null; + service?: ServiceId; +}): Promise<{ + fare: string; + durationSeconds: number; + distanceMeters: number; +} | null> => { + if ( + !userLatitude || + !userLongitude || + !destinationLatitude || + !destinationLongitude + ) + return null; + + try { + const response = await fetch( + `https://maps.googleapis.com/maps/api/directions/json?origin=${userLatitude},${userLongitude}&destination=${destinationLatitude},${destinationLongitude}&key=${directionsAPI}`, + ); + const data = await response.json(); + const leg = data.routes?.[0]?.legs?.[0]; + if (!leg) return null; + + return { + fare: calculateFare( + { + distanceMeters: leg.distance.value, + durationSeconds: leg.duration.value, + }, + service, + ), + durationSeconds: leg.duration.value, + distanceMeters: leg.distance.value, + }; + } catch (error) { + console.error("Error calculating trip fare:", error); + return null; + } +}; \ No newline at end of file diff --git a/lib/otp.ts b/lib/otp.ts index 6627342..81e1d7e 100644 --- a/lib/otp.ts +++ b/lib/otp.ts @@ -1,8 +1,9 @@ // Shared helpers for the 6-digit email codes used by sign-up verification and -// password reset. Both flows store a salted hash keyed by email, so the code -// itself only ever lives in the outgoing mail. +// password reset. Both flows store a peppered HMAC-SHA256 hash keyed by email, +// so the code itself only ever lives in the outgoing mail. The HMAC uses +// AUTH_JWT_SECRET as a pepper: a DB dump alone cannot recover codes without it. -import { createHash, randomInt, timingSafeEqual } from "crypto"; +import { createHmac, randomInt, timingSafeEqual } from "crypto"; export const CODE_TTL_MINUTES = 15; @@ -13,8 +14,18 @@ export const MAX_CODE_ATTEMPTS = 5; export const generateCode = (): string => String(randomInt(0, 1_000_000)).padStart(6, "0"); +// Reuse the existing required secret as a pepper. No new env var and no +// schema change (the codes table has no salt column). +const pepper = (): string => { + const value = process.env.AUTH_JWT_SECRET; + if (!value) throw new Error("Missing AUTH_JWT_SECRET."); + return value; +}; + export const hashCode = (email: string, code: string): string => - createHash("sha256").update(`${email}:${code}`).digest("hex"); + createHmac("sha256", pepper()) + .update(`waseel-otp:${email}:${code}`) + .digest("hex"); export const codeMatches = ( storedHash: string, diff --git a/lib/payment-orders.ts b/lib/payment-orders.ts new file mode 100644 index 0000000..5c8d2fc --- /dev/null +++ b/lib/payment-orders.ts @@ -0,0 +1,127 @@ +// Server-authoritative payment order records. The client may never set +// payment_status or the Areeba successIndicator; both are stored here and +// verified against the gateway before an order can pay for a ride. +// +// A paid order can only be consumed once: consumeOrderForRide atomically +// flips status 'paid' -> 'consumed', so a single card payment can never buy +// two rides. + +import type { QueryResultRow } from "pg"; + +import { sql, type SqlValue } from "@/lib/db"; + +// A tagged-template runner — either the pool-level `sql` helper or the `tx` +// passed inside a transaction() callback. consumeOrderForRide accepts one so +// the consume + ride insert can run on a single connection. +type Runner = ( + strings: TemplateStringsArray, + ...values: SqlValue[] +) => Promise; + +export type PaymentOrder = { + order_id: string; + user_id: string; + amount_cents: number; + currency: string; + driver_id: number | null; + origin_address: string | null; + destination_address: string | null; + origin_latitude: number | null; + origin_longitude: number | null; + destination_latitude: number | null; + destination_longitude: number | null; + ride_time: number | null; + success_indicator: string | null; + status: string; + created_at: Date; + paid_at: Date | null; +}; + +export type NewOrder = { + order_id: string; + user_id: string; + amount_cents: number; + currency: string; + driver_id?: number | null; + origin_address?: string | null; + destination_address?: string | null; + origin_latitude?: number | null; + origin_longitude?: number | null; + destination_latitude?: number | null; + destination_longitude?: number | null; + ride_time?: number | null; + success_indicator: string | null; + status?: string; +}; + +export const createOrder = async (order: NewOrder): Promise => { + const rows = await sql` + INSERT INTO payment_orders ( + order_id, user_id, amount_cents, currency, driver_id, + origin_address, destination_address, + origin_latitude, origin_longitude, + destination_latitude, destination_longitude, + ride_time, success_indicator, status + ) VALUES ( + ${order.order_id}, + ${order.user_id}, + ${order.amount_cents}, + ${order.currency}, + ${order.driver_id ?? null}, + ${order.origin_address ?? null}, + ${order.destination_address ?? null}, + ${order.origin_latitude ?? null}, + ${order.origin_longitude ?? null}, + ${order.destination_latitude ?? null}, + ${order.destination_longitude ?? null}, + ${order.ride_time ?? null}, + ${order.success_indicator}, + ${order.status ?? "pending"} + ) + RETURNING * + `; + return rows[0]; +}; + +export const getOrder = async (orderId: string): Promise => { + const rows = await sql` + SELECT * FROM payment_orders WHERE order_id = ${orderId} + `; + return rows[0] ?? null; +}; + +// Mark an order paid after the gateway confirms capture. The status='pending' +// guard means an already-paid or consumed order can never be flipped back to +// 'paid' — this is what prevents a single payment from being resurrected to +// buy multiple rides (double-spend). verify+api.ts also rejects non-pending +// orders, so this is defense-in-depth. +export const markPaid = async (orderId: string): Promise => { + const rows = await sql` + UPDATE payment_orders + SET status = 'paid', paid_at = CURRENT_TIMESTAMP + WHERE order_id = ${orderId} + AND status = 'pending' + RETURNING * + `; + return rows[0] ?? null; +}; + +// Atomically consume a paid order for a ride. The WHERE status='paid' guard +// means a paid order can only be used once; a second attempt gets no row. +// Pass the transaction `tx` runner so this can run inside ride/create's +// transaction together with the ride insert. +export const consumeOrderForRide = async ( + orderId: string, + userId: string, + runner: Runner = sql, +): Promise => { + const rows = await runner` + UPDATE payment_orders + SET status = 'consumed' + WHERE order_id = ${orderId} + AND user_id = ${userId} + AND status = 'paid' + RETURNING * + `; + return rows[0] ?? null; +}; \ No newline at end of file diff --git a/lib/places.ts b/lib/places.ts new file mode 100644 index 0000000..a0d42e5 --- /dev/null +++ b/lib/places.ts @@ -0,0 +1,86 @@ +// Google Places (New) Nearby Search — powers the "nearby mall / hospital / +// pharmacy / restaurant" destination chips on the home screen. Reuses the same +// API key and header pattern as the autocomplete in components/google-text-input. + +import { haversine } from "@/lib/utils"; +import type { NearbyPlace } from "@/types/type"; + +const googleApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY!; + +// The four POI categories surfaced as quick destination chips. Each maps to a +// Google Places (New) `includedTypes` value. +export type PoiCategory = { + id: "mall" | "hospital" | "pharmacy" | "restaurant"; + label: string; + /** MaterialCommunityIcons glyph name. */ + icon: string; + googleType: string; +}; + +export const POI_CATEGORIES: PoiCategory[] = [ + { id: "mall", label: "Mall", icon: "shopping-mall", googleType: "shopping_mall" }, + { id: "hospital", label: "Hospital", icon: "hospital", googleType: "hospital" }, + { id: "pharmacy", label: "Pharmacy", icon: "pill", googleType: "pharmacy" }, + { id: "restaurant", label: "Restaurant", icon: "silverware-fork-knife", googleType: "restaurant" }, +]; + +const DEFAULT_RADIUS_M = 4000; + +// Searches for the nearest place of `googleType` around (latitude, longitude) +// and returns it as a NearbyPlace with its distance from the rider. Returns +// null when no place of that type is found nearby — the chip then shows an +// empty state rather than a broken one. +export const searchNearby = async ( + googleType: string, + { + latitude, + longitude, + radiusM = DEFAULT_RADIUS_M, + }: { latitude: number; longitude: number; radiusM?: number }, +): Promise => { + try { + const res = await fetch( + "https://places.googleapis.com/v1/places:searchNearby", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": googleApiKey, + "X-Goog-FieldMask": + "places.displayName,places.formattedAddress,places.location,places.id", + }, + body: JSON.stringify({ + includedTypes: [googleType], + languageCode: "en", + regionCode: "lb", + locationRestriction: { + circle: { + center: { latitude, longitude }, + radius: radiusM, + }, + }, + }), + }, + ); + const data = await res.json(); + const place = data.places?.[0]; + if (!place) return null; + + const lat = place.location?.latitude as number; + const lng = place.location?.longitude as number; + + return { + name: (place.displayName?.text as string) ?? "Nearby place", + address: (place.formattedAddress as string) ?? "", + latitude: lat, + longitude: lng, + distanceMeters: + Number.isFinite(lat) && Number.isFinite(lng) + ? haversine(latitude, longitude, lat, lng) + : undefined, + }; + } catch (error) { + console.log("[PLACES_NEARBY]: ", error); + return null; + } +}; \ No newline at end of file diff --git a/lib/pricing.ts b/lib/pricing.ts index bcaeaf8..c10c07e 100644 --- a/lib/pricing.ts +++ b/lib/pricing.ts @@ -4,6 +4,8 @@ // - Prices are quoted in USD (the de facto ride-hailing currency) with an // L.B.P. equivalent shown for cash settlement. +import { DEFAULT_SERVICE, SERVICES, type ServiceId } from "@/constants/services"; + export const FARE = { base: 1.5, // USD, flag drop perKm: 0.55, // USD per kilometer of the trip @@ -14,18 +16,24 @@ export const FARE = { // Parallel market rate used for the L.B.P. cash equivalent shown in-app. export const LBP_RATE = 89500; -export const calculateFare = ({ - distanceMeters, - durationSeconds, -}: { - distanceMeters: number; - durationSeconds: number; -}): string => { +export const calculateFare = ( + { + distanceMeters, + durationSeconds, + }: { + distanceMeters: number; + durationSeconds: number; + }, + service: ServiceId = DEFAULT_SERVICE, +): string => { const km = distanceMeters / 1000; const minutes = durationSeconds / 60; - const fare = FARE.base + km * FARE.perKm + minutes * FARE.perMin; + const multiplier = + SERVICES.find((s) => s.id === service)?.fareMultiplier ?? 1; + const fare = (FARE.base + km * FARE.perKm + minutes * FARE.perMin) * multiplier; + // The minimum fare is a floor on the final amount. return Math.max(fare, FARE.minimum).toFixed(2); }; diff --git a/lib/request-ride.ts b/lib/request-ride.ts new file mode 100644 index 0000000..cefd5ba --- /dev/null +++ b/lib/request-ride.ts @@ -0,0 +1,112 @@ +import * as WebBrowser from "expo-web-browser"; + +import { ApiError, fetchAPI } from "@/lib/fetch"; +import type { ServiceId } from "@/constants/services"; +import type { Ride } from "@/types/type"; + +// The rider's request flow, used by the confirm-ride screen. This is the card +// (Areeba hosted checkout -> server verify -> consume order -> create ride) +// and cash (create ride directly) paths, now unified behind one entry point so +// the screen doesn't re-implement the gateway dance. +// +// The ride is always created with status='requested' and driver_id=null; the +// server's auto-match engine assigns a driver asynchronously. Returns the +// created ride so the caller can navigate to the status screen. + +export type RequestInput = { + method: "cash" | "card"; + service: ServiceId; + user: { name: string; email: string }; + // Location snapshot at request time. + origin: { address: string; latitude: number; longitude: number }; + destination: { address: string; latitude: number; longitude: number }; + rideTimeSeconds: number; + fareCents: number; +}; + +export type RequestResult = { ride: Ride }; + +const recordRide = async ( + input: RequestInput, + method: "cash" | "card", + orderId?: string, +): Promise => { + const res = await fetchAPI("/(api)/ride/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + origin_address: input.origin.address, + destination_address: input.destination.address, + origin_latitude: input.origin.latitude, + origin_longitude: input.origin.longitude, + destination_latitude: input.destination.latitude, + destination_longitude: input.destination.longitude, + ride_time: Math.round(input.rideTimeSeconds), + fare_price: input.fareCents, + payment_method: method, + service: input.service, + ...(orderId ? { payment_order_id: orderId } : {}), + }), + }); + return res.data as Ride; +}; + +export const requestRide = async (input: RequestInput): Promise => { + if (input.method === "cash") { + const ride = await recordRide(input, "cash"); + return { ride }; + } + + // Card: create an Areeba checkout session on our server. + const { orderId, checkoutUrl, error } = await fetchAPI( + "/(api)/(areeba)/create", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: input.user.name || input.user.email, + email: input.user.email, + fare_cents: input.fareCents, + origin_address: input.origin.address, + destination_address: input.destination.address, + origin_latitude: input.origin.latitude, + origin_longitude: input.origin.longitude, + destination_latitude: input.destination.latitude, + destination_longitude: input.destination.longitude, + ride_time: Math.round(input.rideTimeSeconds), + }), + }, + ); + if (error || !checkoutUrl) throw new Error(error || "No checkout URL"); + + // Open Areeba's hosted payment page. After payment the gateway redirects + // back to the app (waseel://book-ride). + const browserResult = await WebBrowser.openAuthSessionAsync( + checkoutUrl, + "waseel://book-ride", + ); + + let resultIndicator: string | undefined; + if (browserResult.type === "success" && browserResult.url) { + resultIndicator = new URL(browserResult.url).searchParams.get( + "resultIndicator", + ) ?? undefined; + } + + // Verify the payment server-side. + const verification = await fetchAPI("/(api)/(areeba)/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ orderId, resultIndicator }), + }); + + if (!verification.success) { + throw new ApiError( + 400, + "Your payment was cancelled or could not be verified. Please try again.", + ); + } + + const ride = await recordRide(input, "card", orderId); + return { ride }; +}; \ No newline at end of file diff --git a/lib/use-driver-location.ts b/lib/use-driver-location.ts new file mode 100644 index 0000000..f9c0c1b --- /dev/null +++ b/lib/use-driver-location.ts @@ -0,0 +1,105 @@ +import * as Location from "expo-location"; +import { AppState } from "react-native"; +import { useEffect, useRef } from "react"; + +import { fetchAPI } from "@/lib/fetch"; + +// While the driver is online, watch their position and POST it to the server +// as a heartbeat. Each ping both updates the driver's lat/lng and refreshes +// last_seen/online, which is what keeps the driver eligible for matching. The +// watch is started when `online` flips true and torn down on false/unmount. +// +// The watch must also restart when the app returns to the foreground: the OS +// suspends location updates in the background, and the subscription we hold +// does not auto-revive. Without this, a driver who briefly backgrounds the app +// goes permanently stale (last_seen older than the dispatch freshness window) +// and stops receiving ride requests until they toggle offline→online again. +// +// Pings are throttled to every PING_INTERVAL_MS so a fast-moving driver +// doesn't hammer the server, and the location permission is only requested +// once the driver actually intends to go online. +const PING_INTERVAL_MS = 5000; + +export const useDriverLocation = (online: boolean) => { + const subscriptionRef = useRef(null); + const onlineRef = useRef(online); + onlineRef.current = online; + + useEffect(() => { + if (!online) return; + + let cancelled = false; + + const ping = async (latitude: number, longitude: number) => { + try { + await fetchAPI("/(api)/driver/location", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ latitude, longitude }), + }); + } catch (error) { + // A failed ping is non-fatal — the next one will retry. last_seen + // going stale is what takes a driver out of the match pool, not a 500. + console.log("[DRIVER_LOCATION_PING]: ", error); + } + }; + + const start = async () => { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (cancelled || status !== "granted") return; + + if (!(await Location.hasServicesEnabledAsync())) return; + + // Seed the server with the last known position immediately, so the + // driver is matchable without waiting for the first watch callback. + const cached = await Location.getLastKnownPositionAsync({ + maxAge: 5 * 60 * 1000, + }); + if (!cancelled && cached) { + void ping(cached.coords.latitude, cached.coords.longitude); + } + + const subscription = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.Balanced, + timeInterval: PING_INTERVAL_MS, + distanceInterval: 20, + }, + ({ coords }) => { + if (!cancelled) void ping(coords.latitude, coords.longitude); + }, + ); + + if (cancelled) { + await subscription.remove(); + return; + } + subscriptionRef.current = subscription; + }; + + const stop = () => { + const sub = subscriptionRef.current; + subscriptionRef.current = null; + void sub?.remove(); + }; + + void start(); + + // Restart the watch whenever the app comes back to the foreground. While + // backgrounded the OS pauses location updates and the old subscription is + // dead; without re-starting it the driver never pings again. + const onAppStateChange = (state: string) => { + if (state !== "active") return; + if (!onlineRef.current) return; + stop(); + if (!cancelled) void start(); + }; + const subscription = AppState.addEventListener("change", onAppStateChange); + + return () => { + cancelled = true; + stop(); + subscription.remove(); + }; + }, [online]); +}; \ No newline at end of file diff --git a/lib/users.ts b/lib/users.ts index 3dee62c..83df7c3 100644 --- a/lib/users.ts +++ b/lib/users.ts @@ -1,4 +1,3 @@ -import { sql } from "@/lib/db"; import { signJwt } from "@/lib/jwt"; export type UserProfile = { @@ -30,11 +29,3 @@ export const issueSession = ( user: toProfile(row), }); -export const findUserByEmail = async ( - email: string, -): Promise => { - const rows = await sql` - SELECT id, name, email, role FROM users WHERE email = ${email} - `; - return rows[0] ?? null; -}; diff --git a/lib/utils.ts b/lib/utils.ts index 37758b7..90ebc1e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -50,3 +50,21 @@ export function normalizePhone(raw: string): string { return `+961${cleaned.replace(/^0+/, "")}`; } + +// Great-circle distance between two lat/lng points, in meters. Used for +// nearest-driver matching and "X m away" POI chips. Haversine formula. +export function haversine( + lat1: number, + lng1: number, + lat2: number, + lng2: number, +): number { + const R = 6371000; // Earth radius, meters + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/package.json b/package.json index 9d4b88b..e4ef50c 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "test": "jest --watchAll", "lint": "expo lint" diff --git a/scripts/seed-db.mjs b/scripts/seed-db.mjs index 6e07048..3c19292 100644 --- a/scripts/seed-db.mjs +++ b/scripts/seed-db.mjs @@ -109,6 +109,19 @@ await sql`CREATE TABLE IF NOT EXISTS drivers ( rating NUMERIC(2,1) NOT NULL )`; +// Driver profiles are linked to a user account (in-app driver onboarding) and +// carry the live state the dispatch engine needs. +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE SET NULL`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS service VARCHAR(20) NOT NULL DEFAULT 'car'`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS latitude DOUBLE PRECISION`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS longitude DOUBLE PRECISION`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS online BOOLEAN NOT NULL DEFAULT FALSE`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS last_seen TIMESTAMPTZ`; +await sql`ALTER TABLE drivers ADD COLUMN IF NOT EXISTS car_model VARCHAR(100)`; +// One driver profile per user account (legacy seed rows have NULL user_id). +await sql`CREATE UNIQUE INDEX IF NOT EXISTS drivers_user_id_key ON drivers(user_id) WHERE user_id IS NOT NULL`; +await sql`CREATE INDEX IF NOT EXISTS drivers_service_online_idx ON drivers(service, online)`; + await sql`CREATE TABLE IF NOT EXISTS rides ( ride_id SERIAL PRIMARY KEY, origin_address TEXT NOT NULL, @@ -122,18 +135,74 @@ await sql`CREATE TABLE IF NOT EXISTS rides ( payment_status VARCHAR(50) NOT NULL, driver_id INTEGER NOT NULL REFERENCES drivers(id), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + payment_order_id TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )`; +// Link a ride to the server-authoritative payment order that paid for it. +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS payment_order_id TEXT`; + +// Ride lifecycle state machine: requested -> accepted -> en_route -> completed +// (or cancelled). A requested ride has no driver yet — auto-match assigns one +// when a driver accepts, so driver_id must be nullable. +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'requested'`; +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS service VARCHAR(20) NOT NULL DEFAULT 'car'`; +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`; +await sql`ALTER TABLE rides ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ`; +await sql` + ALTER TABLE rides ALTER COLUMN driver_id DROP NOT NULL +`; +await sql`CREATE INDEX IF NOT EXISTS rides_user_id_idx ON rides(user_id)`; +await sql`CREATE INDEX IF NOT EXISTS rides_driver_id_idx ON rides(driver_id)`; +await sql`CREATE INDEX IF NOT EXISTS rides_status_idx ON rides(status)`; + +// Server-authoritative record of each card payment intent. The client never +// supplies payment_status or the successIndicator; both live here and are +// verified against the gateway before an order can be consumed for a ride. +await sql`CREATE TABLE IF NOT EXISTS payment_orders ( + order_id TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount_cents INTEGER NOT NULL, + currency VARCHAR(8) NOT NULL DEFAULT 'USD', + driver_id INTEGER REFERENCES drivers(id), + origin_address TEXT, + destination_address TEXT, + origin_latitude DOUBLE PRECISION, + origin_longitude DOUBLE PRECISION, + destination_latitude DOUBLE PRECISION, + destination_longitude DOUBLE PRECISION, + ride_time INTEGER, + success_indicator TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + paid_at TIMESTAMPTZ +)`; + +await sql`CREATE INDEX IF NOT EXISTS payment_orders_user_id_idx ON payment_orders(user_id)`; + +// Dispatch: each attempt to match a requested ride to a driver is recorded as +// an offer. A driver polls for status='offered' rows assigned to them; accept +// flips the ride to 'accepted', decline/expiry triggers the next-nearest match. +await sql`CREATE TABLE IF NOT EXISTS ride_offers ( + id SERIAL PRIMARY KEY, + ride_id INTEGER NOT NULL REFERENCES rides(ride_id) ON DELETE CASCADE, + driver_id INTEGER NOT NULL REFERENCES drivers(id), + status VARCHAR(20) NOT NULL DEFAULT 'offered', + offered_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + responded_at TIMESTAMPTZ +)`; +await sql`CREATE INDEX IF NOT EXISTS ride_offers_driver_status_idx ON ride_offers(driver_id, status)`; +await sql`CREATE INDEX IF NOT EXISTS ride_offers_ride_idx ON ride_offers(ride_id)`; + const count = await sql`SELECT COUNT(*)::int AS n FROM drivers`; if (count[0].n === 0) { await sql`INSERT INTO drivers - (first_name, last_name, profile_image_url, car_image_url, car_seats, rating) + (first_name, last_name, profile_image_url, car_image_url, car_seats, rating, service) VALUES - ('Karim', 'Haddad', 'https://randomuser.me/api/portraits/men/32.jpg', 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=600', 4, 4.8), - ('Rana', 'Khalil', 'https://randomuser.me/api/portraits/women/44.jpg', 'https://images.unsplash.com/photo-1552519507-da3b142c6e3d?w=600', 4, 4.9), - ('Omar', 'Chehab', 'https://randomuser.me/api/portraits/men/75.jpg', 'https://images.unsplash.com/photo-1580273916550-e323be2ae537?w=600', 4, 4.6), - ('Layal', 'Abou-Jaoude', 'https://randomuser.me/api/portraits/women/68.jpg', 'https://images.unsplash.com/photo-1590362891991-f776e747a588?w=600', 2, 4.7)`; + ('Karim', 'Haddad', 'https://randomuser.me/api/portraits/men/32.jpg', 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=600', 4, 4.8, 'car'), + ('Rana', 'Khalil', 'https://randomuser.me/api/portraits/women/44.jpg', 'https://images.unsplash.com/photo-1552519507-da3b142c6e3d?w=600', 1, 4.9, 'moto'), + ('Omar', 'Chehab', 'https://randomuser.me/api/portraits/men/75.jpg', 'https://images.unsplash.com/photo-1580273916550-e323be2ae537?w=600', 4, 4.6, 'car'), + ('Layal', 'Abou-Jaoude', 'https://randomuser.me/api/portraits/women/68.jpg', 'https://images.unsplash.com/photo-1590362891991-f776e747a588?w=600', 2, 4.7, 'courier')`; console.log("Seeded 4 drivers."); } else { console.log(`Drivers table already has ${count[0].n} rows, skipping seed.`); diff --git a/types/type.d.ts b/types/type.d.ts index 17e044f..59e297a 100644 --- a/types/type.d.ts +++ b/types/type.d.ts @@ -1,13 +1,20 @@ import { TextInputProps, TouchableOpacityProps } from "react-native"; declare interface Driver { - driver_id: number; + id: number; first_name: string; last_name: string; profile_image_url: string; car_image_url: string; car_seats: number; rating: number; + service: string; + online?: boolean; + latitude?: number | null; + longitude?: number | null; + user_id?: string | null; + car_model?: string | null; + last_seen?: string | null; } declare interface MarkerData { @@ -21,6 +28,9 @@ declare interface MarkerData { rating: number; first_name: string; last_name: string; + service?: string; + online?: boolean; + car_model?: string | null; time?: number; price?: string; } @@ -34,6 +44,7 @@ declare interface MapProps { } declare interface Ride { + ride_id?: number; origin_address: string; destination_address: string; origin_latitude: number; @@ -43,16 +54,46 @@ declare interface Ride { ride_time: number; fare_price: number; payment_status: string; - driver_id: number; - user_email: string; + status: string; + service: string; + driver_id: number | null; + user_id?: string; + payment_order_id?: string | null; created_at: string; + completed_at?: string | null; + cancelled_at?: string | null; driver: { - first_name: string; - last_name: string; - car_seats: number; + id: number | null; + first_name: string | null; + last_name: string | null; + car_seats: number | null; + profile_image_url: string | null; + car_image_url: string | null; + rating: number | null; + service: string | null; + car_model: string | null; }; } +declare interface RideOffer { + id: number; + ride_id: number; + driver_id: number; + status: string; + offered_at: string; + responded_at: string | null; + ride?: Ride; +} + +declare interface NearbyPlace { + name: string; + address: string; + latitude: number; + longitude: number; + distanceMeters?: number; + category?: string; +} + declare interface ButtonProps extends TouchableOpacityProps { title: string; bgVariant?: "primary" | "secondary" | "danger" | "outline" | "success";