diff --git a/app/api/driver/orders/history/route.ts b/app/api/driver/orders/history/route.ts new file mode 100644 index 0000000..5576066 --- /dev/null +++ b/app/api/driver/orders/history/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { supabase } from "../../../../lib/supabase"; +import { getSupabaseServiceClient } from "../../../../lib/supabaseService"; + +const ACTIVE_DRIVER_STATUSES = [ + "confirmed", + "preparing", + "ready", + "arrived_at_restaurant", + "picked_up", + "in_transit", + "arrived_at_customer", +]; + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value + ); +} + +async function getDriverByUserId(userId: string) { + const client = getSupabaseServiceClient() ?? supabase; + const { data: driver, error } = await client + .from("drivers") + .select("id, status, rating, total_deliveries, vehicle_info, license_number") + .eq("user_id", userId) + .maybeSingle(); + + if (error) throw error; + return driver; +} + +export async function GET(request: Request) { + try { + const client = getSupabaseServiceClient() ?? supabase; + const { searchParams } = new URL(request.url); + const userId = searchParams.get("userId"); + if (!userId) return NextResponse.json({ error: "userId required" }, { status: 400 }); + if (!isUuid(userId)) return NextResponse.json({ error: "Invalid userId" }, { status: 400 }); + + const driver = await getDriverByUserId(userId); + if (!driver?.id) { + return NextResponse.json({ error: "No driver profile found for this user" }, { status: 400 }); + } + + const driverId = driver.id as string; + + const { data: activeRows, error: activeError } = await client + .from("orders") + .select("id, delivery_address, items, total_price, status, created_at, eta, notes") + .eq("driver_id", driverId) + .in("status", ACTIVE_DRIVER_STATUSES) + .order("created_at", { ascending: false }) + .limit(1); + if (activeError) throw activeError; + const activeOrder = activeRows?.[0] ?? null; + + const { data: historyRows, error: historyError } = await client + .from("orders") + .select("id, delivery_address, items, total_price, status, created_at, eta, notes") + .eq("driver_id", driverId) + .in("status", ["delivered", "cancelled"]) + .order("created_at", { ascending: false }) + .limit(50); + if (historyError) throw historyError; + + return NextResponse.json({ + driver, + activeOrder, + orders: historyRows ?? [], + }); + } catch (err) { + console.error("Error fetching driver order history:", err); + return NextResponse.json({ error: "Failed to fetch driver orders" }, { status: 500 }); + } +} + diff --git a/app/api/driver/orders/route.ts b/app/api/driver/orders/route.ts index 40be68c..2299553 100644 --- a/app/api/driver/orders/route.ts +++ b/app/api/driver/orders/route.ts @@ -1,13 +1,47 @@ import { NextResponse } from "next/server"; import { supabase } from "../../../lib/supabase"; +import { getSupabaseServiceClient } from "../../../lib/supabaseService"; + +const ACTIVE_DRIVER_STATUSES = [ + "confirmed", + "preparing", + "ready", + "arrived_at_restaurant", + "picked_up", + "in_transit", + "arrived_at_customer", +]; + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value + ); +} + +async function getDriverByUserId(userId: string) { + const client = getSupabaseServiceClient() ?? supabase; + const { data: driver, error } = await client + .from("drivers") + .select("id, status, rating, total_deliveries, vehicle_info, license_number") + .eq("user_id", userId) + .maybeSingle(); -const ACTIVE_DRIVER_STATUSES = ["confirmed", "preparing", "ready", "in_transit"]; + if (error) throw error; + return driver; +} // GET: /api/driver/orders -> list pending/unassigned orders export async function GET(request: Request) { try { + const client = getSupabaseServiceClient() ?? supabase; const { searchParams } = new URL(request.url); - const driverUserId = searchParams.get("driverId"); + const userId = searchParams.get("userId"); + if (!userId) { + return NextResponse.json({ error: "userId required" }, { status: 400 }); + } + if (!isUuid(userId)) { + return NextResponse.json({ error: "Invalid userId" }, { status: 400 }); + } let activeOrder: Record | null = null; let driver: Record | null = null; @@ -18,65 +52,56 @@ export async function GET(request: Request) { activeDeliveries: 0, }; - if (driverUserId) { - const { data: driverData, error: driverError } = await supabase - .from("drivers") - .select("id, status, rating, total_deliveries, vehicle_info, license_number") - .eq("user_id", driverUserId) - .maybeSingle(); - - if (driverError) throw driverError; - - if (driverData?.id) { - driver = driverData; - - const { data: activeOrderRows, error: activeError } = await supabase - .from("orders") - .select("id, delivery_address, items, total_price, status, created_at") - .eq("driver_id", driverData.id) - .in("status", ACTIVE_DRIVER_STATUSES) - .order("created_at", { ascending: false }) - .limit(1); - - if (activeError) throw activeError; - activeOrder = activeOrderRows?.[0] ?? null; - - const { data: deliveredRows, error: deliveredError } = await supabase - .from("orders") - .select("total_price, created_at") - .eq("driver_id", driverData.id) - .eq("status", "delivered"); - - if (deliveredError) throw deliveredError; - - const today = new Date(); - const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()); - - const delivered = deliveredRows ?? []; - const totalEarnings = delivered.reduce( - (sum, row) => sum + Number(row.total_price ?? 0), - 0 - ); - const todayEarnings = delivered.reduce((sum, row) => { - const createdAt = row.created_at ? new Date(row.created_at) : null; - if (createdAt && createdAt >= todayStart) { - return sum + Number(row.total_price ?? 0); - } - return sum; - }, 0); - - stats = { - todayEarnings, - totalEarnings, - completedDeliveries: delivered.length, - activeDeliveries: activeOrder ? 1 : 0, - }; - } + const driverData = await getDriverByUserId(userId); + if (driverData?.id) { + driver = driverData; + + const { data: activeOrderRows, error: activeError } = await client + .from("orders") + .select("id, delivery_address, items, total_price, status, created_at, eta, notes") + .eq("driver_id", driverData.id) + .in("status", ACTIVE_DRIVER_STATUSES) + .order("created_at", { ascending: false }) + .limit(1); + + if (activeError) throw activeError; + activeOrder = activeOrderRows?.[0] ?? null; + + const { data: deliveredRows, error: deliveredError } = await client + .from("orders") + .select("total_price, created_at") + .eq("driver_id", driverData.id) + .eq("status", "delivered"); + + if (deliveredError) throw deliveredError; + + const today = new Date(); + const todayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + + const delivered = deliveredRows ?? []; + const totalEarnings = delivered.reduce( + (sum, row) => sum + Number(row.total_price ?? 0), + 0 + ); + const todayEarnings = delivered.reduce((sum, row) => { + const createdAt = row.created_at ? new Date(row.created_at) : null; + if (createdAt && createdAt >= todayStart) { + return sum + Number(row.total_price ?? 0); + } + return sum; + }, 0); + + stats = { + todayEarnings, + totalEarnings, + completedDeliveries: delivered.length, + activeDeliveries: activeOrder ? 1 : 0, + }; } - const { data, error } = await supabase + const { data, error } = await client .from("orders") - .select("id, delivery_address, items, total_price, status, created_at") + .select("id, delivery_address, items, total_price, status, created_at, eta") .eq("status", "pending") .is("driver_id", null) .order("created_at", { ascending: true }); @@ -99,24 +124,21 @@ export async function GET(request: Request) { // POST: /api/driver/orders/accept -> body: { orderId, driverId (user id) } export async function POST(request: Request) { try { + const client = getSupabaseServiceClient() ?? supabase; const body = await request.json(); - const { orderId, driverId } = body as { orderId?: string; driverId?: string }; + const { orderId, userId } = body as { orderId?: string; userId?: string }; if (!orderId) { return NextResponse.json({ error: "orderId required" }, { status: 400 }); } - if (!driverId) { - return NextResponse.json({ error: "driverId (your user id) required in this demo" }, { status: 400 }); + if (!userId) { + return NextResponse.json({ error: "userId required" }, { status: 400 }); + } + if (!isUuid(userId)) { + return NextResponse.json({ error: "Invalid userId" }, { status: 400 }); } - // Find the driver record that matches the provided user id - const { data: driverData, error: driverError } = await supabase - .from("drivers") - .select("id, status") - .eq("user_id", driverId) - .maybeSingle(); - - if (driverError) throw driverError; + const driverData = await getDriverByUserId(userId); const driverUuid = driverData?.id; if (!driverUuid) { @@ -131,7 +153,7 @@ export async function POST(request: Request) { } // One-order-at-a-time: block accepts while this driver still has an active order. - const { data: activeRows, error: activeError } = await supabase + const { data: activeRows, error: activeError } = await client .from("orders") .select("id, status") .eq("driver_id", driverUuid) @@ -148,7 +170,7 @@ export async function POST(request: Request) { } // Claim only pending/unassigned orders and move them to a valid next status. - const { data, error } = await supabase + const { data, error } = await client .from("orders") .update({ driver_id: driverUuid, status: "confirmed" }) .eq("id", orderId) @@ -165,7 +187,7 @@ export async function POST(request: Request) { ); } - await supabase + await client .from("drivers") .update({ status: "busy", updated_at: new Date().toISOString() }) .eq("id", driverUuid); @@ -180,41 +202,55 @@ export async function POST(request: Request) { // PATCH: /api/driver/orders -> body: { orderId, driverId, status } export async function PATCH(request: Request) { try { + const client = getSupabaseServiceClient() ?? supabase; const body = await request.json(); - const { orderId, driverId, status } = body as { + const { orderId, userId, status } = body as { orderId?: string; - driverId?: string; + userId?: string; status?: string; + eta?: string; proofNote?: string; proofPhotoUrl?: string; }; + const eta = typeof body.eta === "string" ? body.eta.trim() : ""; const proofNote = typeof body.proofNote === "string" ? body.proofNote.trim() : ""; const proofPhotoUrl = typeof body.proofPhotoUrl === "string" ? body.proofPhotoUrl.trim() : ""; - if (!orderId || !driverId || !status) { + if (!orderId || !userId || !status) { return NextResponse.json( - { error: "orderId, driverId, and status are required" }, + { error: "orderId, userId, and status are required" }, { status: 400 } ); } + if (!isUuid(userId)) { + return NextResponse.json({ error: "Invalid userId" }, { status: 400 }); + } - if (!["in_transit", "delivered", "cancelled"].includes(status)) { + if ( + ![ + "in_transit", + "delivered", + "cancelled", + "arrived_at_restaurant", + "picked_up", + "arrived_at_customer", + ].includes(status) + ) { return NextResponse.json( { error: "Unsupported status transition" }, { status: 400 } ); } - const { data: driverData, error: driverError } = await supabase + const { data: driverForUpdate, error: driverForUpdateError } = await client .from("drivers") - .select("id, total_deliveries") - .eq("user_id", driverId) + .select("id, total_deliveries, status") + .eq("user_id", userId) .maybeSingle(); + if (driverForUpdateError) throw driverForUpdateError; - if (driverError) throw driverError; - - const driverUuid = driverData?.id; + const driverUuid = driverForUpdate?.id; if (!driverUuid) { return NextResponse.json({ error: "No driver profile found for this user" }, { status: 400 }); } @@ -225,12 +261,15 @@ export async function PATCH(request: Request) { status === "delivered" ? `Delivered at: ${new Date().toISOString()}` : "", ].filter(Boolean); - const updatePayload: { status: string; notes?: string } = { status }; + const updatePayload: { status: string; notes?: string; eta?: string } = { status }; if (notesParts.length > 0) { updatePayload.notes = notesParts.join("\n"); } + if (eta) { + updatePayload.eta = eta; + } - const { data, error } = await supabase + const { data, error } = await client .from("orders") .update(updatePayload) .eq("id", orderId) @@ -248,18 +287,18 @@ export async function PATCH(request: Request) { } if (status === "delivered") { - await supabase + await client .from("drivers") .update({ status: "available", - total_deliveries: Number(driverData.total_deliveries ?? 0) + 1, + total_deliveries: Number(driverForUpdate?.total_deliveries ?? 0) + 1, updated_at: new Date().toISOString(), }) .eq("id", driverUuid); } if (status === "cancelled") { - await supabase + await client .from("drivers") .update({ status: "available", updated_at: new Date().toISOString() }) .eq("id", driverUuid); diff --git a/app/api/driver/profile/route.ts b/app/api/driver/profile/route.ts index a537064..684d0ec 100644 --- a/app/api/driver/profile/route.ts +++ b/app/api/driver/profile/route.ts @@ -1,10 +1,12 @@ import { NextResponse } from "next/server"; import { supabase } from "../../../lib/supabase"; +import { getSupabaseServiceClient } from "../../../lib/supabaseService"; const ACTIVE_DRIVER_STATUSES = ["confirmed", "preparing", "ready", "in_transit"]; async function getDriverByUserId(userId: string) { - const { data: driver, error } = await supabase + const client = getSupabaseServiceClient() ?? supabase; + const { data: driver, error } = await client .from("drivers") .select("id, user_id, vehicle_info, license_number, status, rating, total_deliveries") .eq("user_id", userId) @@ -38,6 +40,7 @@ export async function GET(request: Request) { export async function PUT(request: Request) { try { + const client = getSupabaseServiceClient() ?? supabase; const body = (await request.json()) as { driverId?: string; online?: boolean; @@ -59,7 +62,7 @@ export async function PUT(request: Request) { if (online === false) { // Prevent going offline while currently delivering. - const { data: activeRows, error: activeError } = await supabase + const { data: activeRows, error: activeError } = await client .from("orders") .select("id") .eq("driver_id", driver.id) @@ -97,7 +100,7 @@ export async function PUT(request: Request) { payload.license_number = licenseNumber.trim(); } - const { data, error } = await supabase + const { data, error } = await client .from("drivers") .update(payload) .eq("id", driver.id) diff --git a/app/api/orders/[id]/route.ts b/app/api/orders/[id]/route.ts new file mode 100644 index 0000000..4b8d556 --- /dev/null +++ b/app/api/orders/[id]/route.ts @@ -0,0 +1,202 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { supabase } from "../../../lib/supabase"; + +type OrderParty = { + customerUserId: string | null; + driverUserId: string | null; +}; + +async function getOrderParty(orderId: string): Promise { + const { data: order, error: orderError } = await supabase + .from("orders") + .select("id, customer_id, driver_id") + .eq("id", orderId) + .maybeSingle(); + + if (orderError) throw orderError; + if (!order) return null; + + const { data: customer, error: customerError } = await supabase + .from("customers") + .select("user_id") + .eq("id", order.customer_id) + .maybeSingle(); + if (customerError) throw customerError; + + let driverUserId: string | null = null; + if (order.driver_id) { + const { data: driver, error: driverError } = await supabase + .from("drivers") + .select("user_id") + .eq("id", order.driver_id) + .maybeSingle(); + if (driverError) throw driverError; + driverUserId = driver?.user_id ?? null; + } + + return { customerUserId: customer?.user_id ?? null, driverUserId }; +} + +function canAccessOrder(party: OrderParty | null, userId: string): boolean { + if (!party) return false; + return party.customerUserId === userId || party.driverUserId === userId; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id: orderId } = await params; + if (!orderId) { + return NextResponse.json({ error: "orderId required" }, { status: 400 }); + } + + try { + const { searchParams } = new URL(request.url); + const userId = searchParams.get("userId")?.trim(); + if (!userId) { + return NextResponse.json({ error: "userId required" }, { status: 400 }); + } + + const party = await getOrderParty(orderId); + if (!canAccessOrder(party, userId)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { data: order, error } = await supabase + .from("orders") + .select( + "id, restaurant_id, items, total_price, delivery_address, status, created_at, eta, notes, driver_id, tip, dropoff_instructions" + ) + .eq("id", orderId) + .maybeSingle(); + + if (error) throw error; + if (!order) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + let driverName: string | null = null; + if (order.driver_id) { + const { data: driver, error: driverError } = await supabase + .from("drivers") + .select("id, user_id") + .eq("id", order.driver_id) + .maybeSingle(); + if (driverError) throw driverError; + + if (driver?.user_id) { + const { data: userRow, error: userError } = await supabase + .from("users") + .select("name") + .eq("id", driver.user_id) + .maybeSingle(); + if (userError) throw userError; + driverName = userRow?.name ?? null; + } + } + + return NextResponse.json({ + id: order.id, + restaurant_id: String(order.restaurant_id), + items: order.items ?? [], + total_price: Number(order.total_price ?? 0), + delivery_address: order.delivery_address, + status: order.status, + created_at: order.created_at, + driver: driverName, + eta: order.eta ?? null, + notes: order.notes ?? null, + tip: Number(order.tip ?? 0), + dropoffInstructions: order.dropoff_instructions ?? null, + }); + } catch (err) { + console.error("Error fetching order by id:", err); + return NextResponse.json({ error: "Failed to fetch order" }, { status: 500 }); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id: orderId } = await params; + if (!orderId) { + return NextResponse.json({ error: "orderId required" }, { status: 400 }); + } + + try { + const body = (await request.json()) as { + userId?: string; + tip?: number; + dropoffInstructions?: string; + }; + + const userId = body.userId?.trim(); + if (!userId) { + return NextResponse.json({ error: "userId required" }, { status: 400 }); + } + + const party = await getOrderParty(orderId); + if (!party) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + if (party.customerUserId !== userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { data: order, error: orderError } = await supabase + .from("orders") + .select("id, total_price, tip, status") + .eq("id", orderId) + .maybeSingle(); + if (orderError) throw orderError; + if (!order) return NextResponse.json({ error: "Order not found" }, { status: 404 }); + + if (order.status === "delivered" || order.status === "cancelled") { + return NextResponse.json({ error: "Order can no longer be updated" }, { status: 400 }); + } + + const patch: Record = {}; + + if (typeof body.tip !== "undefined") { + const nextTip = Number.isFinite(body.tip) ? Math.max(0, Number(body.tip)) : 0; + const prevTip = Number.isFinite(order.tip) ? Number(order.tip) : 0; + const prevTotal = Number.isFinite(order.total_price) ? Number(order.total_price) : 0; + const nextTotal = Math.max(0, prevTotal - prevTip + nextTip); + patch.tip = nextTip; + patch.total_price = nextTotal; + } + + if (typeof body.dropoffInstructions !== "undefined") { + patch.dropoff_instructions = body.dropoffInstructions?.trim() || null; + } + + if (Object.keys(patch).length === 0) { + return NextResponse.json({ error: "No changes provided" }, { status: 400 }); + } + + const { data: updated, error: updateError } = await supabase + .from("orders") + .update(patch) + .eq("id", orderId) + .select("id, total_price, tip, dropoff_instructions") + .single(); + + if (updateError) throw updateError; + + return NextResponse.json({ + success: true, + order: { + id: updated.id, + total_price: Number(updated.total_price ?? 0), + tip: Number(updated.tip ?? 0), + dropoffInstructions: updated.dropoff_instructions ?? null, + }, + }); + } catch (err) { + console.error("Error updating order:", err); + return NextResponse.json({ error: "Failed to update order" }, { status: 500 }); + } +} diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts index d4fbcfb..67dd3f3 100644 --- a/app/api/orders/route.ts +++ b/app/api/orders/route.ts @@ -3,28 +3,47 @@ import { supabase } from "../../lib/supabase"; import type { CartItem } from "../../context/CartContext"; import { calculatePromoDiscount } from "../../lib/promo"; +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value + ); +} + export async function POST(request: Request) { const body = (await request.json()) as { userId?: string; customerId?: string; restaurantId?: string | number; + restaurantSnapshot?: { + name?: string; + cuisine?: string; + address?: string; + latitude?: number; + longitude?: number; + deliveryFee?: number; + eta?: string; + image?: string; + }; items?: CartItem[]; totalPrice?: number; deliveryFee?: number; tip?: number; promoCode?: string; deliveryAddress?: string; + dropoffInstructions?: string; }; const { userId, customerId, restaurantId, + restaurantSnapshot, items, totalPrice, deliveryFee, tip, promoCode, deliveryAddress, + dropoffInstructions, } = body; if (!userId || !customerId || !restaurantId || !items || !deliveryAddress) { @@ -50,6 +69,60 @@ export async function POST(request: Request) { const normalizedDeliveryFee = Number.isFinite(deliveryFee) ? Number(deliveryFee) : 2.5; const normalizedTip = Number.isFinite(tip) ? Number(tip) : 0; + // Ensure restaurant_id is a UUID. If the client passed a non-UUID (e.g. OSM numeric id), + // resolve/create a restaurant row using the provided snapshot. + let resolvedRestaurantId = String(restaurantId); + if (!isUuid(resolvedRestaurantId)) { + const name = restaurantSnapshot?.name?.trim(); + if (!name) { + return NextResponse.json( + { error: "Invalid restaurantId (UUID required) and restaurantSnapshot.name missing" }, + { status: 400 } + ); + } + + const { data: existing, error: existingError } = await supabase + .from("restaurants") + .select("id") + .ilike("name", name) + .limit(1) + .maybeSingle(); + if (existingError) throw existingError; + + if (existing?.id) { + resolvedRestaurantId = String(existing.id); + } else { + const cuisine = restaurantSnapshot?.cuisine?.trim() || "Restaurant"; + const address = restaurantSnapshot?.address?.trim() || "Unknown address"; + const etaText = restaurantSnapshot?.eta?.trim() || "30-45 mins"; + const deliveryFeeValue = Number.isFinite(restaurantSnapshot?.deliveryFee) + ? Number(restaurantSnapshot?.deliveryFee) + : normalizedDeliveryFee; + + const { data: inserted, error: insertError } = await supabase + .from("restaurants") + .insert({ + name, + cuisine, + address, + latitude: Number.isFinite(restaurantSnapshot?.latitude) + ? Number(restaurantSnapshot?.latitude) + : null, + longitude: Number.isFinite(restaurantSnapshot?.longitude) + ? Number(restaurantSnapshot?.longitude) + : null, + delivery_fee: deliveryFeeValue, + eta: etaText, + image: restaurantSnapshot?.image ?? null, + }) + .select("id") + .single(); + + if (insertError) throw insertError; + resolvedRestaurantId = String(inserted.id); + } + } + let promoDiscount = 0; if (promoCode?.trim()) { const { count, error: countError } = await supabase @@ -69,23 +142,61 @@ export async function POST(request: Request) { const finalTotal = Math.max(0, subtotal + normalizedDeliveryFee + normalizedTip - promoDiscount); - const { error } = await supabase.from("orders").insert({ + const baseInsert = { customer_id: customerId, - restaurant_id: String(restaurantId), + restaurant_id: resolvedRestaurantId, items, total_price: finalTotal, delivery_fee: normalizedDeliveryFee, status: "pending", delivery_address: deliveryAddress, - }); + } as const; - if (error) throw error; + // Newer schema: persist tip + dropoff instructions. + // Backward-compatible fallback: if DB hasn't been migrated yet (missing columns), + // retry without the new fields so checkout still works. + const insertWithExtras = { + ...baseInsert, + tip: normalizedTip, + dropoff_instructions: dropoffInstructions?.trim() || null, + }; + + let created: { id: string } | null = null; + + const attempt1 = await supabase + .from("orders") + .insert(insertWithExtras) + .select("id") + .single(); + + if (!attempt1.error) { + created = attempt1.data ?? null; + } else { + const msg = String((attempt1.error as { message?: unknown })?.message ?? ""); + const isMissingColumn = + msg.includes('column "tip"') || + msg.includes('column "dropoff_instructions"') || + msg.includes("does not exist"); + + if (!isMissingColumn) { + throw attempt1.error; + } + + const attempt2 = await supabase + .from("orders") + .insert(baseInsert) + .select("id") + .single(); + + if (attempt2.error) throw attempt2.error; + created = attempt2.data ?? null; + } - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true, orderId: created?.id ?? null }); } catch (err) { console.error("Error creating order:", err); return NextResponse.json( - { error: "Failed to create order" }, + { error: err instanceof Error ? err.message : "Failed to create order" }, { status: 500 } ); } diff --git a/app/components/AccountNav.tsx b/app/components/AccountNav.tsx new file mode 100644 index 0000000..ccd4d5a --- /dev/null +++ b/app/components/AccountNav.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +export type SidebarView = + | "none" + | "active-orders" + | "past-orders" + | "saved-meals" + | "recently-viewed"; + +type Props = { + userName?: string | null; + sidebarView: SidebarView; + onSelectView: (view: Exclude) => void; + onClosePanel?: () => void; + className?: string; +}; + +const panelItems: Array<{ view: Exclude; label: string; icon: string }> = [ + { view: "active-orders", label: "Active Orders", icon: "πŸš—" }, + { view: "past-orders", label: "Past Orders", icon: "🧾" }, + { view: "saved-meals", label: "Saved Meals", icon: "❀️" }, + { view: "recently-viewed", label: "Recently Viewed", icon: "πŸ•" }, +]; + +export function AccountNav({ userName, sidebarView, onSelectView, onClosePanel, className }: Props) { + return ( +
+
+ {userName?.[0]?.toUpperCase() || "U"} +
+

