From 05f87465daac48ff6b51342200658af0354942de Mon Sep 17 00:00:00 2001 From: Zakariye Mohamed <168684030+zakiscoding@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:48:44 -0400 Subject: [PATCH] fix cart security promo validation and chat fallback Protect cart and order APIs with customer ownership checks, centralize promo calculation with server-side checkout validation, and add chat fallback that finds closest food matches via location-aware Google Places with clickable map cards. Made-with: Cursor --- app/api/cart/customer/route.ts | 48 ++++++++ app/api/cart/route.ts | 50 ++++++-- app/api/chat/route.ts | 180 +++++++++++++++++++++++++++ app/api/orders/route.ts | 73 +++++++++-- app/context/CartContext.tsx | 145 ++++++++++++++-------- app/lib/promo.ts | 47 +++++++ app/page.tsx | 218 ++++++++++++++++++++------------- 7 files changed, 610 insertions(+), 151 deletions(-) create mode 100644 app/api/cart/customer/route.ts create mode 100644 app/lib/promo.ts diff --git a/app/api/cart/customer/route.ts b/app/api/cart/customer/route.ts new file mode 100644 index 0000000..ae52943 --- /dev/null +++ b/app/api/cart/customer/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { supabase } from "../../../lib/supabase"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const userId = searchParams.get("userId"); + + if (!userId) { + return NextResponse.json({ error: "userId required" }, { status: 400 }); + } + + try { + const { data: customer, error } = await supabase + .from("customers") + .select("id") + .eq("user_id", userId) + .single(); + + if (error && error.code !== "PGRST116") { + throw error; + } + + if (customer?.id) { + return NextResponse.json({ customerId: customer.id }); + } + + const { data: newCustomer, error: createError } = await supabase + .from("customers") + .upsert([{ user_id: userId }], { onConflict: "user_id" }) + .select("id") + .single(); + + if (createError) { + throw createError; + } + + return NextResponse.json({ customerId: newCustomer?.id ?? null }); + } catch (err) { + console.error("Error resolving customer:", err); + return NextResponse.json( + { + error: "Failed to resolve customer", + details: err instanceof Error ? err.message : String(err), + }, + { status: 500 } + ); + } +} diff --git a/app/api/cart/route.ts b/app/api/cart/route.ts index 92bf704..8f0410e 100644 --- a/app/api/cart/route.ts +++ b/app/api/cart/route.ts @@ -4,12 +4,24 @@ import { supabase } from "../../lib/supabase"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const customerId = searchParams.get("customerId"); + const userId = searchParams.get("userId"); - if (!customerId) { - return NextResponse.json({ error: "customerId required" }, { status: 400 }); + if (!customerId || !userId) { + return NextResponse.json({ error: "customerId and userId required" }, { status: 400 }); } try { + const { data: customer, error: customerError } = await supabase + .from("customers") + .select("id") + .eq("id", customerId) + .eq("user_id", userId) + .maybeSingle(); + if (customerError) throw customerError; + if (!customer) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { data, error } = await supabase .from("carts") .select("items") @@ -33,19 +45,31 @@ export async function GET(request: Request) { export async function POST(request: Request) { const body = (await request.json()) as { customerId?: string; + userId?: string; restaurantId?: string; items?: unknown[]; }; - const { customerId, restaurantId, items } = body; + const { customerId, userId, restaurantId, items } = body; - if (!customerId || !restaurantId) { + if (!customerId || !userId || !restaurantId) { return NextResponse.json( - { error: "customerId and restaurantId required" }, + { error: "customerId, userId and restaurantId required" }, { status: 400 } ); } try { + const { data: customer, error: customerError } = await supabase + .from("customers") + .select("id") + .eq("id", customerId) + .eq("user_id", userId) + .maybeSingle(); + if (customerError) throw customerError; + if (!customer) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { error } = await supabase.from("carts").upsert( { customer_id: customerId, @@ -71,12 +95,24 @@ export async function POST(request: Request) { export async function DELETE(request: Request) { const { searchParams } = new URL(request.url); const customerId = searchParams.get("customerId"); + const userId = searchParams.get("userId"); - if (!customerId) { - return NextResponse.json({ error: "customerId required" }, { status: 400 }); + if (!customerId || !userId) { + return NextResponse.json({ error: "customerId and userId required" }, { status: 400 }); } try { + const { data: customer, error: customerError } = await supabase + .from("customers") + .select("id") + .eq("id", customerId) + .eq("user_id", userId) + .maybeSingle(); + if (customerError) throw customerError; + if (!customer) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { error } = await supabase .from("carts") .delete() diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index f323d52..74f8a87 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -20,12 +20,161 @@ interface Restaurant { image: string; } +type FoodSearchResult = { + name: string; + address?: string; + rating?: number; + latitude: number; + longitude: number; + distanceMiles: number; +}; + +function calculateDistanceMiles( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 3959; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +function extractFoodQuery(message: string): string | null { + const text = message.toLowerCase(); + const patterns = [ + /(?:find|search|look for|show me|get me)\s+([a-z\s]+?)(?:\s+(?:near me|nearby|close|around here))?$/i, + /(?:any|where can i get|i want)\s+([a-z\s]+?)(?:\s+(?:near me|nearby|close|around here))?$/i, + /([a-z\s]+?)\s+(?:near me|nearby|close|around here)$/i, + ]; + + for (const pattern of patterns) { + const match = message.match(pattern); + if (match?.[1]) { + return match[1].trim().toLowerCase(); + } + } + + if (text.includes("near me") || text.includes("nearby") || text.includes("close")) { + const words = text.replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean); + const stopWords = new Set([ + "find", + "search", + "look", + "for", + "show", + "me", + "get", + "any", + "where", + "can", + "i", + "want", + "near", + "nearby", + "close", + "around", + "here", + "food", + "place", + "places", + "restaurant", + "restaurants", + ]); + const filtered = words.filter((word) => !stopWords.has(word)); + return filtered.length > 0 ? filtered.join(" ") : null; + } + + return null; +} + +function hasMenuMatches(menuData: Restaurant[], foodQuery: string): boolean { + const needle = foodQuery.toLowerCase(); + return menuData.some( + (restaurant) => + restaurant.cuisine?.toLowerCase().includes(needle) || + restaurant.name.toLowerCase().includes(needle) || + restaurant.menu.some((item) => item.name.toLowerCase().includes(needle)) + ); +} + +async function searchGoogleFoodPlaces( + foodQuery: string, + latitude: number, + longitude: number +): Promise { + const apiKey = process.env.GOOGLE_MAPS_API_KEY || process.env.GOOGLE_PLACES_API_KEY; + if (!apiKey) return []; + + const radii = [5000, 15000]; + for (const radius of radii) { + const endpoint = + "https://maps.googleapis.com/maps/api/place/textsearch/json" + + `?query=${encodeURIComponent(`${foodQuery} restaurants`)}` + + `&location=${latitude},${longitude}` + + `&radius=${radius}` + + `&key=${apiKey}`; + + const response = await fetch(endpoint, { cache: "no-store" }); + if (!response.ok) continue; + + const data = (await response.json()) as { + status?: string; + results?: Array<{ + name?: string; + formatted_address?: string; + rating?: number; + geometry?: { location?: { lat?: number; lng?: number } }; + }>; + }; + + if (data.status && !["OK", "ZERO_RESULTS"].includes(data.status)) continue; + + const matches = (data.results || []) + .filter( + (result) => + typeof result.name === "string" && + typeof result.geometry?.location?.lat === "number" && + typeof result.geometry?.location?.lng === "number" + ) + .map((result) => { + const lat = result.geometry?.location?.lat as number; + const lon = result.geometry?.location?.lng as number; + return { + name: result.name as string, + address: result.formatted_address, + rating: result.rating, + latitude: lat, + longitude: lon, + distanceMiles: calculateDistanceMiles(latitude, longitude, lat, lon), + }; + }) + .sort((a, b) => a.distanceMiles - b.distanceMiles) + .slice(0, 5); + + if (matches.length > 0) { + return matches; + } + } + + return []; +} + export async function POST(request: Request) { const body = (await request.json()) as { message?: string; menuData?: Restaurant[]; currentCart?: Array<{ name: string; quantity: number; price: number }>; messages?: Array<{ role: string; content: string }>; + userLocation?: { latitude?: number; longitude?: number } | null; }; const message = body.message?.trim(); @@ -48,6 +197,37 @@ export async function POST(request: Request) { ? body.currentCart.map((item) => `${item.quantity}x ${item.name}`).join(", ") : "empty"; + const foodQuery = extractFoodQuery(message); + const hasLocalFoodMatches = + foodQuery && menuData.length > 0 ? hasMenuMatches(menuData, foodQuery) : false; + const lat = body.userLocation?.latitude; + const lon = body.userLocation?.longitude; + + if ( + foodQuery && + !hasLocalFoodMatches && + typeof lat === "number" && + typeof lon === "number" + ) { + const nearbyMatches = await searchGoogleFoodPlaces(foodQuery, lat, lon); + if (nearbyMatches.length > 0) { + const lines = nearbyMatches.map((place, index) => { + const ratingText = + typeof place.rating === "number" ? ` • ⭐ ${place.rating.toFixed(1)}` : ""; + const addressText = place.address ? ` • ${place.address}` : ""; + return `${index + 1}. ${place.name} (${place.distanceMiles.toFixed(1)} mi${ratingText})${addressText}`; + }); + + return NextResponse.json({ + reply: + `I could not find "${foodQuery}" in nearby QuickBite menus, so I rechecked your location and found these closest options:\n` + + `${lines.join("\n")}\n` + + "Tap a card to open directions, or ask me to find a similar item available in QuickBite.", + fallbackPlaces: nearbyMatches, + }); + } + } + const systemPrompt = `You are a helpful food delivery assistant for QuickBite. INSTRUCTIONS: diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts index f28ed73..d4fbcfb 100644 --- a/app/api/orders/route.ts +++ b/app/api/orders/route.ts @@ -1,32 +1,80 @@ import { NextResponse } from "next/server"; import { supabase } from "../../lib/supabase"; import type { CartItem } from "../../context/CartContext"; +import { calculatePromoDiscount } from "../../lib/promo"; export async function POST(request: Request) { const body = (await request.json()) as { + userId?: string; customerId?: string; restaurantId?: string | number; items?: CartItem[]; totalPrice?: number; deliveryFee?: number; + tip?: number; + promoCode?: string; deliveryAddress?: string; }; - const { customerId, restaurantId, items, totalPrice, deliveryFee, deliveryAddress } = body; + const { + userId, + customerId, + restaurantId, + items, + totalPrice, + deliveryFee, + tip, + promoCode, + deliveryAddress, + } = body; - if (!customerId || !restaurantId || !items || !deliveryAddress) { + if (!userId || !customerId || !restaurantId || !items || !deliveryAddress) { return NextResponse.json( - { error: "customerId, restaurantId, items, and deliveryAddress required" }, + { error: "userId, customerId, restaurantId, items, and deliveryAddress required" }, { status: 400 } ); } try { + const { data: customer, error: customerError } = await supabase + .from("customers") + .select("id") + .eq("id", customerId) + .eq("user_id", userId) + .maybeSingle(); + if (customerError) throw customerError; + if (!customer) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const subtotal = Number.isFinite(totalPrice) ? Number(totalPrice) : 0; + const normalizedDeliveryFee = Number.isFinite(deliveryFee) ? Number(deliveryFee) : 2.5; + const normalizedTip = Number.isFinite(tip) ? Number(tip) : 0; + + let promoDiscount = 0; + if (promoCode?.trim()) { + const { count, error: countError } = await supabase + .from("orders") + .select("id", { count: "exact", head: true }) + .eq("customer_id", customerId); + if (countError) throw countError; + + const promoResult = calculatePromoDiscount(promoCode, subtotal, normalizedDeliveryFee, { + isFirstOrder: (count ?? 0) === 0, + }); + if (!promoResult.valid) { + return NextResponse.json({ error: promoResult.error ?? "Invalid promo code." }, { status: 400 }); + } + promoDiscount = promoResult.discount; + } + + const finalTotal = Math.max(0, subtotal + normalizedDeliveryFee + normalizedTip - promoDiscount); + const { error } = await supabase.from("orders").insert({ customer_id: customerId, restaurant_id: String(restaurantId), items, - total_price: totalPrice ?? 0, - delivery_fee: deliveryFee ?? 2.5, + total_price: finalTotal, + delivery_fee: normalizedDeliveryFee, status: "pending", delivery_address: deliveryAddress, }); @@ -68,11 +116,22 @@ export async function GET(request: Request) { resolvedCustomerId = customer?.id ?? null; } - if (!resolvedCustomerId) { - return NextResponse.json({ error: "customerId or userId required" }, { status: 400 }); + if (!resolvedCustomerId || !userId) { + return NextResponse.json({ error: "userId required" }, { status: 400 }); } try { + const { data: customer, error: customerError } = await supabase + .from("customers") + .select("id") + .eq("id", resolvedCustomerId) + .eq("user_id", userId) + .maybeSingle(); + if (customerError) throw customerError; + if (!customer) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { data, error } = await supabase .from("orders") .select("*") diff --git a/app/context/CartContext.tsx b/app/context/CartContext.tsx index cad2b1b..eadfb01 100644 --- a/app/context/CartContext.tsx +++ b/app/context/CartContext.tsx @@ -2,7 +2,30 @@ import { createContext, useContext, useState, ReactNode, useEffect } from "react"; import { useAuth } from "./AuthContext"; -import { supabase } from "../lib/supabase"; + +const formatError = (error: unknown) => { + if (!error || typeof error !== "object") { + return { message: String(error ?? "Unknown error") }; + } + + const err = error as { + code?: string; + message?: string; + details?: string; + hint?: string; + status?: number; + name?: string; + }; + + return { + name: err.name, + code: err.code, + status: err.status, + message: err.message, + details: err.details, + hint: err.hint, + }; +}; export interface CartItem { id: string; @@ -30,7 +53,15 @@ interface CartContextType { totalPrice: number; isOpen: boolean; setIsOpen: (open: boolean) => void; - saveOrder: (restaurantId: string, deliveryAddress: string) => Promise; + saveOrder: ( + restaurantId: string, + deliveryAddress: string, + extras?: { + deliveryFee?: number; + tip?: number; + promoCode?: string; + } + ) => Promise; isLoading: boolean; restaurantId: string | null; setRestaurantId: (id: string) => void; @@ -63,56 +94,48 @@ export function CartProvider({ children }: { children: ReactNode }) { } try { - // Get customer record for this user - const { data: customer, error } = await supabase - .from("customers") - .select("id") - .eq("user_id", user.id) - .single(); - - if (error && error.code === "PGRST116") { - // Customer doesn't exist yet, create it - const { data: newCustomer, error: createError } = await supabase - .from("customers") - .insert([{ user_id: user.id }]) - .select("id") - .single(); - - if (createError) { - console.error("Could not create customer, continuing without persistence:", createError); - setIsLoading(false); - return; - } + const customerResponse = await fetch( + `/api/cart/customer?userId=${encodeURIComponent(user.id)}` + ); - if (newCustomer?.id) { - setCustomerId(newCustomer.id); - } - } else if (error) { - console.error("Could not fetch customer, continuing without persistence:", error); - setIsLoading(false); - return; - } else if (customer?.id) { - setCustomerId(customer.id); - - // Load cart from Supabase - try { - const response = await fetch(`/api/cart?customerId=${customer.id}`); - if (response.ok) { - const data = (await response.json()) as { items?: CartItem[] }; - if (data.items && data.items.length > 0) { - setItems(data.items); - // Set restaurant from first item - if (data.items[0]) { - setRestaurantId(String(data.items[0].restaurantId)); - } - } + if (!customerResponse.ok) { + const errText = await customerResponse.text(); + throw new Error(errText || "Could not fetch customer"); + } + + const customerData = (await customerResponse.json()) as { + customerId?: string; + }; + + if (!customerData.customerId) { + throw new Error("Customer ID missing from response"); + } + + setCustomerId(customerData.customerId); + + // Load cart from API + try { + const response = await fetch( + `/api/cart?customerId=${customerData.customerId}&userId=${encodeURIComponent(user.id)}` + ); + if (response.ok) { + const data = (await response.json()) as { items?: CartItem[] }; + const normalizedItems = Array.isArray(data.items) ? data.items : []; + setItems(normalizedItems); + if (normalizedItems[0]) { + setRestaurantId(String(normalizedItems[0].restaurantId)); + } else { + setRestaurantId(null); } - } catch (cartErr) { - console.error("Could not load cart from DB:", cartErr); } + } catch (cartErr) { + console.error("Could not load cart from DB:", formatError(cartErr)); } } catch (err) { - console.error("Error initializing cart (app will work without persistence):", err); + console.error( + "Error initializing cart (app will work without persistence):", + formatError(err) + ); } finally { setIsLoading(false); } @@ -124,13 +147,15 @@ export function CartProvider({ children }: { children: ReactNode }) { // Save cart to Supabase whenever items change useEffect(() => { if (!customerId || !restaurantId) return; + if (!user?.id) return; + const userId = user.id; const saveCart = async () => { try { await fetch("/api/cart", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ customerId, restaurantId, items }), + body: JSON.stringify({ customerId, userId, restaurantId, items }), }); } catch (err) { console.error("Error saving cart:", err); @@ -139,7 +164,7 @@ export function CartProvider({ children }: { children: ReactNode }) { const timer = setTimeout(saveCart, 500); return () => clearTimeout(timer); - }, [items, customerId, restaurantId]); + }, [items, customerId, restaurantId, user]); const addItem = (newItem: Omit) => { // Queue a switch confirmation if cart contains another restaurant. @@ -200,10 +225,21 @@ export function CartProvider({ children }: { children: ReactNode }) { setRestaurantId(null); }; - const saveOrder = async (restaurantId: string, deliveryAddress: string) => { + const saveOrder = async ( + restaurantId: string, + deliveryAddress: string, + extras?: { + deliveryFee?: number; + tip?: number; + promoCode?: string; + } + ) => { if (!customerId) { throw new Error("Account not ready. Please refresh and try again."); } + if (!user) { + throw new Error("You must be logged in to place an order."); + } if (items.length === 0) return; try { @@ -211,10 +247,14 @@ export function CartProvider({ children }: { children: ReactNode }) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + userId: user.id, customerId, restaurantId, items, totalPrice, + deliveryFee: extras?.deliveryFee, + tip: extras?.tip, + promoCode: extras?.promoCode, deliveryAddress, }), }); @@ -228,7 +268,10 @@ export function CartProvider({ children }: { children: ReactNode }) { clearCart(); // Delete the cart from DB - await fetch(`/api/cart?customerId=${customerId}`, { method: "DELETE" }); + await fetch( + `/api/cart?customerId=${customerId}&userId=${encodeURIComponent(user.id)}`, + { method: "DELETE" } + ); } catch (err) { console.error("Error saving order:", err); if (err instanceof Error) throw err; diff --git a/app/lib/promo.ts b/app/lib/promo.ts new file mode 100644 index 0000000..5fe0874 --- /dev/null +++ b/app/lib/promo.ts @@ -0,0 +1,47 @@ +export const PROMO_CODES = { + PIZZA20: { type: "percent", value: 0.2 }, + BURGER15: { type: "percent", value: 0.15 }, + SUSHI25: { type: "percent", value: 0.25 }, + DELIVERY5: { type: "delivery_waive", value: 0 }, + FIRSTORDER: { type: "fixed", value: 10, firstOrderOnly: true }, + WEEKDAY10: { type: "percent", value: 0.1 }, +} as const; + +type PromoCode = keyof typeof PROMO_CODES; + +export function isPromoCodeValid(code: string): code is PromoCode { + return Object.prototype.hasOwnProperty.call(PROMO_CODES, code); +} + +export function calculatePromoDiscount( + rawCode: string, + subtotal: number, + deliveryFee: number, + options?: { isFirstOrder?: boolean } +): { valid: boolean; discount: number; error?: string } { + const code = rawCode.trim().toUpperCase(); + if (!isPromoCodeValid(code)) { + return { valid: false, discount: 0, error: "Invalid promo code." }; + } + + const promo = PROMO_CODES[code]; + if ("firstOrderOnly" in promo && promo.firstOrderOnly && !options?.isFirstOrder) { + return { + valid: false, + discount: 0, + error: "This promo is only valid on your first order.", + }; + } + + if (promo.type === "percent") { + return { valid: true, discount: Math.max(0, subtotal * promo.value) }; + } + if (promo.type === "fixed") { + return { valid: true, discount: Math.max(0, Math.min(subtotal, promo.value)) }; + } + if (promo.type === "delivery_waive") { + return { valid: true, discount: Math.max(0, deliveryFee) }; + } + + return { valid: false, discount: 0, error: "Invalid promo code." }; +} diff --git a/app/page.tsx b/app/page.tsx index 7f8c4a5..6f6f53d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,6 +12,8 @@ import { getUserLocationAndNearbyRestaurants, type Location } from "./lib/geolocation"; +import { calculatePromoDiscount } from "./lib/promo"; +import { getRestaurantImage } from "./lib/imageMapping"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -37,7 +39,7 @@ interface Restaurant { } type ChatCard = { - type: "restaurant" | "item"; + type: "restaurant" | "item" | "external_place"; id: string | number; name: string; image: string; @@ -45,6 +47,7 @@ type ChatCard = { restaurantId?: number; restaurantName?: string; itemData?: MenuItem; + mapUrl?: string; }; type ChatMessage = { @@ -61,7 +64,17 @@ type OrderAction = { delivery_address?: string; }; -type ChatResponse = { reply: string; action?: OrderAction }; +type ChatResponse = { + reply: string; + action?: OrderAction; + fallbackPlaces?: Array<{ + name: string; + address?: string; + latitude: number; + longitude: number; + distanceMiles: number; + }>; +}; type SavedMeal = { id: string; @@ -195,11 +208,6 @@ export default function Home() { const [splitCount, setSplitCount] = useState(1); const [savedAddresses, setSavedAddresses] = useState>([]); - // Favorites & filters - const [favoriteIds, setFavoriteIds] = useState>(new Set()); - const [dietaryFilter, setDietaryFilter] = useState("All"); - const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); - // Chat const [chatMessages, setChatMessages] = useState([ { @@ -222,8 +230,6 @@ export default function Home() { if (saved) setSavedMeals(JSON.parse(saved) as SavedMeal[]); const recent = localStorage.getItem("quickbite_recent"); if (recent) setRecentRestaurants(JSON.parse(recent) as RecentRestaurant[]); - const favs = localStorage.getItem("quickbite_favorites"); - if (favs) setFavoriteIds(new Set(JSON.parse(favs) as number[])); const addrs = localStorage.getItem("quickbite_addresses"); if (addrs) setSavedAddresses(JSON.parse(addrs)); }, []); @@ -359,39 +365,37 @@ export default function Home() { const isSaved = (item: MenuItem, restaurant: Restaurant) => savedMeals.some((m) => m.id === `${restaurant.id}:${item.id}`); - // ─── Favorites ──────────────────────────────────────────────────────────── - - const toggleFavorite = (rId: number) => { - setFavoriteIds((prev) => { - const next = new Set(prev); - if (next.has(rId)) next.delete(rId); - else next.add(rId); - localStorage.setItem("quickbite_favorites", JSON.stringify([...next])); - return next; - }); - }; - // ─── Promo codes ────────────────────────────────────────────────────────── - const PROMO_CODES: Record = { - PIZZA20: 0.20, BURGER15: 0.15, SUSHI25: 0.25, - DELIVERY5: 5, FIRSTORDER: 10, WEEKDAY10: 0.10, - }; - const applyPromo = () => { const code = promoCode.trim().toUpperCase(); if (!code) return; - const discount = PROMO_CODES[code]; - if (!discount) { - setPromoError("Invalid promo code."); + const result = calculatePromoDiscount(code, totalPrice, deliveryFee, { + // The server validates FIRSTORDER authoritatively on checkout. + isFirstOrder: true, + }); + if (!result.valid) { + setPromoError(result.error ?? "Invalid promo code."); setPromoDiscount(0); return; } setPromoError(null); - setPromoDiscount(discount < 1 ? totalPrice * discount : discount); + setPromoDiscount(result.discount); setStatusMessage(`✅ Promo "${code}" applied!`); }; + useEffect(() => { + const code = promoCode.trim().toUpperCase(); + if (!code || promoDiscount <= 0) return; + const result = calculatePromoDiscount(code, totalPrice, deliveryFee, { + isFirstOrder: true, + }); + if (result.valid) { + setPromoDiscount(result.discount); + setPromoError(null); + } + }, [promoCode, totalPrice, deliveryFee, promoDiscount]); + // ─── Past orders ────────────────────────────────────────────────────────── const loadPastOrders = async () => { @@ -477,13 +481,29 @@ export default function Home() { body: JSON.stringify({ message: text, menuData: restaurants, + userLocation, currentCart: items.map((i) => ({ name: i.name, quantity: i.quantity, price: i.price })), messages: chatMessages.map((m) => ({ role: m.role, content: m.content })), }), }); if (!res.ok) throw new Error(); const data: ChatResponse = await res.json(); - const { reply, action } = data; + const { reply, action, fallbackPlaces } = data; + + if (fallbackPlaces && fallbackPlaces.length > 0) { + const cards: ChatCard[] = fallbackPlaces.map((place, idx) => ({ + type: "external_place", + id: `external-${idx}-${place.name}`, + name: place.name, + image: getRestaurantImage(place.name, "Restaurant"), + subtitle: `${place.distanceMiles.toFixed(1)} mi${place.address ? ` • ${place.address}` : ""}`, + mapUrl: `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent( + `${place.latitude},${place.longitude}` + )}`, + })); + addAssistantMessage(reply || "Closest places found near you.", cards); + return; + } if (action?.action === "add_to_cart" && action.restaurant && action.items) { const target = restaurants.find( @@ -527,7 +547,11 @@ export default function Home() { } else { const rId = items[0]?.restaurantId; if (!rId) { setStatusMessage("⚠️ Invalid restaurant"); return; } - await saveOrder(rId, action.delivery_address); + await saveOrder(rId, action.delivery_address, { + deliveryFee, + tip, + promoCode: promoCode.trim() || undefined, + }); setStatusMessage("🚗 Your order is confirmed and on the way."); addAssistantMessage(`✅ Order confirmed! Your driver is heading to ${action.delivery_address}.`); return; @@ -557,7 +581,11 @@ export default function Home() { if (!id) return; try { setCheckoutError(null); - await saveOrder(id, deliveryAddress); + await saveOrder(id, deliveryAddress, { + deliveryFee, + tip, + promoCode: promoCode.trim() || undefined, + }); // Award reward points const earned = 100 + Math.floor(orderTotal); @@ -672,7 +700,7 @@ export default function Home() {
{/* ── Sidebar ── */} -