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:
Krikorios
2026-08-24 13:57:21 +03:00
co-authored by Claude
parent 4e0a7cca51
commit f50ff27e11
48 changed files with 3342 additions and 602 deletions
+104 -19
View File
@@ -1,38 +1,123 @@
import { randomUUID } from "crypto";
import { requireAuth } from "@/lib/jwt";
import { createCheckoutSession } from "@/lib/areeba";
import { createOrder } from "@/lib/payment-orders";
// Only these return URLs may be handed to the gateway. Anything else
// (including open redirects) is rejected and we fall back to the deep link.
const isAllowedReturnUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
// The app's own deep link is always allowed.
if (parsed.protocol === "waseel:") return true;
// The configured server origin (EXPO_PUBLIC_SERVER_URL), if set.
const serverUrl = process.env.EXPO_PUBLIC_SERVER_URL;
if (serverUrl) {
const server = new URL(serverUrl);
if (parsed.protocol === server.protocol && parsed.host === server.host)
return true;
}
return false;
} catch {
return false;
}
};
// Resolve the fare to integer cents. Accept fare_cents directly, or fare /
// amount in dollars (legacy client field) and convert.
const resolveAmountCents = (fareCents: unknown, fare: unknown, amount: unknown): number | null => {
let cents: number;
if (fareCents !== undefined && fareCents !== null) {
cents = Math.round(Number(fareCents));
} else if (fare !== undefined && fare !== null) {
cents = Math.round(Number(fare) * 100);
} else if (amount !== undefined && amount !== null) {
cents = Math.round(Number(amount) * 100);
} else {
return null;
}
if (!Number.isFinite(cents) || cents <= 0) return null;
return cents;
};
export async function POST(req: Request) {
const body = await req.json();
const { name, email, amount, returnUrl } = body;
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
if (!name || !email || !amount)
return new Response(
JSON.stringify({ error: "Missing required payment information." }),
const body = await req.json().catch(() => ({}));
const {
name,
email,
amount,
fare_cents,
fare,
returnUrl,
driver_id,
origin_address,
destination_address,
origin_latitude,
origin_longitude,
destination_latitude,
destination_longitude,
ride_time,
} = body;
if (!name || !email)
return Response.json(
{ error: "Missing required payment information." },
{ status: 400 },
);
const amountCents = resolveAmountCents(fare_cents, fare, amount);
if (amountCents === null)
return Response.json({ error: "Invalid fare amount." }, { status: 400 });
const finalReturnUrl =
typeof returnUrl === "string" && isAllowedReturnUrl(returnUrl)
? returnUrl
: "waseel://book-ride";
try {
const orderId = `waseel-${Date.now()}`;
const orderId = randomUUID();
const session = await createCheckoutSession({
orderId,
amount: parseFloat(amount),
amount: amountCents / 100,
currency: "USD",
description: `Waseel ride payment for ${name}`,
returnUrl: returnUrl || "waseel://book-ride",
returnUrl: finalReturnUrl,
});
return new Response(
JSON.stringify({
orderId,
checkoutUrl: session.checkoutUrl,
successIndicator: session.successIndicator,
}),
);
// The successIndicator stays server-side; the client never sees it.
if (!session.successIndicator)
throw new Error("Areeba did not return a successIndicator.");
await createOrder({
order_id: orderId,
user_id: auth.userId,
amount_cents: amountCents,
currency: "USD",
driver_id: driver_id ?? null,
origin_address: origin_address ?? null,
destination_address: destination_address ?? null,
origin_latitude: origin_latitude ?? null,
origin_longitude: origin_longitude ?? null,
destination_latitude: destination_latitude ?? null,
destination_longitude: destination_longitude ?? null,
ride_time: ride_time ?? null,
success_indicator: session.successIndicator,
status: "pending",
});
return Response.json({ orderId, checkoutUrl: session.checkoutUrl });
} catch (err) {
console.log("[AREEBA_PAYMENT_CREATE]: ", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
+57 -23
View File
@@ -1,35 +1,69 @@
import { requireAuth } from "@/lib/jwt";
import { retrieveOrder } from "@/lib/areeba";
import { getOrder, markPaid } from "@/lib/payment-orders";
export async function POST(req: Request) {
const body = await req.json();
const { orderId, resultIndicator, successIndicator } = body;
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
if (!orderId)
return new Response(JSON.stringify({ error: "Missing order id." }), {
status: 400,
});
const body = await req.json().catch(() => ({}));
const { orderId, resultIndicator } = body;
if (!orderId || !resultIndicator)
return Response.json(
{ error: "Missing order id or result indicator." },
{ status: 400 },
);
try {
const order = await retrieveOrder(orderId);
// Look the order up server-side — never trust a client-supplied
// successIndicator value, only the one we persisted at creation time.
const order = await getOrder(orderId);
if (!order)
return Response.json({ error: "Order not found." }, { status: 404 });
// The gateway appends resultIndicator to the return URL after payment;
// it must match the successIndicator issued when the session was created.
const indicatorMatches =
!successIndicator || resultIndicator === successIndicator;
if (order.user_id !== auth.userId)
return Response.json({ error: "Unauthorized." }, { status: 403 });
return new Response(
JSON.stringify({
success: order.paid && indicatorMatches,
status: order.status,
amount: order.amount,
currency: order.currency,
}),
);
// An order can only be verified once. A 'paid' or 'consumed' order has
// already settled — rejecting here is the primary double-spend defense: it
// stops a client from re-verifying an order it already used for a ride.
if (order.status !== "pending")
return Response.json(
{ error: "This payment order is no longer pending." },
{ status: 400 },
);
if (
!order.success_indicator ||
order.success_indicator !== resultIndicator
)
return Response.json(
{ error: "Payment verification failed." },
{ status: 400 },
);
const retrieved = await retrieveOrder(orderId);
if (!retrieved.paid)
return Response.json({ error: "Payment not captured." }, { status: 400 });
// Reconcile the gateway's amount/currency against what we stored, so a
// tampered or partial payment cannot mark a full-fare order paid.
const gatewayCents = Math.round(Number(retrieved.amount) * 100);
const gatewayCurrency = retrieved.currency ?? "USD";
if (gatewayCents !== order.amount_cents || gatewayCurrency !== order.currency)
return Response.json(
{ error: "Payment amount mismatch." },
{ status: 400 },
);
await markPaid(orderId);
return Response.json({ success: true, orderId });
} catch (err) {
console.log("[AREEBA_PAYMENT_VERIFY]: ", err);
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 500,
});
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
+5 -5
View File
@@ -25,11 +25,11 @@ export async function GET(request: Request) {
(SELECT COUNT(*)::int FROM users) AS users,
(SELECT COUNT(*)::int FROM drivers) AS drivers,
(SELECT COUNT(*)::int FROM rides) AS rides,
(SELECT COALESCE(SUM(fare_price), 0)::int FROM rides WHERE payment_status = 'paid') AS revenue,
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE payment_status = 'paid') AS revenue,
(SELECT COUNT(*)::int FROM rides WHERE created_at >= CURRENT_DATE) AS rides_today,
(SELECT COALESCE(ROUND(AVG(fare_price)), 0)::int FROM rides WHERE payment_status = 'paid') AS avg_fare,
(SELECT COALESCE(ROUND(AVG(fare_price) / 100.0, 2), 0)::float8 FROM rides WHERE payment_status = 'paid') AS avg_fare,
(SELECT COUNT(*)::int FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_count,
(SELECT COALESCE(SUM(fare_price), 0)::int FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_revenue,
(SELECT COALESCE(SUM(fare_price) / 100.0, 0)::float8 FROM rides WHERE LOWER(payment_status) <> 'paid') AS pending_revenue,
(SELECT COUNT(*)::int FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '7 days') AS new_users_7d
`;
@@ -37,7 +37,7 @@ export async function GET(request: Request) {
SELECT
TO_CHAR(DAY, 'YYYY-MM-DD') AS day,
COUNT(r.ride_id)::int AS rides,
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid'), 0)::int AS revenue
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue
FROM generate_series(
CURRENT_DATE - INTERVAL '13 days',
CURRENT_DATE,
@@ -58,7 +58,7 @@ export async function GET(request: Request) {
d.id AS driver_id,
d.first_name || ' ' || d.last_name AS name,
COUNT(r.ride_id)::int AS rides,
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid'), 0)::int AS revenue
COALESCE(SUM(r.fare_price) FILTER (WHERE r.payment_status = 'paid') / 100.0, 0)::float8 AS revenue
FROM drivers d
LEFT JOIN rides r ON r.driver_id = d.id
GROUP BY d.id, d.first_name, d.last_name
+22 -17
View File
@@ -1,5 +1,5 @@
import { sql } from "@/lib/db";
import { sendEmail } from "@/lib/mailer";
import { sql, transaction } from "@/lib/db";
import { isDevOtpExposed, sendEmail } from "@/lib/mailer";
import {
CODE_TTL_MINUTES,
generateCode,
@@ -26,20 +26,24 @@ export async function POST(req: Request) {
return Response.json({ data: { sent: false } });
}
const code = generateCode();
const code = await transaction(async (tx) => {
const generated = generateCode();
await sql`
INSERT INTO password_reset_codes (email, code_hash, expires_at)
VALUES (
${normalized},
${hashCode(normalized, code)},
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
)
ON CONFLICT (email) DO UPDATE SET
code_hash = EXCLUDED.code_hash,
expires_at = EXCLUDED.expires_at,
attempts = 0
`;
await tx`
INSERT INTO password_reset_codes (email, code_hash, expires_at)
VALUES (
${normalized},
${hashCode(normalized, generated)},
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
)
ON CONFLICT (email) DO UPDATE SET
code_hash = EXCLUDED.code_hash,
expires_at = EXCLUDED.expires_at,
attempts = 0
`;
return generated;
});
const mail = resetEmail(code);
const delivered = await sendEmail(normalized, mail.subject, mail.text);
@@ -48,8 +52,9 @@ export async function POST(req: Request) {
data: {
sent: delivered,
// Without SMTP configured there is nothing to receive, so surface the
// code to keep the reset flow usable on a self-hosted box.
...(delivered ? {} : { devCode: code }),
// code to keep the reset flow usable on a self-hosted box. Never
// expose the code in production, even on delivery failure.
...(delivered || !isDevOtpExposed() ? {} : { devCode: code }),
},
});
} catch (error) {
+40 -39
View File
@@ -1,18 +1,13 @@
import { sql } from "@/lib/db";
import { sql, transaction } from "@/lib/db";
import { hashPassword } from "@/lib/password";
import { sendEmail } from "@/lib/mailer";
import { isDevOtpExposed, sendEmail } from "@/lib/mailer";
import {
CODE_TTL_MINUTES,
generateCode,
hashCode,
verificationEmail,
} from "@/lib/otp";
const normalizePhone = (raw: string): string => {
const cleaned = raw.replace(/[^\d+]/g, "");
if (cleaned.startsWith("+")) return cleaned;
return `+961${cleaned.replace(/^0+/, "")}`;
};
import { normalizePhone } from "@/lib/utils";
export async function POST(req: Request) {
const { name, email, phone, password, role } = await req.json();
@@ -46,37 +41,42 @@ export async function POST(req: Request) {
}
// Unverified rows may be re-registered (e.g. the first mail never arrived).
await sql`
INSERT INTO users (name, email, phone, password_hash, email_verified, role)
VALUES (
${name.trim()},
${email.trim().toLowerCase()},
${phone ? normalizePhone(phone) : null},
${hashPassword(password)},
FALSE,
${normalizedRole}
)
ON CONFLICT (email) DO UPDATE SET
name = EXCLUDED.name,
phone = COALESCE(EXCLUDED.phone, users.phone),
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role
`;
const code = await transaction(async (tx) => {
await tx`
INSERT INTO users (name, email, phone, password_hash, email_verified, role)
VALUES (
${name.trim()},
${email.trim().toLowerCase()},
${phone ? normalizePhone(phone) : null},
${hashPassword(password)},
FALSE,
${normalizedRole}
)
ON CONFLICT (email) DO UPDATE SET
name = EXCLUDED.name,
phone = COALESCE(EXCLUDED.phone, users.phone),
password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role
WHERE users.email_verified = FALSE
`;
const code = generateCode();
const generated = generateCode();
await sql`
INSERT INTO email_verification_codes (email, code_hash, expires_at)
VALUES (
${email.trim().toLowerCase()},
${hashCode(email.trim().toLowerCase(), code)},
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
)
ON CONFLICT (email) DO UPDATE SET
code_hash = EXCLUDED.code_hash,
expires_at = EXCLUDED.expires_at,
attempts = 0
`;
await tx`
INSERT INTO email_verification_codes (email, code_hash, expires_at)
VALUES (
${email.trim().toLowerCase()},
${hashCode(email.trim().toLowerCase(), generated)},
CURRENT_TIMESTAMP + make_interval(mins => ${CODE_TTL_MINUTES})
)
ON CONFLICT (email) DO UPDATE SET
code_hash = EXCLUDED.code_hash,
expires_at = EXCLUDED.expires_at,
attempts = 0
`;
return generated;
});
const mail = verificationEmail(code);
const delivered = await sendEmail(
@@ -90,8 +90,9 @@ export async function POST(req: Request) {
data: {
sent: delivered,
// Without SMTP/Gmail configured there is nothing to receive, so
// surface the code to keep self-hosted sign-up usable.
...(delivered ? {} : { devCode: code }),
// surface the code to keep self-hosted sign-up usable. Never expose
// the code in production, even on delivery failure.
...(delivered || !isDevOtpExposed() ? {} : { devCode: code }),
},
},
{ status: 201 },
+49 -34
View File
@@ -1,4 +1,4 @@
import { sql } from "@/lib/db";
import { transaction } from "@/lib/db";
import { MAX_CODE_ATTEMPTS, codeMatches } from "@/lib/otp";
import { hashPassword } from "@/lib/password";
import { issueSession, toProfile } from "@/lib/users";
@@ -24,52 +24,67 @@ export async function POST(req: Request) {
try {
// Charge the attempt before comparing so concurrent guesses can't race
// past the cap, and so a correct guess still costs one of the five.
const attempts = await sql<{ code_hash: string; attempts: number }>`
UPDATE password_reset_codes
SET attempts = attempts + 1
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
RETURNING code_hash, attempts
`;
// past the cap, and so a correct guess still costs one of the five. Wrap
// the attempt charge, password update, and code cleanup in one
// transaction.
const result = await transaction(async (tx) => {
const attempts = await tx<{ code_hash: string; attempts: number }>`
UPDATE password_reset_codes
SET attempts = attempts + 1
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
RETURNING code_hash, attempts
`;
const record = attempts[0];
const record = attempts[0];
if (
!record ||
record.attempts > MAX_CODE_ATTEMPTS ||
!codeMatches(record.code_hash, normalized, code)
) {
if (
!record ||
record.attempts > MAX_CODE_ATTEMPTS ||
!codeMatches(record.code_hash, normalized, code)
) {
return { kind: "invalid" as const };
}
// A successful reset also proves control of the mailbox, so verify it
// too.
const rows = await tx<{
id: string;
name: string;
email: string;
role: string | null;
}>`
UPDATE users
SET password_hash = ${hashPassword(password)}, email_verified = TRUE
WHERE email = ${normalized}
RETURNING id, name, email, role
`;
const user = rows[0];
if (!user) {
return { kind: "not_found" as const };
}
await tx`DELETE FROM password_reset_codes WHERE email = ${normalized}`;
return { kind: "ok" as const, user };
});
if (result.kind === "invalid") {
return Response.json(
{ error: "Invalid or expired reset code." },
{ status: 400 },
);
}
// A successful reset also proves control of the mailbox, so verify it too.
const rows = await sql<{
id: string;
name: string;
email: string;
role: string | null;
}>`
UPDATE users
SET password_hash = ${hashPassword(password)}, email_verified = TRUE
WHERE email = ${normalized}
RETURNING id, name, email, role
`;
const user = rows[0];
if (!user) {
if (result.kind === "not_found") {
return Response.json({ error: "User not found." }, { status: 404 });
}
await sql`DELETE FROM password_reset_codes WHERE email = ${normalized}`;
const session = issueSession(user);
const session = issueSession(result.user);
return Response.json({
data: { token: session.token, user: toProfile(user) },
data: { token: session.token, user: toProfile(result.user) },
});
} catch (error) {
console.error("[RESET_PASSWORD]: ", error);
+45 -32
View File
@@ -1,4 +1,4 @@
import { sql } from "@/lib/db";
import { transaction } from "@/lib/db";
import {
MAX_CODE_ATTEMPTS,
codeMatches,
@@ -19,50 +19,63 @@ export async function POST(req: Request) {
try {
// Charge the attempt before comparing so concurrent guesses can't race
// past the cap, and so a correct guess still costs one of the five.
const attempts = await sql<{ code_hash: string; attempts: number }>`
UPDATE email_verification_codes
SET attempts = attempts + 1
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
RETURNING code_hash, attempts
`;
// past the cap, and so a correct guess still costs one of the five. Wrap
// the attempt charge, verification, and code cleanup in one transaction.
const result = await transaction(async (tx) => {
const attempts = await tx<{ code_hash: string; attempts: number }>`
UPDATE email_verification_codes
SET attempts = attempts + 1
WHERE email = ${normalized} AND expires_at > CURRENT_TIMESTAMP
RETURNING code_hash, attempts
`;
const record = attempts[0];
const record = attempts[0];
if (
!record ||
record.attempts > MAX_CODE_ATTEMPTS ||
!codeMatches(record.code_hash, normalized, code)
) {
if (
!record ||
record.attempts > MAX_CODE_ATTEMPTS ||
!codeMatches(record.code_hash, normalized, code)
) {
return { kind: "invalid" as const };
}
const rows = await tx<{
id: string;
name: string;
email: string;
role: string | null;
}>`
UPDATE users SET email_verified = TRUE
WHERE email = ${normalized}
RETURNING id, name, email, role
`;
const user = rows[0];
if (!user) {
return { kind: "not_found" as const };
}
await tx`DELETE FROM email_verification_codes WHERE email = ${normalized}`;
return { kind: "ok" as const, user };
});
if (result.kind === "invalid") {
return Response.json(
{ error: "Invalid or expired verification code." },
{ status: 400 },
);
}
const rows = await sql<{
id: string;
name: string;
email: string;
role: string | null;
}>`
UPDATE users SET email_verified = TRUE
WHERE email = ${normalized}
RETURNING id, name, email, role
`;
const user = rows[0];
if (!user) {
if (result.kind === "not_found") {
return Response.json({ error: "User not found." }, { status: 404 });
}
await sql`DELETE FROM email_verification_codes WHERE email = ${normalized}`;
const session = issueSession(user);
const session = issueSession(result.user);
return Response.json({
data: { token: session.token, user: toProfile(user) },
data: { token: session.token, user: toProfile(result.user) },
});
} catch (error) {
console.error("[VERIFY]: ", error);
+10 -3
View File
@@ -1,8 +1,15 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
export async function GET() {
export async function GET(req: Request) {
const auth = requireAuth(req);
if ("error" in auth) return auth.error;
try {
const response = await sql`SELECT * FROM drivers`;
const response = await sql`
SELECT id, first_name, last_name, profile_image_url, car_image_url, car_seats, rating
FROM drivers
`;
return Response.json({ data: response });
} catch (error) {
@@ -10,4 +17,4 @@ export async function GET() {
return Response.json({ error }, { status: 500 });
}
}
}
+44
View File
@@ -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 });
}
}
+45
View File
@@ -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 });
}
}
+151
View File
@@ -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 });
}
}
+111
View File
@@ -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;
};
+136 -34
View File
@@ -1,46 +1,148 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
import { sql, query } from "@/lib/db";
import { matchNextDriver } from "@/lib/dispatch";
import { requireDriverProfile } from "@/lib/driver";
// GET — single ride by id, the rider's status-poll endpoint. If the ride is
// still 'requested' with no offer in flight, kick auto-match before reading
// so the rider's poll itself drives matching forward (no background worker).
export async function GET(request: Request, { id }: { id: string }) {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
try {
const response = await sql`
SELECT
rides.ride_id,
rides.origin_address,
rides.destination_address,
rides.origin_latitude,
rides.origin_longitude,
rides.destination_latitude,
rides.destination_longitude,
rides.ride_time,
rides.fare_price,
rides.payment_status,
rides.created_at,
json_build_object(
'driver_id', drivers.id,
'first_name', drivers.first_name,
'last_name', drivers.last_name,
'profile_image_url', drivers.profile_image_url,
'car_image_url', drivers.car_image_url,
'car_seats', drivers.car_seats,
'rating', drivers.rating
) AS driver
FROM
rides
INNER JOIN
drivers ON rides.driver_id = drivers.id
WHERE
rides.user_id = ${auth.userId}
ORDER BY
rides.created_at DESC;
`;
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
return Response.json({ data: response });
try {
const ride = await sql`
SELECT status FROM rides WHERE ride_id = ${rideId} AND user_id = ${auth.userId}
`;
if (!ride[0]) {
return Response.json({ error: "Ride not found." }, { status: 404 });
}
// Lazy match: try to offer the ride to a driver if it's still requested.
if (ride[0].status === "requested") {
void matchNextDriver(rideId);
}
const rows = await sql`
SELECT
r.ride_id,
r.origin_address,
r.destination_address,
r.origin_latitude,
r.origin_longitude,
r.destination_latitude,
r.destination_longitude,
r.ride_time,
r.fare_price,
r.payment_status,
r.status,
r.service,
r.created_at,
r.completed_at,
r.cancelled_at,
json_build_object(
'id', d.id,
'first_name', d.first_name,
'last_name', d.last_name,
'car_seats', d.car_seats,
'profile_image_url', d.profile_image_url,
'car_image_url', d.car_image_url,
'rating', d.rating,
'service', d.service,
'car_model', d.car_model,
'latitude', d.latitude,
'longitude', d.longitude
) AS driver
FROM rides r
LEFT JOIN drivers d ON d.id = r.driver_id
WHERE r.ride_id = ${rideId}
`;
return Response.json({ data: rows[0] });
} catch (error) {
console.error("[GET_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// PATCH — ride lifecycle transitions.
// Rider: { status: 'cancelled' } — only from 'requested' or 'accepted', and
// only on their own ride.
// Driver: { status: 'en_route' | 'completed' } — only on the ride they own
// (driver_id = their profile), from the right prior state.
export async function PATCH(request: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
let body: { status?: string };
try {
body = await request.json();
} catch {
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
}
const next = body.status;
try {
// Rider cancel — authenticate by ownership of the ride.
if (next === "cancelled") {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
const rows = await sql<{ status: string }>`
UPDATE rides
SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND user_id = ${auth.userId}
AND status IN ('requested', 'accepted')
RETURNING status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot be cancelled." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
}
// Driver transitions — must be the driver assigned to the ride.
if (next === "en_route" || next === "completed") {
const result = await requireDriverProfile(request);
if ("error" in result) return result.error;
const { driverId } = result;
const priorStatus = next === "en_route" ? "accepted" : "en_route";
const setClause =
next === "completed"
? "status = $1, completed_at = CURRENT_TIMESTAMP, driver_id = $2"
: "status = $1, driver_id = $2";
const rows = await query<{ status: string }>(
`UPDATE rides SET ${setClause}
WHERE ride_id = $3 AND driver_id = $2 AND status = $4
RETURNING status`,
[next, driverId, rideId, priorStatus],
);
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
}
return Response.json({ error: "Unknown status transition." }, { status: 400 });
} catch (error) {
console.error("[PATCH_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
+102
View File
@@ -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 });
}
}
+168 -41
View File
@@ -1,6 +1,18 @@
import { requireAuth } from "@/lib/jwt";
import { sql } from "@/lib/db";
import { sql, transaction } from "@/lib/db";
import { getOrder, consumeOrderForRide } from "@/lib/payment-orders";
import { matchNextDriver } from "@/lib/dispatch";
import { isServiceId } from "@/lib/driver";
import { DEFAULT_SERVICE } from "@/constants/services";
// Explicit missing check — a truthy check would reject legitimate 0 values
// like latitude 0.0 (the equator) or a zero fare.
const isMissing = (v: unknown): boolean => v === undefined || v === null;
// POST — request a ride. The rider no longer picks a driver; the ride is
// created with status='requested' and driver_id=NULL, then auto-match offers
// it to the nearest eligible driver of the requested service. `driver_id` in
// the body is accepted for backward compatibility but ignored.
export async function POST(request: Request) {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
@@ -16,21 +28,20 @@ export async function POST(request: Request) {
destination_longitude,
ride_time,
fare_price,
payment_status,
driver_id,
payment_method,
payment_order_id,
service,
} = body;
if (
!origin_address ||
!destination_address ||
!origin_latitude ||
!origin_longitude ||
!destination_latitude ||
!destination_longitude ||
!ride_time ||
!fare_price ||
!payment_status ||
!driver_id
isMissing(origin_address) ||
isMissing(destination_address) ||
isMissing(origin_latitude) ||
isMissing(origin_longitude) ||
isMissing(destination_latitude) ||
isMissing(destination_longitude) ||
isMissing(ride_time) ||
isMissing(fare_price)
) {
return Response.json(
{ error: "Missing required fields" },
@@ -38,38 +49,154 @@ export async function POST(request: Request) {
);
}
const response = await sql`
INSERT INTO rides (
origin_address,
destination_address,
origin_latitude,
origin_longitude,
destination_latitude,
destination_longitude,
ride_time,
fare_price,
payment_status,
driver_id,
user_id
) VALUES (
${origin_address},
${destination_address},
${origin_latitude},
${origin_longitude},
${destination_latitude},
${destination_longitude},
${ride_time},
${fare_price},
${payment_status},
${driver_id},
${auth.userId}
)
RETURNING *;
if (payment_method !== "card" && payment_method !== "cash")
return Response.json(
{ error: "Invalid payment method." },
{ status: 400 },
);
const rideService = isServiceId(service) ? service : DEFAULT_SERVICE;
const fareCents = Math.round(Number(fare_price));
if (payment_method === "card") {
// Card: the ride is only recorded once a paid, server-authoritative
// payment order is consumed. The client can no longer self-declare
// payment_status='paid'.
if (isMissing(payment_order_id))
return Response.json(
{ error: "Missing payment order id." },
{ status: 400 },
);
const order = await getOrder(payment_order_id);
if (!order)
return Response.json(
{ error: "Payment order not found." },
{ status: 404 },
);
if (order.user_id !== auth.userId)
return Response.json({ error: "Unauthorized." }, { status: 403 });
if (order.status !== "paid")
return Response.json(
{ error: "Payment not verified." },
{ status: 400 },
);
if (order.amount_cents !== fareCents)
return Response.json(
{ error: "Payment amount mismatch." },
{ status: 400 },
);
// Reconcile route intent (driver isn't known yet, so driver_id is no
// longer part of the intent check). Null intent fields are skipped.
const intentsMatch =
(order.origin_address === null ||
order.origin_address === origin_address) &&
(order.destination_address === null ||
order.destination_address === destination_address) &&
(order.ride_time === null || order.ride_time === Number(ride_time));
if (!intentsMatch)
return Response.json(
{ error: "Payment does not match this ride." },
{ status: 400 },
);
// Consume the order and insert the ride on one connection, so a failure
// rolls back both and no paid order is wasted without a ride.
const inserted = await transaction(async (tx) => {
const consumed = await consumeOrderForRide(
payment_order_id,
auth.userId,
tx,
);
if (!consumed) throw new Error("PAYMENT_ORDER_NOT_CONSUMABLE");
const rows = await tx`
INSERT INTO rides (
origin_address,
destination_address,
origin_latitude,
origin_longitude,
destination_latitude,
destination_longitude,
ride_time,
fare_price,
payment_status,
driver_id,
user_id,
payment_order_id,
status,
service
) VALUES (
${origin_address},
${destination_address},
${origin_latitude},
${origin_longitude},
${destination_latitude},
${destination_longitude},
${ride_time},
${fareCents},
'paid',
NULL,
${auth.userId},
${payment_order_id},
'requested',
${rideService}
)
RETURNING *
`;
return rows[0];
});
// Kick off auto-match asynchronously — don't block the response on it.
void matchNextDriver(inserted.ride_id);
return Response.json({ data: inserted }, { status: 201 });
}
// Cash: settled directly with the driver at drop-off. No order involved.
const response = await sql`
INSERT INTO rides (
origin_address,
destination_address,
origin_latitude,
origin_longitude,
destination_latitude,
destination_longitude,
ride_time,
fare_price,
payment_status,
driver_id,
user_id,
status,
service
) VALUES (
${origin_address},
${destination_address},
${origin_latitude},
${origin_longitude},
${destination_latitude},
${destination_longitude},
${ride_time},
${fareCents},
'cash',
NULL,
${auth.userId},
'requested',
${rideService}
)
RETURNING *
`;
void matchNextDriver(response[0].ride_id);
return Response.json({ data: response[0] }, { status: 201 });
} catch (error) {
console.error("[CREATE_RIDES]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}
+53
View File
@@ -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 });
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ const TabIcon = ({
const TabsLayout = () => (
<Tabs
initialRouteName="index"
initialRouteName="home"
screenOptions={{
tabBarActiveTintColor: "white",
tabBarInactiveTintColor: "white",
+6 -3
View File
@@ -12,6 +12,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { GoogleTextInput } from "@/components/google-text-input";
import { LocationNotice } from "@/components/location-notice";
import { Map } from "@/components/map";
import { NearbySuggestions } from "@/components/nearby-suggestions";
import { RideCard } from "@/components/ride-card";
import { ServiceSelector } from "@/components/service-selector";
import { icons, images } from "@/constants";
@@ -26,9 +27,7 @@ const Home = () => {
(state) => state.setDestinationLocation,
);
const { signOut, user } = useSession();
const { data: recentRides, loading } = useFetch<Ride[]>(
`/(api)/ride/${user?.id}`,
);
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
const { status: locationStatus, retry: retryLocation } = useUserLocation();
@@ -140,6 +139,10 @@ const Home = () => {
<ServiceSelector />
<View className="mt-5">
<NearbySuggestions />
</View>
<Text className="text-xl font-JakartaBold mt-5 mb-3">
Recent Rides
</Text>
+4 -3
View File
@@ -2,6 +2,7 @@ import { Image, ScrollView, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { InputField } from "@/components/input-field";
import { icons } from "@/constants";
import { useSession } from "@/lib/session";
const Profile = () => {
@@ -17,7 +18,7 @@ const Profile = () => {
<View className="flex items-center justify-center my-5">
<Image
source={{ uri: user?.avatarUrl ?? undefined }}
source={user?.avatarUrl ? { uri: user.avatarUrl } : icons.profile}
alt="Your Avatar"
style={{ width: 110, height: 110, borderRadius: 110 / 2 }}
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">
<InputField
label="First name"
placeholder={user?.name.split(" ")[0] ?? "Your First name"}
placeholder={user?.name?.split(" ")[0] || "Your First name"}
containerStyles="w-full mb-4"
inputStyles="p-3.5"
editable={false}
@@ -36,7 +37,7 @@ const Profile = () => {
<InputField
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"
inputStyles="p-3.5"
editable={false}
+1 -5
View File
@@ -4,14 +4,10 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { RideCard } from "@/components/ride-card";
import { images } from "@/constants";
import { useFetch } from "@/lib/fetch";
import { useSession } from "@/lib/session";
import type { Ride } from "@/types/type";
const Rides = () => {
const { user } = useSession();
const { data: recentRides, loading } = useFetch<Ride[]>(
`/(api)/ride/${user?.id}`,
);
const { data: recentRides, loading } = useFetch<Ride[]>("/(api)/ride/list");
return (
<SafeAreaView>
+219 -107
View File
@@ -1,136 +1,248 @@
import { router } from "expo-router";
import { Image, Text, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Image,
Text,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { CustomButton } from "@/components/custom-button";
import { Payment } from "@/components/payment";
import { RideLayout } from "@/components/ride-layout";
import { icons } from "@/constants";
import { formatLBP } from "@/lib/pricing";
import { useSession } from "@/lib/session";
import { Map } from "@/components/map";
import { icons, images } from "@/constants";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { formatTime } from "@/lib/utils";
import { useDriverStore, useLocationStore } from "@/store";
import { useLocationStore } from "@/store";
import type { Ride } from "@/types/type";
const POLL_MS = 3000;
const statusLabel: Record<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 { user } = useSession();
const { userAddress, destinationAddress } = useLocationStore();
const { drivers, selectedDriver } = useDriverStore();
const { id } = useLocalSearchParams<{ id: string }>();
const rideId = Number(id);
const setUserLocation = useLocationStore((s) => s.setUserLocation);
const setDestinationLocation = useLocationStore((s) => s.setDestinationLocation);
const driverDetails = drivers?.filter(
(driver) => +driver.id === selectedDriver,
)[0];
const [ride, setRide] = useState<Ride | null>(null);
const [loading, setLoading] = useState(true);
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 (
<RideLayout title="Book Ride">
<View className="flex-1 items-center justify-center">
<Text className="text-base text-general-200 font-JakartaMedium text-center">
No driver selected.{"\n"}Please go back and choose a driver first.
</Text>
<CustomButton
title="Choose a Driver"
onPress={() => router.replace("/(root)/confirm-ride")}
className="mt-6"
/>
</View>
</RideLayout>
<SafeAreaView className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#0286ff" />
</SafeAreaView>
);
}
if (error || !ride) {
return (
<SafeAreaView className="flex-1 bg-white items-center justify-center px-7">
<Text className="text-base text-general-200 text-center">
{error ?? "Could not load this ride."}
</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 (
<RideLayout title="Book Ride">
<>
<Text className="text-xl font-JakartaSemiBold mb-3">
Ride Information
<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>
<View className="flex flex-col w-full items-center justify-center mt-10">
<Image
source={{ uri: driverDetails?.profile_image_url }}
alt="Driver Avatar"
className="w-28 h-28 rounded-full"
/>
<View className="flex flex-row items-center justify-center mt-5 space-x-2">
<Text className="text-lg font-JakartaSemiBold">
{driverDetails?.title}
{/* Searching state */}
{ride.status === "requested" ? (
<View className="items-center mt-6">
<ActivityIndicator size="large" color="#0286ff" />
<Text className="text-general-200 mt-3 text-center">
We&apos;re matching you with the nearest {ride.service} driver.
</Text>
</View>
) : null}
<View className="flex flex-row items-center space-x-0.5">
{/* 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={icons.star}
alt="Star"
className="w-5 h-5"
resizeMode="contain"
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">
{driver.first_name} {driver.last_name}
</Text>
<View className="flex-row items-center mt-1">
<Image source={icons.star} className="w-4 h-4" />
<Text className="ml-1 text-general-200">
{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>
<Text className="text-lg font-JakartaRegular">
{driverDetails?.rating}
<View className="flex-row items-center gap-x-2 mt-4">
<Image source={icons.to} className="w-4 h-4" />
<Text className="font-JakartaMedium text-sm" numberOfLines={1}>
{ride.origin_address}
</Text>
</View>
<View className="flex-row items-center gap-x-2 mt-2">
<Image source={icons.point} className="w-4 h-4" />
<Text className="font-JakartaMedium text-sm" 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>
</View>
) : null}
<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>
{/* 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}
<View className="flex flex-col items-end">
<Text className="text-lg font-JakartaRegular text-[#0CC25F]">
${driverDetails?.price}
{/* 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")}
/>
) : (
<TouchableOpacity
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 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>
</TouchableOpacity>
)}
</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>
</View>
</SafeAreaView>
);
};
export default BookRide;
export default BookRide;
+311 -30
View File
@@ -1,44 +1,325 @@
import { router } from "expo-router";
import { FlatList, Text, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, Alert, Text, TouchableOpacity, View } from "react-native";
import { CustomButton } from "@/components/custom-button";
import { DriverCard } from "@/components/driver-card";
import { RideLayout } from "@/components/ride-layout";
import { useDriverStore } from "@/store";
import { SERVICES } from "@/constants/services";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { calculateTripFare } from "@/lib/map";
import { formatLBP } from "@/lib/pricing";
import { requestRide } from "@/lib/request-ride";
import { useSession } from "@/lib/session";
import { formatTime, haversine } from "@/lib/utils";
import { useLocationStore, useServiceStore } from "@/store";
type PaymentMethod = "cash" | "card";
type NearbyDriver = {
id: number;
first_name: string;
latitude: number;
longitude: number;
};
// Confirm-ride is now the request screen: the rider no longer browses and
// picks a driver. They see a single fare estimate + nearest-driver ETA, pick a
// payment method, and tap Request — auto-match assigns the driver and they're
// routed to the live status screen.
const ConfirmRide = () => {
const { drivers, selectedDriver, setSelectedDriver } = useDriverStore();
const params = useLocalSearchParams<{ service?: string }>();
const {
userAddress,
userLatitude,
userLongitude,
destinationAddress,
destinationLatitude,
destinationLongitude,
} = useLocationStore();
const { service: storeService, setService } = useServiceStore();
const { user } = useSession();
const service = params.service ?? storeService;
const selected = SERVICES.find((s) => s.id === service) ?? SERVICES[0];
const [method, setMethod] = useState<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 (
<RideLayout title="Choose a Driver" snapPoints={["65%", "85%"]}>
<FlatList
data={drivers}
renderItem={({ item }) => (
<DriverCard
item={item}
selected={selectedDriver ?? 0}
setSelected={() => setSelectedDriver(item.id)}
/>
)}
ListEmptyComponent={
<Text className="text-center text-general-200 font-JakartaMedium mt-10">
No drivers available on this route right now.{"\n"}Please try
another destination.
<RideLayout title="Request Ride" snapPoints={["60%", "88%"]}>
<Text className="text-xl font-JakartaSemiBold mb-1">Your trip</Text>
<View className="flex-row items-center gap-x-2 mb-1">
<Text className="text-general-200 text-xs">Pickup</Text>
</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"
}
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>
}
className="mt-4"
onPress={request}
disabled={processing || estimating || !estimate || driversOnline === 0}
/>
</RideLayout>
);
};
export default ConfirmRide;
export default ConfirmRide;
+533 -24
View File
@@ -1,40 +1,549 @@
import { Image, Text, View } from "react-native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Image,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { CustomButton } from "@/components/custom-button";
import { images } from "@/constants";
import { icons, images } from "@/constants";
import { SERVICES, type ServiceId } from "@/constants/services";
import { ApiError, fetchAPI } from "@/lib/fetch";
import { useSession } from "@/lib/session";
import { useDriverLocation } from "@/lib/use-driver-location";
import { formatTime } from "@/lib/utils";
// Poll cadence for the driver dashboard (offers / active ride / earnings).
const POLL_MS = 4000;
type Profile = {
id: number;
first_name: string;
last_name: string;
profile_image_url: string | null;
car_image_url: string | null;
car_seats: number;
rating: number;
service: ServiceId;
online: boolean;
car_model: string | null;
};
type Offer = {
offer_id: number;
offered_at: string;
ride_id: number;
origin_address: string;
destination_address: string;
ride_time: number;
fare_price: number;
payment_status: string;
service: string;
};
type ActiveRide = {
ride_id: number;
status: string;
service: string;
payment_status: string;
origin_address: string;
destination_address: string;
ride_time: number;
fare_price: number;
rider_name: string | null;
rider_phone: string | null;
};
type Dashboard = {
offers: Offer[];
active: ActiveRide | null;
recent: { ride_id: number; fare_price: number; service: string }[];
earnings: number;
};
// Placeholder driver home. The driver experience (going online, accepting
// rides) is not built yet — drivers are registered here and managed in the
// database for now.
const DriverHome = () => {
const { signOut, user } = useSession();
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<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 (
<SafeAreaView className="flex-1 bg-white justify-center items-center px-7">
<Image
source={images.check}
alt="Registered"
className="w-[110px] h-[110px] mb-5"
/>
<SafeAreaView className="flex-1 bg-general-500">
<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">
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">
You&apos;re registered as a driver, {user?.name || "there"}!
</Text>
{/* Online / offline toggle */}
<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>
<Text className="text-base text-general-200 font-Jakarta text-center mt-3">
Driver mode is coming soon. We&apos;ll contact you at{" "}
{user?.email} once your account is activated.
</Text>
{/* 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&apos;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>
<CustomButton
title="Sign Out"
onPress={() => signOut()}
className="mt-10"
/>
{/* 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>
{!online ? null : dashboard?.offers.length ? (
dashboard.offers.map((offer) => (
<OfferCard
key={offer.offer_id}
offer={offer}
busy={busy}
onAccept={() => respond(offer, "accept")}
onDecline={() => respond(offer, "decline")}
/>
))
) : (
<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>
);
};
export default DriverHome;
// --- 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 18.");
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;
-3
View File
@@ -3,7 +3,6 @@ import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { LogBox } from "react-native";
import "react-native-reanimated";
import { SessionProvider } from "@/lib/session";
@@ -11,8 +10,6 @@ import { SessionProvider } from "@/lib/session";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
LogBox.ignoreAllLogs();
const RootLayout = () => {
const [loaded] = useFonts({
"Jakarta-Bold": require("../assets/fonts/PlusJakartaSans-Bold.ttf"),