+ {userName || "User"} +

+ +

+ πŸ”₯ My Account +

+ +
+ {panelItems.map(({ view, label, icon }) => { + const isActive = sidebarView === view; + return ( + + ); + })} +
+ +
+ + +
+ + 🧾 Order History + + + πŸ† Rewards + + + 🎟 Deals + + + βš™οΈ Settings + +
+
+
+ ); +} + diff --git a/app/components/DriverDashboard.tsx b/app/components/DriverDashboard.tsx index bfd64e9..10622b8 100644 --- a/app/components/DriverDashboard.tsx +++ b/app/components/DriverDashboard.tsx @@ -1,9 +1,17 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "../context/AuthContext"; import { useRouter } from "next/navigation"; import { useTheme } from "../context/ThemeContext"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; type DriverProfile = { id: string; @@ -21,6 +29,7 @@ type DriverOrder = { total_price: number; status: string; created_at: string; + eta?: string; }; type DriverStats = { @@ -51,17 +60,35 @@ export default function DriverDashboard() { }); const [errorMessage, setErrorMessage] = useState(null); const [successMessage, setSuccessMessage] = useState(null); + const [sortMode, setSortMode] = useState<"oldest" | "newest" | "highest_pay">("oldest"); - const [intent, setIntent] = useState< - "eta_update" | "cannot_reach_customer" | "arrival_message" | "delay_notice" - >("eta_update"); - const [copilotText, setCopilotText] = useState(""); - const [generatingMessage, setGeneratingMessage] = useState(false); const [updatingOrder, setUpdatingOrder] = useState(false); - const [showEarningsPanel, setShowEarningsPanel] = useState(false); + // Earnings details panel removed (tabs now show summary) const [confirmCancel, setConfirmCancel] = useState(false); + const [proofNote, setProofNote] = useState(""); + const [proofPhotoUrl, setProofPhotoUrl] = useState(""); + const [proofFileName, setProofFileName] = useState(""); + const proofFileInputRef = useRef(null); + const [eta, setEta] = useState(""); + const [showOrderChat, setShowOrderChat] = useState(false); + const [orderMessages, setOrderMessages] = useState< + Array<{ id: string; sender_role: "customer" | "driver"; content: string; created_at: string }> + >([]); + const [orderChatInput, setOrderChatInput] = useState(""); + const [sendingOrderMsg, setSendingOrderMsg] = useState(false); const isBusy = useMemo(() => activeOrder != null, [activeOrder]); + const sortedOrders = useMemo(() => { + const copy = [...orders]; + if (sortMode === "newest") { + copy.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + } else if (sortMode === "highest_pay") { + copy.sort((a, b) => Number(b.total_price ?? 0) - Number(a.total_price ?? 0)); + } else { + copy.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + } + return copy; + }, [orders, sortMode]); const handleLogout = () => { logout(); @@ -71,22 +98,32 @@ export default function DriverDashboard() { const fetchDriverData = useCallback(async () => { if (!user?.id) return; try { - const res = await fetch(`/api/driver/orders?driverId=${encodeURIComponent(user.id)}`); - if (!res.ok) throw new Error("Could not load driver data"); - const data = await res.json(); - setOrders(Array.isArray(data.orders) ? data.orders : []); - setActiveOrder((data.activeOrder as DriverOrder | null) ?? null); - setDriver((data.driver as DriverProfile | null) ?? null); + const res = await fetch(`/api/driver/orders?userId=${encodeURIComponent(user.id)}`); + const data = (await res.json().catch(() => null)) as + | { + orders?: unknown; + activeOrder?: unknown; + driver?: unknown; + stats?: unknown; + error?: string; + } + | null; + if (!res.ok) throw new Error(data?.error ?? "Could not load driver data"); + const payload = data ?? {}; + setOrders(Array.isArray(payload.orders) ? (payload.orders as DriverOrder[]) : []); + setActiveOrder((payload.activeOrder as DriverOrder | null) ?? null); + setDriver((payload.driver as DriverProfile | null) ?? null); setStats( - (data.stats as DriverStats) ?? { + (payload.stats as DriverStats) ?? { todayEarnings: 0, totalEarnings: 0, completedDeliveries: 0, activeDeliveries: 0, } ); - if (data.driver?.status) { - setIsOnline(data.driver.status !== "offline"); + const driverStatus = (payload.driver as DriverProfile | null)?.status; + if (driverStatus) { + setIsOnline(driverStatus !== "offline"); } setErrorMessage(null); } catch (err) { @@ -101,9 +138,9 @@ export default function DriverDashboard() { useEffect(() => { if (!user?.id || !isOnline) return; - const timer = setInterval(fetchDriverData, 15000); + const timer = setInterval(fetchDriverData, isBusy ? 8000 : 15000); return () => clearInterval(timer); - }, [user?.id, isOnline, fetchDriverData]); + }, [user?.id, isOnline, isBusy, fetchDriverData]); const handleToggleOnline = async () => { if (!user?.id || loadingToggle) return; @@ -137,7 +174,7 @@ export default function DriverDashboard() { const res = await fetch("/api/driver/orders", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ orderId, driverId: user.id }), + body: JSON.stringify({ orderId, userId: user.id }), }); const data = await res.json(); if (!res.ok) { @@ -153,7 +190,15 @@ export default function DriverDashboard() { } }; - const updateOrderStatus = async (status: "in_transit" | "delivered" | "cancelled") => { + const updateOrderStatus = async ( + status: + | "arrived_at_restaurant" + | "picked_up" + | "arrived_at_customer" + | "in_transit" + | "delivered" + | "cancelled" + ) => { if (!user?.id || !activeOrder?.id) return; try { setUpdatingOrder(true); @@ -161,7 +206,14 @@ export default function DriverDashboard() { const res = await fetch("/api/driver/orders", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ orderId: activeOrder.id, driverId: user.id, status }), + body: JSON.stringify({ + orderId: activeOrder.id, + userId: user.id, + status, + eta: eta.trim() || undefined, + proofNote: proofNote.trim() || undefined, + proofPhotoUrl: proofPhotoUrl.trim() || undefined, + }), }); const data = await res.json(); if (!res.ok) { @@ -180,35 +232,67 @@ export default function DriverDashboard() { } }; - const generateDriverMessage = async () => { - if (!user?.name) return; + useEffect(() => { + if (!activeOrder) return; + const anyOrder = activeOrder as unknown as { eta?: string; notes?: string }; + setEta(typeof anyOrder.eta === "string" ? anyOrder.eta : ""); + if (typeof anyOrder.notes === "string") { + const noteMatch = anyOrder.notes.match(/Proof note:\s*(.*)/i); + const photoMatch = anyOrder.notes.match(/Proof photo URL:\s*(.*)/i); + setProofNote(noteMatch?.[1]?.trim() ?? ""); + setProofPhotoUrl(photoMatch?.[1]?.trim() ?? ""); + } else { + setProofNote(""); + setProofPhotoUrl(""); + } + }, [activeOrder]); + + + const fetchOrderMessages = useCallback(async () => { + if (!user?.id || !activeOrder?.id) return; + try { + const res = await fetch( + `/api/messages?orderId=${encodeURIComponent(activeOrder.id)}&userId=${encodeURIComponent( + user.id + )}` + ); + if (!res.ok) return; + const data = (await res.json()) as { messages?: typeof orderMessages }; + setOrderMessages(Array.isArray(data.messages) ? data.messages : []); + } catch { + // Ignore transient fetch errors for chat polling + } + }, [user?.id, activeOrder?.id]); + + useEffect(() => { + if (!showOrderChat) return; + fetchOrderMessages(); + const timer = setInterval(fetchOrderMessages, 5000); + return () => clearInterval(timer); + }, [showOrderChat, fetchOrderMessages]); + + const sendOrderMessage = async () => { + if (!user?.id || !activeOrder?.id) return; + if (!orderChatInput.trim() || sendingOrderMsg) return; try { - setGeneratingMessage(true); - const res = await fetch("/api/driver/chat", { + setSendingOrderMsg(true); + await fetch("/api/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - intent, - driverName: user.name, - activeOrder, + orderId: activeOrder.id, + userId: user.id, + senderRole: "driver", + content: orderChatInput.trim(), }), }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? "Failed to generate message"); - setCopilotText(data.message ?? ""); - } catch (err) { - setErrorMessage(err instanceof Error ? err.message : "Failed to generate message"); + setOrderChatInput(""); + await fetchOrderMessages(); } finally { - setGeneratingMessage(false); + setSendingOrderMsg(false); } }; - const copyCopilotText = async () => { - if (!copilotText.trim()) return; - await navigator.clipboard.writeText(copilotText); - setSuccessMessage("Message copied to clipboard"); - setTimeout(() => setSuccessMessage(null), 2200); - }; if (user?.role !== "driver") return null; @@ -222,228 +306,437 @@ export default function DriverDashboard() {
- - + - +
{errorMessage && ( -
- {errorMessage} -
+ + {errorMessage} + )} {successMessage && ( -
- {successMessage} -
+ + {successMessage} + )} - {activeOrder ? ( -
-

🧾 Active Delivery

-

Order #{activeOrder.id.slice(0, 8)}

-

πŸ“ {activeOrder.delivery_address}

-

- Items: {(activeOrder.items || []).map((it) => `${it.quantity ?? it.qty ?? 1}Γ— ${it.name}`).join(", ") || "N/A"} -

-
- - - - - πŸ—ΊοΈ Open in Maps - -
- {confirmCancel && ( -
-

Cancel this delivery?

-
- - -
-
+ + + Deliveries + Earnings + + + + {activeOrder ? ( + + +
+
+ 🧾 Active delivery +

+ Order #{activeOrder.id.slice(0, 8)} β€’ {activeOrder.delivery_address} +

+
+ + {String(activeOrder.status).replaceAll("_", " ")} + + {eta.trim() && ( + ETA: {eta.trim()} + )} +
+
+ +
+
+ +

+ Items:{" "} + {(activeOrder.items || []) + .map((it) => `${it.quantity ?? it.qty ?? 1}Γ— ${it.name}`) + .join(", ") || "N/A"} +

+ +
+
+

+ Customer ETA +

+ setEta(e.target.value)} + placeholder="e.g. 8-12 min" + /> +

+ Saved when you press any status update. +

+
+ +
+

+ Proof of delivery +

+ setProofNote(e.target.value)} + placeholder="Proof note (optional)" + /> + setProofPhotoUrl(e.target.value)} + placeholder="Proof photo URL (optional)" + /> +
+ { + const file = e.target.files?.[0]; + setProofFileName(file?.name ?? ""); + }} + /> + + {proofFileName ? ( + + Selected: {proofFileName} + + ) : null} +
+
+
+ + + +
+
+

+ Customer chat +

+ +
+ + {showOrderChat && ( +
+ +
+ {orderMessages.length === 0 ? ( +

+ No messages yet. Send an update to your customer. +

+ ) : ( + orderMessages.map((msg) => ( +
+
+ {msg.content} +
+
+ )) + )} +
+
+
+ setOrderChatInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendOrderMessage()} + placeholder="Type a message..." + /> + +
+
+ )} +
+ + + +
+ + + + + + +
+ + {confirmCancel && ( + + +
+ + Cancel this delivery? + +
+ + +
+
+
+
+ )} +
+
+ ) : ( + + + No active order yet. Accept one below. + + )} -
- ) : ( -
-

No active order yet. Accept one below.

-
- )} -
-

