From 580e4e42f22c361ae81a261575f07b54ce7c0e18 Mon Sep 17 00:00:00 2001 From: indar suthar Date: Sat, 8 Nov 2025 17:12:18 +0530 Subject: [PATCH] feat: Implement notification UI for auction --- backend/src/app.ts | 3 +- backend/src/controllers/auction.controller.ts | 225 ++++++++++++++++++ backend/src/models/auction.model.ts | 10 + backend/src/routes/auctionRoutes.ts | 17 +- frontend/src/App.tsx | 3 + frontend/src/components/CartContext.tsx | 18 +- .../src/components/NotificationContext.tsx | 63 +++++ frontend/src/pages/Browse.tsx | 5 +- frontend/src/pages/Payment.tsx | 6 + frontend/src/pages/ProductDetail.tsx | 208 ++++++++++++---- 10 files changed, 498 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/NotificationContext.tsx diff --git a/backend/src/app.ts b/backend/src/app.ts index 3f25774..612fa0e 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,6 +6,7 @@ import healthRoutes from './routes/healthRoutes'; import userRoutes from "./routes/user.routes"; import productRoutes from "./routes/product.routes"; import cartRoutes from "./routes/cartRoutes"; +import auctionRoutes from "./routes/auctionRoutes"; import mongoose from "mongoose"; import { requestLogger } from "./middleware/loggerMiddleware"; import { errorHandler } from "./middleware/errorMiddleware"; @@ -37,7 +38,7 @@ app.use('/api/health', healthRoutes); app.use("/api/products", productRoutes); app.use("/api/users", userRoutes); app.use("/api/cart", cartRoutes); -app.use("/api/users", userRoutes); +app.use("/api/auctions", auctionRoutes); // MongoDB connect (optional) const mongoUri = process.env.MONGO_URI; diff --git a/backend/src/controllers/auction.controller.ts b/backend/src/controllers/auction.controller.ts index e69de29..29188da 100644 --- a/backend/src/controllers/auction.controller.ts +++ b/backend/src/controllers/auction.controller.ts @@ -0,0 +1,225 @@ +import { Request, Response } from "express"; +import Auction from "../models/auction.model"; +import Bid from "../models/bid.model"; +import Product from "../models/product.model"; +import mongoose from "mongoose"; + +//new auction +export const createAuction = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + const { productId, startPrice, minIncrement, durationHours } = req.body; + if (!productId || !startPrice) { + res.status(400).json({ message: "Product ID and start price are required" }); + return; + } + //if product exists + const product = await Product.findById(productId); + if (!product) { + res.status(404).json({ message: "Product not found" }); + return; + } + const existingAuction = await Auction.findOne({ productId: new mongoose.Types.ObjectId(productId), status: "active" }); + if (existingAuction) { + res.status(400).json({ message: "An active auction already exists for this product" }); + return; + } + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + (durationHours || 48)); + + const auction = new Auction({ + productId: new mongoose.Types.ObjectId(productId), + itemName: product.name, + itemDescription: product.description, + startPrice, + currentHighestBid: startPrice, + minIncrement: minIncrement || 100, + seller: new mongoose.Types.ObjectId(userId), + expiresAt, + status: "active", + }); + await auction.save(); + res.status(201).json(auction); + } catch (error: any) { + console.error("Create auction error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; + +export const placeBid = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const { id } = req.params; + const { amount } = req.body; + + if (!amount || amount <= 0) { + res.status(400).json({ message: "Valid bid amount is required" }); + return; + } + let auction = await Auction.findById(id); + if (!auction) { + auction = await Auction.findOne({ productId: new mongoose.Types.ObjectId(id), status: "active" }); + } + + if (!auction) { + res.status(404).json({ message: "Auction not found or not active" }); + return; + } + + if (auction.status !== "active") { + res.status(400).json({ message: "Auction is not active" }); + return; + } + + // Check if expired + if (new Date() > auction.expiresAt) { + auction.status = "expired"; + await auction.save(); + res.status(400).json({ message: "Auction has expired" }); + return; + } + if (auction.seller.toString() === userId) { + res.status(400).json({ message: "You cannot bid on your own auction" }); + return; + } + const minBid = auction.currentHighestBid + (auction.minIncrement || 100); + if (amount < minBid) { + res.status(400).json({ message: `Bid must be at least ₹${minBid}` }); + return; + } + const bid = new Bid({ + auctionId: auction._id, + bidder: new mongoose.Types.ObjectId(userId), + amount, + }); + + await bid.save(); + auction.currentHighestBid = amount; + auction.highestBidder = new mongoose.Types.ObjectId(userId); + await auction.save(); + + res.status(201).json({ + message: "Bid placed successfully", + bid, + auction: { + currentHighestBid: auction.currentHighestBid, + highestBidder: auction.highestBidder, + }, + }); + } catch (error: any) { + console.error("Place bid error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; +export const acceptHighestBid = async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + + const { id } = req.params; + let auction = await Auction.findById(id); + if (!auction) { + auction = await Auction.findOne({ productId: new mongoose.Types.ObjectId(id), status: "active" }); + } + + if (!auction) { + res.status(404).json({ message: "Auction not found" }); + return; + } + + if (auction.seller.toString() !== userId) { + res.status(403).json({ message: "Only the seller can accept bids" }); + return; + } + + if (!auction.highestBidder) { + res.status(400).json({ message: "No bids to accept" }); + return; + } + // Mark auction as sold + auction.status = "sold"; + auction.soldTo = auction.highestBidder; + auction.soldPrice = auction.currentHighestBid; + await auction.save(); + + res.status(200).json({ + message: "Bid accepted successfully", + auction, + }); + } catch (error: any) { + console.error("Accept bid error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; + +// Get auction by product ID +export const getAuctionByProductId = async (req: Request, res: Response): Promise => { + try { + const { productId } = req.params; + + const auction = await Auction.findOne({ + productId: new mongoose.Types.ObjectId(productId), + status: "active", + }).populate("seller", "name email").populate("highestBidder", "name email"); + + if (!auction) { + res.status(404).json({ message: "No active auction found for this product" }); + return; + } + + //time left + const now = new Date(); + const expiresAt = new Date(auction.expiresAt); + const timeLeftMs = expiresAt.getTime() - now.getTime(); + const timeLeftHours = Math.max(0, Math.floor(timeLeftMs / (1000 * 60 * 60))); + const timeLeftMinutes = Math.max(0, Math.floor((timeLeftMs % (1000 * 60 * 60)) / (1000 * 60))); + + res.status(200).json({ + ...auction.toObject(), + timeLeft: timeLeftMs > 0 ? `${timeLeftHours}h ${timeLeftMinutes}m` : "Expired", + isExpired: timeLeftMs <= 0, + }); + } catch (error: any) { + console.error("Get auction error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; + +// Get all bids for an auction +export const getAuctionBids = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + let auction = await Auction.findById(id); + if (!auction) { + auction = await Auction.findOne({ productId: new mongoose.Types.ObjectId(id) }); + } + + if (!auction) { + res.status(404).json({ message: "Auction not found" }); + return; + } + + const bids = await Bid.find({ auctionId: auction._id }) + .populate("bidder", "name email") + .sort({ createdAt: -1 }); + + res.status(200).json(bids); + } catch (error: any) { + console.error("Get bids error:", error); + res.status(500).json({ message: error.message || "Server error" }); + } +}; + diff --git a/backend/src/models/auction.model.ts b/backend/src/models/auction.model.ts index ce56b61..d3cfbde 100644 --- a/backend/src/models/auction.model.ts +++ b/backend/src/models/auction.model.ts @@ -1,6 +1,11 @@ import mongoose from "mongoose"; const auctionSchema = new mongoose.Schema({ + productId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Product", + required: true, + }, itemName: { type: String, required: true, @@ -15,6 +20,11 @@ const auctionSchema = new mongoose.Schema({ required: true, min: 0, }, + minIncrement: { + type: Number, + default: 100, + min: 1, + }, currentHighestBid: { type: Number, default: function () { return this.startPrice; }, diff --git a/backend/src/routes/auctionRoutes.ts b/backend/src/routes/auctionRoutes.ts index 5f4f908..649b4a2 100644 --- a/backend/src/routes/auctionRoutes.ts +++ b/backend/src/routes/auctionRoutes.ts @@ -3,18 +3,21 @@ import { createAuction, placeBid, acceptHighestBid, + getAuctionByProductId, + getAuctionBids, } from "../controllers/auction.controller"; -import { authenticate, authorizeRole } from "../middleware/authMiddleware"; +import { authenticate } from "../middleware/authMiddleware"; const router = express.Router(); -// Create a new auction (only sellers) -router.post("/", authenticate, authorizeRole("seller"), createAuction); +router.get("/product/:productId", getAuctionByProductId); +router.get("/:id/bids", getAuctionBids); +router.post("/", authenticate, createAuction); -// Place a bid (only buyers) -router.post("/:id/bid", authenticate, authorizeRole("buyer"), placeBid); +// Place a bid (authenticated) +router.post("/:id/bid", authenticate, placeBid); -// Seller accepts the highest bid (only sellers) -router.post("/:id/accept", authenticate, authorizeRole("seller"), acceptHighestBid); +// Seller accepts the highest bid (authenticated) +router.post("/:id/accept", authenticate, acceptHighestBid); export default router; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d43d4b1..f24a7e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { CartProvider } from "./components/CartContext"; import Cart from "./pages/Cart"; import Payment from "./pages/Payment"; import Dashboard from "./pages/Dashboard"; +import { NotificationProvider } from "./components/NotificationContext"; const queryClient = new QueryClient(); @@ -20,6 +21,7 @@ const App = () => ( + @@ -38,6 +40,7 @@ const App = () => ( + ); diff --git a/frontend/src/components/CartContext.tsx b/frontend/src/components/CartContext.tsx index 19aa05c..34fd46d 100644 --- a/frontend/src/components/CartContext.tsx +++ b/frontend/src/components/CartContext.tsx @@ -1,4 +1,7 @@ import React, { createContext, useContext, useEffect, useReducer } from "react"; +import { useNotification } from "./NotificationContext"; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:5000"; export interface CartItem { _id?: string; @@ -82,6 +85,7 @@ export const useCart = (): CartContextValue => { export const CartProvider: React.FC> = ({ children }) => { const [state, dispatch] = React.useReducer(reducer, initialState); + const { notifyAddToCart } = useNotification(); useEffect(() => { try { @@ -115,7 +119,7 @@ export const CartProvider: React.FC> = ({ children } const token = localStorage.getItem("accessToken"); if (!token) return; // nothing to do try { - const res = await callBackend("/api/cart", { method: "GET", headers: getAuthHeaders() }); + const res = await callBackend(`${API_BASE_URL}/api/cart`, { method: "GET", headers: getAuthHeaders() }); const cart = await res.json(); const items: CartItem[] = (cart?.items || []).map((it: any) => ({ _id: String((it as any)._id || ""), @@ -141,7 +145,7 @@ export const CartProvider: React.FC> = ({ children } price: item.price, quantity: item.quantity, }); - const res = await callBackend("/api/cart", { method: "POST", headers: getAuthHeaders(), body }); + const res = await callBackend(`${API_BASE_URL}/api/cart`, { method: "POST", headers: getAuthHeaders(), body }); const savedCart = await res.json(); const items: CartItem[] = (savedCart?.items || []).map((it: any) => ({ _id: String((it as any)._id || ""), @@ -152,6 +156,7 @@ export const CartProvider: React.FC> = ({ children } image: it.image, })); dispatch({ type: "SET_CART", payload: items }); + notifyAddToCart(item.name); return; } catch (err: any) { if ((err as any).status === 401 || (err as any).status === 403) { @@ -163,6 +168,7 @@ export const CartProvider: React.FC> = ({ children } } // Fallback dispatch({ type: "ADD_ITEM", payload: item }); + notifyAddToCart(item.name); }; const removeItemByIndex = async (index: number) => { @@ -171,7 +177,7 @@ export const CartProvider: React.FC> = ({ children } const item = state.items[index]; if (item && item._id) { try { - await callBackend(`/api/cart/${item._id}`, { method: "DELETE", headers: getAuthHeaders() }); + await callBackend(`${API_BASE_URL}/api/cart/${item._id}`, { method: "DELETE", headers: getAuthHeaders() }); await refreshFromServer(); return; } catch { @@ -187,7 +193,7 @@ export const CartProvider: React.FC> = ({ children } const token = localStorage.getItem("accessToken"); if (token) { try { - await callBackend(`/api/cart/${id}`, { method: "DELETE", headers: getAuthHeaders() }); + await callBackend(`${API_BASE_URL}/api/cart/${id}`, { method: "DELETE", headers: getAuthHeaders() }); await refreshFromServer(); return; } catch { @@ -201,7 +207,7 @@ export const CartProvider: React.FC> = ({ children } const token = localStorage.getItem("accessToken"); if (token && params.itemId) { try { - await callBackend(`/api/cart/${params.itemId}`, { + await callBackend(`${API_BASE_URL}/api/cart/${params.itemId}`, { method: "PATCH", headers: getAuthHeaders(), body: JSON.stringify({ quantity: params.quantity }), @@ -221,7 +227,7 @@ export const CartProvider: React.FC> = ({ children } const token = localStorage.getItem("accessToken"); if (token) { try { - await callBackend("/api/cart/checkout", { method: "POST", headers: getAuthHeaders() }); + await callBackend(`${API_BASE_URL}/api/cart/checkout`, { method: "POST", headers: getAuthHeaders() }); // backend will clear cart await refreshFromServer(); return; diff --git a/frontend/src/components/NotificationContext.tsx b/frontend/src/components/NotificationContext.tsx new file mode 100644 index 0000000..0532129 --- /dev/null +++ b/frontend/src/components/NotificationContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useContext, useCallback } from "react"; +import { toast } from "sonner"; + +interface NotificationCtxType { + notifyOutbid: (itemTitle: string, newBid: number) => void; + notifySold: (itemTitle: string, salePrice: number) => void; + notifyBidPlaced: (itemTitle: string, bid: number) => void; + notifyBuyItem: (itemTitle: string, price: number) => void; + notifyAddToCart: (itemTitle: string) => void; +} + +const NotificationContext = createContext(undefined); + +export const useNotification = (): NotificationCtxType => { + const ctx = useContext(NotificationContext); + if (!ctx) throw new Error("useNotification must be used within NotificationProvider"); + return ctx; +}; + +export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const notifyOutbid = useCallback((itemTitle: string, newBid: number) => { + toast.warning("🚨 Outbid Alert!", { + description: `You've been outbid on "${itemTitle}". Highest bid is now ₹${newBid}.`, + duration: 5000, + }); + }, []); + + const notifySold = useCallback((itemTitle: string, salePrice: number) => { + toast.success("🎉 Item Sold!", { + description: `Your item "${itemTitle}" is sold for ₹${salePrice}.`, + duration: 5000, + }); + }, []); + + const notifyBidPlaced = useCallback((itemTitle: string, bid: number) => { + toast.success("✅ Bid Placed", { + description: `Your bid of ₹${bid} for "${itemTitle}" was placed successfully.`, + duration: 5000, + }); + }, []); + + const notifyBuyItem = useCallback((itemTitle: string, price: number) => { + toast.success("🛒 Purchased!", { + description: `You bought "${itemTitle}" for ₹${price}!`, + duration: 5000, + }); + }, []); + + const notifyAddToCart = useCallback((itemTitle: string) => { + toast.info("🛒 Added to Cart", { + description: `"${itemTitle}" has been added to your cart.`, + duration: 5000, + }); + }, []); + + return ( + + {children} + + ); +}; + +export default NotificationContext; \ No newline at end of file diff --git a/frontend/src/pages/Browse.tsx b/frontend/src/pages/Browse.tsx index 776e722..4b0cedd 100644 --- a/frontend/src/pages/Browse.tsx +++ b/frontend/src/pages/Browse.tsx @@ -6,6 +6,7 @@ import { Search, ShoppingCart } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle } from "../components/ui/drawer"; import { useCart } from "../components/CartContext"; +import { useNotification } from "../components/NotificationContext"; const Browse = () => { const navigate = useNavigate(); @@ -17,6 +18,7 @@ const Browse = () => { const [isDrawerOpen, setIsDrawerOpen] = useState(false); const { addItem } = useCart(); + const { notifyAddToCart } = useNotification(); const demoProducts = [ { id: "6", name: "Desk Lamp", price: "₹15", category: "Electronics", seller: "Thor", images: ["/lamp1.jpg"] }, { id: "7", name: "Organic Chemistry Notes", price: "₹25", category: "Notes", seller: "Wanda", images: ["/notes2.jpg"] }, @@ -98,7 +100,8 @@ const Browse = () => { quantity: 1, image: product.imageUrl || (product.images?.[0]), }); - alert(`${product.name} added to cart`); + // 3. Replace the alert with the toast notification + notifyAddToCart(product.name); }; return ( diff --git a/frontend/src/pages/Payment.tsx b/frontend/src/pages/Payment.tsx index f56b4ce..3228f1d 100644 --- a/frontend/src/pages/Payment.tsx +++ b/frontend/src/pages/Payment.tsx @@ -2,10 +2,12 @@ import React, { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { useCart } from "../components/CartContext"; +import { useNotification } from "../components/NotificationContext"; const Payment: React.FC = () => { const { state, clearCart } = useCart(); const navigate = useNavigate(); + const { notifyBuyItem } = useNotification(); const [paymentMethod, setPaymentMethod] = useState<"card" | "upi" | "wallet" | "netbanking">("card"); const [formData, setFormData] = useState({ cardNumber: "", @@ -44,6 +46,10 @@ const Payment: React.FC = () => { // Simulate payment processing setTimeout(async () => { setIsProcessing(false); + // Notify for each purchased item + state.items.forEach(item => { + notifyBuyItem(item.name, item.price * item.quantity); + }); await clearCart(); navigate("/success", { replace: true }); }, 2200); diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx index 72ab156..c4c497d 100644 --- a/frontend/src/pages/ProductDetail.tsx +++ b/frontend/src/pages/ProductDetail.tsx @@ -3,12 +3,15 @@ import { useParams, useNavigate } from "react-router-dom"; import { Button } from "../components/ui/button"; import { ArrowLeft } from "lucide-react"; import { useCart } from "../components/CartContext"; +import { useNotification } from "../components/NotificationContext"; +import { toast } from "sonner"; type AuctionInfo = { isAuction: boolean; highestBid?: number; minIncrement?: number; timeLeft?: string; + isExpired?: boolean; }; type SellerInfo = { name: string; Used?: string; email?: string }; @@ -25,12 +28,13 @@ type Product = { [key: string]: any; }; +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "http://localhost:5000" const demoProducts: Product[] = [ { id: 1, title: "Calculus Textbook", price: 45, description: "A comprehensive calculus guide ideal for engineering and science students. Covers differential, integral, and multivariable calculus with step-by-step examples and university-level exercises. Perfect for semester preparation and concept building.", images: ["/book1.jpg"], seller: { name: "Shubham", Used: "1.2 year", email: "xyz@gmail.com" }, auction: { isAuction: true, highestBid: 50, minIncrement: 5, timeLeft: "10h 45m" } }, { id: 2, title: "Laptop Stand", price: 20, description: "Ergonomic aluminum laptop stand that improves posture and cooling. Lightweight, foldable, and ideal for long study sessions in hostels or libraries. Adjustable height ensures comfort while typing or attending online classes.", images: ["/laptop1.jpg"], seller: { name: "Steve Rogers", Used: "12 months", email: "xyz@gmail.com" }, auction: { isAuction: false } }, { id: 3, title: "Study Notes - Physics", price: 10, description: "Handwritten physics notes neatly organized chapter-wise, covering Mechanics, Thermodynamics, Electromagnetism, and Modern Physics. Simplified concepts and formulas for quick revision and last-minute preparation.", images: ["/notes1.jpg"], seller: { name: "Bruce Banner", Used: "1 year", email: "xyz@gmail.com" }, auction: { isAuction: false } }, { id: 4, title: "Scientific Calculator", price: 30, description: "Casio fx-991EX ClassWiz calculator with 552 functions. Ideal for engineering students — handles matrices, statistics, and complex equations with ease. Excellent condition with original case and battery included.", images: ["/calc1.jpg"], seller: { name: "Peter", Used: "6 months", email: "xyz@gmail.com" }, auction: { isAuction: true, highestBid: 35, minIncrement: 5, timeLeft: "6h 15m" } }, - { id: 5, title: "C++ Programming Book", price: 499, description: "‘Programming in C++’ by E. Balagurusamy. A complete guide to mastering Object-Oriented Programming. Covers basics to advanced topics like inheritance and polymorphism with solved examples and practice problems.", images: ["/book2.jpg"], seller: { name: "Thor", Used: "2 years", email: "xyz@gmail.com" }, auction: { isAuction: true, highestBid: 550, minIncrement: 50, timeLeft: "12h 30m" } }, + { id: 5, title: "C++ Programming Book", price: 499, description: "'Programming in C++' by E. Balagurusamy. A complete guide to mastering Object-Oriented Programming. Covers basics to advanced topics like inheritance and polymorphism with solved examples and practice problems.", images: ["/book2.jpg"], seller: { name: "Thor", Used: "2 years", email: "xyz@gmail.com" }, auction: { isAuction: true, highestBid: 550, minIncrement: 50, timeLeft: "12h 30m" } }, { id: 6, title: "Desk Lamp", price: 15, description: "Adjustable LED desk lamp with 3 brightness levels. Energy-efficient, flexible design suitable for night study sessions. USB rechargeable and portable — perfect for hostel or dorm desk setups.", images: ["/lamp1.jpg"], seller: { name: "Tony Stark", Used: "2 year", email: "xyz@gmail.com" }, auction: { isAuction: true, highestBid: 18, minIncrement: 2, timeLeft: "5h 20m" } }, { id: 7, title: "Organic Chemistry Notes", price: 25, description: "Comprehensive handwritten organic chemistry notes with reaction mechanisms, named reactions, and visual memory aids. Perfect for college exams and quick conceptual revision.", images: ["/notes2.jpg"], seller: { name: "Wanda", Used: "1 year", email: "xyz@gmail.com" }, auction: { isAuction: false } }, { id: 8, title: "Bluetooth Headphones", price: 150, description: "High-quality wireless headphones with deep bass, noise isolation, and 20-hour battery life. Ideal for music, online classes, or movies. Lightweight design with soft ear cushions for comfort.", images: ["/headphones.jpg"], seller: { name: "Natasha", Used: "8 months", email: "xyz@gmail.com" }, auction: { isAuction: true, highestBid: 160, minIncrement: 10, timeLeft: "3h 45m" } } @@ -40,20 +44,34 @@ export default function ProductDetailsPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { addItem } = useCart(); + const { notifyBidPlaced, notifyBuyItem } = useNotification(); const [product, setProduct] = useState(null); const [mainImage, setMainImage] = useState("/placeholder.png"); const [bid, setBid] = useState(0); const [isBidding, setIsBidding] = useState(false); + const [isLoading, setIsLoading] = useState(true); - // Load product from backend + // Load product and auction data from backend useEffect(() => { let isMounted = true; async function fetchProduct() { + setIsLoading(true); try { - const res = await fetch(`http://localhost:5000/api/products/${id}`); - if (!res.ok) throw new Error("Backend product not found"); - const prod = await res.json(); + // Fetch product + const productRes = await fetch(`${API_BASE_URL}/api/products/${id}`); + if (!productRes.ok) throw new Error("Product not found"); + const prod = await productRes.json(); + + let auctionData = null; + try { + const auctionRes = await fetch(`${API_BASE_URL}/api/auctions/product/${id}`); + if (auctionRes.ok) { + auctionData = await auctionRes.json(); + } + } catch (err) { + // No auction + } const prodNorm: Product = { id: prod._id ?? prod.id, @@ -62,22 +80,44 @@ export default function ProductDetailsPage() { description: prod.description ?? "", images: prod.imageUrl ? [prod.imageUrl] : [], seller: { name: "User", Used: "-", email: prod.sellerEmail || "" }, - auction: { isAuction: false }, + auction: auctionData ? { + isAuction: true, + highestBid: auctionData.currentHighestBid || auctionData.startPrice, + minIncrement: auctionData.minIncrement || 100, + timeLeft: auctionData.timeLeft || "", + isExpired: auctionData.isExpired || false, + } : { isAuction: false }, imageUrl: prod.imageUrl, ...prod }; if (isMounted) { setProduct(prodNorm); setMainImage(prodNorm.images?.[0] ?? prodNorm.imageUrl ?? "/placeholder.png"); + if (prodNorm.auction?.isAuction && !prodNorm.auction.isExpired) { + const minBid = (prodNorm.auction.highestBid || 0) + (prodNorm.auction.minIncrement || 100); + setBid(minBid); + } } - } catch { - // fallback to dummy + } catch (err) { + console.error("Error fetching product from backend:", err); + // Fallback to demo products const local = demoProducts.find((p) => String(p.id) === String(id)); if (isMounted && local) { setProduct(local); setMainImage(local.images?.[0] ?? "/placeholder.png"); + if (local.auction?.isAuction && !local.auction.isExpired) { + const minBid = (local.auction.highestBid || 0) + (local.auction.minIncrement || 100); + setBid(minBid); + } } else if (isMounted) { setProduct(null); + toast.error("Product not found", { + description: "The product you're looking for doesn't exist.", + }); + } + } finally { + if (isMounted) { + setIsLoading(false); } } } @@ -85,70 +125,142 @@ export default function ProductDetailsPage() { return () => { isMounted = false; }; }, [id]); - useEffect(() => { - if (product?.auction?.isAuction) { - setBid((product.auction.highestBid || 0) + (product.auction.minIncrement || 1)); + const placeBid = async () => { + if (!product || !product.auction.isAuction) { + toast.error("This item is not in auction."); + return; } - }, [product]); - if (!product) return
Product not found.
; - - const placeBid = async () => { - if (!product.auction.isAuction) { - alert("This item is not in auction."); + if (product.auction.isExpired) { + toast.error("This auction has expired."); return; } - const min = (product.auction.highestBid || 0) + (product.auction.minIncrement || 1); + + const min = (product.auction.highestBid || 0) + (product.auction.minIncrement || 100); if (bid < min) { - alert(`Bid must be at least ₹${min}`); + toast.error(`Bid must be at least ₹${min}`); return; } + const isDemoProduct = demoProducts.some(p => String(p.id) === String(product?.id)); setIsBidding(true); try { + if (isDemoProduct) { + notifyBidPlaced(product.title ?? product.name ?? "Item", bid); + setProduct((prod) => prod ? ({ + ...prod, + auction: { + ...prod.auction, + highestBid: bid, + }, + }) : null); + const newMinBid = bid + (product.auction.minIncrement || 100); + setBid(newMinBid); + return; + } const token = localStorage.getItem("accessToken"); - const headers: any = { "Content-Type": "application/json" }; - if (token) headers["Authorization"] = `Bearer ${token}`; - const res = await fetch(`/api/auctions/${product.id}/bid`, { + if (!token) { + toast.error("Please sign in to place a bid", { + description: "You need to be logged in to bid on items.", + }); + navigate("/signin"); + return; + } + + const headers: any = { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }; + const res = await fetch(`${API_BASE_URL}/api/auctions/${product.id}/bid`, { method: "POST", headers, body: JSON.stringify({ amount: bid }), }); if (res.ok) { - alert(`Bid of ₹${bid} placed successfully!`); + const data = await res.json(); + notifyBidPlaced(product.title ?? product.name ?? "Item", bid); setProduct((prod) => prod ? ({ ...prod, - auction: { ...prod.auction, highestBid: bid }, + auction: { + ...prod.auction, + highestBid: data.auction?.currentHighestBid || bid, + }, }) : null); - setBid(bid + (product.auction.minIncrement || 1)); + const newMinBid = (data.auction?.currentHighestBid || bid) + (product.auction.minIncrement || 100); + setBid(newMinBid); } else { - const errText = await res.text(); - alert(`Failed to place bid: ${errText || res.statusText}`); + const errorData = await res.json().catch(() => ({ message: res.statusText })); + toast.error("Failed to place bid", { + description: errorData.message || "Please try again.", + }); + } + } catch (err: any) { + console.error("Bid error:", err); + + if (isDemoProduct) { + notifyBidPlaced(product.title ?? product.name ?? "Item", bid); + setProduct((prod) => prod ? ({ + ...prod, + auction: { + ...prod.auction, + highestBid: bid, + }, + }) : null); + const newMinBid = bid + (product.auction.minIncrement || 100); + setBid(newMinBid); + } else { + toast.error("Could not reach server", { + description: "Please check your connection and try again.", + }); } - } catch { - alert(`Could not reach server. Local simulated bid of ₹${bid} placed.`); - setProduct((prod) => prod ? ({ - ...prod, - auction: { ...prod.auction, highestBid: bid }, - }) : null); - setBid(bid + (product.auction.minIncrement || 1)); } finally { setIsBidding(false); } }; const handleBuyNow = async () => { - await addItem({ - productId: String(product.id), - name: product.title ?? product.name ?? "", - price: product.price, - quantity: 1, - image: product.images?.[0] || product.imageUrl || "/placeholder.png", - }); - navigate("/payment"); + if (!product) return; + + try { + await addItem({ + productId: String(product.id), + name: product.title ?? product.name ?? "", + price: product.price, + quantity: 1, + image: product.images?.[0] || product.imageUrl || "/placeholder.png", + }); + notifyBuyItem(product.title ?? product.name ?? "Item", product.price); + navigate("/cart"); + } catch (err) { + toast.error("Failed to add item to cart", { + description: "Please try again.", + }); + } }; + if (isLoading) { + return ( +
+
+
+

Loading product...

+
+
+ ); + } + + if (!product) { + return ( +
+
+

Product not found

+ +
+
+ ); + } + // Render UI return (
@@ -173,7 +285,7 @@ export default function ProductDetailsPage() {

{product.title ?? product.name}

{product.description}

- {product.auction?.isAuction ? ( + {product.auction?.isAuction && !product.auction.isExpired ? (

Highest Bid: ₹{product.auction.highestBid}

⏰ {product.auction.timeLeft ? `Time left: ${product.auction.timeLeft}` : ""}

@@ -182,12 +294,18 @@ export default function ProductDetailsPage() { - +
+ ) : product.auction?.isAuction && product.auction.isExpired ? ( +
+

Auction Ended

+

Final Bid: ₹{product.auction.highestBid?.toLocaleString()}

+ +
) : (
-

₹{product.price}

+

₹{product.price.toLocaleString()}

)}