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 <noreply@anthropic.com>
This commit is contained in:
+7
-2
@@ -22,10 +22,12 @@ SMTP_USER=you@gmail.com
|
|||||||
SMTP_PASS=your-16-char-app-password
|
SMTP_PASS=your-16-char-app-password
|
||||||
SMTP_FROM="Waseel <you@gmail.com>"
|
SMTP_FROM="Waseel <you@gmail.com>"
|
||||||
|
|
||||||
# geoapify api key
|
# geoapify api key (static map tiles only)
|
||||||
EXPO_PUBLIC_GEOAPIFY_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
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
|
EXPO_PUBLIC_GOOGLE_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
|
||||||
# areeba payment gateway (credentials issued after merchant onboarding)
|
# 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_MERCHANT_ID=XXXXXXXXXXXX
|
||||||
AREEBA_API_PASSWORD=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
AREEBA_API_PASSWORD=XXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
AREEBA_API_VERSION=100
|
AREEBA_API_VERSION=100
|
||||||
|
|
||||||
|
# admin dashboard origin for CORS (lib/admin.ts); defaults to * when unset
|
||||||
|
ADMIN_CORS_ORIGIN=*
|
||||||
|
|||||||
@@ -21,3 +21,10 @@ expo-env.d.ts
|
|||||||
|
|
||||||
# env
|
# env
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Native projects generated by `expo prebuild` / `expo run:*`.
|
||||||
|
/android
|
||||||
|
/ios
|
||||||
|
|
||||||
|
# admin dashboard build output
|
||||||
|
dashboard/dist/
|
||||||
|
|||||||
+51
-48
@@ -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
|
// react-native-maps renders blank tiles (just the Google logo, nothing else)
|
||||||
// (AndroidManifest `com.google.android.geo.API_KEY`), not from the JS bundle,
|
// on Android when no Maps API key is set in the AndroidManifest. Expo's
|
||||||
// so EXPO_PUBLIC_GOOGLE_API_KEY has to be injected here at build time. Without
|
// prebuild reads `android.config.googleMaps.apiKey` and writes it to the
|
||||||
// it Android renders an empty grey tile area instead of a map.
|
// 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 googleMapsApiKey = process.env.EXPO_PUBLIC_GOOGLE_API_KEY;
|
||||||
|
|
||||||
const LOCATION_PERMISSION =
|
module.exports = ({ config }) => ({
|
||||||
"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,
|
...config,
|
||||||
ios: {
|
name: "Waseel",
|
||||||
...config.ios,
|
description: "Find your perfect ride with Waseel.",
|
||||||
// iOS uses Apple Maps via PROVIDER_DEFAULT, so this only matters if the
|
githubUrl: "https://github.com/sanidhyy/uber-clone",
|
||||||
// Map component is switched to PROVIDER_GOOGLE.
|
slug: "waseel",
|
||||||
config: { ...config.ios?.config, googleMapsApiKey },
|
version: "1.0.0",
|
||||||
infoPlist: {
|
orientation: "portrait",
|
||||||
...config.ios?.infoPlist,
|
icon: "./assets/images/icon.png",
|
||||||
NSLocationWhenInUseUsageDescription: LOCATION_PERMISSION,
|
scheme: "waseel",
|
||||||
|
userInterfaceStyle: "automatic",
|
||||||
|
splash: {
|
||||||
|
image: "./assets/images/splash.png",
|
||||||
|
resizeMode: "contain",
|
||||||
|
backgroundColor: "#2F80ED",
|
||||||
},
|
},
|
||||||
|
ios: {
|
||||||
|
supportsTablet: true,
|
||||||
|
bundleIdentifier: "com.waseel.app",
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
...config.android,
|
adaptiveIcon: {
|
||||||
config: {
|
foregroundImage: "./assets/images/adaptive-icon.png",
|
||||||
...config.android?.config,
|
backgroundColor: "#ffffff",
|
||||||
googleMaps: { apiKey: googleMapsApiKey },
|
|
||||||
},
|
},
|
||||||
// Location permissions come from the expo-location plugin below.
|
package: "com.waseel.app",
|
||||||
|
config: {
|
||||||
|
googleMaps: {
|
||||||
|
apiKey: googleMapsApiKey ?? "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
bundler: "metro",
|
||||||
|
output: "server",
|
||||||
|
favicon: "./assets/images/favicon.png",
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
...(config.plugins ?? []),
|
|
||||||
[
|
[
|
||||||
"expo-location",
|
"expo-router",
|
||||||
{
|
{
|
||||||
locationAlwaysAndWhenInUsePermission: LOCATION_PERMISSION,
|
origin: "https://example.com/",
|
||||||
locationWhenInUsePermission: LOCATION_PERMISSION,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
};
|
experiments: {
|
||||||
};
|
typedRoutes: true,
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
router: {
|
||||||
|
origin: "https://example.com/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{
|
|
||||||
"expo": {
|
|
||||||
"name": "Waseel",
|
|
||||||
"description": "Find your perfect ride with Waseel.",
|
|
||||||
"githubUrl": "https://github.com/sanidhyy/uber-clone",
|
|
||||||
"slug": "waseel",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"orientation": "portrait",
|
|
||||||
"icon": "./assets/images/icon.png",
|
|
||||||
"scheme": "waseel",
|
|
||||||
"userInterfaceStyle": "automatic",
|
|
||||||
"splash": {
|
|
||||||
"image": "./assets/images/splash.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#2F80ED"
|
|
||||||
},
|
|
||||||
"ios": {
|
|
||||||
"supportsTablet": true,
|
|
||||||
"bundleIdentifier": "com.waseel.app"
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"adaptiveIcon": {
|
|
||||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
|
||||||
"backgroundColor": "#ffffff"
|
|
||||||
},
|
|
||||||
"package": "com.waseel.app"
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"bundler": "metro",
|
|
||||||
"output": "server",
|
|
||||||
"favicon": "./assets/images/favicon.png"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
[
|
|
||||||
"expo-router",
|
|
||||||
{
|
|
||||||
"origin": "https://example.com/"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"experiments": {
|
|
||||||
"typedRoutes": true
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"router": {
|
|
||||||
"origin": "https://example.com/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +1,123 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
import { requireAuth } from "@/lib/jwt";
|
||||||
import { createCheckoutSession } from "@/lib/areeba";
|
import { 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) {
|
export async function POST(req: Request) {
|
||||||
const body = await req.json();
|
const auth = requireAuth(req);
|
||||||
const { name, email, amount, returnUrl } = body;
|
if ("error" in auth) return auth.error;
|
||||||
|
|
||||||
if (!name || !email || !amount)
|
const body = await req.json().catch(() => ({}));
|
||||||
return new Response(
|
const {
|
||||||
JSON.stringify({ error: "Missing required payment information." }),
|
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 },
|
{ 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 {
|
try {
|
||||||
const orderId = `waseel-${Date.now()}`;
|
const orderId = randomUUID();
|
||||||
|
|
||||||
const session = await createCheckoutSession({
|
const session = await createCheckoutSession({
|
||||||
orderId,
|
orderId,
|
||||||
amount: parseFloat(amount),
|
amount: amountCents / 100,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
description: `Waseel ride payment for ${name}`,
|
description: `Waseel ride payment for ${name}`,
|
||||||
returnUrl: returnUrl || "waseel://book-ride",
|
returnUrl: finalReturnUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
// The successIndicator stays server-side; the client never sees it.
|
||||||
JSON.stringify({
|
if (!session.successIndicator)
|
||||||
orderId,
|
throw new Error("Areeba did not return a successIndicator.");
|
||||||
checkoutUrl: session.checkoutUrl,
|
|
||||||
successIndicator: session.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) {
|
} catch (err) {
|
||||||
console.log("[AREEBA_PAYMENT_CREATE]: ", err);
|
console.log("[AREEBA_PAYMENT_CREATE]: ", err);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,69 @@
|
|||||||
|
import { requireAuth } from "@/lib/jwt";
|
||||||
import { retrieveOrder } from "@/lib/areeba";
|
import { retrieveOrder } from "@/lib/areeba";
|
||||||
|
import { getOrder, markPaid } from "@/lib/payment-orders";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const body = await req.json();
|
const auth = requireAuth(req);
|
||||||
const { orderId, resultIndicator, successIndicator } = body;
|
if ("error" in auth) return auth.error;
|
||||||
|
|
||||||
if (!orderId)
|
const body = await req.json().catch(() => ({}));
|
||||||
return new Response(JSON.stringify({ error: "Missing order id." }), {
|
const { orderId, resultIndicator } = body;
|
||||||
status: 400,
|
|
||||||
});
|
if (!orderId || !resultIndicator)
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Missing order id or result indicator." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
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;
|
if (order.user_id !== auth.userId)
|
||||||
// it must match the successIndicator issued when the session was created.
|
return Response.json({ error: "Unauthorized." }, { status: 403 });
|
||||||
const indicatorMatches =
|
|
||||||
!successIndicator || resultIndicator === successIndicator;
|
|
||||||
|
|
||||||
return new Response(
|
// An order can only be verified once. A 'paid' or 'consumed' order has
|
||||||
JSON.stringify({
|
// already settled — rejecting here is the primary double-spend defense: it
|
||||||
success: order.paid && indicatorMatches,
|
// stops a client from re-verifying an order it already used for a ride.
|
||||||
status: order.status,
|
if (order.status !== "pending")
|
||||||
amount: order.amount,
|
return Response.json(
|
||||||
currency: order.currency,
|
{ 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) {
|
} catch (err) {
|
||||||
console.log("[AREEBA_PAYMENT_VERIFY]: ", err);
|
console.log("[AREEBA_PAYMENT_VERIFY]: ", err);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,11 +25,11 @@ export async function GET(request: Request) {
|
|||||||
(SELECT COUNT(*)::int FROM users) AS users,
|
(SELECT COUNT(*)::int FROM users) AS users,
|
||||||
(SELECT COUNT(*)::int FROM drivers) AS drivers,
|
(SELECT COUNT(*)::int FROM drivers) AS drivers,
|
||||||
(SELECT COUNT(*)::int FROM rides) AS rides,
|
(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 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 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
|
(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
|
SELECT
|
||||||
TO_CHAR(DAY, 'YYYY-MM-DD') AS day,
|
TO_CHAR(DAY, 'YYYY-MM-DD') AS day,
|
||||||
COUNT(r.ride_id)::int AS rides,
|
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(
|
FROM generate_series(
|
||||||
CURRENT_DATE - INTERVAL '13 days',
|
CURRENT_DATE - INTERVAL '13 days',
|
||||||
CURRENT_DATE,
|
CURRENT_DATE,
|
||||||
@@ -58,7 +58,7 @@ export async function GET(request: Request) {
|
|||||||
d.id AS driver_id,
|
d.id AS driver_id,
|
||||||
d.first_name || ' ' || d.last_name AS name,
|
d.first_name || ' ' || d.last_name AS name,
|
||||||
COUNT(r.ride_id)::int AS rides,
|
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
|
FROM drivers d
|
||||||
LEFT JOIN rides r ON r.driver_id = d.id
|
LEFT JOIN rides r ON r.driver_id = d.id
|
||||||
GROUP BY d.id, d.first_name, d.last_name
|
GROUP BY d.id, d.first_name, d.last_name
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { sql } from "@/lib/db";
|
import { sql, transaction } from "@/lib/db";
|
||||||
import { sendEmail } from "@/lib/mailer";
|
import { isDevOtpExposed, sendEmail } from "@/lib/mailer";
|
||||||
import {
|
import {
|
||||||
CODE_TTL_MINUTES,
|
CODE_TTL_MINUTES,
|
||||||
generateCode,
|
generateCode,
|
||||||
@@ -26,13 +26,14 @@ export async function POST(req: Request) {
|
|||||||
return Response.json({ data: { sent: false } });
|
return Response.json({ data: { sent: false } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = generateCode();
|
const code = await transaction(async (tx) => {
|
||||||
|
const generated = generateCode();
|
||||||
|
|
||||||
await sql`
|
await tx`
|
||||||
INSERT INTO password_reset_codes (email, code_hash, expires_at)
|
INSERT INTO password_reset_codes (email, code_hash, expires_at)
|
||||||
VALUES (
|
VALUES (
|
||||||
${normalized},
|
${normalized},
|
||||||
${hashCode(normalized, code)},
|
${hashCode(normalized, generated)},
|
||||||
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||||
)
|
)
|
||||||
ON CONFLICT (email) DO UPDATE SET
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
@@ -41,6 +42,9 @@ export async function POST(req: Request) {
|
|||||||
attempts = 0
|
attempts = 0
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
return generated;
|
||||||
|
});
|
||||||
|
|
||||||
const mail = resetEmail(code);
|
const mail = resetEmail(code);
|
||||||
const delivered = await sendEmail(normalized, mail.subject, mail.text);
|
const delivered = await sendEmail(normalized, mail.subject, mail.text);
|
||||||
|
|
||||||
@@ -48,8 +52,9 @@ export async function POST(req: Request) {
|
|||||||
data: {
|
data: {
|
||||||
sent: delivered,
|
sent: delivered,
|
||||||
// Without SMTP configured there is nothing to receive, so surface the
|
// Without SMTP configured there is nothing to receive, so surface the
|
||||||
// code to keep the reset flow usable on a self-hosted box.
|
// code to keep the reset flow usable on a self-hosted box. Never
|
||||||
...(delivered ? {} : { devCode: code }),
|
// expose the code in production, even on delivery failure.
|
||||||
|
...(delivered || !isDevOtpExposed() ? {} : { devCode: code }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import { sql } from "@/lib/db";
|
import { sql, transaction } from "@/lib/db";
|
||||||
import { hashPassword } from "@/lib/password";
|
import { hashPassword } from "@/lib/password";
|
||||||
import { sendEmail } from "@/lib/mailer";
|
import { isDevOtpExposed, sendEmail } from "@/lib/mailer";
|
||||||
import {
|
import {
|
||||||
CODE_TTL_MINUTES,
|
CODE_TTL_MINUTES,
|
||||||
generateCode,
|
generateCode,
|
||||||
hashCode,
|
hashCode,
|
||||||
verificationEmail,
|
verificationEmail,
|
||||||
} from "@/lib/otp";
|
} from "@/lib/otp";
|
||||||
|
import { normalizePhone } from "@/lib/utils";
|
||||||
const normalizePhone = (raw: string): string => {
|
|
||||||
const cleaned = raw.replace(/[^\d+]/g, "");
|
|
||||||
if (cleaned.startsWith("+")) return cleaned;
|
|
||||||
return `+961${cleaned.replace(/^0+/, "")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const { name, email, phone, password, role } = await req.json();
|
const { name, email, phone, password, role } = await req.json();
|
||||||
@@ -46,7 +41,8 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unverified rows may be re-registered (e.g. the first mail never arrived).
|
// Unverified rows may be re-registered (e.g. the first mail never arrived).
|
||||||
await sql`
|
const code = await transaction(async (tx) => {
|
||||||
|
await tx`
|
||||||
INSERT INTO users (name, email, phone, password_hash, email_verified, role)
|
INSERT INTO users (name, email, phone, password_hash, email_verified, role)
|
||||||
VALUES (
|
VALUES (
|
||||||
${name.trim()},
|
${name.trim()},
|
||||||
@@ -61,15 +57,16 @@ export async function POST(req: Request) {
|
|||||||
phone = COALESCE(EXCLUDED.phone, users.phone),
|
phone = COALESCE(EXCLUDED.phone, users.phone),
|
||||||
password_hash = EXCLUDED.password_hash,
|
password_hash = EXCLUDED.password_hash,
|
||||||
role = EXCLUDED.role
|
role = EXCLUDED.role
|
||||||
|
WHERE users.email_verified = FALSE
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const code = generateCode();
|
const generated = generateCode();
|
||||||
|
|
||||||
await sql`
|
await tx`
|
||||||
INSERT INTO email_verification_codes (email, code_hash, expires_at)
|
INSERT INTO email_verification_codes (email, code_hash, expires_at)
|
||||||
VALUES (
|
VALUES (
|
||||||
${email.trim().toLowerCase()},
|
${email.trim().toLowerCase()},
|
||||||
${hashCode(email.trim().toLowerCase(), code)},
|
${hashCode(email.trim().toLowerCase(), generated)},
|
||||||
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
|
||||||
)
|
)
|
||||||
ON CONFLICT (email) DO UPDATE SET
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
@@ -78,6 +75,9 @@ export async function POST(req: Request) {
|
|||||||
attempts = 0
|
attempts = 0
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
return generated;
|
||||||
|
});
|
||||||
|
|
||||||
const mail = verificationEmail(code);
|
const mail = verificationEmail(code);
|
||||||
const delivered = await sendEmail(
|
const delivered = await sendEmail(
|
||||||
email.trim().toLowerCase(),
|
email.trim().toLowerCase(),
|
||||||
@@ -90,8 +90,9 @@ export async function POST(req: Request) {
|
|||||||
data: {
|
data: {
|
||||||
sent: delivered,
|
sent: delivered,
|
||||||
// Without SMTP/Gmail configured there is nothing to receive, so
|
// Without SMTP/Gmail configured there is nothing to receive, so
|
||||||
// surface the code to keep self-hosted sign-up usable.
|
// surface the code to keep self-hosted sign-up usable. Never expose
|
||||||
...(delivered ? {} : { devCode: code }),
|
// the code in production, even on delivery failure.
|
||||||
|
...(delivered || !isDevOtpExposed() ? {} : { devCode: code }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ status: 201 },
|
{ status: 201 },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sql } from "@/lib/db";
|
import { transaction } from "@/lib/db";
|
||||||
import { MAX_CODE_ATTEMPTS, codeMatches } from "@/lib/otp";
|
import { MAX_CODE_ATTEMPTS, codeMatches } from "@/lib/otp";
|
||||||
import { hashPassword } from "@/lib/password";
|
import { hashPassword } from "@/lib/password";
|
||||||
import { issueSession, toProfile } from "@/lib/users";
|
import { issueSession, toProfile } from "@/lib/users";
|
||||||
@@ -24,8 +24,11 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Charge the attempt before comparing so concurrent guesses can't race
|
// 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.
|
// past the cap, and so a correct guess still costs one of the five. Wrap
|
||||||
const attempts = await sql<{ code_hash: string; attempts: number }>`
|
// 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
|
UPDATE password_reset_codes
|
||||||
SET attempts = attempts + 1
|
SET attempts = attempts + 1
|
||||||
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
||||||
@@ -39,14 +42,12 @@ export async function POST(req: Request) {
|
|||||||
record.attempts > MAX_CODE_ATTEMPTS ||
|
record.attempts > MAX_CODE_ATTEMPTS ||
|
||||||
!codeMatches(record.code_hash, normalized, code)
|
!codeMatches(record.code_hash, normalized, code)
|
||||||
) {
|
) {
|
||||||
return Response.json(
|
return { kind: "invalid" as const };
|
||||||
{ error: "Invalid or expired reset code." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A successful reset also proves control of the mailbox, so verify it too.
|
// A successful reset also proves control of the mailbox, so verify it
|
||||||
const rows = await sql<{
|
// too.
|
||||||
|
const rows = await tx<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -61,15 +62,29 @@ export async function POST(req: Request) {
|
|||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
return { kind: "not_found" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx`DELETE FROM password_reset_codes WHERE email = ${normalized}`;
|
||||||
|
|
||||||
|
return { kind: "ok" as const, user };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.kind === "invalid") {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid or expired reset code." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.kind === "not_found") {
|
||||||
return Response.json({ error: "User not found." }, { status: 404 });
|
return Response.json({ error: "User not found." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await sql`DELETE FROM password_reset_codes WHERE email = ${normalized}`;
|
const session = issueSession(result.user);
|
||||||
|
|
||||||
const session = issueSession(user);
|
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
data: { token: session.token, user: toProfile(user) },
|
data: { token: session.token, user: toProfile(result.user) },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[RESET_PASSWORD]: ", error);
|
console.error("[RESET_PASSWORD]: ", error);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sql } from "@/lib/db";
|
import { transaction } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
MAX_CODE_ATTEMPTS,
|
MAX_CODE_ATTEMPTS,
|
||||||
codeMatches,
|
codeMatches,
|
||||||
@@ -19,8 +19,10 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Charge the attempt before comparing so concurrent guesses can't race
|
// 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.
|
// past the cap, and so a correct guess still costs one of the five. Wrap
|
||||||
const attempts = await sql<{ code_hash: string; attempts: number }>`
|
// 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
|
UPDATE email_verification_codes
|
||||||
SET attempts = attempts + 1
|
SET attempts = attempts + 1
|
||||||
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
|
||||||
@@ -34,13 +36,10 @@ export async function POST(req: Request) {
|
|||||||
record.attempts > MAX_CODE_ATTEMPTS ||
|
record.attempts > MAX_CODE_ATTEMPTS ||
|
||||||
!codeMatches(record.code_hash, normalized, code)
|
!codeMatches(record.code_hash, normalized, code)
|
||||||
) {
|
) {
|
||||||
return Response.json(
|
return { kind: "invalid" as const };
|
||||||
{ error: "Invalid or expired verification code." },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await sql<{
|
const rows = await tx<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -54,15 +53,29 @@ export async function POST(req: Request) {
|
|||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
|
|
||||||
if (!user) {
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.kind === "not_found") {
|
||||||
return Response.json({ error: "User not found." }, { status: 404 });
|
return Response.json({ error: "User not found." }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await sql`DELETE FROM email_verification_codes WHERE email = ${normalized}`;
|
const session = issueSession(result.user);
|
||||||
|
|
||||||
const session = issueSession(user);
|
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
data: { token: session.token, user: toProfile(user) },
|
data: { token: session.token, user: toProfile(result.user) },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[VERIFY]: ", error);
|
console.error("[VERIFY]: ", error);
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
|
import { requireAuth } from "@/lib/jwt";
|
||||||
import { sql } from "@/lib/db";
|
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 {
|
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 });
|
return Response.json({ data: response });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
+131
-29
@@ -1,46 +1,148 @@
|
|||||||
import { requireAuth } from "@/lib/jwt";
|
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 }) {
|
export async function GET(request: Request, { id }: { id: string }) {
|
||||||
const auth = requireAuth(request);
|
const auth = requireAuth(request);
|
||||||
if ("error" in auth) return auth.error;
|
if ("error" in auth) return auth.error;
|
||||||
|
|
||||||
|
const rideId = Number(id);
|
||||||
|
if (!Number.isInteger(rideId)) {
|
||||||
|
return Response.json({ error: "Invalid ride id." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await sql`
|
const ride = await sql`
|
||||||
|
SELECT status FROM rides WHERE ride_id = ${rideId} AND user_id = ${auth.userId}
|
||||||
|
`;
|
||||||
|
if (!ride[0]) {
|
||||||
|
return Response.json({ error: "Ride not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy 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
|
SELECT
|
||||||
rides.ride_id,
|
r.ride_id,
|
||||||
rides.origin_address,
|
r.origin_address,
|
||||||
rides.destination_address,
|
r.destination_address,
|
||||||
rides.origin_latitude,
|
r.origin_latitude,
|
||||||
rides.origin_longitude,
|
r.origin_longitude,
|
||||||
rides.destination_latitude,
|
r.destination_latitude,
|
||||||
rides.destination_longitude,
|
r.destination_longitude,
|
||||||
rides.ride_time,
|
r.ride_time,
|
||||||
rides.fare_price,
|
r.fare_price,
|
||||||
rides.payment_status,
|
r.payment_status,
|
||||||
rides.created_at,
|
r.status,
|
||||||
|
r.service,
|
||||||
|
r.created_at,
|
||||||
|
r.completed_at,
|
||||||
|
r.cancelled_at,
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'driver_id', drivers.id,
|
'id', d.id,
|
||||||
'first_name', drivers.first_name,
|
'first_name', d.first_name,
|
||||||
'last_name', drivers.last_name,
|
'last_name', d.last_name,
|
||||||
'profile_image_url', drivers.profile_image_url,
|
'car_seats', d.car_seats,
|
||||||
'car_image_url', drivers.car_image_url,
|
'profile_image_url', d.profile_image_url,
|
||||||
'car_seats', drivers.car_seats,
|
'car_image_url', d.car_image_url,
|
||||||
'rating', drivers.rating
|
'rating', d.rating,
|
||||||
|
'service', d.service,
|
||||||
|
'car_model', d.car_model,
|
||||||
|
'latitude', d.latitude,
|
||||||
|
'longitude', d.longitude
|
||||||
) AS driver
|
) AS driver
|
||||||
FROM
|
FROM rides r
|
||||||
rides
|
LEFT JOIN drivers d ON d.id = r.driver_id
|
||||||
INNER JOIN
|
WHERE r.ride_id = ${rideId}
|
||||||
drivers ON rides.driver_id = drivers.id
|
|
||||||
WHERE
|
|
||||||
rides.user_id = ${auth.userId}
|
|
||||||
ORDER BY
|
|
||||||
rides.created_at DESC;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return Response.json({ data: response });
|
return Response.json({ data: rows[0] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GET_RIDE]: ", error);
|
console.error("[GET_RIDE]: ", error);
|
||||||
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
-19
@@ -1,6 +1,18 @@
|
|||||||
import { requireAuth } from "@/lib/jwt";
|
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) {
|
export async function POST(request: Request) {
|
||||||
const auth = requireAuth(request);
|
const auth = requireAuth(request);
|
||||||
if ("error" in auth) return auth.error;
|
if ("error" in auth) return auth.error;
|
||||||
@@ -16,21 +28,20 @@ export async function POST(request: Request) {
|
|||||||
destination_longitude,
|
destination_longitude,
|
||||||
ride_time,
|
ride_time,
|
||||||
fare_price,
|
fare_price,
|
||||||
payment_status,
|
payment_method,
|
||||||
driver_id,
|
payment_order_id,
|
||||||
|
service,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!origin_address ||
|
isMissing(origin_address) ||
|
||||||
!destination_address ||
|
isMissing(destination_address) ||
|
||||||
!origin_latitude ||
|
isMissing(origin_latitude) ||
|
||||||
!origin_longitude ||
|
isMissing(origin_longitude) ||
|
||||||
!destination_latitude ||
|
isMissing(destination_latitude) ||
|
||||||
!destination_longitude ||
|
isMissing(destination_longitude) ||
|
||||||
!ride_time ||
|
isMissing(ride_time) ||
|
||||||
!fare_price ||
|
isMissing(fare_price)
|
||||||
!payment_status ||
|
|
||||||
!driver_id
|
|
||||||
) {
|
) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Missing required fields" },
|
{ error: "Missing required fields" },
|
||||||
@@ -38,6 +49,116 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payment_method !== "card" && payment_method !== "cash")
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid payment method." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const rideService = isServiceId(service) ? service : DEFAULT_SERVICE;
|
||||||
|
const fareCents = Math.round(Number(fare_price));
|
||||||
|
|
||||||
|
if (payment_method === "card") {
|
||||||
|
// Card: the ride is only recorded once a paid, server-authoritative
|
||||||
|
// payment order is consumed. The client can no longer self-declare
|
||||||
|
// payment_status='paid'.
|
||||||
|
if (isMissing(payment_order_id))
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Missing payment order id." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const order = await getOrder(payment_order_id);
|
||||||
|
if (!order)
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Payment order not found." },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (order.user_id !== auth.userId)
|
||||||
|
return Response.json({ error: "Unauthorized." }, { status: 403 });
|
||||||
|
|
||||||
|
if (order.status !== "paid")
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Payment not verified." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (order.amount_cents !== fareCents)
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Payment amount mismatch." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reconcile route intent (driver isn't known yet, so driver_id is no
|
||||||
|
// longer part of the intent check). Null intent fields are skipped.
|
||||||
|
const intentsMatch =
|
||||||
|
(order.origin_address === null ||
|
||||||
|
order.origin_address === origin_address) &&
|
||||||
|
(order.destination_address === null ||
|
||||||
|
order.destination_address === destination_address) &&
|
||||||
|
(order.ride_time === null || order.ride_time === Number(ride_time));
|
||||||
|
|
||||||
|
if (!intentsMatch)
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Payment does not match this ride." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Consume the order and insert the ride on one connection, so a failure
|
||||||
|
// rolls back both and no paid order is wasted without a ride.
|
||||||
|
const inserted = await transaction(async (tx) => {
|
||||||
|
const consumed = await consumeOrderForRide(
|
||||||
|
payment_order_id,
|
||||||
|
auth.userId,
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
if (!consumed) throw new Error("PAYMENT_ORDER_NOT_CONSUMABLE");
|
||||||
|
|
||||||
|
const rows = await tx`
|
||||||
|
INSERT INTO rides (
|
||||||
|
origin_address,
|
||||||
|
destination_address,
|
||||||
|
origin_latitude,
|
||||||
|
origin_longitude,
|
||||||
|
destination_latitude,
|
||||||
|
destination_longitude,
|
||||||
|
ride_time,
|
||||||
|
fare_price,
|
||||||
|
payment_status,
|
||||||
|
driver_id,
|
||||||
|
user_id,
|
||||||
|
payment_order_id,
|
||||||
|
status,
|
||||||
|
service
|
||||||
|
) VALUES (
|
||||||
|
${origin_address},
|
||||||
|
${destination_address},
|
||||||
|
${origin_latitude},
|
||||||
|
${origin_longitude},
|
||||||
|
${destination_latitude},
|
||||||
|
${destination_longitude},
|
||||||
|
${ride_time},
|
||||||
|
${fareCents},
|
||||||
|
'paid',
|
||||||
|
NULL,
|
||||||
|
${auth.userId},
|
||||||
|
${payment_order_id},
|
||||||
|
'requested',
|
||||||
|
${rideService}
|
||||||
|
)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return rows[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kick off auto-match asynchronously — don't block the response on it.
|
||||||
|
void matchNextDriver(inserted.ride_id);
|
||||||
|
|
||||||
|
return Response.json({ data: inserted }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash: settled directly with the driver at drop-off. No order involved.
|
||||||
const response = await sql`
|
const response = await sql`
|
||||||
INSERT INTO rides (
|
INSERT INTO rides (
|
||||||
origin_address,
|
origin_address,
|
||||||
@@ -50,7 +171,9 @@ export async function POST(request: Request) {
|
|||||||
fare_price,
|
fare_price,
|
||||||
payment_status,
|
payment_status,
|
||||||
driver_id,
|
driver_id,
|
||||||
user_id
|
user_id,
|
||||||
|
status,
|
||||||
|
service
|
||||||
) VALUES (
|
) VALUES (
|
||||||
${origin_address},
|
${origin_address},
|
||||||
${destination_address},
|
${destination_address},
|
||||||
@@ -59,14 +182,18 @@ export async function POST(request: Request) {
|
|||||||
${destination_latitude},
|
${destination_latitude},
|
||||||
${destination_longitude},
|
${destination_longitude},
|
||||||
${ride_time},
|
${ride_time},
|
||||||
${fare_price},
|
${fareCents},
|
||||||
${payment_status},
|
'cash',
|
||||||
${driver_id},
|
NULL,
|
||||||
${auth.userId}
|
${auth.userId},
|
||||||
|
'requested',
|
||||||
|
${rideService}
|
||||||
)
|
)
|
||||||
RETURNING *;
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
void matchNextDriver(response[0].ride_id);
|
||||||
|
|
||||||
return Response.json({ data: response[0] }, { status: 201 });
|
return Response.json({ data: response[0] }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CREATE_RIDES]: ", error);
|
console.error("[CREATE_RIDES]: ", error);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ const TabIcon = ({
|
|||||||
|
|
||||||
const TabsLayout = () => (
|
const TabsLayout = () => (
|
||||||
<Tabs
|
<Tabs
|
||||||
initialRouteName="index"
|
initialRouteName="home"
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: "white",
|
tabBarActiveTintColor: "white",
|
||||||
tabBarInactiveTintColor: "white",
|
tabBarInactiveTintColor: "white",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
|||||||
import { GoogleTextInput } from "@/components/google-text-input";
|
import { GoogleTextInput } from "@/components/google-text-input";
|
||||||
import { LocationNotice } from "@/components/location-notice";
|
import { LocationNotice } from "@/components/location-notice";
|
||||||
import { Map } from "@/components/map";
|
import { Map } from "@/components/map";
|
||||||
|
import { NearbySuggestions } from "@/components/nearby-suggestions";
|
||||||
import { RideCard } from "@/components/ride-card";
|
import { RideCard } from "@/components/ride-card";
|
||||||
import { ServiceSelector } from "@/components/service-selector";
|
import { ServiceSelector } from "@/components/service-selector";
|
||||||
import { icons, images } from "@/constants";
|
import { icons, images } from "@/constants";
|
||||||
@@ -26,9 +27,7 @@ const Home = () => {
|
|||||||
(state) => state.setDestinationLocation,
|
(state) => state.setDestinationLocation,
|
||||||
);
|
);
|
||||||
const { signOut, user } = useSession();
|
const { signOut, user } = useSession();
|
||||||
const { data: recentRides, loading } = useFetch<Ride[]>(
|
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
|
||||||
`/(api)/ride/${user?.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { status: locationStatus, retry: retryLocation } = useUserLocation();
|
const { status: locationStatus, retry: retryLocation } = useUserLocation();
|
||||||
|
|
||||||
@@ -140,6 +139,10 @@ const Home = () => {
|
|||||||
|
|
||||||
<ServiceSelector />
|
<ServiceSelector />
|
||||||
|
|
||||||
|
<View className="mt-5">
|
||||||
|
<NearbySuggestions />
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text className="text-xl font-JakartaBold mt-5 mb-3">
|
<Text className="text-xl font-JakartaBold mt-5 mb-3">
|
||||||
Recent Rides
|
Recent Rides
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Image, ScrollView, Text, View } from "react-native";
|
|||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { InputField } from "@/components/input-field";
|
import { InputField } from "@/components/input-field";
|
||||||
|
import { icons } from "@/constants";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
@@ -17,7 +18,7 @@ const Profile = () => {
|
|||||||
|
|
||||||
<View className="flex items-center justify-center my-5">
|
<View className="flex items-center justify-center my-5">
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: user?.avatarUrl ?? undefined }}
|
source={user?.avatarUrl ? { uri: user.avatarUrl } : icons.profile}
|
||||||
alt="Your Avatar"
|
alt="Your Avatar"
|
||||||
style={{ width: 110, height: 110, borderRadius: 110 / 2 }}
|
style={{ width: 110, height: 110, borderRadius: 110 / 2 }}
|
||||||
className=" rounded-full h-[110px] w-[110px] border-[3px] border-white shadow-sm shadow-neutral-300"
|
className=" rounded-full h-[110px] w-[110px] border-[3px] border-white shadow-sm shadow-neutral-300"
|
||||||
@@ -28,7 +29,7 @@ const Profile = () => {
|
|||||||
<View className="flex flex-col items-start justify-start w-full">
|
<View className="flex flex-col items-start justify-start w-full">
|
||||||
<InputField
|
<InputField
|
||||||
label="First name"
|
label="First name"
|
||||||
placeholder={user?.name.split(" ")[0] ?? "Your First name"}
|
placeholder={user?.name?.split(" ")[0] || "Your First name"}
|
||||||
containerStyles="w-full mb-4"
|
containerStyles="w-full mb-4"
|
||||||
inputStyles="p-3.5"
|
inputStyles="p-3.5"
|
||||||
editable={false}
|
editable={false}
|
||||||
@@ -36,7 +37,7 @@ const Profile = () => {
|
|||||||
|
|
||||||
<InputField
|
<InputField
|
||||||
label="Last name"
|
label="Last name"
|
||||||
placeholder={user?.name.split(" ").slice(1).join(" ") ?? "Your Last name"}
|
placeholder={user?.name?.split(" ").slice(1).join(" ") || "Your Last name"}
|
||||||
containerStyles="w-full mb-4"
|
containerStyles="w-full mb-4"
|
||||||
inputStyles="p-3.5"
|
inputStyles="p-3.5"
|
||||||
editable={false}
|
editable={false}
|
||||||
|
|||||||
@@ -4,14 +4,10 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
|||||||
import { RideCard } from "@/components/ride-card";
|
import { RideCard } from "@/components/ride-card";
|
||||||
import { images } from "@/constants";
|
import { images } from "@/constants";
|
||||||
import { useFetch } from "@/lib/fetch";
|
import { useFetch } from "@/lib/fetch";
|
||||||
import { useSession } from "@/lib/session";
|
|
||||||
import type { Ride } from "@/types/type";
|
import type { Ride } from "@/types/type";
|
||||||
|
|
||||||
const Rides = () => {
|
const Rides = () => {
|
||||||
const { user } = useSession();
|
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
|
||||||
const { data: recentRides, loading } = useFetch<Ride[]>(
|
|
||||||
`/(api)/ride/${user?.id}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView>
|
<SafeAreaView>
|
||||||
|
|||||||
+222
-110
@@ -1,135 +1,247 @@
|
|||||||
import { router } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { Image, Text, View } from "react-native";
|
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 { CustomButton } from "@/components/custom-button";
|
||||||
import { Payment } from "@/components/payment";
|
import { Map } from "@/components/map";
|
||||||
import { RideLayout } from "@/components/ride-layout";
|
import { icons, images } from "@/constants";
|
||||||
import { icons } from "@/constants";
|
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||||
import { formatLBP } from "@/lib/pricing";
|
|
||||||
import { useSession } from "@/lib/session";
|
|
||||||
import { formatTime } from "@/lib/utils";
|
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<string, string> = {
|
||||||
|
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 BookRide = () => {
|
||||||
const { user } = useSession();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { userAddress, destinationAddress } = useLocationStore();
|
const rideId = Number(id);
|
||||||
const { drivers, selectedDriver } = useDriverStore();
|
const setUserLocation = useLocationStore((s) => s.setUserLocation);
|
||||||
|
const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation);
|
||||||
|
|
||||||
const driverDetails = drivers?.filter(
|
const [ride, setRide] = useState<Ride | null>(null);
|
||||||
(driver) => +driver.id === selectedDriver,
|
const [loading, setLoading] = useState(true);
|
||||||
)[0];
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
return (
|
||||||
<RideLayout title="Book Ride">
|
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
||||||
<View className="flex-1 items-center justify-center">
|
<ActivityIndicator size="large" color="#0286ff" />
|
||||||
<Text className="text-base text-general-200 font-JakartaMedium text-center">
|
</SafeAreaView>
|
||||||
No driver selected.{"\n"}Please go back and choose a driver first.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<CustomButton
|
|
||||||
title="Choose a Driver"
|
|
||||||
onPress={() => router.replace("/(root)/confirm-ride")}
|
|
||||||
className="mt-6"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</RideLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error || !ride) {
|
||||||
return (
|
return (
|
||||||
<RideLayout title="Book Ride">
|
<SafeAreaView className="flex-1 bg-white items-center justify-center px-7">
|
||||||
<>
|
<Text className="text-base text-general-200 text-center">
|
||||||
<Text className="text-xl font-JakartaSemiBold mb-3">
|
{error ?? "Could not load this ride."}
|
||||||
Ride Information
|
</Text>
|
||||||
|
<CustomButton
|
||||||
|
title="Back Home"
|
||||||
|
onPress={() => router.replace("/(root)/(tabs)/home")}
|
||||||
|
className="mt-6"
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const driver = ride.driver;
|
||||||
|
const terminal = ride.status === "completed" || ride.status === "cancelled";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-general-500">
|
||||||
|
<View className="h-[45%] bg-blue-500">
|
||||||
|
<Map />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 px-5 pt-4">
|
||||||
|
<Text className="text-2xl font-JakartaExtraBold mb-2">
|
||||||
|
{statusLabel[ride.status] ?? ride.status}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View className="flex flex-col w-full items-center justify-center mt-10">
|
{/* Searching state */}
|
||||||
<Image
|
{ride.status === "requested" ? (
|
||||||
source={{ uri: driverDetails?.profile_image_url }}
|
<View className="items-center mt-6">
|
||||||
alt="Driver Avatar"
|
<ActivityIndicator size="large" color="#0286ff" />
|
||||||
className="w-28 h-28 rounded-full"
|
<Text className="text-general-200 mt-3 text-center">
|
||||||
/>
|
We're matching you with the nearest {ride.service} driver.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-center mt-5 space-x-2">
|
{/* Driver card — shown once a driver is assigned. */}
|
||||||
|
{driver?.id ? (
|
||||||
|
<View className="bg-white rounded-2xl p-4 mt-2">
|
||||||
|
<View className="flex-row items-center">
|
||||||
|
<Image
|
||||||
|
source={{ uri: driver.profile_image_url ?? undefined }}
|
||||||
|
className="w-16 h-16 rounded-full"
|
||||||
|
/>
|
||||||
|
<View className="ml-4 flex-1">
|
||||||
<Text className="text-lg font-JakartaSemiBold">
|
<Text className="text-lg font-JakartaSemiBold">
|
||||||
{driverDetails?.title}
|
{driver.first_name} {driver.last_name}
|
||||||
</Text>
|
</Text>
|
||||||
|
<View className="flex-row items-center mt-1">
|
||||||
|
<Image source={icons.star} className="w-4 h-4" />
|
||||||
|
<Text className="ml-1 text-general-200">
|
||||||
|
{driver.rating?.toFixed(1) ?? "—"}
|
||||||
|
</Text>
|
||||||
|
{driver.car_model ? (
|
||||||
|
<Text className="ml-3 text-general-200">
|
||||||
|
{driver.car_model}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className="text-xs text-general-200 capitalize">
|
||||||
|
{driver.service ?? ride.service}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row items-center space-x-0.5">
|
<View className="flex-row items-center gap-x-2 mt-4">
|
||||||
<Image
|
<Image source={icons.to} className="w-4 h-4" />
|
||||||
source={icons.star}
|
<Text className="font-JakartaMedium text-sm" numberOfLines={1}>
|
||||||
alt="Star"
|
{ride.origin_address}
|
||||||
className="w-5 h-5"
|
</Text>
|
||||||
resizeMode="contain"
|
</View>
|
||||||
|
<View className="flex-row items-center gap-x-2 mt-2">
|
||||||
|
<Image source={icons.point} className="w-4 h-4" />
|
||||||
|
<Text className="font-JakartaMedium text-sm" numberOfLines={1}>
|
||||||
|
{ride.destination_address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between mt-4 pt-3 border-t border-neutral-100">
|
||||||
|
<Text className="text-general-200 text-xs">
|
||||||
|
{ride.payment_status === "cash"
|
||||||
|
? "💵 Cash to driver"
|
||||||
|
: "💳 Paid by card"}
|
||||||
|
</Text>
|
||||||
|
<Text className="font-JakartaBold text-emerald-600">
|
||||||
|
${(ride.fare_price / 100).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Completed summary */}
|
||||||
|
{ride.status === "completed" ? (
|
||||||
|
<View className="bg-white rounded-2xl p-4 mt-4 items-center">
|
||||||
|
<Image source={images.check} className="w-12 h-12" />
|
||||||
|
<Text className="text-lg font-JakartaBold mt-3">
|
||||||
|
Fare: ${(ride.fare_price / 100).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-general-200 text-sm mt-1">
|
||||||
|
Trip time {formatTime(ride.ride_time)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Cancelled */}
|
||||||
|
{ride.status === "cancelled" ? (
|
||||||
|
<View className="bg-white rounded-2xl p-4 mt-4 items-center">
|
||||||
|
<Text className="text-general-200">
|
||||||
|
This ride was cancelled.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View className="mt-auto pt-6">
|
||||||
|
{terminal ? (
|
||||||
|
<CustomButton
|
||||||
|
title="Back Home"
|
||||||
|
onPress={() => router.replace("/(root)/(tabs)/home")}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
<Text className="text-lg font-JakartaRegular">
|
<TouchableOpacity
|
||||||
{driverDetails?.rating}
|
onPress={cancel}
|
||||||
|
disabled={cancelling}
|
||||||
|
className="rounded-full py-3 bg-white items-center border border-rose-300"
|
||||||
|
>
|
||||||
|
<Text className="font-JakartaBold text-rose-500">
|
||||||
|
{cancelling ? "Cancelling…" : "Cancel Ride"}
|
||||||
</Text>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</SafeAreaView>
|
||||||
|
|
||||||
<View className="flex flex-col w-full items-start justify-center py-3 px-5 rounded-3xl bg-general-600 mt-5">
|
|
||||||
<View className="flex flex-row items-center justify-between w-full border-b border-white py-3">
|
|
||||||
<Text className="text-lg font-JakartaRegular">Ride Price</Text>
|
|
||||||
|
|
||||||
<View className="flex flex-col items-end">
|
|
||||||
<Text className="text-lg font-JakartaRegular text-[#0CC25F]">
|
|
||||||
${driverDetails?.price}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="text-xs font-JakartaRegular text-general-200">
|
|
||||||
≈ {formatLBP(parseFloat(driverDetails?.price ?? "0"))}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-between w-full border-b border-white py-3">
|
|
||||||
<Text className="text-lg font-JakartaRegular">Pickup Time</Text>
|
|
||||||
|
|
||||||
<Text className="text-lg font-JakartaRegular">
|
|
||||||
{formatTime(driverDetails?.time!)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-between w-full py-3">
|
|
||||||
<Text className="text-lg font-JakartaRegular">Car Seats</Text>
|
|
||||||
|
|
||||||
<Text className="text-lg font-JakartaRegular">
|
|
||||||
{driverDetails?.car_seats}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-col w-full items-start justify-center mt-5">
|
|
||||||
<View className="flex flex-row items-center justify-start mt-3 border-t border-b border-general-700 w-full py-3">
|
|
||||||
<Image source={icons.to} alt="To" className="w-6 h-6" />
|
|
||||||
|
|
||||||
<Text className="text-lg font-JakartaRegular ml-2">
|
|
||||||
{userAddress}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex flex-row items-center justify-start border-b border-general-700 w-full py-3">
|
|
||||||
<Image source={icons.point} alt="Point" className="w-6 h-6" />
|
|
||||||
|
|
||||||
<Text className="text-lg font-JakartaRegular ml-2">
|
|
||||||
{destinationAddress}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Payment
|
|
||||||
fullName={user?.name ?? ""}
|
|
||||||
email={user?.email ?? ""}
|
|
||||||
amount={driverDetails?.price ?? "0"}
|
|
||||||
driverId={driverDetails?.id}
|
|
||||||
rideTime={driverDetails?.time ?? 0}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
</RideLayout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+310
-29
@@ -1,41 +1,322 @@
|
|||||||
import { router } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { FlatList, Text, View } from "react-native";
|
import { useEffect, useState } from "react";
|
||||||
|
import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
import { CustomButton } from "@/components/custom-button";
|
import { CustomButton } from "@/components/custom-button";
|
||||||
import { DriverCard } from "@/components/driver-card";
|
|
||||||
import { RideLayout } from "@/components/ride-layout";
|
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 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<PaymentMethod>("cash");
|
||||||
|
const [estimate, setEstimate] = useState<{
|
||||||
|
fare: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [nearestEta, setNearestEta] = useState<number | null>(null);
|
||||||
|
const [driversOnline, setDriversOnline] = useState<number | null>(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 (
|
return (
|
||||||
<RideLayout title="Choose a Driver" snapPoints={["65%", "85%"]}>
|
<RideLayout title="Request Ride" snapPoints={["60%", "88%"]}>
|
||||||
<FlatList
|
<Text className="text-xl font-JakartaSemiBold mb-1">Your trip</Text>
|
||||||
data={drivers}
|
|
||||||
renderItem={({ item }) => (
|
<View className="flex-row items-center gap-x-2 mb-1">
|
||||||
<DriverCard
|
<Text className="text-general-200 text-xs">Pickup</Text>
|
||||||
item={item}
|
|
||||||
selected={selectedDriver ?? 0}
|
|
||||||
setSelected={() => setSelectedDriver(item.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<Text className="text-center text-general-200 font-JakartaMedium mt-10">
|
|
||||||
No drivers available on this route right now.{"\n"}Please try
|
|
||||||
another destination.
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
ListFooterComponent={
|
|
||||||
<View className="mx-5 mt-10">
|
|
||||||
<CustomButton
|
|
||||||
title="Select Ride"
|
|
||||||
onPress={() => router.push("/(root)/book-ride")}
|
|
||||||
disabled={selectedDriver === null}
|
|
||||||
className={selectedDriver === null ? "opacity-50" : ""}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
<Text className="font-JakartaMedium mb-3" numberOfLines={1}>
|
||||||
|
{userAddress}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-x-2 mb-1">
|
||||||
|
<Text className="text-general-200 text-xs">Destination</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="font-JakartaMedium mb-4" numberOfLines={1}>
|
||||||
|
{destinationAddress}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className="flex-row items-center justify-between bg-general-500 rounded-2xl p-4 mb-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-general-200 text-xs font-JakartaMedium">
|
||||||
|
{selected.label} · {selected.tagline}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-general-200 text-xs mt-1">
|
||||||
|
Trip time {estimate ? formatTime(estimate.durationSeconds / 60) : "…"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-end">
|
||||||
|
<Text className="text-2xl font-JakartaExtraBold">
|
||||||
|
{estimating ? "…" : estimate ? `$${estimate.fare}` : "—"}
|
||||||
|
</Text>
|
||||||
|
{estimate ? (
|
||||||
|
<Text className="text-xs text-general-200">
|
||||||
|
≈ {formatLBP(parseFloat(estimate.fare))}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
className={`text-base font-JakartaMedium mb-2 ${
|
||||||
|
driversOnline === 0 ? "text-rose-500" : "text-general-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{driversOnline === 0
|
||||||
|
? `No ${selected.label} drivers online right now`
|
||||||
|
: nearestEta == null
|
||||||
|
? "Finding drivers nearby…"
|
||||||
|
: `Nearest driver ≈ ${nearestEta} min away`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-lg font-JakartaSemiBold mt-2 mb-2">
|
||||||
|
Payment Method
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row gap-x-3 mb-2">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setMethod("cash")}
|
||||||
|
className={`flex-1 items-center py-3 rounded-xl border ${
|
||||||
|
method === "cash"
|
||||||
|
? "bg-general-600 border-primary-500"
|
||||||
|
: "bg-white border-general-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`font-JakartaMedium ${
|
||||||
|
method === "cash" ? "text-white" : "text-black"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
💵 Cash
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setMethod("card")}
|
||||||
|
className={`flex-1 items-center py-3 rounded-xl border ${
|
||||||
|
method === "card"
|
||||||
|
? "bg-general-600 border-primary-500"
|
||||||
|
: "bg-white border-general-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`font-JakartaMedium ${
|
||||||
|
method === "card" ? "text-white" : "text-black"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
💳 Card
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
title={
|
||||||
|
processing
|
||||||
|
? "Requesting…"
|
||||||
|
: driversOnline === 0
|
||||||
|
? "No drivers online"
|
||||||
|
: method === "cash"
|
||||||
|
? "Request Ride · Pay cash to driver"
|
||||||
|
: "Request Ride · Pay by card"
|
||||||
}
|
}
|
||||||
|
className="mt-4"
|
||||||
|
onPress={request}
|
||||||
|
disabled={processing || estimating || !estimate || driversOnline === 0}
|
||||||
/>
|
/>
|
||||||
</RideLayout>
|
</RideLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
+531
-22
@@ -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 { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { CustomButton } from "@/components/custom-button";
|
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 { 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 DriverHome = () => {
|
||||||
const { signOut, user } = useSession();
|
const { signOut, user } = useSession();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
const [online, setOnline] = useState(false);
|
||||||
|
const [dashboard, setDashboard] = useState<Dashboard | null>(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 (
|
||||||
|
<SafeAreaView className="flex-1 bg-white items-center justify-center">
|
||||||
|
<ActivityIndicator size="large" color="#0286ff" />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<Onboarding onCreated={loadProfile} signOut={signOut} userName={user?.name} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const earnings = dashboard?.earnings ?? 0;
|
||||||
|
const rideCount = dashboard?.recent.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-white justify-center items-center px-7">
|
<SafeAreaView className="flex-1 bg-general-500">
|
||||||
<Image
|
<ScrollView
|
||||||
source={images.check}
|
className="flex-1 px-5"
|
||||||
alt="Registered"
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
className="w-[110px] h-[110px] mb-5"
|
>
|
||||||
/>
|
<View className="flex-row items-center justify-between my-5">
|
||||||
|
<Text className="text-2xl font-JakartaExtraBold">
|
||||||
|
Driver mode
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={signOut}
|
||||||
|
className="w-10 h-10 rounded-full bg-white items-center justify-center"
|
||||||
|
>
|
||||||
|
<Image source={icons.out} className="w-4 h-4" alt="Sign out" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Text className="text-2xl font-JakartaBold text-center">
|
{/* Online / offline toggle */}
|
||||||
You're registered as a driver, {user?.name || "there"}!
|
<TouchableOpacity
|
||||||
|
onPress={toggleOnline}
|
||||||
|
disabled={busy}
|
||||||
|
className={`rounded-2xl p-5 items-center mb-4 ${
|
||||||
|
online ? "bg-emerald-500" : "bg-neutral-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text className="text-white text-lg font-JakartaBold">
|
||||||
|
{online ? "● Online — receiving ride requests" : "○ Go online to drive"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Earnings summary */}
|
||||||
|
<View className="bg-white rounded-2xl p-4 mb-4 flex-row justify-between">
|
||||||
|
<View>
|
||||||
|
<Text className="text-general-200 text-xs font-JakartaMedium">
|
||||||
|
Today's earnings
|
||||||
|
</Text>
|
||||||
|
<Text className="text-2xl font-JakartaExtraBold">
|
||||||
|
${(earnings / 100).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="items-end">
|
||||||
|
<Text className="text-general-200 text-xs font-JakartaMedium">
|
||||||
|
Completed today
|
||||||
|
</Text>
|
||||||
|
<Text className="text-2xl font-JakartaExtraBold">{rideCount}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Active ride */}
|
||||||
|
{dashboard?.active ? (
|
||||||
|
<ActiveRideCard
|
||||||
|
ride={dashboard.active}
|
||||||
|
busy={busy}
|
||||||
|
onAdvance={advance}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Incoming offers */}
|
||||||
|
<Text className="text-xl font-JakartaBold mt-4 mb-3">
|
||||||
|
Incoming requests {online ? "" : "(offline)"}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="text-base text-general-200 font-Jakarta text-center mt-3">
|
{!online ? null : dashboard?.offers.length ? (
|
||||||
Driver mode is coming soon. We'll contact you at{" "}
|
dashboard.offers.map((offer) => (
|
||||||
{user?.email} once your account is activated.
|
<OfferCard
|
||||||
</Text>
|
key={offer.offer_id}
|
||||||
|
offer={offer}
|
||||||
<CustomButton
|
busy={busy}
|
||||||
title="Sign Out"
|
onAccept={() => respond(offer, "accept")}
|
||||||
onPress={() => signOut()}
|
onDecline={() => respond(offer, "decline")}
|
||||||
className="mt-10"
|
|
||||||
/>
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className="bg-white rounded-2xl p-6 items-center">
|
||||||
|
<Image source={images.noResult} className="w-24 h-24" resizeMode="contain" />
|
||||||
|
<Text className="text-general-200 mt-2">
|
||||||
|
{online ? "Waiting for ride requests…" : "Go online to start driving."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Onboarding form ------------------------------------------------------
|
||||||
|
|
||||||
|
const Onboarding = ({
|
||||||
|
onCreated,
|
||||||
|
signOut,
|
||||||
|
userName,
|
||||||
|
}: {
|
||||||
|
onCreated: () => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
userName?: string | null;
|
||||||
|
}) => {
|
||||||
|
const [service, setService] = useState<ServiceId>("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 (
|
||||||
|
<SafeAreaView className="flex-1 bg-white">
|
||||||
|
<ScrollView className="flex-1 px-5" contentContainerStyle={{ paddingBottom: 40 }}>
|
||||||
|
<View className="flex-row items-center justify-between my-5">
|
||||||
|
<Text className="text-2xl font-JakartaExtraBold">
|
||||||
|
Welcome, {userName?.split(" ")[0] || "driver"}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={signOut}
|
||||||
|
className="w-10 h-10 rounded-full bg-neutral-100 items-center justify-center"
|
||||||
|
>
|
||||||
|
<Image source={icons.out} className="w-4 h-4" alt="Sign out" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-base text-general-200 font-Jakarta mb-4">
|
||||||
|
Set up your driver profile to start receiving ride requests.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-lg font-JakartaSemiBold mb-3">
|
||||||
|
What will you drive?
|
||||||
|
</Text>
|
||||||
|
<View className="flex-row gap-2 mb-5">
|
||||||
|
{SERVICES.map((item) => {
|
||||||
|
const active = item.id === service;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.id}
|
||||||
|
onPress={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={item.icon}
|
||||||
|
size={24}
|
||||||
|
color={active ? "#0286ff" : "#858585"}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className={`mt-1.5 text-xs font-JakartaBold ${
|
||||||
|
active ? "text-primary-500" : "text-black"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-lg font-JakartaSemiBold mb-3">Car model</Text>
|
||||||
|
<TextInput
|
||||||
|
value={carModel}
|
||||||
|
onChangeText={setCarModel}
|
||||||
|
placeholder="e.g. Toyota Camry"
|
||||||
|
className="bg-neutral-100 rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-4"
|
||||||
|
autoCapitalize="words"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text className="text-lg font-JakartaSemiBold mb-3">Car seats</Text>
|
||||||
|
<TextInput
|
||||||
|
value={carSeats}
|
||||||
|
onChangeText={setCarSeats}
|
||||||
|
placeholder="4"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
className="bg-neutral-100 rounded-full px-4 py-4 font-JakartaSemiBold text-[15px] mb-8"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomButton
|
||||||
|
title={submitting ? "Saving…" : "Start driving"}
|
||||||
|
onPress={submit}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Offer card -----------------------------------------------------------
|
||||||
|
|
||||||
|
const OfferCard = ({
|
||||||
|
offer,
|
||||||
|
busy,
|
||||||
|
onAccept,
|
||||||
|
onDecline,
|
||||||
|
}: {
|
||||||
|
offer: Offer;
|
||||||
|
busy: boolean;
|
||||||
|
onAccept: () => void;
|
||||||
|
onDecline: () => void;
|
||||||
|
}) => (
|
||||||
|
<View className="bg-white rounded-2xl p-4 mb-3">
|
||||||
|
<View className="flex-row items-center justify-between mb-2">
|
||||||
|
<Text className="text-sm font-JakartaBold text-primary-500">
|
||||||
|
New request · {offer.service}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-general-200">
|
||||||
|
{offer.payment_status === "cash" ? "💵 Cash" : "💳 Card"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-x-2 mb-1">
|
||||||
|
<Image source={icons.to} alt="From" className="w-4 h-4" />
|
||||||
|
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||||
|
{offer.origin_address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row items-center gap-x-2 mb-3">
|
||||||
|
<Image source={icons.point} alt="To" className="w-4 h-4" />
|
||||||
|
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||||
|
{offer.destination_address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between mb-3">
|
||||||
|
<Text className="text-general-200 text-xs">Trip time</Text>
|
||||||
|
<Text className="font-JakartaMedium text-xs">
|
||||||
|
{formatTime(offer.ride_time)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row justify-between mb-3">
|
||||||
|
<Text className="text-general-200 text-xs">Fare</Text>
|
||||||
|
<Text className="font-JakartaMedium text-xs text-emerald-600">
|
||||||
|
${(offer.fare_price / 100).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row gap-3">
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onDecline}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex-1 rounded-full py-3 bg-neutral-200 items-center"
|
||||||
|
>
|
||||||
|
<Text className="font-JakartaBold text-neutral-700">Decline</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onAccept}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex-1 rounded-full py-3 bg-emerald-500 items-center"
|
||||||
|
>
|
||||||
|
<Text className="font-JakartaBold text-white">
|
||||||
|
{busy ? "…" : "Accept"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- 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 (
|
||||||
|
<View className="bg-primary-500/10 border border-primary-500 rounded-2xl p-4 mb-4">
|
||||||
|
<View className="flex-row items-center justify-between mb-2">
|
||||||
|
<Text className="text-sm font-JakartaBold text-primary-500">
|
||||||
|
● {statusLabel}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-general-200">{ride.service}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{ride.rider_name ? (
|
||||||
|
<Text className="font-JakartaBold mb-2">{ride.rider_name}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View className="flex-row items-center gap-x-2 mb-1">
|
||||||
|
<Image source={icons.to} alt="From" className="w-4 h-4" />
|
||||||
|
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||||
|
{ride.origin_address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-row items-center gap-x-2 mb-3">
|
||||||
|
<Image source={icons.point} alt="To" className="w-4 h-4" />
|
||||||
|
<Text className="font-JakartaMedium" numberOfLines={1}>
|
||||||
|
{ride.destination_address}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-row justify-between mb-4">
|
||||||
|
<Text className="text-general-200 text-xs">Fare</Text>
|
||||||
|
<Text className="font-JakartaMedium text-xs text-emerald-600">
|
||||||
|
${(ride.fare_price / 100).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{ride.status === "accepted" ? (
|
||||||
|
<CustomButton
|
||||||
|
title={busy ? "…" : "Start trip"}
|
||||||
|
bgVariant="success"
|
||||||
|
onPress={() => onAdvance(ride.ride_id, "en_route")}
|
||||||
|
className="mb-2"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{ride.status === "en_route" ? (
|
||||||
|
<CustomButton
|
||||||
|
title={busy ? "…" : "Complete trip"}
|
||||||
|
bgVariant="success"
|
||||||
|
onPress={() => onAdvance(ride.ride_id, "completed")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default DriverHome;
|
export default DriverHome;
|
||||||
@@ -3,7 +3,6 @@ import { Stack } from "expo-router";
|
|||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { LogBox } from "react-native";
|
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
|
|
||||||
import { SessionProvider } from "@/lib/session";
|
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.
|
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
LogBox.ignoreAllLogs();
|
|
||||||
|
|
||||||
const RootLayout = () => {
|
const RootLayout = () => {
|
||||||
const [loaded] = useFonts({
|
const [loaded] = useFonts({
|
||||||
"Jakarta-Bold": require("../assets/fonts/PlusJakartaSans-Bold.ttf"),
|
"Jakarta-Bold": require("../assets/fonts/PlusJakartaSans-Bold.ttf"),
|
||||||
|
|||||||
+16
-5
@@ -10,7 +10,7 @@ import {
|
|||||||
calculateRegion,
|
calculateRegion,
|
||||||
generateMarkersFromData,
|
generateMarkersFromData,
|
||||||
} from "@/lib/map";
|
} from "@/lib/map";
|
||||||
import { useDriverStore, useLocationStore } from "@/store";
|
import { useDriverStore, useLocationStore, useServiceStore } from "@/store";
|
||||||
import type { Driver, MarkerData } from "@/types/type";
|
import type { Driver, MarkerData } from "@/types/type";
|
||||||
|
|
||||||
// react-native-maps sizes itself from a real style object, so give it explicit
|
// 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 = () => {
|
export const Map = () => {
|
||||||
const { data: drivers, error } = useFetch<Driver[]>("/(api)/driver");
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userLatitude,
|
userLatitude,
|
||||||
userLongitude,
|
userLongitude,
|
||||||
destinationLatitude,
|
destinationLatitude,
|
||||||
destinationLongitude,
|
destinationLongitude,
|
||||||
} = useLocationStore();
|
} = useLocationStore();
|
||||||
|
const { service } = useServiceStore();
|
||||||
const { selectedDriver, setDrivers } = useDriverStore();
|
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<Driver[]>(
|
||||||
|
`/(api)/driver/nearby?service=${service}&lat=${lat}&lng=${lng}`,
|
||||||
|
);
|
||||||
|
|
||||||
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
const [markers, setMarkers] = useState<MarkerData[]>([]);
|
||||||
|
|
||||||
const region = calculateRegion({
|
const region = calculateRegion({
|
||||||
@@ -81,8 +90,9 @@ export const Map = () => {
|
|||||||
userLongitude,
|
userLongitude,
|
||||||
destinationLatitude,
|
destinationLatitude,
|
||||||
destinationLongitude,
|
destinationLongitude,
|
||||||
}).then((drivers) => {
|
service,
|
||||||
setDrivers(drivers as MarkerData[]);
|
}).then((driversWithTimes) => {
|
||||||
|
setDrivers((driversWithTimes as MarkerData[]) ?? []);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -92,6 +102,7 @@ export const Map = () => {
|
|||||||
userLatitude,
|
userLatitude,
|
||||||
userLongitude,
|
userLongitude,
|
||||||
setDrivers,
|
setDrivers,
|
||||||
|
service,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// The map itself never waits on the driver list or the location fix: drivers
|
// The map itself never waits on the driver list or the location fix: drivers
|
||||||
|
|||||||
@@ -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<Record<string, ChipState>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userLatitude == null || userLongitude == null) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setChips({});
|
||||||
|
|
||||||
|
// Resolve all four categories in parallel.
|
||||||
|
POI_CATEGORIES.forEach(async (category) => {
|
||||||
|
setChips((prev) => ({ ...prev, [category.id]: { status: "loading" } }));
|
||||||
|
const place = await searchNearby(category.googleType, {
|
||||||
|
latitude: userLatitude,
|
||||||
|
longitude: userLongitude,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
setChips((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[category.id]: place ? { status: "ready", place } : { status: "empty" },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [userLatitude, userLongitude]);
|
||||||
|
|
||||||
|
const select = (place: NearbyPlace) => {
|
||||||
|
setDestinationLocation({
|
||||||
|
latitude: place.latitude,
|
||||||
|
longitude: place.longitude,
|
||||||
|
address: place.name,
|
||||||
|
});
|
||||||
|
router.push("/(root)/find-ride");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<Text className="text-base font-JakartaSemiBold mb-3">
|
||||||
|
Nearby suggestions
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={{ gap: 10, paddingBottom: 4 }}
|
||||||
|
>
|
||||||
|
{POI_CATEGORIES.map((category) => {
|
||||||
|
const state = chips[category.id];
|
||||||
|
const ready = state?.status === "ready" ? state.place : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={category.id}
|
||||||
|
disabled={!ready}
|
||||||
|
onPress={() => ready && select(ready)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
className={`flex-row items-center rounded-2xl border px-3 py-2.5 ${
|
||||||
|
ready
|
||||||
|
? "border-primary-500 bg-primary-500/10"
|
||||||
|
: "border-neutral-200 bg-neutral-100"
|
||||||
|
}`}
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={category.icon as never}
|
||||||
|
size={20}
|
||||||
|
color={ready ? "#0286ff" : "#a3a3a3"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="ml-2 flex-1">
|
||||||
|
<Text
|
||||||
|
className={`text-xs font-JakartaBold ${
|
||||||
|
ready ? "text-primary-500" : "text-neutral-400"
|
||||||
|
}`}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-[11px] text-general-200"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{!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}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
+37
-10
@@ -5,7 +5,7 @@ import { Alert, Image, Text, TouchableOpacity, View } from "react-native";
|
|||||||
import ReactNativeModal from "react-native-modal";
|
import ReactNativeModal from "react-native-modal";
|
||||||
|
|
||||||
import { images } from "@/constants";
|
import { images } from "@/constants";
|
||||||
import { fetchAPI } from "@/lib/fetch";
|
import { ApiError, fetchAPI } from "@/lib/fetch";
|
||||||
import { formatLBP } from "@/lib/pricing";
|
import { formatLBP } from "@/lib/pricing";
|
||||||
import { useLocationStore } from "@/store";
|
import { useLocationStore } from "@/store";
|
||||||
import type { PaymentProps } from "@/types/type";
|
import type { PaymentProps } from "@/types/type";
|
||||||
@@ -33,7 +33,9 @@ export const Payment = ({
|
|||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [processing, setProcessing] = 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", {
|
await fetchAPI("/(api)/ride/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -47,8 +49,9 @@ export const Payment = ({
|
|||||||
destination_latitude: destinationLatitude,
|
destination_latitude: destinationLatitude,
|
||||||
destination_longitude: destinationLongitude,
|
destination_longitude: destinationLongitude,
|
||||||
ride_time: rideTime.toFixed(0),
|
ride_time: rideTime.toFixed(0),
|
||||||
fare_price: Math.round(parseFloat(amount) * 100), // in cents
|
fare_price: fareCents,
|
||||||
payment_status: paymentStatus,
|
payment_method: paymentMethod,
|
||||||
|
...(orderId ? { payment_order_id: orderId } : {}),
|
||||||
driver_id: driverId,
|
driver_id: driverId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -75,8 +78,10 @@ export const Payment = ({
|
|||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Create an Areeba checkout session on our server.
|
// 1. Create an Areeba checkout session on our server. The server stores
|
||||||
const { orderId, checkoutUrl, successIndicator, error } = await fetchAPI(
|
// the ride intent and the successIndicator; the client only gets an
|
||||||
|
// orderId + checkoutUrl.
|
||||||
|
const { orderId, checkoutUrl, error } = await fetchAPI(
|
||||||
"/(api)/(areeba)/create",
|
"/(api)/(areeba)/create",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -86,7 +91,15 @@ export const Payment = ({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: fullName || email,
|
name: fullName || email,
|
||||||
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;
|
) 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", {
|
const verification = await fetchAPI("/(api)/(areeba)/verify", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-type": "application/json",
|
"Content-type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ orderId, resultIndicator, successIndicator }),
|
body: JSON.stringify({ orderId, resultIndicator }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (verification.success) {
|
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);
|
setSuccess(true);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
@@ -127,10 +144,20 @@ export const Payment = ({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log("[PAYMENT]: ", err);
|
console.log("[PAYMENT]: ", err);
|
||||||
|
// Verification failures (cancelled, not captured, amount/intent mismatch)
|
||||||
|
// come back as 400s. fetchAPI throws ApiError on non-2xx, so without this
|
||||||
|
// branch every cancellation lands in the generic "something went wrong".
|
||||||
|
if (err instanceof ApiError && err.status === 400) {
|
||||||
|
Alert.alert(
|
||||||
|
"Payment not completed",
|
||||||
|
"Your payment was cancelled or could not be verified. Please try again.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Error",
|
"Error",
|
||||||
"Something went wrong while processing your payment. Please try again.",
|
"Something went wrong while processing your payment. Please try again.",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export type Service = {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Shown under the row once the service is selected. */
|
/** Shown under the row once the service is selected. */
|
||||||
tagline: string;
|
tagline: string;
|
||||||
|
/** Multiplier applied to the base fare for this service (car = 1.0). */
|
||||||
|
fareMultiplier: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SERVICES: Service[] = [
|
export const SERVICES: Service[] = [
|
||||||
@@ -27,24 +29,28 @@ export const SERVICES: Service[] = [
|
|||||||
icon: "car",
|
icon: "car",
|
||||||
label: "Car",
|
label: "Car",
|
||||||
tagline: "An everyday ride, up to 4 seats.",
|
tagline: "An everyday ride, up to 4 seats.",
|
||||||
|
fareMultiplier: 1.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "moto",
|
id: "moto",
|
||||||
icon: "motorbike",
|
icon: "motorbike",
|
||||||
label: "Moto",
|
label: "Moto",
|
||||||
tagline: "Beat the traffic — one passenger, no luggage.",
|
tagline: "Beat the traffic — one passenger, no luggage.",
|
||||||
|
fareMultiplier: 0.7,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "courier",
|
id: "courier",
|
||||||
icon: "package-variant-closed",
|
icon: "package-variant-closed",
|
||||||
label: "Courier",
|
label: "Courier",
|
||||||
tagline: "Send a parcel across town without riding along.",
|
tagline: "Send a parcel across town without riding along.",
|
||||||
|
fareMultiplier: 0.85,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "chauffeur",
|
id: "chauffeur",
|
||||||
icon: "steering",
|
icon: "steering",
|
||||||
label: "My Car",
|
label: "My Car",
|
||||||
tagline: "A driver comes to you and drives your own car.",
|
tagline: "A driver comes to you and drives your own car.",
|
||||||
|
fareMultiplier: 1.5,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ type Driver = {
|
|||||||
car_image_url: string | null;
|
car_image_url: string | null;
|
||||||
car_seats: number;
|
car_seats: number;
|
||||||
rating: string;
|
rating: string;
|
||||||
|
service: string;
|
||||||
|
online: boolean;
|
||||||
|
car_model: string | null;
|
||||||
total_rides: number;
|
total_rides: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
};
|
};
|
||||||
@@ -63,8 +66,10 @@ export default function Drivers() {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
<th>Service</th>
|
||||||
<th>Seats</th>
|
<th>Seats</th>
|
||||||
<th>Rating</th>
|
<th>Rating</th>
|
||||||
|
<th>Online</th>
|
||||||
<th>Rides</th>
|
<th>Rides</th>
|
||||||
<th>Revenue</th>
|
<th>Revenue</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -77,8 +82,12 @@ export default function Drivers() {
|
|||||||
<td>
|
<td>
|
||||||
{d.first_name} {d.last_name}
|
{d.first_name} {d.last_name}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`tag tag-${d.service}`}>{d.service}</span>
|
||||||
|
</td>
|
||||||
<td>{d.car_seats}</td>
|
<td>{d.car_seats}</td>
|
||||||
<td>{d.rating}</td>
|
<td>{d.rating}</td>
|
||||||
|
<td>{d.online ? "● online" : "○ off"}</td>
|
||||||
<td>{d.total_rides}</td>
|
<td>{d.total_rides}</td>
|
||||||
<td>{d.revenue.toLocaleString()}</td>
|
<td>{d.revenue.toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
+107
@@ -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<number | null> => {
|
||||||
|
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<EligibleDriver>`
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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<DriverProfile | AuthError> => {
|
||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -15,6 +15,11 @@ const getPassword = (): string | undefined =>
|
|||||||
export const isMailConfigured = (): boolean =>
|
export const isMailConfigured = (): boolean =>
|
||||||
Boolean(process.env.SMTP_USER && getPassword());
|
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;
|
let transporter: nodemailer.Transporter | null = null;
|
||||||
|
|
||||||
const getTransporter = (): nodemailer.Transporter => {
|
const getTransporter = (): nodemailer.Transporter => {
|
||||||
@@ -47,7 +52,10 @@ export const sendEmail = async (
|
|||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (!isMailConfigured()) {
|
if (!isMailConfigured()) {
|
||||||
// Not configured: fall back to the server log so development still works.
|
// Not configured: fall back to the server log so development still works.
|
||||||
|
// In production never log the code to stdout; just report not sent.
|
||||||
|
if (isDevOtpExposed()) {
|
||||||
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
|
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +68,9 @@ export const sendEmail = async (
|
|||||||
// Delivery is best-effort: report the failure and let the caller surface
|
// Delivery is best-effort: report the failure and let the caller surface
|
||||||
// the code another way instead of failing the whole request.
|
// the code another way instead of failing the whole request.
|
||||||
console.error(`[MAIL to=${to}] send failed:`, error);
|
console.error(`[MAIL to=${to}] send failed:`, error);
|
||||||
|
if (isDevOtpExposed()) {
|
||||||
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
|
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+82
-9
@@ -1,8 +1,12 @@
|
|||||||
import { calculateFare } from "@/lib/pricing";
|
import { calculateFare } from "@/lib/pricing";
|
||||||
|
import { DEFAULT_SERVICE, type ServiceId } from "@/constants/services";
|
||||||
import type { Driver, MarkerData } from "@/types/type";
|
import type { Driver, MarkerData } from "@/types/type";
|
||||||
|
|
||||||
const directionsAPI = process.env.EXPO_PUBLIC_GOOGLE_API_KEY;
|
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 = ({
|
export const generateMarkersFromData = ({
|
||||||
data,
|
data,
|
||||||
userLatitude,
|
userLatitude,
|
||||||
@@ -12,16 +16,23 @@ export const generateMarkersFromData = ({
|
|||||||
userLatitude: number;
|
userLatitude: number;
|
||||||
userLongitude: number;
|
userLongitude: number;
|
||||||
}): MarkerData[] => {
|
}): MarkerData[] => {
|
||||||
return data.map((driver, i) => {
|
return data
|
||||||
const latOffset = (Math.random() - 0.5) * 0.01; // Random offset between -0.005 and 0.005
|
.filter((driver) => driver.latitude != null && driver.longitude != null)
|
||||||
const lngOffset = (Math.random() - 0.5) * 0.01; // Random offset between -0.005 and 0.005
|
.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 {
|
return {
|
||||||
id: i,
|
|
||||||
latitude: userLatitude + latOffset,
|
|
||||||
longitude: userLongitude + lngOffset,
|
|
||||||
title: `${driver.first_name} ${driver.last_name}`,
|
|
||||||
...driver,
|
...driver,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
title: `${driver.first_name} ${driver.last_name}`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -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 ({
|
export const calculateDriverTimes = async ({
|
||||||
markers,
|
markers,
|
||||||
userLatitude,
|
userLatitude,
|
||||||
userLongitude,
|
userLongitude,
|
||||||
destinationLatitude,
|
destinationLatitude,
|
||||||
destinationLongitude,
|
destinationLongitude,
|
||||||
|
service = DEFAULT_SERVICE,
|
||||||
}: {
|
}: {
|
||||||
markers: MarkerData[];
|
markers: MarkerData[];
|
||||||
userLatitude: number | null;
|
userLatitude: number | null;
|
||||||
userLongitude: number | null;
|
userLongitude: number | null;
|
||||||
destinationLatitude: number | null;
|
destinationLatitude: number | null;
|
||||||
destinationLongitude: number | null;
|
destinationLongitude: number | null;
|
||||||
|
service?: ServiceId;
|
||||||
}) => {
|
}) => {
|
||||||
if (
|
if (
|
||||||
!userLatitude ||
|
!userLatitude ||
|
||||||
@@ -120,10 +136,13 @@ export const calculateDriverTimes = async ({
|
|||||||
|
|
||||||
// The rider pays for the trip leg only (distance + duration) —
|
// The rider pays for the trip leg only (distance + duration) —
|
||||||
// never for the driver's approach.
|
// never for the driver's approach.
|
||||||
const price = calculateFare({
|
const price = calculateFare(
|
||||||
|
{
|
||||||
distanceMeters: legToDestination.distance.value,
|
distanceMeters: legToDestination.distance.value,
|
||||||
durationSeconds: timeToDestination,
|
durationSeconds: timeToDestination,
|
||||||
});
|
},
|
||||||
|
service,
|
||||||
|
);
|
||||||
|
|
||||||
const totalTripTime = (timeToUser + timeToDestination) / 60; // Minutes until drop-off
|
const totalTripTime = (timeToUser + timeToDestination) / 60; // Minutes until drop-off
|
||||||
|
|
||||||
@@ -135,3 +154,57 @@ export const calculateDriverTimes = async ({
|
|||||||
console.error("Error calculating driver times:", error);
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
+15
-4
@@ -1,8 +1,9 @@
|
|||||||
// Shared helpers for the 6-digit email codes used by sign-up verification and
|
// 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
|
// password reset. Both flows store a peppered HMAC-SHA256 hash keyed by email,
|
||||||
// itself only ever lives in the outgoing mail.
|
// 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;
|
export const CODE_TTL_MINUTES = 15;
|
||||||
|
|
||||||
@@ -13,8 +14,18 @@ export const MAX_CODE_ATTEMPTS = 5;
|
|||||||
export const generateCode = (): string =>
|
export const generateCode = (): string =>
|
||||||
String(randomInt(0, 1_000_000)).padStart(6, "0");
|
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 =>
|
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 = (
|
export const codeMatches = (
|
||||||
storedHash: string,
|
storedHash: string,
|
||||||
|
|||||||
@@ -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 = <R extends QueryResultRow = QueryResultRow>(
|
||||||
|
strings: TemplateStringsArray,
|
||||||
|
...values: SqlValue[]
|
||||||
|
) => Promise<R[]>;
|
||||||
|
|
||||||
|
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<PaymentOrder> => {
|
||||||
|
const rows = await sql<PaymentOrder>`
|
||||||
|
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<PaymentOrder | null> => {
|
||||||
|
const rows = await sql<PaymentOrder>`
|
||||||
|
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<PaymentOrder | null> => {
|
||||||
|
const rows = await sql<PaymentOrder>`
|
||||||
|
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<PaymentOrder | null> => {
|
||||||
|
const rows = await runner<PaymentOrder>`
|
||||||
|
UPDATE payment_orders
|
||||||
|
SET status = 'consumed'
|
||||||
|
WHERE order_id = ${orderId}
|
||||||
|
AND user_id = ${userId}
|
||||||
|
AND status = 'paid'
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return rows[0] ?? null;
|
||||||
|
};
|
||||||
@@ -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<NearbyPlace | null> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
+12
-4
@@ -4,6 +4,8 @@
|
|||||||
// - Prices are quoted in USD (the de facto ride-hailing currency) with an
|
// - Prices are quoted in USD (the de facto ride-hailing currency) with an
|
||||||
// L.B.P. equivalent shown for cash settlement.
|
// L.B.P. equivalent shown for cash settlement.
|
||||||
|
|
||||||
|
import { DEFAULT_SERVICE, SERVICES, type ServiceId } from "@/constants/services";
|
||||||
|
|
||||||
export const FARE = {
|
export const FARE = {
|
||||||
base: 1.5, // USD, flag drop
|
base: 1.5, // USD, flag drop
|
||||||
perKm: 0.55, // USD per kilometer of the trip
|
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.
|
// Parallel market rate used for the L.B.P. cash equivalent shown in-app.
|
||||||
export const LBP_RATE = 89500;
|
export const LBP_RATE = 89500;
|
||||||
|
|
||||||
export const calculateFare = ({
|
export const calculateFare = (
|
||||||
|
{
|
||||||
distanceMeters,
|
distanceMeters,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
}: {
|
}: {
|
||||||
distanceMeters: number;
|
distanceMeters: number;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
}): string => {
|
},
|
||||||
|
service: ServiceId = DEFAULT_SERVICE,
|
||||||
|
): string => {
|
||||||
const km = distanceMeters / 1000;
|
const km = distanceMeters / 1000;
|
||||||
const minutes = durationSeconds / 60;
|
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);
|
return Math.max(fare, FARE.minimum).toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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<Ride> => {
|
||||||
|
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<RequestResult> => {
|
||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -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<Location.LocationSubscription | null>(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]);
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { sql } from "@/lib/db";
|
|
||||||
import { signJwt } from "@/lib/jwt";
|
import { signJwt } from "@/lib/jwt";
|
||||||
|
|
||||||
export type UserProfile = {
|
export type UserProfile = {
|
||||||
@@ -30,11 +29,3 @@ export const issueSession = (
|
|||||||
user: toProfile(row),
|
user: toProfile(row),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const findUserByEmail = async (
|
|
||||||
email: string,
|
|
||||||
): Promise<UserRow | null> => {
|
|
||||||
const rows = await sql<UserRow>`
|
|
||||||
SELECT id, name, email, role FROM users WHERE email = ${email}
|
|
||||||
`;
|
|
||||||
return rows[0] ?? null;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -50,3 +50,21 @@ export function normalizePhone(raw: string): string {
|
|||||||
|
|
||||||
return `+961${cleaned.replace(/^0+/, "")}`;
|
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));
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"test": "jest --watchAll",
|
"test": "jest --watchAll",
|
||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
|
|||||||
+74
-5
@@ -109,6 +109,19 @@ await sql`CREATE TABLE IF NOT EXISTS drivers (
|
|||||||
rating NUMERIC(2,1) NOT NULL
|
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 (
|
await sql`CREATE TABLE IF NOT EXISTS rides (
|
||||||
ride_id SERIAL PRIMARY KEY,
|
ride_id SERIAL PRIMARY KEY,
|
||||||
origin_address TEXT NOT NULL,
|
origin_address TEXT NOT NULL,
|
||||||
@@ -122,18 +135,74 @@ await sql`CREATE TABLE IF NOT EXISTS rides (
|
|||||||
payment_status VARCHAR(50) NOT NULL,
|
payment_status VARCHAR(50) NOT NULL,
|
||||||
driver_id INTEGER NOT NULL REFERENCES drivers(id),
|
driver_id INTEGER NOT NULL REFERENCES drivers(id),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
payment_order_id TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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`;
|
const count = await sql`SELECT COUNT(*)::int AS n FROM drivers`;
|
||||||
if (count[0].n === 0) {
|
if (count[0].n === 0) {
|
||||||
await sql`INSERT INTO drivers
|
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
|
VALUES
|
||||||
('Karim', 'Haddad', 'https://randomuser.me/api/portraits/men/32.jpg', 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=600', 4, 4.8),
|
('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', 4, 4.9),
|
('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),
|
('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)`;
|
('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.");
|
console.log("Seeded 4 drivers.");
|
||||||
} else {
|
} else {
|
||||||
console.log(`Drivers table already has ${count[0].n} rows, skipping seed.`);
|
console.log(`Drivers table already has ${count[0].n} rows, skipping seed.`);
|
||||||
|
|||||||
Vendored
+47
-6
@@ -1,13 +1,20 @@
|
|||||||
import { TextInputProps, TouchableOpacityProps } from "react-native";
|
import { TextInputProps, TouchableOpacityProps } from "react-native";
|
||||||
|
|
||||||
declare interface Driver {
|
declare interface Driver {
|
||||||
driver_id: number;
|
id: number;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
profile_image_url: string;
|
profile_image_url: string;
|
||||||
car_image_url: string;
|
car_image_url: string;
|
||||||
car_seats: number;
|
car_seats: number;
|
||||||
rating: 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 {
|
declare interface MarkerData {
|
||||||
@@ -21,6 +28,9 @@ declare interface MarkerData {
|
|||||||
rating: number;
|
rating: number;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
|
service?: string;
|
||||||
|
online?: boolean;
|
||||||
|
car_model?: string | null;
|
||||||
time?: number;
|
time?: number;
|
||||||
price?: string;
|
price?: string;
|
||||||
}
|
}
|
||||||
@@ -34,6 +44,7 @@ declare interface MapProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare interface Ride {
|
declare interface Ride {
|
||||||
|
ride_id?: number;
|
||||||
origin_address: string;
|
origin_address: string;
|
||||||
destination_address: string;
|
destination_address: string;
|
||||||
origin_latitude: number;
|
origin_latitude: number;
|
||||||
@@ -43,16 +54,46 @@ declare interface Ride {
|
|||||||
ride_time: number;
|
ride_time: number;
|
||||||
fare_price: number;
|
fare_price: number;
|
||||||
payment_status: string;
|
payment_status: string;
|
||||||
driver_id: number;
|
status: string;
|
||||||
user_email: string;
|
service: string;
|
||||||
|
driver_id: number | null;
|
||||||
|
user_id?: string;
|
||||||
|
payment_order_id?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
cancelled_at?: string | null;
|
||||||
driver: {
|
driver: {
|
||||||
first_name: string;
|
id: number | null;
|
||||||
last_name: string;
|
first_name: string | null;
|
||||||
car_seats: number;
|
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 {
|
declare interface ButtonProps extends TouchableOpacityProps {
|
||||||
title: string;
|
title: string;
|
||||||
bgVariant?: "primary" | "secondary" | "danger" | "outline" | "success";
|
bgVariant?: "primary" | "secondary" | "danger" | "outline" | "success";
|
||||||
|
|||||||
Reference in New Issue
Block a user