πŸ“‹ Available Deliveries

- {!isOnline && ( -
- You are offline. Go online to receive deliveries. -
- )} - {isOnline && orders.length === 0 && ( -
- Waiting for new orders... -
- )} - -
- {orders.map((order) => ( -
-

Order #{order.id.slice(0, 8)}

-

πŸ“ {order.delivery_address}

-

{fmt(Number(order.total_price ?? 0))}

- + Oldest + + +
- ))} -
-
- -
-

πŸ’° Earnings

-
-
-

Today

-

{fmt(stats.todayEarnings)}

-
-
-

This Week

-

{fmt(stats.totalEarnings)}

-
-
-

Rating

-

{(driver?.rating ?? 5).toFixed(1)} ⭐

-
-
-

Rides

-

{stats.completedDeliveries}

-
-
- {showEarningsPanel && ( -
-

Average per delivery: {fmt(stats.completedDeliveries > 0 ? stats.totalEarnings / stats.completedDeliveries : 0)}

-

Active deliveries: {stats.activeDeliveries}

-
- )} - -
- -
- - +
+ {sortedOrders.map((order) => ( + + +
+
+ Order #{order.id.slice(0, 8)} +

+ πŸ“ {order.delivery_address} +

+
+ {order.eta ? ( + ETA: {order.eta} + ) : null} +
+
+ +
{fmt(Number(order.total_price ?? 0))}
+ +
+
+ ))} +
+
+ + + +

πŸ’° Earnings

+
+ + +

Today

+

{fmt(stats.todayEarnings)}

+
+
+ + +

This Week

+

{fmt(stats.totalEarnings)}

+
+
+ + +

Rating

+

{(driver?.rating ?? 5).toFixed(1)} ⭐

+
+
+ + +

Rides

+

{stats.completedDeliveries}

+
+
-