From f52693dfc2e7c13017066fa5187b8aa88cd6ff61 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Tue, 25 Mar 2025 14:47:54 -0600 Subject: [PATCH 01/37] Homepg & ProductDet.. now updated --- backend/controllers/product.js | 43 ++++++-- backend/routes/product.js | 12 +- frontend/src/pages/Home.jsx | 12 +- frontend/src/pages/ProductDetail.jsx | 157 +++++++++++++++++++-------- 4 files changed, 156 insertions(+), 68 deletions(-) diff --git a/backend/controllers/product.js b/backend/controllers/product.js index bd40907..0a85686 100644 --- a/backend/controllers/product.js +++ b/backend/controllers/product.js @@ -24,9 +24,19 @@ exports.addToFavorite = async (req, res) => { exports.getAllProducts = async (req, res) => { try { const [data, fields] = await db.execute(` - SELECT p.*, i.URL - FROM Product p - LEFT JOIN Image_URL i ON p.ProductID = i.ProductID + SELECT + P.ProductID, + P.Name AS ProductName, + P.Price, + P.Date AS DateUploaded, + U.Name AS SellerName, + I.URL AS ProductImage, + C.Name AS Category + FROM Product P + LEFT JOIN + (SELECT ProductID, URL FROM Image_URL LIMIT 1) I ON P.ProductID = I.ProductID + JOIN User U ON P.UserID = U.UserID + JOIN Category C ON P.CategoryID = C.CategoryID; `); res.json({ @@ -43,10 +53,10 @@ exports.getAllProducts = async (req, res) => { } }; -// Get a single product by ID along with image URLs exports.getProductById = async (req, res) => { const { id } = req.params; - console.log(id); + console.log("Received Product ID:", id); + try { const [data] = await db.execute( ` @@ -58,29 +68,42 @@ exports.getProductById = async (req, res) => { [id], ); + // Log raw data for debugging + console.log("Raw Database Result:", data); + if (data.length === 0) { + console.log("No product found with ID:", id); return res.status(404).json({ success: false, message: "Product not found", }); } - // Assuming that `data` contains product information and the image URLs + // Collect all image URLs + const images = data + .map((row) => row.image_url) + .filter((url) => url !== null); + + // Create product object with all details from first row and collected images const product = { - ...data[0], // First product found in the query - images: data.map((image) => image.image_url), // Collect all image URLs into an array + ...data[0], // Base product details + images: images, // Collected image URLs }; + // Log processed product for debugging + console.log("Processed Product:", product); + res.json({ success: true, message: "Product fetched successfully", data: product, }); } catch (error) { - console.error("Error fetching product:", error); + console.error("Full Error Details:", error); return res.status(500).json({ success: false, - error: "Database error occurred", + message: "Database error occurred", + error: error.message, }); } }; diff --git a/backend/routes/product.js b/backend/routes/product.js index aeea0d9..90c7914 100644 --- a/backend/routes/product.js +++ b/backend/routes/product.js @@ -1,16 +1,20 @@ +// routes/product.js const express = require("express"); const { addToFavorite, getAllProducts, getProductById, } = require("../controllers/product"); - const router = express.Router(); +// Add detailed logging middleware +router.use((req, res, next) => { + console.log(`Incoming ${req.method} request to ${req.path}`); + next(); +}); + router.post("/add_fav_product", addToFavorite); - router.get("/get_product", getAllProducts); - -router.post("/get_productID", getProductById); +router.get("/:id", getProductById); // Simplified route module.exports = router; diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 4ae5a6e..5b3fb4b 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -21,14 +21,14 @@ const Home = () => { setListings( data.data.map((product) => ({ id: product.ProductID, - title: product.Name, + title: product.ProductName, // Use the alias from SQL price: product.Price, - category: product.CategoryID, - image: product.URL, + category: product.Category, // Ensure this gets the category name + image: product.ProductImage, // Use the alias for image URL condition: "New", // Modify based on actual data - seller: product.UserID, // Modify if seller info is available - datePosted: "Just now", - isFavorite: false, + seller: product.SellerName, // Fetch seller name properly + datePosted: product.DateUploaded, // Use the actual date + isFavorite: false, // Default state })), ); } else { diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx index 95a5286..6ad1c10 100644 --- a/frontend/src/pages/ProductDetail.jsx +++ b/frontend/src/pages/ProductDetail.jsx @@ -5,6 +5,8 @@ import { Heart, ArrowLeft, Tag, User, Calendar } from "lucide-react"; const ProductDetail = () => { const { id } = useParams(); const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [isFavorite, setIsFavorite] = useState(false); const [showContactForm, setShowContactForm] = useState(false); const [message, setMessage] = useState(""); @@ -14,19 +16,28 @@ const ProductDetail = () => { useEffect(() => { const fetchProduct = async () => { try { - const response = await fetch( - `http://localhost:3030/api/product/get_productID/${id}`, - ); - if (!response.ok) throw new Error("Failed to fetch product"); + setLoading(true); + const response = await fetch(`http://localhost:3030/api/product/${id}`); - const data = await response.json(); - if (data.success) { - setProduct(data.data); // Update the state with product details + if (!response.ok) { + throw new Error("Failed to fetch product"); + } + + const result = await response.json(); + console.log(result); + + if (result.success) { + setProduct(result.data); + setError(null); } else { - throw new Error(data.message || "Error fetching product"); + throw new Error(result.message || "Error fetching product"); } } catch (error) { console.error("Error fetching product:", error); + setError(error.message); + setProduct(null); + } finally { + setLoading(false); } }; @@ -34,14 +45,35 @@ const ProductDetail = () => { }, [id]); // Handle favorite toggle - const toggleFavorite = () => { - setIsFavorite(!isFavorite); + const toggleFavorite = async () => { + try { + const response = await fetch( + "http://localhost:3030/api/product/add_to_favorite", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userID: 1, // Replace with actual user ID + productsID: id, + }), + }, + ); + + const result = await response.json(); + if (result.success) { + setIsFavorite(!isFavorite); + } + } catch (error) { + console.error("Error toggling favorite:", error); + } }; // Handle message submission const handleSendMessage = (e) => { e.preventDefault(); - // Handle message logic here (send to seller) + // TODO: Implement actual message sending logic console.log("Message sent:", message); setMessage(""); setShowContactForm(false); @@ -50,23 +82,53 @@ const ProductDetail = () => { // Image navigation const nextImage = () => { - setCurrentImage((prev) => - prev === product.images.length - 1 ? 0 : prev + 1, - ); + if (product && product.images) { + setCurrentImage((prev) => + prev === product.images.length - 1 ? 0 : prev + 1, + ); + } }; const prevImage = () => { - setCurrentImage((prev) => - prev === 0 ? product.images.length - 1 : prev - 1, - ); + if (product && product.images) { + setCurrentImage((prev) => + prev === 0 ? product.images.length - 1 : prev - 1, + ); + } }; const selectImage = (index) => { setCurrentImage(index); }; - if (!product) return
Loading...
; // Handle loading state + // Render loading state + if (loading) { + return ( +
+
+
+ ); + } + // Render error state + if (error) { + return ( +
+
+

Error Loading Product

+

{error}

+ + Back to Listings + +
+
+ ); + } + + // Render product details return (
@@ -82,15 +144,21 @@ const ProductDetail = () => {
- {product.title} + {product.images && product.images.length > 0 ? ( + {product.Name} + ) : ( +
+ No Image Available +
+ )}
- {product.images.length > 1 && ( + {product.images && product.images.length > 1 && (
{product.images.map((image, index) => (
{ > {`${product.title}
@@ -113,7 +181,7 @@ const ProductDetail = () => {

- {product.title} + {product.Name}

- ${product.price} + ${product.Price}
- {product.category} + {product.Category}
Condition: @@ -139,12 +207,12 @@ const ProductDetail = () => {
- Posted on {product.datePosted} + Posted on {product.Date}
-

{product.shortDescription}

+

{product.Description}

-
+ {/*

Description

-
{product.description}
+
{product.Description}
-
+
*/}
); }; From 7a87fc1e49ddd6381e843b1852fbf545ae1e1a49 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Tue, 25 Mar 2025 14:48:28 -0600 Subject: [PATCH 02/37] Initial Server and Client for recommond engine --- recommondation-engine/client.js | 48 ++++++++++++++++++++++++++++ recommondation-engine/server1.py | 54 +++++++++++++++++++++----------- 2 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 recommondation-engine/client.js diff --git a/recommondation-engine/client.js b/recommondation-engine/client.js new file mode 100644 index 0000000..67258ae --- /dev/null +++ b/recommondation-engine/client.js @@ -0,0 +1,48 @@ +const net = require("net"); + +// Function to get recommendations from the Python server +function getRecommendations(userId) { + const client = new net.Socket(); + + // Connect to the server on localhost at port 9999 + client.connect(9999, "localhost", function () { + console.log(`Connected to server, sending user_id: ${userId}`); + + // Send the user_id in JSON format + const message = JSON.stringify({ user_id: userId }); + client.write(message); + }); + + // Listen for data from the server + client.on("data", function (data) { + const recommendations = JSON.parse(data.toString()); + console.log( + `Recommendations for User ${userId}:`, + recommendations.recommendations, + ); + + // Close the connection after receiving the response + client.destroy(); + }); + + // Handle connection errors + client.on("error", function (error) { + console.error("Connection error:", error.message); + }); + + // Handle connection close + client.on("close", function () { + console.log(`Connection to server closed for User ${userId}`); + }); +} + +// Function to simulate multiple users requesting recommendations +function simulateClients() { + for (let i = 1; i <= 5; i++) { + setTimeout(() => { + getRecommendations(i); // Simulate clients with IDs 1 to 5 + }, i * 1000); // Stagger requests every second + } +} + +simulateClients(); diff --git a/recommondation-engine/server1.py b/recommondation-engine/server1.py index 94f1fd4..bd7cdad 100644 --- a/recommondation-engine/server1.py +++ b/recommondation-engine/server1.py @@ -1,24 +1,42 @@ -import asyncio -import websockets +import socket +import threading +import json -async def handle_client(websocket, path): - try: - # Receive user ID - user_id = await websocket.recv() - print(f"Received user ID: {user_id}") +# Sample recommendations function +def get_recommendations(user_id): + # This is a mock function. Replace it with your actual recommendation logic. + return {"recommendations": [f"Product {user_id} - Item 1", f"Product {user_id} - Item 2", f"Product {user_id} - Item 3"]} - # Optional: You can add more logic here if needed +# Handle client connection +def handle_client(client_socket, client_address): + print(f"New connection from {client_address}") - except websockets.exceptions.ConnectionClosed: - print("Client disconnected") - except Exception as e: - print(f"Error processing request: {str(e)}") + # Receive the client request (user_id) + request = client_socket.recv(1024).decode("utf-8") + if request: + data = json.loads(request) + user_id = data.get("user_id") -async def start_server(): - server = await websockets.serve(handle_client, "localhost", 5555) - print("WebSocket server started") - await server.wait_closed() + # Get recommendations for the user + recommendations = get_recommendations(user_id) + + # Send the response back to the client + client_socket.send(json.dumps(recommendations).encode("utf-8")) + + # Close the connection after sending the response + client_socket.close() + +# Start the server to handle multiple clients +def start_server(): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.bind(("0.0.0.0", 9999)) # Bind to all interfaces on port 9999 + server.listen(5) + print("Server listening on port 9999...") + + while True: + client_socket, client_address = server.accept() + client_thread = threading.Thread(target=handle_client, args=(client_socket, client_address)) + client_thread.start() # Run the server -if __name__ == "__main__": - asyncio.run(start_server()) +start_server() From 91ec43627a1f4846ff451ef5f91752ea9d7cd732 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Sat, 29 Mar 2025 16:13:22 -0600 Subject: [PATCH 03/37] updated sql wit example dataset now --- README.md | 2 +- mysql-code/example-data.sql | 761 ++++++++++++++++++++++++++++++++++++ mysql-code/init-db.py | 3 +- 3 files changed, 764 insertions(+), 2 deletions(-) create mode 100644 mysql-code/example-data.sql diff --git a/README.md b/README.md index 47f82f1..8659323 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,6 @@ ### Database 1. To Create the DB use the command bellow ```Bash - python3 ./SQL_code/init-db.py + python3 ./mysql-code/init-db.py ``` - MySql Version 9.2.0 diff --git a/mysql-code/example-data.sql b/mysql-code/example-data.sql new file mode 100644 index 0000000..a2b7242 --- /dev/null +++ b/mysql-code/example-data.sql @@ -0,0 +1,761 @@ +-- Inserting sample data into the Marketplace database +-- Clear existing data (if needed) +SET + FOREIGN_KEY_CHECKS = 0; + +TRUNCATE TABLE Product_Category; + +TRUNCATE TABLE Favorites; + +TRUNCATE TABLE History; + +TRUNCATE TABLE Recommendation; + +TRUNCATE TABLE Transaction; + +TRUNCATE TABLE Review; + +TRUNCATE TABLE Image_URL; + +TRUNCATE TABLE Product; + +TRUNCATE TABLE Category; + +TRUNCATE TABLE UserRole; + +TRUNCATE TABLE User; + +TRUNCATE TABLE AuthVerification; + +SET + FOREIGN_KEY_CHECKS = 1; + +-- Insert Users +INSERT INTO + User ( + UserID, + Name, + Email, + UCID, + Password, + Phone, + Address + ) +VALUES + ( + 1, + 'John Doe', + 'john.doe@example.com', + 'U123456', + 'hashedpassword1', + '555-123-4567', + '123 Main St, Calgary, AB' + ), + ( + 2, + 'Jane Smith', + 'jane.smith@example.com', + 'U234567', + 'hashedpassword2', + '555-234-5678', + '456 Oak Ave, Calgary, AB' + ), + ( + 3, + 'Michael Brown', + 'michael.b@example.com', + 'U345678', + 'hashedpassword3', + '555-345-6789', + '789 Pine Rd, Calgary, AB' + ), + ( + 4, + 'Sarah Wilson', + 'sarah.w@example.com', + 'U456789', + 'hashedpassword4', + '555-456-7890', + '101 Elm Blvd, Calgary, AB' + ), + ( + 5, + 'David Taylor', + 'david.t@example.com', + 'U567890', + 'hashedpassword5', + '555-567-8901', + '202 Maple Dr, Calgary, AB' + ); + +-- Insert User Roles +INSERT INTO + UserRole (UserID, Client, Admin) +VALUES + (1, TRUE, TRUE), + (2, TRUE, FALSE), + (3, TRUE, FALSE), + (4, TRUE, FALSE), + (5, TRUE, FALSE); + +-- Insert Categories +INSERT INTO + Category (CategoryID, Name) +VALUES + (1, 'Textbooks'), + (2, 'Electronics'), + (3, 'Furniture'), + (4, 'Clothing'), + (5, 'Sports Equipment'), + (6, 'Musical Instruments'), + (7, 'Art Supplies'), + (8, 'Kitchen Appliances'), + (9, 'Gaming'), + (10, 'Bicycles'), + (11, 'Computer Accessories'), + (12, 'Stationery'), + (13, 'Fitness Equipment'), + (14, 'Winter Sports'), + (15, 'Lab Equipment'), + (16, 'Camping Gear'), + (17, 'School Supplies'), + (18, 'Office Furniture'), + (19, 'Books (Non-textbook)'), + (20, 'Math & Science Resources'), + (21, 'Engineering Tools'), + (22, 'Backpacks & Bags'), + (23, 'Audio Equipment'), + (24, 'Dorm Essentials'), + (25, 'Smartphones & Tablets'), + (26, 'Winter Clothing'), + (27, 'Photography Equipment'), + (28, 'Event Tickets'), + (29, 'Software Licenses'), + (30, 'Transportation (Car Pool)'); + +-- Insert Products +INSERT INTO + Product ( + ProductID, + Name, + Price, + StockQuantity, + UserID, + Description, + CategoryID, + Date + ) +VALUES + ( + 1, + 'Calculus Textbook 8th Edition', + 79.99, + 5, + 1, + 'Like new calculus textbook, minor highlighting', + 1, + '2024-10-15 10:00:00' + ), + ( + 2, + 'HP Laptop', + 699.99, + 1, + 2, + '2023 HP Pavilion, 16GB RAM, 512GB SSD', + 2, + '2024-10-10 14:30:00' + ), + ( + 3, + 'Dorm Desk', + 120.00, + 1, + 3, + 'Sturdy desk perfect for studying, minor scratches', + 3, + '2024-10-12 09:15:00' + ), + ( + 4, + 'University Hoodie', + 35.00, + 3, + 1, + 'Size L, university logo, worn twice', + 4, + '2024-10-14 16:45:00' + ), + ( + 5, + 'Basketball', + 25.50, + 1, + 4, + 'Slightly used indoor basketball', + 5, + '2024-10-11 11:20:00' + ), + ( + 6, + 'Acoustic Guitar', + 175.00, + 1, + 2, + 'Beginner acoustic guitar with case', + 6, + '2024-10-09 13:10:00' + ), + ( + 7, + 'Physics Textbook', + 65.00, + 2, + 5, + 'University Physics 14th Edition, good condition', + 1, + '2024-10-08 10:30:00' + ), + ( + 8, + 'Mini Fridge', + 85.00, + 1, + 3, + 'Small dorm fridge, works perfectly', + 8, + '2024-10-13 15:00:00' + ), + ( + 9, + 'PlayStation 5 Controller', + 55.00, + 1, + 4, + 'Extra controller, barely used', + 9, + '2024-10-07 17:20:00' + ), + ( + 10, + 'Mountain Bike', + 350.00, + 1, + 5, + 'Trek mountain bike, great condition, new tires', + 10, + '2024-10-06 14:00:00' + ), + ( + 11, + 'Wireless Mouse', + 22.99, + 3, + 1, + 'Logitech wireless mouse with battery', + 11, + '2024-10-05 09:30:00' + ), + ( + 12, + 'Chemistry Lab Coat', + 30.00, + 2, + 2, + 'Size M, required for chem labs', + 15, + '2024-10-04 13:45:00' + ), + ( + 13, + 'Graphing Calculator', + 75.00, + 1, + 3, + 'TI-84 Plus, perfect working condition', + 12, + '2024-10-03 11:15:00' + ), + ( + 14, + 'Yoga Mat', + 20.00, + 1, + 4, + 'Thick yoga mat, barely used', + 13, + '2024-10-02 16:00:00' + ), + ( + 15, + 'Winter Jacket', + 120.00, + 1, + 5, + 'Columbia winter jacket, size XL, very warm', + 26, + '2024-10-01 10:20:00' + ), + ( + 16, + 'Computer Science Textbook', + 70.00, + 1, + 1, + 'Introduction to Algorithms, like new', + 1, + '2024-09-30 14:30:00' + ), + ( + 17, + 'Desk Lamp', + 15.00, + 2, + 2, + 'LED desk lamp with adjustable brightness', + 24, + '2024-09-29 12:00:00' + ), + ( + 18, + 'Scientific Calculator', + 25.00, + 1, + 3, + 'Casio scientific calculator', + 12, + '2024-09-28 11:30:00' + ), + ( + 19, + 'Bluetooth Speaker', + 45.00, + 1, + 4, + 'JBL Bluetooth speaker, great sound', + 23, + '2024-09-27 15:45:00' + ), + ( + 20, + 'Backpack', + 40.00, + 1, + 5, + 'North Face backpack, lots of pockets', + 22, + '2024-09-26 09:15:00' + ); + +-- Insert Image URLs +INSERT INTO + Image_URL (URL, ProductID) +VALUES + ('/image1.avif', 1), + ('/image1.avif', 2), + ('/image1.avif', 3), + ('/image1.avif', 4), + ('/image1.avif', 5), + ('/image1.avif', 6), + ('/image1.avif', 7), + ('/image1.avif', 8), + ('/image1.avif', 9), + ('/image1.avif', 10); + +-- Insert Product-Category relationships (products with multiple categories) +INSERT INTO + Product_Category (ProductID, CategoryID) +VALUES + (1, 1), + (1, 17), + (1, 20), -- Calculus book: Textbooks, School Supplies, Math Resources + (2, 2), + (2, 11), + (2, 25), -- Laptop: Electronics, Computer Accessories, Smartphones & Tablets + (3, 3), + (3, 18), + (3, 24), -- Desk: Furniture, Office Furniture, Dorm Essentials + (4, 4), + (4, 26), -- Hoodie: Clothing, Winter Clothing + (5, 5), + (5, 13), -- Basketball: Sports Equipment, Fitness Equipment + (6, 6), + (6, 23), -- Guitar: Musical Instruments, Audio Equipment + (7, 1), + (7, 15), + (7, 20), -- Physics book: Textbooks, Lab Equipment, Math & Science Resources + (8, 8), + (8, 24), -- Mini Fridge: Kitchen Appliances, Dorm Essentials + (9, 9), + (9, 2), -- PS5 Controller: Gaming, Electronics + (10, 10), + (10, 5), + (10, 13), -- Mountain Bike: Bicycles, Sports Equipment, Fitness Equipment + (11, 11), + (11, 2), -- Mouse: Computer Accessories, Electronics + (12, 15), + (12, 17), -- Lab Coat: Lab Equipment, School Supplies + (13, 12), + (13, 17), + (13, 20), -- Calculator: Stationery, School Supplies, Math & Science Resources + (14, 13), + (14, 5), -- Yoga Mat: Fitness Equipment, Sports Equipment + (15, 26), + (15, 4), + (15, 14), -- Winter Jacket: Winter Clothing, Clothing, Winter Sports + (16, 1), + (16, 17), + (16, 19), -- CS Book: Textbooks, School Supplies, Books (Non-textbook) + (17, 24), + (17, 2), -- Desk Lamp: Dorm Essentials, Electronics + (18, 12), + (18, 17), + (18, 20), -- Scientific Calculator: Stationery, School Supplies, Math & Science + (19, 23), + (19, 2), + (19, 24), -- Bluetooth Speaker: Audio Equipment, Electronics, Dorm Essentials + (20, 22), + (20, 17), + (20, 24); + +-- Backpack: Backpacks & Bags, School Supplies, Dorm Essentials +-- Insert History records +-- +INSERT INTO + History (HistoryID, UserID, ProductID, Date) +VALUES + (1, 1, 1, '2024-10-15 11:30:00'), + (2, 1, 2, '2024-10-14 13:45:00'), + (3, 1, 5, '2024-10-13 09:20:00'), + (4, 1, 4, '2024-10-12 16:10:00'); + +-- +INSERT INTO + History (HistoryID, UserID, ProductID, Date) +VALUES + (1, 2, 1, '2024-10-15 11:30:00'), -- User 2 viewed Calculus Textbook + (2, 3, 2, '2024-10-14 13:45:00'), -- User 3 viewed HP Laptop + (3, 4, 3, '2024-10-13 09:20:00'), -- User 4 viewed Dorm Desk + (4, 5, 4, '2024-10-12 16:10:00'), -- User 5 viewed University Hoodie + (5, 1, 5, '2024-10-11 14:30:00'), -- User 1 viewed Basketball + (6, 2, 6, '2024-10-10 10:15:00'), -- User 2 viewed Acoustic Guitar + (7, 3, 7, '2024-10-09 15:40:00'), -- User 3 viewed Physics Textbook + (8, 4, 8, '2024-10-08 11:25:00'), -- User 4 viewed Mini Fridge + (9, 5, 9, '2024-10-07 17:50:00'), -- User 5 viewed PS5 Controller + (10, 1, 10, '2024-10-06 14:15:00'); + +-- User 1 viewed Mountain Bike +-- Insert Reviews +INSERT INTO + Review ( + ReviewID, + UserID, + ProductID, + Comment, + Rating, + Date + ) +VALUES + ( + 1, + 2, + 1, + 'Great condition, exactly as described!', + 5, + '2024-10-16 09:30:00' + ), + ( + 2, + 3, + 2, + 'Works well, but had a small scratch not mentioned in the listing.', + 4, + '2024-10-15 14:20:00' + ), + ( + 3, + 4, + 6, + 'Perfect for beginners, sounds great!', + 5, + '2024-10-14 11:10:00' + ), + ( + 4, + 5, + 8, + 'Keeps my drinks cold, but a bit noisy at night.', + 3, + '2024-10-13 16:45:00' + ), + ( + 5, + 1, + 10, + 'Excellent bike, well maintained!', + 5, + '2024-10-12 13:25:00' + ); + +-- Insert Favorites +INSERT INTO + Favorites (UserID, ProductID) +VALUES + (1, 2), -- User 1 likes HP Laptop + (1, 7), -- User 1 likes Physics Textbook + (2, 3), -- User 2 likes Dorm Desk + (2, 10), -- User 2 likes Mountain Bike + (3, 6), -- User 3 likes Acoustic Guitar + (4, 5), -- User 4 likes Basketball + (5, 8); + +-- User 5 likes Mini Fridge +-- Insert Transactions +INSERT INTO + Transaction ( + TransactionID, + UserID, + ProductID, + Date, + PaymentStatus + ) +VALUES + (1, 2, 1, '2024-10-16 10:30:00', 'Completed'), + (2, 3, 6, '2024-10-15 15:45:00', 'Completed'), + (3, 4, 8, '2024-10-14 12:20:00', 'Pending'), + (4, 5, 10, '2024-10-13 17:10:00', 'Completed'), + (5, 1, 4, '2024-10-12 14:30:00', 'Completed'); + +-- Insert Recommendations +INSERT INTO + Recommendation (RecommendationID_PK, UserID, RecommendedProductID) +VALUES + (1, 1, 7), -- Recommend Physics Textbook to User 1 + (2, 1, 13), -- Recommend Graphing Calculator to User 1 + (3, 2, 3), -- Recommend Dorm Desk to User 2 + (4, 2, 17), -- Recommend Desk Lamp to User 2 + (5, 3, 16), -- Recommend CS Textbook to User 3 + (6, 4, 14), -- Recommend Yoga Mat to User 4 + (7, 5, 15); + +INSERT INTO + Recommendation (RecommendationID_PK, UserID, RecommendedProductID) +VALUES + (12, 1, 19), + (13, 1, 9), + (14, 1, 11), + (15, 1, 16), + -- Insert Authentication records +INSERT INTO + AuthVerification (Email, VerificationCode, Authenticated, Date) +VALUES + ( + 'john.doe@example.com', + '123456', + TRUE, + '2024-10-01 09:00:00' + ), + ( + 'jane.smith@example.com', + '234567', + TRUE, + '2024-10-02 10:15:00' + ), + ( + 'michael.b@example.com', + '345678', + TRUE, + '2024-10-03 11:30:00' + ), + ( + 'sarah.w@example.com', + '456789', + TRUE, + '2024-10-04 12:45:00' + ), + ( + 'david.t@example.com', + '567890', + TRUE, + '2024-10-05 14:00:00' + ); + +INSERT INTO + Product ( + ProductID, + Name, + Description, + Price, + StockQuantity, + CategoryID + ) +VALUES + ( + 101, + 'Smart Coffee Maker', + 'Wi-Fi enabled coffee machine with scheduling feature', + 129.99, + 50, + 11 + ), + ( + 102, + 'Ergonomic Office Chair', + 'Adjustable mesh chair with lumbar support', + 199.99, + 35, + 12 + ), + ( + 103, + 'Wireless Mechanical Keyboard', + 'RGB-backlit wireless keyboard with mechanical switches', + 89.99, + 60, + 13 + ), + ( + 104, + 'Portable Solar Charger', + 'Foldable solar power bank with USB-C support', + 59.99, + 40, + 14 + ), + ( + 105, + 'Noise-Canceling Headphones', + 'Over-ear Bluetooth headphones with ANC', + 179.99, + 25, + 15 + ), + ( + 106, + 'Smart Water Bottle', + 'Tracks water intake and glows as a hydration reminder', + 39.99, + 75, + 11 + ), + ( + 107, + 'Compact Air Purifier', + 'HEPA filter air purifier for small rooms', + 149.99, + 30, + 16 + ), + ( + 108, + 'Smart LED Desk Lamp', + 'Adjustable LED lamp with voice control', + 69.99, + 45, + 12 + ), + ( + 109, + '4K Streaming Device', + 'HDMI streaming stick with voice remote', + 49.99, + 80, + 17 + ), + ( + 110, + 'Smart Plant Monitor', + 'Bluetooth-enabled sensor for plant health tracking', + 34.99, + 55, + 18 + ), + ( + 111, + 'Wireless Charging Pad', + 'Fast-charging pad for Qi-compatible devices', + 29.99, + 90, + 13 + ), + ( + 112, + 'Mini Projector', + 'Portable projector with built-in speakers', + 129.99, + 20, + 14 + ), + ( + 113, + 'Foldable Bluetooth Keyboard', + 'Ultra-thin keyboard for travel use', + 39.99, + 70, + 19 + ), + ( + 114, + 'Smart Alarm Clock', + 'AI-powered alarm clock with sunrise simulation', + 79.99, + 40, + 15 + ), + ( + 115, + 'Touchscreen Toaster', + 'Customizable toaster with a digital display', + 99.99, + 30, + 11 + ), + ( + 116, + 'Cordless Vacuum Cleaner', + 'Lightweight handheld vacuum with strong suction', + 159.99, + 25, + 16 + ), + ( + 117, + 'Smart Bike Lock', + 'Fingerprint and app-controlled bike security lock', + 89.99, + 35, + 20 + ), + ( + 118, + 'Bluetooth Sleep Headband', + 'Comfortable sleep headband with built-in speakers', + 49.99, + 60, + 18 + ), + ( + 119, + 'Retro Game Console', + 'Plug-and-play console with 500+ classic games', + 79.99, + 50, + 17 + ), + ( + 120, + 'Automatic Pet Feeder', + 'App-controlled food dispenser for pets', + 99.99, + 40, + 20 + ); + +SELECT + p.*, + i.URL AS image_url +FROM + Product p + LEFT JOIN Image_URL i ON p.ProductID = i.ProductID +WHERE + p.ProductID = 1 diff --git a/mysql-code/init-db.py b/mysql-code/init-db.py index 910368a..a222193 100644 --- a/mysql-code/init-db.py +++ b/mysql-code/init-db.py @@ -1,3 +1,4 @@ import subprocess -if (subprocess.run("mysql -u root mysql < SQL_code/Schema.sql", shell=True, check=True)): + +if (subprocess.run("mysql -u root mysql < mysql-code/Schema.sql", shell=True, check=True)): print("successfully created the Marketplace databse") From 2e77ef49f49017aa026967fabea276a0c9ce3efc Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Sat, 29 Mar 2025 17:28:09 -0600 Subject: [PATCH 04/37] search bar now working --- backend/controllers/product.js | 4 +- backend/controllers/search.js | 164 ++++++++++++++++++++ backend/index.js | 2 + backend/routes/search.js | 14 ++ backend/utils/database.js | 2 +- frontend/package-lock.json | 115 ++++++++++++-- frontend/package.json | 1 + frontend/src/App.jsx | 11 ++ frontend/src/components/Navbar.jsx | 22 ++- frontend/src/pages/Home.jsx | 216 +++++++++++++++++++++------ frontend/src/pages/ProductDetail.jsx | 4 +- frontend/src/pages/SearchPage.jsx | 203 +++++++++++++++++++++++++ 12 files changed, 687 insertions(+), 71 deletions(-) create mode 100644 backend/controllers/search.js create mode 100644 backend/routes/search.js create mode 100644 frontend/src/pages/SearchPage.jsx diff --git a/backend/controllers/product.js b/backend/controllers/product.js index 0a85686..71396b8 100644 --- a/backend/controllers/product.js +++ b/backend/controllers/product.js @@ -33,12 +33,12 @@ exports.getAllProducts = async (req, res) => { I.URL AS ProductImage, C.Name AS Category FROM Product P - LEFT JOIN - (SELECT ProductID, URL FROM Image_URL LIMIT 1) I ON P.ProductID = I.ProductID + JOIN Image_URL I ON p.ProductID = i.ProductID JOIN User U ON P.UserID = U.UserID JOIN Category C ON P.CategoryID = C.CategoryID; `); + console.log(data); res.json({ success: true, message: "Products fetched successfully", diff --git a/backend/controllers/search.js b/backend/controllers/search.js new file mode 100644 index 0000000..ba6700c --- /dev/null +++ b/backend/controllers/search.js @@ -0,0 +1,164 @@ +const db = require("../utils/database"); + +exports.searchProductsByName = async (req, res) => { + const { name } = req.query; + + if (name.length === 0) { + console.log("Searching for products with no name", name); + } + + console.log("Searching for products with name:", name); + + try { + // Modify SQL to return all products when no search term is provided + const sql = ` + SELECT p.*, i.URL as image + FROM Product p + LEFT JOIN Image_URL i ON p.ProductID = i.ProductID + ${name ? "WHERE p.Name LIKE ?" : ""} + ORDER BY p.ProductID + `; + + const params = name ? [`%${name}%`] : []; + console.log("Executing SQL:", sql); + console.log("With parameters:", params); + + const [data] = await db.execute(sql, params); + + console.log("Raw Database Result:", data); + + if (data.length === 0) { + console.log("No products found matching:", name); + return res.status(404).json({ + success: false, + message: "No products found matching your search", + }); + } + + // Group products by ProductID to handle multiple images per product + const productsMap = new Map(); + + data.forEach((row) => { + if (!productsMap.has(row.ProductID)) { + const product = { + ProductID: row.ProductID, + Name: row.Name, + Description: row.Description, + Price: row.Price, + images: row.image, + }; + productsMap.set(row.ProductID, product); + } else if (row.image_url) { + productsMap.get(row.ProductID).images.push(row.image_url); + } + }); + + const products = Array.from(productsMap.values()); + + console.log("Processed Products:", products); + + res.json({ + success: true, + message: "Products fetched successfully", + data: products, + count: products.length, + }); + } catch (error) { + console.error("Database Error:", error); + return res.status(500).json({ + success: false, + message: "Database error occurred", + error: error.message || "Unknown database error", + }); + } +}; + +// exports.searchProductsByName = async (req, res) => { +// const { name } = req.query; + +// // Add better validation and error handling +// if (!name || typeof name !== "string") { +// return res.status(400).json({ +// success: false, +// message: "Valid search term is required", +// }); +// } + +// console.log("Searching for products with name:", name); + +// try { +// // Log the SQL query and parameters for debugging +// const sql = ` +// SELECT p.*, i.URL AS image_url +// FROM Product p +// LEFT JOIN Image_URL i ON p.ProductID = i.ProductID +// WHERE p.Name LIKE ? +// `; +// const params = [`%${name}%`]; +// console.log("Executing SQL:", sql); +// console.log("With parameters:", params); + +// const [data] = await db.execute(sql, params); + +// // Log raw data for debugging +// console.log("Raw Database Result:", data); + +// if (data.length === 0) { +// console.log("No products found matching:", name); +// return res.status(404).json({ +// success: false, +// message: "No products found matching your search", +// }); +// } + +// // Group products by ProductID to handle multiple images per product +// const productsMap = new Map(); + +// data.forEach((row) => { +// if (!productsMap.has(row.ProductID)) { +// // Create a clean object without circular references +// const product = { +// ProductID: row.ProductID, +// Name: row.Name, +// Description: row.Description, +// Price: row.Price, +// // Add any other product fields you need +// images: row.image_url ? [row.image_url] : [], +// }; +// productsMap.set(row.ProductID, product); +// } else if (row.image_url) { +// // Add additional image to existing product +// productsMap.get(row.ProductID).images.push(row.image_url); +// } +// }); + +// // Convert map to array of products +// const products = Array.from(productsMap.values()); + +// // Log processed products for debugging +// console.log("Processed Products:", products); + +// res.json({ +// success: true, +// message: "Products fetched successfully", +// data: products, +// count: products.length, +// }); +// } catch (error) { +// // Enhanced error logging +// console.error("Database Error Details:", { +// message: error.message, +// code: error.code, +// errno: error.errno, +// sqlState: error.sqlState, +// sqlMessage: error.sqlMessage, +// sql: error.sql, +// }); + +// return res.status(500).json({ +// success: false, +// message: "Database error occurred", +// error: error.message || "Unknown database error", +// }); +// } +// }; diff --git a/backend/index.js b/backend/index.js index 01fc880..c78cda7 100644 --- a/backend/index.js +++ b/backend/index.js @@ -5,6 +5,7 @@ const db = require("./utils/database"); const userRouter = require("./routes/user"); const productRouter = require("./routes/product"); +const searchRouter = require("./routes/search"); const { generateEmailTransporter } = require("./utils/mail"); const { cleanupExpiredCodes, @@ -34,6 +35,7 @@ checkDatabaseConnection(db); //Routes app.use("/api/user", userRouter); //prefix with /api/user app.use("/api/product", productRouter); //prefix with /api/product +app.use("/api/search_products", searchRouter); //prefix with /api/product // Set up a scheduler to run cleanup every hour setInterval(cleanupExpiredCodes, 60 * 60 * 1000); diff --git a/backend/routes/search.js b/backend/routes/search.js new file mode 100644 index 0000000..2871eba --- /dev/null +++ b/backend/routes/search.js @@ -0,0 +1,14 @@ +// routes/product.js +const express = require("express"); +const { searchProductsByName } = require("../controllers/search"); +const router = express.Router(); + +// Add detailed logging middleware +router.use((req, res, next) => { + console.log(`Incoming ${req.method} request to ${req.path}`); + next(); +}); + +router.get("/search", searchProductsByName); + +module.exports = router; diff --git a/backend/utils/database.js b/backend/utils/database.js index 0bd82e5..689785e 100644 --- a/backend/utils/database.js +++ b/backend/utils/database.js @@ -4,7 +4,7 @@ const mysql = require("mysql2"); const pool = mysql.createPool({ host: "localhost", user: "root", - database: "marketplace", + database: "Marketplace", }); //Export a promise for promise-based query diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2d81364..7b44684 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tailwindcss/vite": "^4.0.9", + "axios": "^1.8.4", "lucide-react": "^0.477.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -1770,6 +1771,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -1824,6 +1831,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", + "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1898,7 +1916,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1993,6 +2010,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2153,6 +2182,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -2182,7 +2220,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2283,7 +2320,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2293,7 +2329,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2331,7 +2366,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2344,7 +2378,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2732,6 +2765,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -2748,6 +2801,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -2780,7 +2848,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2831,7 +2898,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2856,7 +2922,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -2931,7 +2996,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3002,7 +3066,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3015,7 +3078,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -3031,7 +3093,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -3895,12 +3956,32 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4251,6 +4332,12 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index aa89b14..8a323b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@tailwindcss/vite": "^4.0.9", + "axios": "^1.8.4", "lucide-react": "^0.477.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f6f42cd..bc7f83b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import Transactions from "./pages/Transactions"; import Favorites from "./pages/Favorites"; import ProductDetail from "./pages/ProductDetail"; import ItemForm from "./pages/MyListings"; +import SearchPage from "./pages/SearchPage"; // Make sure to import the SearchPage function App() { // Authentication state - initialize from localStorage if available @@ -634,6 +635,16 @@ function App() { } /> + +
+ +
+ + } + /> { const [searchQuery, setSearchQuery] = useState(""); + const navigate = useNavigate(); const handleSearchChange = (e) => { setSearchQuery(e.target.value); @@ -12,8 +13,14 @@ const Navbar = ({ onLogout, userName }) => { const handleSearchSubmit = (e) => { e.preventDefault(); - console.log("Searching for:", searchQuery); - // TODO: Implement search functionality + + // if (!searchQuery.trim()) return; + + // Navigate to search page with query + navigate({ + pathname: "/search", + search: `?name=${encodeURIComponent(searchQuery)}`, + }); }; return ( @@ -41,13 +48,19 @@ const Navbar = ({ onLogout, userName }) => {
+
@@ -61,6 +74,7 @@ const Navbar = ({ onLogout, userName }) => { > + {/* User Profile */}
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 5b3fb4b..da47d13 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -39,7 +39,6 @@ const Home = () => { setError(error.message); } }; - fetchProducts(); }, []); @@ -112,65 +111,186 @@ const Home = () => { */} {/* Recent Listings */} -
+

- Recent Listings + Recommendation

-
- {listings.map((listing) => ( - -
- {listing.title} - -
-
-
+
+ {/* Left Button - Overlaid on products */} + + + {/* Scrollable Listings Container */} +
+ {listings.map((listing) => ( + +
+ {listing.title} + +
+ +

{listing.title}

- + ${listing.price} + +
+ + {listing.category} + + {listing.condition} +
+ +
+ + {listing.datePosted} + + + {listing.seller} + +
+
+ + ))} +
+ + {/* Right Button - Overlaid on products */} + +
+
+ + {/* Recent Listings */} +
+

+ Recent Listings +

+ +
+ {/* Left Button - Overlaid on products */} + + + {/* Scrollable Listings Container */} +
+ {listings.map((listing) => ( + +
+ {listing.title} +
-
- - {listing.category} - - {listing.condition} -
+
+

+ {listing.title} +

+ + ${listing.price} + -
- - {listing.datePosted} - - - {listing.seller} - +
+ + {listing.category} + + {listing.condition} +
+ +
+ + {listing.datePosted} + + + {listing.seller} + +
-
- - ))} + + ))} +
+ + {/* Right Button - Overlaid on products */} +
diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx index 6ad1c10..15c8d86 100644 --- a/frontend/src/pages/ProductDetail.jsx +++ b/frontend/src/pages/ProductDetail.jsx @@ -133,11 +133,11 @@ const ProductDetail = () => {
- Back to listings + Back
diff --git a/frontend/src/pages/SearchPage.jsx b/frontend/src/pages/SearchPage.jsx new file mode 100644 index 0000000..905eb70 --- /dev/null +++ b/frontend/src/pages/SearchPage.jsx @@ -0,0 +1,203 @@ +import React, { useState, useEffect } from "react"; +import { Filter, Grid, Heart, Tag, X } from "lucide-react"; +import { useLocation, Link } from "react-router-dom"; +import axios from "axios"; + +const SearchPage = () => { + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const nameParam = queryParams.get("name") || ""; + const initialSearchQuery = location.state?.query || nameParam || ""; + + const [products, setProducts] = useState([]); + const [filteredProducts, setFilteredProducts] = useState([]); + const [searchQuery, setSearchQuery] = useState(initialSearchQuery); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [priceRange, setPriceRange] = useState({ min: 0, max: 1000 }); + const [isFilterOpen, setIsFilterOpen] = useState(false); + + useEffect(() => { + fetchProducts(initialSearchQuery); + }, [initialSearchQuery]); + + const fetchProducts = async (query) => { + setLoading(true); + setError(null); + + try { + const response = await axios.get( + `http://localhost:3030/api/search_products/search`, + { + params: { name: query }, + }, + ); + + if (response.data.success) { + const transformedProducts = response.data.data.map((product) => ({ + id: product.ProductID, + title: product.Name, + description: product.Description || "", + price: product.Price || 0, + category: product.Category || "Uncategorized", + condition: product.Condition || "Used", + image: product.images, + seller: product.SellerName || "Unknown Seller", + isFavorite: false, + })); + + setProducts(transformedProducts); + setFilteredProducts(transformedProducts); + } else { + setError(response.data.message || "Failed to fetch products"); + setProducts([]); + setFilteredProducts([]); + } + } catch (err) { + console.error("Error fetching products:", err); + setError(err.response?.data?.message || "Error connecting to the server"); + setProducts([]); + setFilteredProducts([]); + } finally { + setLoading(false); + } + }; + + const toggleFavorite = (id, e) => { + e.preventDefault(); + setProducts((prev) => + prev.map((product) => + product.id === id + ? { ...product, isFavorite: !product.isFavorite } + : product, + ), + ); + }; + + const filterProducts = () => { + let result = products; + result = result.filter( + (product) => + product.price >= priceRange.min && product.price <= priceRange.max, + ); + setFilteredProducts(result); + }; + + const applyFilters = () => { + filterProducts(); + setIsFilterOpen(false); + }; + + const resetFilters = () => { + setPriceRange({ min: 0, max: 1000 }); + setFilteredProducts(products); + }; + + return ( +
+
+
+
+

Filters

+ +
+ +
+
+

Price Range

+
+
+ + setPriceRange((prev) => ({ + ...prev, + min: Number(e.target.value), + })) + } + className="w-full p-2 border rounded text-gray-700" + /> + + setPriceRange((prev) => ({ + ...prev, + max: Number(e.target.value), + })) + } + className="w-full p-2 border rounded text-gray-700" + /> +
+
+
+
+ + +
+
+
+ +
+

+ {filteredProducts.length} Results + {searchQuery && ( + + {" "} + for "{searchQuery}" + + )} +

+ +
+ {filteredProducts.map((listing) => ( + + {listing.title} +
+

+ {listing.title} +

+

+ ${Number(listing.price).toFixed(2)} +

+
+ + ))} +
+
+
+
+ ); +}; + +export default SearchPage; From 71a90265d99dab823523a472dc7f041df02175e2 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Sun, 30 Mar 2025 00:20:42 -0600 Subject: [PATCH 05/37] update to engin --- frontend/src/App.jsx | 51 ++++++++++++++++++++++++++++++++ recommondation-engine/server.py | 26 ++++++++++++++++ recommondation-engine/server1.py | 42 -------------------------- 3 files changed, 77 insertions(+), 42 deletions(-) create mode 100644 recommondation-engine/server.py delete mode 100644 recommondation-engine/server1.py diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bc7f83b..12b3231 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,7 @@ import Favorites from "./pages/Favorites"; import ProductDetail from "./pages/ProductDetail"; import ItemForm from "./pages/MyListings"; import SearchPage from "./pages/SearchPage"; // Make sure to import the SearchPage +import axios from "axios"; function App() { // Authentication state - initialize from localStorage if available @@ -31,6 +32,8 @@ function App() { const [error, setError] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [userId, setUserId] = useState(null); + // New verification states const [verificationStep, setVerificationStep] = useState("initial"); // 'initial', 'code-sent', 'verifying' const [tempUserData, setTempUserData] = useState(null); @@ -190,6 +193,9 @@ function App() { sessionStorage.setItem("isAuthenticated", "true"); sessionStorage.setItem("user", JSON.stringify(newUser)); + // After successful signup, send session data to server + sendSessionDataToServer(); // Call it after signup + // Reset verification steps setVerificationStep("initial"); setTempUserData(null); @@ -293,6 +299,9 @@ function App() { sessionStorage.setItem("isAuthenticated", "true"); sessionStorage.setItem("user", JSON.stringify(userObj)); + // After successful signup, send session data to server + sendSessionDataToServer(); // Call it after signup + sessionStorage.getItem("user"); console.log("Login successful for:", userData.email); @@ -357,6 +366,48 @@ function App() { setError(""); }; + const sendSessionDataToServer = async () => { + try { + // Retrieve data from sessionStorage + const user = JSON.parse(sessionStorage.getItem("user")); + const isAuthenticated = + sessionStorage.getItem("isAuthenticated") === "true"; + + if (!user || !isAuthenticated) { + console.log("User is not authenticated"); + return; + } + + // Prepare the data to send + const requestData = { + userId: user.ID, // or user.ID depending on your user structure + email: user.email, + isAuthenticated, + }; + + console.log("Sending user data to the server:", requestData); + + // Send data to Python server (replace with your actual server URL) + const response = await fetch("http://localhost:5000/api/user/session", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestData), + }); + + // Check the response + if (response.ok) { + const result = await response.json(); + console.log("Server response:", result); + } else { + console.error("Failed to send session data to the server"); + } + } catch (error) { + console.error("Error sending session data:", error); + } + }; + // Login component const LoginComponent = () => (
diff --git a/recommondation-engine/server.py b/recommondation-engine/server.py new file mode 100644 index 0000000..89850b1 --- /dev/null +++ b/recommondation-engine/server.py @@ -0,0 +1,26 @@ +from flask import Flask, request, jsonify +from flask_cors import CORS + +app = Flask(__name__) +CORS(app) # Enable CORS for all routes + +@app.route('/api/user/session', methods=['POST']) +def handle_session_data(): + try: + data = request.get_json() + user_id = data.get('userId') + email = data.get('email') + is_authenticated = data.get('isAuthenticated') + + if not user_id or not email or is_authenticated is None: + return jsonify({'error': 'Invalid data'}), 400 + + print(f"Received session data: User ID: {user_id}, Email: {email}, Authenticated: {is_authenticated}") + return jsonify({'message': 'Session data received successfully'}) + + except Exception as e: + print(f"Error: {e}") + return jsonify({'error': 'Server error'}), 500 + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/recommondation-engine/server1.py b/recommondation-engine/server1.py deleted file mode 100644 index bd7cdad..0000000 --- a/recommondation-engine/server1.py +++ /dev/null @@ -1,42 +0,0 @@ -import socket -import threading -import json - -# Sample recommendations function -def get_recommendations(user_id): - # This is a mock function. Replace it with your actual recommendation logic. - return {"recommendations": [f"Product {user_id} - Item 1", f"Product {user_id} - Item 2", f"Product {user_id} - Item 3"]} - -# Handle client connection -def handle_client(client_socket, client_address): - print(f"New connection from {client_address}") - - # Receive the client request (user_id) - request = client_socket.recv(1024).decode("utf-8") - if request: - data = json.loads(request) - user_id = data.get("user_id") - - # Get recommendations for the user - recommendations = get_recommendations(user_id) - - # Send the response back to the client - client_socket.send(json.dumps(recommendations).encode("utf-8")) - - # Close the connection after sending the response - client_socket.close() - -# Start the server to handle multiple clients -def start_server(): - server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - server.bind(("0.0.0.0", 9999)) # Bind to all interfaces on port 9999 - server.listen(5) - print("Server listening on port 9999...") - - while True: - client_socket, client_address = server.accept() - client_thread = threading.Thread(target=handle_client, args=(client_socket, client_address)) - client_thread.start() - -# Run the server -start_server() From ff8b7f20813ffd50ea61cc2d406ecbe129d5610c Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Sun, 30 Mar 2025 00:27:40 -0600 Subject: [PATCH 06/37] Delete client.js --- recommondation-engine/client.js | 48 --------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 recommondation-engine/client.js diff --git a/recommondation-engine/client.js b/recommondation-engine/client.js deleted file mode 100644 index 67258ae..0000000 --- a/recommondation-engine/client.js +++ /dev/null @@ -1,48 +0,0 @@ -const net = require("net"); - -// Function to get recommendations from the Python server -function getRecommendations(userId) { - const client = new net.Socket(); - - // Connect to the server on localhost at port 9999 - client.connect(9999, "localhost", function () { - console.log(`Connected to server, sending user_id: ${userId}`); - - // Send the user_id in JSON format - const message = JSON.stringify({ user_id: userId }); - client.write(message); - }); - - // Listen for data from the server - client.on("data", function (data) { - const recommendations = JSON.parse(data.toString()); - console.log( - `Recommendations for User ${userId}:`, - recommendations.recommendations, - ); - - // Close the connection after receiving the response - client.destroy(); - }); - - // Handle connection errors - client.on("error", function (error) { - console.error("Connection error:", error.message); - }); - - // Handle connection close - client.on("close", function () { - console.log(`Connection to server closed for User ${userId}`); - }); -} - -// Function to simulate multiple users requesting recommendations -function simulateClients() { - for (let i = 1; i <= 5; i++) { - setTimeout(() => { - getRecommendations(i); // Simulate clients with IDs 1 to 5 - }, i * 1000); // Stagger requests every second - } -} - -simulateClients(); From 755069d279996d369d2ea855e1e0ec042809676c Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:16:28 -0600 Subject: [PATCH 07/37] Create example.py --- recommondation-engine/example.py | 68 ++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 recommondation-engine/example.py diff --git a/recommondation-engine/example.py b/recommondation-engine/example.py new file mode 100644 index 0000000..c101c76 --- /dev/null +++ b/recommondation-engine/example.py @@ -0,0 +1,68 @@ +# pip install mysql.connector + +import mysql.connector + +def database(): + db_connection = mysql.connector.connect( + host = "localhost", + port = "3306", + user = "root", + database = "Marketplace" + ) + return db_connection + +def get_all_products(): + + db_con = database() + cursor = db_con.cursor() + + # query the category Table for everything + cursor.execute("SELECT CategoryID FROM Category") + categories = cursor.fetchall() + + select_clause = "SELECT p.ProductID" + for category in categories: + category_id = category[0] # get the uid of the catefory and then append that to the new column + select_clause += f", MAX(CASE WHEN pc.CategoryID = {category_id} THEN 1 ELSE 0 END) AS `Cat_{category_id}`" + + final_query = f""" + {select_clause} + FROM Product p + LEFT JOIN Product_Category pc ON p.ProductID = pc.ProductID + LEFT JOIN Category c ON pc.CategoryID = c.CategoryID + GROUP BY p.ProductID; + """ + + cursor.execute(final_query) + results = cursor.fetchall() + + print(results[1]) + for row in results: + print(row) + + cursor.close() + +def get_user_history(): + db_con = database() + cursor = db_con.cursor() + user_id = 1 + query= f"""select ProductID from History where UserID={user_id}""" + cursor.execute(query) + data = cursor.fetchall() + product_arr = [] + for item in data: + product_arr.append(item[0]) + + querydata= tuple(product_arr) + prod_query = f"""select * from Product where ProductID IN {querydata} """ + cursor.execute(prod_query) + res = cursor.fetchall() + for i in res: + print(i,"\n") + + + +get_user_history() + + +#get_all_products() From a1ca7304eb32f5d7636ea0db8bfa6031bfcf773c Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:56:48 -0600 Subject: [PATCH 08/37] updatepush --- recommondation-engine/example.py | 69 +++++++++++++++++++++---------- recommondation-engine/example.sql | 5 +++ recommondation-engine/test.py | 33 +++++++++++++++ 3 files changed, 86 insertions(+), 21 deletions(-) create mode 100644 recommondation-engine/example.sql create mode 100644 recommondation-engine/test.py diff --git a/recommondation-engine/example.py b/recommondation-engine/example.py index c101c76..d55c0f7 100644 --- a/recommondation-engine/example.py +++ b/recommondation-engine/example.py @@ -11,6 +11,7 @@ def database(): ) return db_connection + def get_all_products(): db_con = database() @@ -37,32 +38,58 @@ def get_all_products(): results = cursor.fetchall() print(results[1]) + final = [] for row in results: + final.append(row) + print(row) + + cursor.close() + db_con.close() + return final + +def get_user_history(user_id): + db_con = database() + cursor = db_con.cursor() + + cursor.execute("SELECT CategoryID FROM Category") + categories = cursor.fetchall() + + select_clause = "SELECT p.ProductID" + for category in categories: + category_id = category[0] # get the uid of the catefory and then append that to the new column + select_clause += f", MAX(CASE WHEN pc.CategoryID = {category_id} THEN 1 ELSE 0 END) AS `Cat_{category_id}`" + + final_query = f""" + {select_clause} + FROM Product p + LEFT JOIN Product_Category pc ON p.ProductID = pc.ProductID + LEFT JOIN Category c ON pc.CategoryID = c.CategoryID + where p.ProductID in (select ProductID from History where UserID = {user_id}) + GROUP BY p.ProductID; + """ + + cursor.execute(final_query) + results = cursor.fetchall() + + print(results[1]) + final = [] + for row in results: + final.append(row) print(row) cursor.close() + db_con.close() + return final -def get_user_history(): - db_con = database() - cursor = db_con.cursor() - user_id = 1 - query= f"""select ProductID from History where UserID={user_id}""" - cursor.execute(query) - data = cursor.fetchall() - product_arr = [] - for item in data: - product_arr.append(item[0]) - - querydata= tuple(product_arr) - prod_query = f"""select * from Product where ProductID IN {querydata} """ - cursor.execute(prod_query) - res = cursor.fetchall() - for i in res: - print(i,"\n") +print("all products:") +get_all_products() + +print("User History products:") +get_user_history(1) +def Calculate_cosin_similarity(): + pass -get_user_history() - - -#get_all_products() +def Main: + pass diff --git a/recommondation-engine/example.sql b/recommondation-engine/example.sql new file mode 100644 index 0000000..f7fccb7 --- /dev/null +++ b/recommondation-engine/example.sql @@ -0,0 +1,5 @@ +select * +from Product +Where ProductID in (select ProductID + from History + where UserID=1); diff --git a/recommondation-engine/test.py b/recommondation-engine/test.py new file mode 100644 index 0000000..3107da7 --- /dev/null +++ b/recommondation-engine/test.py @@ -0,0 +1,33 @@ +import mysql.connector as db_con + +#TODO: Specify all the required queries +query_get_all_Prod= ("SELECT * FROM Product ") + + +#TODO: connect with the db +def database(): + db = db_con.connect( + host = "localhost", + port = 3306, + user = "root", + database = "Marketplace" + ) + + cursor = db.cursor() + cursor.execute(query_get_all_Prod) + + data = [None] + for item in cursor: + data.append(item) + # print(item) + + print(data[1]) + cursor.close() + db.close() + + + +#TODO: Get All products +# Make it into a dictionary with product id and the list of category it would have +# {Prod1:[1,0,0,0,1]} this could mean its a [elctronics, 0,0,0, kitchen] +database() From e7580c36f5ad5390515ec5c1c2fec67ec8c75654 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Wed, 2 Apr 2025 19:53:42 -0600 Subject: [PATCH 09/37] update --- frontend/src/App.jsx | 5 +- .../__pycache__/server.cpython-313.pyc | Bin 0 -> 1595 bytes recommondation-engine/example.py | 325 ++++++++++++++---- recommondation-engine/example1.py | 221 ++++++++++++ 4 files changed, 473 insertions(+), 78 deletions(-) create mode 100644 recommondation-engine/__pycache__/server.cpython-313.pyc create mode 100644 recommondation-engine/example1.py diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 12b3231..6703948 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -298,10 +298,7 @@ function App() { // Save to localStorage to persist across refreshes sessionStorage.setItem("isAuthenticated", "true"); sessionStorage.setItem("user", JSON.stringify(userObj)); - - // After successful signup, send session data to server sendSessionDataToServer(); // Call it after signup - sessionStorage.getItem("user"); console.log("Login successful for:", userData.email); @@ -388,7 +385,7 @@ function App() { console.log("Sending user data to the server:", requestData); // Send data to Python server (replace with your actual server URL) - const response = await fetch("http://localhost:5000/api/user/session", { + const response = await fetch("http://0.0.0.0:5000/api/user/session", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/recommondation-engine/__pycache__/server.cpython-313.pyc b/recommondation-engine/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4f9faf72f3a6b5e3b07c6c4d8c2c15b08c6db95f GIT binary patch literal 1595 zcmZ`(&2Jk;6rcU_u46l!k2YzND%k|1#Hg*4Hea<0C~1S^RHRilmY|7PZSAqM)ZR5S zyAkmLrGQi)(?eBrfr?YO<;bx_`~lkCT#%hzCPUlQg4P=U%^2P+I>R#?QYaEMdr9ebT$5s;vOO=b*i z`evBpS4i9x$XRTjiCkRkZbG+rl~egBqY9T<)i(%c8Up0s^r>R(9RTzmKwqA2zi4u* zr25qkHE@~ukqu^pkJpSs9HMQ6s)eA<{ z!4O3;#MrP*kH2|CT_*}+9@<-08mF3O=q`~z$quM}M522DhQS8Ct*fRvacs zf;FKnX>uyl%!|pqM1yTCDfnfI>Dt@O_di>NaoupVK53d>YYS!M4*oYVDc}Z29rENYQ=N{ ziC05yB(-yz#q0>_ER$d0!;L+|WWej(2*@xIdDZjL$|cgC0Q0($^JD`rIQAAaZ6l@I zklrI?p<*y$ZtFQCt)z8ZH}**Bipm;HL1P=Hl}6p`vY3)2fJ)+8Oey-$DN7WUu-)gB z6Sy&jb_d3K78b|UY;RBD)(2qMoYrc(8w3_pUu1Bnaa?tE1mgW?)x0N<8x2O-+b0P zSdKq>&mEes^v)b$w>RbnW6!0K+ckO6f8hUm;PK4kaW`?x-MHi4%DJ8S?JW{ zZ71vTTNar;e8EEYY9!zZ89J2ckOlSH9~Zl$eygP{gb|&$6xY=AP}4k4FBCihS&j|s ziS{_;m|uY!(E_Nyu6reKrJWpn1HDR)bns%GkSfP8%+Db51VnxUL%;e0rR7hoUG7J~ z@2a2re_{B019u19!0>+GzO*~wGE;xCGBa1>0DG}Yx#}3;gQa4H8#v;{d-1#Ry>R*6 S{q^1WQ~qL=1N', methods=['GET']) +def recommend(user_id): + # Check if products are loaded + if recommender.products_df is None: + products = get_all_products() + if not products: + return jsonify({'error': 'No products available'}), 500 + recommender.load_products(products) + + # Generate recommendations using cosine similarity + recommendations = recommender.recommend_products_for_user(user_id) + + # Store recommendations in database + if store_user_recommendations(user_id, recommendations): + return jsonify({ + 'userId': user_id, + 'recommendations': recommendations, + 'count': len(recommendations) + }) + else: + return jsonify({'error': 'Failed to store recommendations'}), 500 + +@app.route('/api/user/session', methods=['POST']) +def handle_session_data(): + try: + data = request.get_json() + print("Received data:", data) # Debug print + + user_id = data.get('userId') + email = data.get('email') + is_authenticated = data.get('isAuthenticated') + + if not user_id or not email or is_authenticated is None: + print("Missing required fields") # Debug print + return jsonify({'error': 'Invalid data'}), 400 + + print(f"Processing session data: User ID: {user_id}, Email: {email}, Authenticated: {is_authenticated}") + + # Test database connection first + try: + conn = get_db_connection() + conn.close() + print("Database connection successful") + except Exception as db_err: + print(f"Database connection error: {db_err}") + return jsonify({'error': f'Database connection error: {str(db_err)}'}), 500 + + # Continue with the rest of your code... + + except Exception as e: + import traceback + print(f"Error in handle_session_data: {e}") + print(traceback.format_exc()) # Print full stack trace + return jsonify({'error': f'Server error: {str(e)}'}), 500 + +if __name__ == '__main__': + # Load products on startup + products = get_all_products() + if products: + recommender.load_products(products) + print(f"Loaded {len(products)} products at startup") + else: + print("Warning: No products loaded at startup") + + app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/recommondation-engine/example1.py b/recommondation-engine/example1.py new file mode 100644 index 0000000..5686e9a --- /dev/null +++ b/recommondation-engine/example1.py @@ -0,0 +1,221 @@ + +import pandas as pd +import numpy as np +from sklearn.feature_extraction.text import TfidfVectorizer +from sklearn.metrics.pairwise import cosine_similarity + +''' +Recommender system using content-based filtering +''' +class Recommender: + def __init__(self): + # Initialize data structures + self.products_df = None + self.user_profiles = {} + self.tfidf_matrix = None + self.tfidf_vectorizer = None + self.product_indices = None + + def load_products(self, products_data): + """ + Load product data into the recommender system + + products_data: list of dictionaries with product info (id, name, description, category, etc.) + """ + self.products_df = pd.DataFrame(products_data) + + # Create a text representation for each product (combining various features) + self.products_df['content'] = ( + self.products_df['category'] + ' ' + + self.products_df['name'] + ' ' + + self.products_df['description'] + ) + + # Initialize TF-IDF vectorizer to convert text to vectors + self.tfidf_vectorizer = TfidfVectorizer( + stop_words='english', + max_features=5000, # Limit features to avoid sparse matrices + ngram_range=(1, 2) # Use both unigrams and bigrams + ) + + # Compute TF-IDF matrix + self.tfidf_matrix = self.tfidf_vectorizer.fit_transform(self.products_df['content']) + + # Create a mapping from product_id to index + self.product_indices = pd.Series( + self.products_df.index, + index=self.products_df['product_id'] + ).drop_duplicates() + + def track_user_click(self, user_id, product_id): + """ + Track user clicks on products to build user profiles + """ + if user_id not in self.user_profiles: + self.user_profiles[user_id] = { + 'clicks': {}, + 'category_weights': { + 'electronics': 0, + 'school supplies': 0, + 'rental place': 0, + 'furniture': 0 + } + } + + # Get the clicked product's index and details + if product_id in self.product_indices: + product_idx = self.product_indices[product_id] + product_category = self.products_df.iloc[product_idx]['category'] + + # Update click count + if product_id in self.user_profiles[user_id]['clicks']: + self.user_profiles[user_id]['clicks'][product_id] += 1 + else: + self.user_profiles[user_id]['clicks'][product_id] = 1 + + # Update category weight + self.user_profiles[user_id]['category_weights'][product_category] += 1 + + def get_user_profile_vector(self, user_id): + """ + Generate a user profile vector based on their click history + """ + if user_id not in self.user_profiles or not self.user_profiles[user_id]['clicks']: + # Return a zero vector if no click history + return np.zeros((1, self.tfidf_matrix.shape[1])) + + # Create a weighted average of all clicked products' TF-IDF vectors + clicked_product_vectors = [] + weights = [] + + for product_id, click_count in self.user_profiles[user_id]['clicks'].items(): + if product_id in self.product_indices: + product_idx = self.product_indices[product_id] + product_category = self.products_df.iloc[product_idx]['category'] + category_weight = self.user_profiles[user_id]['category_weights'][product_category] + + # Weight is based on both click count and category preference + weight = click_count * (1 + 0.5 * category_weight) + weights.append(weight) + clicked_product_vectors.append(self.tfidf_matrix[product_idx]) + + # Normalize weights + weights = np.array(weights) / np.sum(weights) + + # Compute weighted average + user_profile = np.zeros((1, self.tfidf_matrix.shape[1])) + for i, vector in enumerate(clicked_product_vectors): + user_profile += weights[i] * vector.toarray() + + return user_profile + + def recommend_products(self, user_id, n=5, category_filter=None): + """ + Recommend products to a user based on their profile + + user_id: ID of the user + n: Number of recommendations to return + category_filter: Optional filter to limit recommendations to a specific category + """ + # Get user profile vector + user_profile = self.get_user_profile_vector(user_id) + + # If user has no profile, recommend popular products (not implemented) + if np.sum(user_profile) == 0: + return self._get_popular_products(n, category_filter) + + # Calculate similarity scores + sim_scores = cosine_similarity(user_profile, self.tfidf_matrix) + sim_scores = sim_scores.flatten() + + # Create a DataFrame for easier filtering + recommendations_df = pd.DataFrame({ + 'product_id': self.products_df['product_id'], + 'score': sim_scores, + 'category': self.products_df['category'] + }) + + # Filter out products that the user has already clicked on + if user_id in self.user_profiles and self.user_profiles[user_id]['clicks']: + clicked_products = list(self.user_profiles[user_id]['clicks'].keys()) + recommendations_df = recommendations_df[~recommendations_df['product_id'].isin(clicked_products)] + + # Apply category filter if provided + if category_filter: + recommendations_df = recommendations_df[recommendations_df['category'] == category_filter] + + # Sort by similarity score and get top n recommendations + recommendations_df = recommendations_df.sort_values('score', ascending=False).head(n) + + # Return recommended product IDs + return recommendations_df['product_id'].tolist() + + def _get_popular_products(self, n=5, category_filter=None): + """ + Return popular products when a user has no profile + (This would typically be implemented with actual popularity metrics) + """ + filtered_df = self.products_df + + if category_filter: + filtered_df = filtered_df[filtered_df['category'] == category_filter] + + # Just return random products for now (in a real system you'd use popularity metrics) + if len(filtered_df) >= n: + return filtered_df.sample(n)['product_id'].tolist() + else: + return filtered_df['product_id'].tolist() + + def recommend_by_category_preference(self, user_id, n=5): + """ + Recommend products based primarily on the user's category preferences + """ + if user_id not in self.user_profiles: + return self._get_popular_products(n) + + # Get the user's most clicked category + category_weights = self.user_profiles[user_id]['category_weights'] + + # If no category has been clicked, return popular products + if sum(category_weights.values()) == 0: + return self._get_popular_products(n) + + # Sort categories by number of clicks + sorted_categories = sorted( + category_weights.items(), + key=lambda x: x[1], + reverse=True + ) + + recommendations = [] + remaining = n + + # Allocate recommendations proportionally across categories + for category, weight in sorted_categories: + if weight > 0: + # Allocate recommendations proportionally to category weight + category_allocation = max(1, int(remaining * (weight / sum(category_weights.values())))) + if category_allocation > remaining: + category_allocation = remaining + + # Get recommendations for this category + category_recs = self.recommend_products(user_id, category_allocation, category) + recommendations.extend(category_recs) + + # Update remaining slots + remaining -= len(category_recs) + + if remaining <= 0: + break + + # If we still have slots to fill, add general recommendations + if remaining > 0: + general_recs = self.recommend_products(user_id, remaining) + # Filter out duplicates + general_recs = [rec for rec in general_recs if rec not in recommendations] + recommendations.extend(general_recs[:remaining]) + + return recommendations + + +exported = Recommender() From 99f12319d50b2d512356bcfbb49fe0e2cc342fd6 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Thu, 3 Apr 2025 11:59:25 -0600 Subject: [PATCH 10/37] Cosine Sim Calc now Working properly --- .../{example-data.sql => Init-Data.sql} | 413 ++---------------- recommondation-engine/example.py | 272 ------------ recommondation-engine/example.sql | 5 - recommondation-engine/example1.py | 316 +++++--------- recommondation-engine/test.py | 33 -- 5 files changed, 152 insertions(+), 887 deletions(-) rename mysql-code/{example-data.sql => Init-Data.sql} (52%) delete mode 100644 recommondation-engine/example.py delete mode 100644 recommondation-engine/example.sql delete mode 100644 recommondation-engine/test.py diff --git a/mysql-code/example-data.sql b/mysql-code/Init-Data.sql similarity index 52% rename from mysql-code/example-data.sql rename to mysql-code/Init-Data.sql index a2b7242..b75516e 100644 --- a/mysql-code/example-data.sql +++ b/mysql-code/Init-Data.sql @@ -1,5 +1,4 @@ -- Inserting sample data into the Marketplace database --- Clear existing data (if needed) SET FOREIGN_KEY_CHECKS = 0; @@ -59,33 +58,6 @@ VALUES 'hashedpassword2', '555-234-5678', '456 Oak Ave, Calgary, AB' - ), - ( - 3, - 'Michael Brown', - 'michael.b@example.com', - 'U345678', - 'hashedpassword3', - '555-345-6789', - '789 Pine Rd, Calgary, AB' - ), - ( - 4, - 'Sarah Wilson', - 'sarah.w@example.com', - 'U456789', - 'hashedpassword4', - '555-456-7890', - '101 Elm Blvd, Calgary, AB' - ), - ( - 5, - 'David Taylor', - 'david.t@example.com', - 'U567890', - 'hashedpassword5', - '555-567-8901', - '202 Maple Dr, Calgary, AB' ); -- Insert User Roles @@ -93,10 +65,7 @@ INSERT INTO UserRole (UserID, Client, Admin) VALUES (1, TRUE, TRUE), - (2, TRUE, FALSE), - (3, TRUE, FALSE), - (4, TRUE, FALSE), - (5, TRUE, FALSE); + (2, TRUE, FALSE); -- Insert Categories INSERT INTO @@ -161,7 +130,7 @@ VALUES 'HP Laptop', 699.99, 1, - 2, + 1, '2023 HP Pavilion, 16GB RAM, 512GB SSD', 2, '2024-10-10 14:30:00' @@ -171,7 +140,7 @@ VALUES 'Dorm Desk', 120.00, 1, - 3, + 2, 'Sturdy desk perfect for studying, minor scratches', 3, '2024-10-12 09:15:00' @@ -181,7 +150,7 @@ VALUES 'University Hoodie', 35.00, 3, - 1, + 2, 'Size L, university logo, worn twice', 4, '2024-10-14 16:45:00' @@ -191,7 +160,7 @@ VALUES 'Basketball', 25.50, 1, - 4, + 2, 'Slightly used indoor basketball', 5, '2024-10-11 11:20:00' @@ -201,7 +170,7 @@ VALUES 'Acoustic Guitar', 175.00, 1, - 2, + 1, 'Beginner acoustic guitar with case', 6, '2024-10-09 13:10:00' @@ -211,7 +180,7 @@ VALUES 'Physics Textbook', 65.00, 2, - 5, + 2, 'University Physics 14th Edition, good condition', 1, '2024-10-08 10:30:00' @@ -221,7 +190,7 @@ VALUES 'Mini Fridge', 85.00, 1, - 3, + 1, 'Small dorm fridge, works perfectly', 8, '2024-10-13 15:00:00' @@ -231,7 +200,7 @@ VALUES 'PlayStation 5 Controller', 55.00, 1, - 4, + 2, 'Extra controller, barely used', 9, '2024-10-07 17:20:00' @@ -241,7 +210,7 @@ VALUES 'Mountain Bike', 350.00, 1, - 5, + 1, 'Trek mountain bike, great condition, new tires', 10, '2024-10-06 14:00:00' @@ -271,7 +240,7 @@ VALUES 'Graphing Calculator', 75.00, 1, - 3, + 1, 'TI-84 Plus, perfect working condition', 12, '2024-10-03 11:15:00' @@ -281,7 +250,7 @@ VALUES 'Yoga Mat', 20.00, 1, - 4, + 2, 'Thick yoga mat, barely used', 13, '2024-10-02 16:00:00' @@ -291,7 +260,7 @@ VALUES 'Winter Jacket', 120.00, 1, - 5, + 1, 'Columbia winter jacket, size XL, very warm', 26, '2024-10-01 10:20:00' @@ -301,7 +270,7 @@ VALUES 'Computer Science Textbook', 70.00, 1, - 1, + 2, 'Introduction to Algorithms, like new', 1, '2024-09-30 14:30:00' @@ -321,7 +290,7 @@ VALUES 'Scientific Calculator', 25.00, 1, - 3, + 1, 'Casio scientific calculator', 12, '2024-09-28 11:30:00' @@ -331,7 +300,7 @@ VALUES 'Bluetooth Speaker', 45.00, 1, - 4, + 1, 'JBL Bluetooth speaker, great sound', 23, '2024-09-27 15:45:00' @@ -341,7 +310,7 @@ VALUES 'Backpack', 40.00, 1, - 5, + 2, 'North Face backpack, lots of pockets', 22, '2024-09-26 09:15:00' @@ -360,7 +329,17 @@ VALUES ('/image1.avif', 7), ('/image1.avif', 8), ('/image1.avif', 9), - ('/image1.avif', 10); + ('/image1.avif', 10), + ('/image1.avif', 11), + ('/image1.avif', 12), + ('/image1.avif', 13), + ('/image1.avif', 14), + ('/image1.avif', 15), + ('/image1.avif', 16), + ('/image1.avif', 17), + ('/image1.avif', 18), + ('/image1.avif', 19), + ('/image1.avif', 20); -- Insert Product-Category relationships (products with multiple categories) INSERT INTO @@ -420,82 +399,20 @@ VALUES -- Backpack: Backpacks & Bags, School Supplies, Dorm Essentials -- Insert History records --- INSERT INTO - History (HistoryID, UserID, ProductID, Date) + History (HistoryID, UserID, ProductID) VALUES - (1, 1, 1, '2024-10-15 11:30:00'), - (2, 1, 2, '2024-10-14 13:45:00'), - (3, 1, 5, '2024-10-13 09:20:00'), - (4, 1, 4, '2024-10-12 16:10:00'); - --- -INSERT INTO - History (HistoryID, UserID, ProductID, Date) -VALUES - (1, 2, 1, '2024-10-15 11:30:00'), -- User 2 viewed Calculus Textbook - (2, 3, 2, '2024-10-14 13:45:00'), -- User 3 viewed HP Laptop - (3, 4, 3, '2024-10-13 09:20:00'), -- User 4 viewed Dorm Desk - (4, 5, 4, '2024-10-12 16:10:00'), -- User 5 viewed University Hoodie - (5, 1, 5, '2024-10-11 14:30:00'), -- User 1 viewed Basketball - (6, 2, 6, '2024-10-10 10:15:00'), -- User 2 viewed Acoustic Guitar - (7, 3, 7, '2024-10-09 15:40:00'), -- User 3 viewed Physics Textbook - (8, 4, 8, '2024-10-08 11:25:00'), -- User 4 viewed Mini Fridge - (9, 5, 9, '2024-10-07 17:50:00'), -- User 5 viewed PS5 Controller - (10, 1, 10, '2024-10-06 14:15:00'); - --- User 1 viewed Mountain Bike --- Insert Reviews -INSERT INTO - Review ( - ReviewID, - UserID, - ProductID, - Comment, - Rating, - Date - ) -VALUES - ( - 1, - 2, - 1, - 'Great condition, exactly as described!', - 5, - '2024-10-16 09:30:00' - ), - ( - 2, - 3, - 2, - 'Works well, but had a small scratch not mentioned in the listing.', - 4, - '2024-10-15 14:20:00' - ), - ( - 3, - 4, - 6, - 'Perfect for beginners, sounds great!', - 5, - '2024-10-14 11:10:00' - ), - ( - 4, - 5, - 8, - 'Keeps my drinks cold, but a bit noisy at night.', - 3, - '2024-10-13 16:45:00' - ), - ( - 5, - 1, - 10, - 'Excellent bike, well maintained!', - 5, - '2024-10-12 13:25:00' - ); + (1, 1, 1), + (2, 1, 3), + (3, 1, 5), + (4, 1, 7), + (5, 1, 9), + (6, 1, 11), + (7, 2, 2), + (8, 2, 4), + (9, 2, 5), + (10, 1, 15), + (11, 1, 18); -- Insert Favorites INSERT INTO @@ -505,9 +422,9 @@ VALUES (1, 7), -- User 1 likes Physics Textbook (2, 3), -- User 2 likes Dorm Desk (2, 10), -- User 2 likes Mountain Bike - (3, 6), -- User 3 likes Acoustic Guitar - (4, 5), -- User 4 likes Basketball - (5, 8); + (1, 6), -- User 3 likes Acoustic Guitar + (1, 5), -- User 4 likes Basketball + (2, 8); -- User 5 likes Mini Fridge -- Insert Transactions @@ -520,242 +437,8 @@ INSERT INTO PaymentStatus ) VALUES - (1, 2, 1, '2024-10-16 10:30:00', 'Completed'), - (2, 3, 6, '2024-10-15 15:45:00', 'Completed'), - (3, 4, 8, '2024-10-14 12:20:00', 'Pending'), - (4, 5, 10, '2024-10-13 17:10:00', 'Completed'), - (5, 1, 4, '2024-10-12 14:30:00', 'Completed'); - --- Insert Recommendations -INSERT INTO - Recommendation (RecommendationID_PK, UserID, RecommendedProductID) -VALUES - (1, 1, 7), -- Recommend Physics Textbook to User 1 - (2, 1, 13), -- Recommend Graphing Calculator to User 1 - (3, 2, 3), -- Recommend Dorm Desk to User 2 - (4, 2, 17), -- Recommend Desk Lamp to User 2 - (5, 3, 16), -- Recommend CS Textbook to User 3 - (6, 4, 14), -- Recommend Yoga Mat to User 4 - (7, 5, 15); - -INSERT INTO - Recommendation (RecommendationID_PK, UserID, RecommendedProductID) -VALUES - (12, 1, 19), - (13, 1, 9), - (14, 1, 11), - (15, 1, 16), - -- Insert Authentication records -INSERT INTO - AuthVerification (Email, VerificationCode, Authenticated, Date) -VALUES - ( - 'john.doe@example.com', - '123456', - TRUE, - '2024-10-01 09:00:00' - ), - ( - 'jane.smith@example.com', - '234567', - TRUE, - '2024-10-02 10:15:00' - ), - ( - 'michael.b@example.com', - '345678', - TRUE, - '2024-10-03 11:30:00' - ), - ( - 'sarah.w@example.com', - '456789', - TRUE, - '2024-10-04 12:45:00' - ), - ( - 'david.t@example.com', - '567890', - TRUE, - '2024-10-05 14:00:00' - ); - -INSERT INTO - Product ( - ProductID, - Name, - Description, - Price, - StockQuantity, - CategoryID - ) -VALUES - ( - 101, - 'Smart Coffee Maker', - 'Wi-Fi enabled coffee machine with scheduling feature', - 129.99, - 50, - 11 - ), - ( - 102, - 'Ergonomic Office Chair', - 'Adjustable mesh chair with lumbar support', - 199.99, - 35, - 12 - ), - ( - 103, - 'Wireless Mechanical Keyboard', - 'RGB-backlit wireless keyboard with mechanical switches', - 89.99, - 60, - 13 - ), - ( - 104, - 'Portable Solar Charger', - 'Foldable solar power bank with USB-C support', - 59.99, - 40, - 14 - ), - ( - 105, - 'Noise-Canceling Headphones', - 'Over-ear Bluetooth headphones with ANC', - 179.99, - 25, - 15 - ), - ( - 106, - 'Smart Water Bottle', - 'Tracks water intake and glows as a hydration reminder', - 39.99, - 75, - 11 - ), - ( - 107, - 'Compact Air Purifier', - 'HEPA filter air purifier for small rooms', - 149.99, - 30, - 16 - ), - ( - 108, - 'Smart LED Desk Lamp', - 'Adjustable LED lamp with voice control', - 69.99, - 45, - 12 - ), - ( - 109, - '4K Streaming Device', - 'HDMI streaming stick with voice remote', - 49.99, - 80, - 17 - ), - ( - 110, - 'Smart Plant Monitor', - 'Bluetooth-enabled sensor for plant health tracking', - 34.99, - 55, - 18 - ), - ( - 111, - 'Wireless Charging Pad', - 'Fast-charging pad for Qi-compatible devices', - 29.99, - 90, - 13 - ), - ( - 112, - 'Mini Projector', - 'Portable projector with built-in speakers', - 129.99, - 20, - 14 - ), - ( - 113, - 'Foldable Bluetooth Keyboard', - 'Ultra-thin keyboard for travel use', - 39.99, - 70, - 19 - ), - ( - 114, - 'Smart Alarm Clock', - 'AI-powered alarm clock with sunrise simulation', - 79.99, - 40, - 15 - ), - ( - 115, - 'Touchscreen Toaster', - 'Customizable toaster with a digital display', - 99.99, - 30, - 11 - ), - ( - 116, - 'Cordless Vacuum Cleaner', - 'Lightweight handheld vacuum with strong suction', - 159.99, - 25, - 16 - ), - ( - 117, - 'Smart Bike Lock', - 'Fingerprint and app-controlled bike security lock', - 89.99, - 35, - 20 - ), - ( - 118, - 'Bluetooth Sleep Headband', - 'Comfortable sleep headband with built-in speakers', - 49.99, - 60, - 18 - ), - ( - 119, - 'Retro Game Console', - 'Plug-and-play console with 500+ classic games', - 79.99, - 50, - 17 - ), - ( - 120, - 'Automatic Pet Feeder', - 'App-controlled food dispenser for pets', - 99.99, - 40, - 20 - ); - -SELECT - p.*, - i.URL AS image_url -FROM - Product p - LEFT JOIN Image_URL i ON p.ProductID = i.ProductID -WHERE - p.ProductID = 1 + (1, 1, 1, '2024-10-16 10:30:00', 'Completed'), + (2, 1, 6, '2024-10-15 15:45:00', 'Completed'), + (3, 1, 8, '2024-10-14 12:20:00', 'Pending'), + (4, 2, 10, '2024-10-13 17:10:00', 'Completed'), + (5, 2, 4, '2024-10-12 14:30:00', 'Completed'); diff --git a/recommondation-engine/example.py b/recommondation-engine/example.py deleted file mode 100644 index 4210bed..0000000 --- a/recommondation-engine/example.py +++ /dev/null @@ -1,272 +0,0 @@ -from flask import Flask, request, jsonify -from flask_cors import CORS -import pandas as pd -import numpy as np -from sklearn.feature_extraction.text import TfidfVectorizer -from sklearn.metrics.pairwise import cosine_similarity -import mysql.connector as db_con - -# Flask app initialization -app = Flask(__name__) -CORS(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True) - -# Database connection setup -def get_db_connection(): - return db_con.connect( - host="localhost", - port=3306, - user="root", - database="Marketplace" - ) - -# Fetch all products with category names -def get_all_products(): - query = """ - SELECT p.ProductID, p.Name, p.Description, c.Name AS Category - FROM Product p - JOIN Category c ON p.CategoryID = c.CategoryID - """ - - try: - connection = get_db_connection() - cursor = connection.cursor(dictionary=True) - cursor.execute(query) - products = cursor.fetchall() - cursor.close() - connection.close() - return products - except Exception as e: - print(f"Database error getting products: {e}") - return [] - -# Fetch user history -def get_user_history(user_id): - query = """ - SELECT p.ProductID, p.Name, p.Description, c.Name AS Category - FROM History h - JOIN Product p ON h.ProductID = p.ProductID - JOIN Category c ON p.CategoryID = c.CategoryID - WHERE h.UserID = %s - """ - try: - connection = get_db_connection() - cursor = connection.cursor(dictionary=True) - cursor.execute(query, (user_id,)) - history = cursor.fetchall() - cursor.close() - connection.close() - return history - except Exception as e: - print(f"Error getting user history: {e}") - return [] - -# Store recommendations -def store_user_recommendations(user_id, recommendations): - delete_query = "DELETE FROM Recommendation WHERE UserID = %s" - insert_query = "INSERT INTO Recommendation (UserID, RecommendedProductID) VALUES (%s, %s)" - - try: - connection = get_db_connection() - cursor = connection.cursor() - - # First delete existing recommendations - cursor.execute(delete_query, (user_id,)) - - # Then insert new recommendations - for product_id in recommendations: - cursor.execute(insert_query, (user_id, product_id)) - - connection.commit() - cursor.close() - connection.close() - return True - except Exception as e: - print(f"Error storing recommendations: {e}") - return False - -# Fetch stored recommendations -def get_stored_recommendations(user_id): - query = """ - SELECT p.ProductID, p.Name, p.Description, c.Name AS Category - FROM Recommendation r - JOIN Product p ON r.RecommendedProductID = p.ProductID - JOIN Category c ON p.CategoryID = c.CategoryID - WHERE r.UserID = %s - """ - try: - connection = get_db_connection() - cursor = connection.cursor(dictionary=True) - cursor.execute(query, (user_id,)) - recommendations = cursor.fetchall() - cursor.close() - connection.close() - return recommendations - except Exception as e: - print(f"Error getting stored recommendations: {e}") - return [] - -# Initialize Recommender class -class Recommender: - def __init__(self): - self.products_df = None - self.tfidf_matrix = None - self.tfidf_vectorizer = None - self.product_indices = None - - def load_products(self, products_data): - self.products_df = pd.DataFrame(products_data) - - # Combine relevant features for content-based filtering - self.products_df['content'] = ( - self.products_df['Category'] + ' ' + - self.products_df['Name'] + ' ' + - self.products_df['Description'].fillna('') - ) - - # Create TF-IDF matrix - self.tfidf_vectorizer = TfidfVectorizer( - stop_words='english', - max_features=5000, - ngram_range=(1, 2) - ) - self.tfidf_matrix = self.tfidf_vectorizer.fit_transform(self.products_df['content']) - - # Map product IDs to indices for quick lookup - self.product_indices = pd.Series( - self.products_df.index, - index=self.products_df['ProductID'] - ).drop_duplicates() - - def recommend_products_for_user(self, user_id, top_n=40): - """ - Generate product recommendations based on user history using cosine similarity - """ - # Get user history - user_history = get_user_history(user_id) - - # If no history, return popular products - if not user_history: - # In a real system, you might return popular products here - return self.recommend_popular_products(top_n) - - # Convert user history to DataFrame - history_df = pd.DataFrame(user_history) - - # Get indices of products in user history - history_indices = [] - for product_id in history_df['ProductID']: - if product_id in self.product_indices: - history_indices.append(self.product_indices[product_id]) - - if not history_indices: - return self.recommend_popular_products(top_n) - - # Get TF-IDF vectors for user's history - user_profile = self.tfidf_matrix[history_indices].mean(axis=0).reshape(1, -1) - - # Calculate similarity scores - similarity_scores = cosine_similarity(user_profile, self.tfidf_matrix) - similarity_scores = similarity_scores.flatten() - - # Create a Series with product indices and similarity scores - product_scores = pd.Series(similarity_scores, index=self.products_df.index) - - # Remove products the user has already interacted with - product_scores = product_scores.drop(history_indices) - - # Sort by similarity score (highest first) - product_scores = product_scores.sort_values(ascending=False) - - # Get top N product indices - top_indices = product_scores.iloc[:top_n].index - - # Get product IDs for these indices - recommended_product_ids = self.products_df.iloc[top_indices]['ProductID'].tolist() - - return recommended_product_ids - - def recommend_popular_products(self, n=40): - """ - Fallback recommendation strategy when user has no history - In a real system, this would use actual popularity metrics - """ - # For now, just returning random products - return self.products_df.sample(min(n, len(self.products_df)))['ProductID'].tolist() - -# Create recommender instance -recommender = Recommender() - -@app.route('/load_products', methods=['GET']) -def load_products(): - products = get_all_products() - if not products: - return jsonify({'error': 'Failed to load products from database'}), 500 - - recommender.load_products(products) - return jsonify({'message': 'Products loaded successfully', 'count': len(products)}) - -@app.route('/recommend/', methods=['GET']) -def recommend(user_id): - # Check if products are loaded - if recommender.products_df is None: - products = get_all_products() - if not products: - return jsonify({'error': 'No products available'}), 500 - recommender.load_products(products) - - # Generate recommendations using cosine similarity - recommendations = recommender.recommend_products_for_user(user_id) - - # Store recommendations in database - if store_user_recommendations(user_id, recommendations): - return jsonify({ - 'userId': user_id, - 'recommendations': recommendations, - 'count': len(recommendations) - }) - else: - return jsonify({'error': 'Failed to store recommendations'}), 500 - -@app.route('/api/user/session', methods=['POST']) -def handle_session_data(): - try: - data = request.get_json() - print("Received data:", data) # Debug print - - user_id = data.get('userId') - email = data.get('email') - is_authenticated = data.get('isAuthenticated') - - if not user_id or not email or is_authenticated is None: - print("Missing required fields") # Debug print - return jsonify({'error': 'Invalid data'}), 400 - - print(f"Processing session data: User ID: {user_id}, Email: {email}, Authenticated: {is_authenticated}") - - # Test database connection first - try: - conn = get_db_connection() - conn.close() - print("Database connection successful") - except Exception as db_err: - print(f"Database connection error: {db_err}") - return jsonify({'error': f'Database connection error: {str(db_err)}'}), 500 - - # Continue with the rest of your code... - - except Exception as e: - import traceback - print(f"Error in handle_session_data: {e}") - print(traceback.format_exc()) # Print full stack trace - return jsonify({'error': f'Server error: {str(e)}'}), 500 - -if __name__ == '__main__': - # Load products on startup - products = get_all_products() - if products: - recommender.load_products(products) - print(f"Loaded {len(products)} products at startup") - else: - print("Warning: No products loaded at startup") - - app.run(debug=True, host='0.0.0.0', port=5000) diff --git a/recommondation-engine/example.sql b/recommondation-engine/example.sql deleted file mode 100644 index f7fccb7..0000000 --- a/recommondation-engine/example.sql +++ /dev/null @@ -1,5 +0,0 @@ -select * -from Product -Where ProductID in (select ProductID - from History - where UserID=1); diff --git a/recommondation-engine/example1.py b/recommondation-engine/example1.py index 5686e9a..9a8ad35 100644 --- a/recommondation-engine/example1.py +++ b/recommondation-engine/example1.py @@ -1,221 +1,113 @@ +# pip install mysql.connector -import pandas as pd -import numpy as np +import mysql.connector from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity +import numpy as np +import logging -''' -Recommender system using content-based filtering -''' -class Recommender: - def __init__(self): - # Initialize data structures - self.products_df = None - self.user_profiles = {} - self.tfidf_matrix = None - self.tfidf_vectorizer = None - self.product_indices = None - - def load_products(self, products_data): +def database(): + db_connection = mysql.connector.connect( + host = "localhost", + port = "3306", + user = "root", + database = "Marketplace" + ) + return db_connection + + +def get_all_products(): + + db_con = database() + cursor = db_con.cursor() + + cursor.execute("SELECT CategoryID FROM Category") + categories = cursor.fetchall() + + select_clause = "SELECT p.ProductID" + for category in categories: + category_id = category[0] + select_clause += f", MAX(CASE WHEN pc.CategoryID = {category_id} THEN 1 ELSE 0 END) AS `Cat_{category_id}`" + + final_query = f""" + {select_clause} + FROM Product p + LEFT JOIN Product_Category pc ON p.ProductID = pc.ProductID + LEFT JOIN Category c ON pc.CategoryID = c.CategoryID + GROUP BY p.ProductID; """ - Load product data into the recommender system - - products_data: list of dictionaries with product info (id, name, description, category, etc.) + + cursor.execute(final_query) + results = cursor.fetchall() + + final = [] + for row in results: + text_list = list(row) + text_list.pop(0) + final.append(text_list) + + cursor.close() + db_con.close() + return final + +def get_user_history(user_id): + db_con = database() + cursor = db_con.cursor() + + cursor.execute("SELECT CategoryID FROM Category") + categories = cursor.fetchall() + + select_clause = "SELECT p.ProductID" + for category in categories: + category_id = category[0] # get the uid of the catefory and then append that to the new column + select_clause += f", MAX(CASE WHEN pc.CategoryID = {category_id} THEN 1 ELSE 0 END) AS `Cat_{category_id}`" + + final_query = f""" + {select_clause} + FROM Product p + LEFT JOIN Product_Category pc ON p.ProductID = pc.ProductID + LEFT JOIN Category c ON pc.CategoryID = c.CategoryID + where p.ProductID in (select ProductID from History where UserID = {user_id}) + GROUP BY p.ProductID; """ - self.products_df = pd.DataFrame(products_data) - - # Create a text representation for each product (combining various features) - self.products_df['content'] = ( - self.products_df['category'] + ' ' + - self.products_df['name'] + ' ' + - self.products_df['description'] - ) - - # Initialize TF-IDF vectorizer to convert text to vectors - self.tfidf_vectorizer = TfidfVectorizer( - stop_words='english', - max_features=5000, # Limit features to avoid sparse matrices - ngram_range=(1, 2) # Use both unigrams and bigrams - ) - - # Compute TF-IDF matrix - self.tfidf_matrix = self.tfidf_vectorizer.fit_transform(self.products_df['content']) - - # Create a mapping from product_id to index - self.product_indices = pd.Series( - self.products_df.index, - index=self.products_df['product_id'] - ).drop_duplicates() - - def track_user_click(self, user_id, product_id): - """ - Track user clicks on products to build user profiles - """ - if user_id not in self.user_profiles: - self.user_profiles[user_id] = { - 'clicks': {}, - 'category_weights': { - 'electronics': 0, - 'school supplies': 0, - 'rental place': 0, - 'furniture': 0 - } - } - - # Get the clicked product's index and details - if product_id in self.product_indices: - product_idx = self.product_indices[product_id] - product_category = self.products_df.iloc[product_idx]['category'] - - # Update click count - if product_id in self.user_profiles[user_id]['clicks']: - self.user_profiles[user_id]['clicks'][product_id] += 1 - else: - self.user_profiles[user_id]['clicks'][product_id] = 1 - - # Update category weight - self.user_profiles[user_id]['category_weights'][product_category] += 1 - - def get_user_profile_vector(self, user_id): - """ - Generate a user profile vector based on their click history - """ - if user_id not in self.user_profiles or not self.user_profiles[user_id]['clicks']: - # Return a zero vector if no click history - return np.zeros((1, self.tfidf_matrix.shape[1])) - - # Create a weighted average of all clicked products' TF-IDF vectors - clicked_product_vectors = [] - weights = [] - - for product_id, click_count in self.user_profiles[user_id]['clicks'].items(): - if product_id in self.product_indices: - product_idx = self.product_indices[product_id] - product_category = self.products_df.iloc[product_idx]['category'] - category_weight = self.user_profiles[user_id]['category_weights'][product_category] - - # Weight is based on both click count and category preference - weight = click_count * (1 + 0.5 * category_weight) - weights.append(weight) - clicked_product_vectors.append(self.tfidf_matrix[product_idx]) - - # Normalize weights - weights = np.array(weights) / np.sum(weights) - - # Compute weighted average - user_profile = np.zeros((1, self.tfidf_matrix.shape[1])) - for i, vector in enumerate(clicked_product_vectors): - user_profile += weights[i] * vector.toarray() - - return user_profile - - def recommend_products(self, user_id, n=5, category_filter=None): - """ - Recommend products to a user based on their profile - - user_id: ID of the user - n: Number of recommendations to return - category_filter: Optional filter to limit recommendations to a specific category - """ - # Get user profile vector - user_profile = self.get_user_profile_vector(user_id) - - # If user has no profile, recommend popular products (not implemented) - if np.sum(user_profile) == 0: - return self._get_popular_products(n, category_filter) - - # Calculate similarity scores - sim_scores = cosine_similarity(user_profile, self.tfidf_matrix) - sim_scores = sim_scores.flatten() - - # Create a DataFrame for easier filtering - recommendations_df = pd.DataFrame({ - 'product_id': self.products_df['product_id'], - 'score': sim_scores, - 'category': self.products_df['category'] - }) - - # Filter out products that the user has already clicked on - if user_id in self.user_profiles and self.user_profiles[user_id]['clicks']: - clicked_products = list(self.user_profiles[user_id]['clicks'].keys()) - recommendations_df = recommendations_df[~recommendations_df['product_id'].isin(clicked_products)] - - # Apply category filter if provided - if category_filter: - recommendations_df = recommendations_df[recommendations_df['category'] == category_filter] - - # Sort by similarity score and get top n recommendations - recommendations_df = recommendations_df.sort_values('score', ascending=False).head(n) - + + cursor.execute(final_query) + results = cursor.fetchall() + final = [] + for row in results: + text_list = list(row) + text_list.pop(0) + final.append(text_list) + + cursor.close() + db_con.close() + return final + + +def get_recommendations(user_id, top_n=40): + try: + # Get all products and user history with their category vectors + all_products = get_all_products() + user_history = get_user_history(user_id) + + # if not user_history: + # # Cold start: return popular products + # return get_popular_products(top_n) + + # Calculate similarity between all products and user history + user_profile = np.mean(user_history, axis=0) # Average user preferences + similarities = cosine_similarity([user_profile], all_products) + + # finds the indices of the top N products that have the highest + # cosine similarity with the user's profile and sorted from most similar to least similar. + product_indices = similarities[0].argsort()[-top_n:][::-1] + print("product", product_indices) + # Return recommended product IDs - return recommendations_df['product_id'].tolist() - - def _get_popular_products(self, n=5, category_filter=None): - """ - Return popular products when a user has no profile - (This would typically be implemented with actual popularity metrics) - """ - filtered_df = self.products_df - - if category_filter: - filtered_df = filtered_df[filtered_df['category'] == category_filter] - - # Just return random products for now (in a real system you'd use popularity metrics) - if len(filtered_df) >= n: - return filtered_df.sample(n)['product_id'].tolist() - else: - return filtered_df['product_id'].tolist() - - def recommend_by_category_preference(self, user_id, n=5): - """ - Recommend products based primarily on the user's category preferences - """ - if user_id not in self.user_profiles: - return self._get_popular_products(n) - - # Get the user's most clicked category - category_weights = self.user_profiles[user_id]['category_weights'] - - # If no category has been clicked, return popular products - if sum(category_weights.values()) == 0: - return self._get_popular_products(n) - - # Sort categories by number of clicks - sorted_categories = sorted( - category_weights.items(), - key=lambda x: x[1], - reverse=True - ) - - recommendations = [] - remaining = n - - # Allocate recommendations proportionally across categories - for category, weight in sorted_categories: - if weight > 0: - # Allocate recommendations proportionally to category weight - category_allocation = max(1, int(remaining * (weight / sum(category_weights.values())))) - if category_allocation > remaining: - category_allocation = remaining - - # Get recommendations for this category - category_recs = self.recommend_products(user_id, category_allocation, category) - recommendations.extend(category_recs) - - # Update remaining slots - remaining -= len(category_recs) - - if remaining <= 0: - break - - # If we still have slots to fill, add general recommendations - if remaining > 0: - general_recs = self.recommend_products(user_id, remaining) - # Filter out duplicates - general_recs = [rec for rec in general_recs if rec not in recommendations] - recommendations.extend(general_recs[:remaining]) - - return recommendations + return [all_products[i][0] for i in product_indices] # Product IDs + except Exception as e: + logging.error(f"Recommendation error for user {user_id}: {str(e)}") + # return get_popular_products(top_n) # Fallback to popular products -exported = Recommender() +get_recommendations(1) diff --git a/recommondation-engine/test.py b/recommondation-engine/test.py deleted file mode 100644 index 3107da7..0000000 --- a/recommondation-engine/test.py +++ /dev/null @@ -1,33 +0,0 @@ -import mysql.connector as db_con - -#TODO: Specify all the required queries -query_get_all_Prod= ("SELECT * FROM Product ") - - -#TODO: connect with the db -def database(): - db = db_con.connect( - host = "localhost", - port = 3306, - user = "root", - database = "Marketplace" - ) - - cursor = db.cursor() - cursor.execute(query_get_all_Prod) - - data = [None] - for item in cursor: - data.append(item) - # print(item) - - print(data[1]) - cursor.close() - db.close() - - - -#TODO: Get All products -# Make it into a dictionary with product id and the list of category it would have -# {Prod1:[1,0,0,0,1]} this could mean its a [elctronics, 0,0,0, kitchen] -database() From ac099da486acead701ed13eac820e4fdabca2868 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Thu, 3 Apr 2025 12:15:32 -0600 Subject: [PATCH 11/37] =?UTF-8?q?server/client=20recom..=20connection=20?= =?UTF-8?q?=F0=9F=91=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mysql-code/init-db.py | 4 ---- .../__pycache__/example1.cpython-313.pyc | Bin 0 -> 3997 bytes recommondation-engine/example1.py | 4 ---- recommondation-engine/server.py | 6 ++++++ 4 files changed, 6 insertions(+), 8 deletions(-) delete mode 100644 mysql-code/init-db.py create mode 100644 recommondation-engine/__pycache__/example1.cpython-313.pyc diff --git a/mysql-code/init-db.py b/mysql-code/init-db.py deleted file mode 100644 index a222193..0000000 --- a/mysql-code/init-db.py +++ /dev/null @@ -1,4 +0,0 @@ -import subprocess - -if (subprocess.run("mysql -u root mysql < mysql-code/Schema.sql", shell=True, check=True)): - print("successfully created the Marketplace databse") diff --git a/recommondation-engine/__pycache__/example1.cpython-313.pyc b/recommondation-engine/__pycache__/example1.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8fb9638b1295d56befa8c760ee3b31a69fe1853 GIT binary patch literal 3997 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 S4l)U*<*$QG2Q$jJzWxQ4-+VFv literal 0 HcmV?d00001 diff --git a/recommondation-engine/example1.py b/recommondation-engine/example1.py index 9a8ad35..621469d 100644 --- a/recommondation-engine/example1.py +++ b/recommondation-engine/example1.py @@ -1,7 +1,6 @@ # pip install mysql.connector import mysql.connector -from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity import numpy as np import logging @@ -108,6 +107,3 @@ def get_recommendations(user_id, top_n=40): except Exception as e: logging.error(f"Recommendation error for user {user_id}: {str(e)}") # return get_popular_products(top_n) # Fallback to popular products - - -get_recommendations(1) diff --git a/recommondation-engine/server.py b/recommondation-engine/server.py index 89850b1..c371a3c 100644 --- a/recommondation-engine/server.py +++ b/recommondation-engine/server.py @@ -1,5 +1,8 @@ from flask import Flask, request, jsonify from flask_cors import CORS +from example1 import get_recommendations +import time + app = Flask(__name__) CORS(app) # Enable CORS for all routes @@ -15,6 +18,9 @@ def handle_session_data(): if not user_id or not email or is_authenticated is None: return jsonify({'error': 'Invalid data'}), 400 + print(get_recommendations(user_id)) + time.sleep(2) + print(f"Received session data: User ID: {user_id}, Email: {email}, Authenticated: {is_authenticated}") return jsonify({'message': 'Session data received successfully'}) From 3537e698b1d01891f6b48d03356143e4b1555499 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:05:26 -0600 Subject: [PATCH 12/37] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8659323..4e3f899 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,6 @@ ### Database 1. To Create the DB use the command bellow ```Bash - python3 ./mysql-code/init-db.py + mysql -u root mysql < mysql-code/Schema.sql ``` - MySql Version 9.2.0 From 643b9e357cbfbdfb34dc18f2670fd7e452dedee5 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Thu, 3 Apr 2025 18:56:39 -0600 Subject: [PATCH 13/37] recommendation engine 75% polished --- backend/controllers/product.js | 5 +- backend/controllers/recommendation.js | 39 +++++++++ backend/index.js | 2 + backend/routes/recommendation.js | 8 ++ frontend/src/pages/Home.jsx | 76 ++++++++++++++---- frontend/src/pages/ProductDetail.jsx | 10 +-- frontend/src/pages/Settings.jsx | 56 +------------ mysql-code/Schema.sql | 7 +- .../__pycache__/app.cpython-313.pyc | Bin 0 -> 5045 bytes recommondation-engine/{example1.py => app.py} | 41 ++++++++-- recommondation-engine/server.py | 7 +- 11 files changed, 162 insertions(+), 89 deletions(-) create mode 100644 backend/controllers/recommendation.js create mode 100644 backend/routes/recommendation.js create mode 100644 recommondation-engine/__pycache__/app.cpython-313.pyc rename recommondation-engine/{example1.py => app.py} (73%) diff --git a/backend/controllers/product.js b/backend/controllers/product.js index 71396b8..1fcf06b 100644 --- a/backend/controllers/product.js +++ b/backend/controllers/product.js @@ -33,7 +33,7 @@ exports.getAllProducts = async (req, res) => { I.URL AS ProductImage, C.Name AS Category FROM Product P - JOIN Image_URL I ON p.ProductID = i.ProductID + JOIN Image_URL I ON P.ProductID = I.ProductID JOIN User U ON P.UserID = U.UserID JOIN Category C ON P.CategoryID = C.CategoryID; `); @@ -60,9 +60,10 @@ exports.getProductById = async (req, res) => { try { const [data] = await db.execute( ` - SELECT p.*, i.URL AS image_url + SELECT p.*,U.Name AS SellerName, i.URL AS image_url FROM Product p LEFT JOIN Image_URL i ON p.ProductID = i.ProductID + JOIN User U ON p.UserID = U.UserID WHERE p.ProductID = ? `, [id], diff --git a/backend/controllers/recommendation.js b/backend/controllers/recommendation.js new file mode 100644 index 0000000..488b089 --- /dev/null +++ b/backend/controllers/recommendation.js @@ -0,0 +1,39 @@ +const db = require("../utils/database"); + +// TODO: Get the recommondaed product given the userID +exports.RecommondationByUserId = async (req, res) => { + const { id } = req.body; + try { + const [data, fields] = await db.execute( + ` + SELECT + P.ProductID, + P.Name AS ProductName, + P.Price, + P.Date AS DateUploaded, + U.Name AS SellerName, + I.URL AS ProductImage, + C.Name AS Category + FROM Product P + JOIN Image_URL I ON P.ProductID = I.ProductID + JOIN User U ON P.UserID = U.UserID + JOIN Category C ON P.CategoryID = C.CategoryID + JOIN Recommendation R ON P.ProductID = R.RecommendedProductID + Where R.UserID = ?;`, + [id], + ); + + console.log(data); + res.json({ + success: true, + message: "Products fetched successfully", + data, + }); + } catch (error) { + console.error("Error finding products:", error); + return res.status(500).json({ + found: false, + error: "Database error occurred", + }); + } +}; diff --git a/backend/index.js b/backend/index.js index c78cda7..c3df135 100644 --- a/backend/index.js +++ b/backend/index.js @@ -6,6 +6,7 @@ const db = require("./utils/database"); const userRouter = require("./routes/user"); const productRouter = require("./routes/product"); const searchRouter = require("./routes/search"); +const recommendedRouter = require("./routes/recommendation"); const { generateEmailTransporter } = require("./utils/mail"); const { cleanupExpiredCodes, @@ -36,6 +37,7 @@ checkDatabaseConnection(db); app.use("/api/user", userRouter); //prefix with /api/user app.use("/api/product", productRouter); //prefix with /api/product app.use("/api/search_products", searchRouter); //prefix with /api/product +app.use("/api/Engine", recommendedRouter); //prefix with /api/ // Set up a scheduler to run cleanup every hour setInterval(cleanupExpiredCodes, 60 * 60 * 1000); diff --git a/backend/routes/recommendation.js b/backend/routes/recommendation.js new file mode 100644 index 0000000..e252a34 --- /dev/null +++ b/backend/routes/recommendation.js @@ -0,0 +1,8 @@ +// routes/product.js +const express = require("express"); +const { RecommondationByUserId } = require("../controllers/recommendation"); +const router = express.Router(); + +router.post("/recommended", RecommondationByUserId); + +module.exports = router; diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index da47d13..d55af11 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -1,12 +1,60 @@ import { useState, useEffect } from "react"; import { Link, useNavigate } from "react-router-dom"; -import { Tag, Book, Laptop, Sofa, Utensils, Gift, Heart } from "lucide-react"; +import { Tag, Heart } from "lucide-react"; const Home = () => { const navigate = useNavigate(); const [listings, setListings] = useState([]); + const [recommended, setRecommended] = useState([]); const [error, setError] = useState(null); + useEffect(() => { + const fetchrecomProducts = async () => { + // Get the user's data from localStorage + const storedUser = JSON.parse(sessionStorage.getItem("user")); + console.log(storedUser); + try { + const response = await fetch( + "http://localhost:3030/api/engine/recommended", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: storedUser.ID, + }), + }, + ); + if (!response.ok) throw new Error("Failed to fetch products"); + + const data = await response.json(); + console.log(data); + if (data.success) { + setRecommended( + data.data.map((product) => ({ + id: product.ProductID, + title: product.ProductName, // Use the alias from SQL + price: product.Price, + category: product.Category, // Ensure this gets the category name + image: product.ProductImage, // Use the alias for image URL + condition: "New", // Modify based on actual data + seller: product.SellerName, // Fetch seller name properly + datePosted: product.DateUploaded, // Use the actual date + isFavorite: false, // Default state + })), + ); + } else { + throw new Error(data.message || "Error fetching products"); + } + } catch (error) { + console.error("Error fetching products:", error); + setError(error.message); + } + }; + fetchrecomProducts(); + }, []); + useEffect(() => { const fetchProducts = async () => { try { @@ -134,25 +182,25 @@ const Home = () => { id="RecomContainer" className="overflow-x-auto whitespace-nowrap flex space-x-6 scroll-smooth scrollbar-hide px-10 pl-0" > - {listings.map((listing) => ( + {recommended.map((recommended) => (
{listing.title}
-
- Condition: - {product.condition} -
+
Posted on {product.Date} @@ -287,11 +284,10 @@ const ProductDetail = () => {

- {product.UserID || "Unknown Seller"} + {product.SellerName || "Unknown Seller"}

- Member since{" "} - {product.seller ? product.seller.memberSince : "N/A"} + Member since {product.SellerName}

diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 150ff05..a7e3992 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -415,64 +415,10 @@ const Settings = () => {
- {/* Privacy Section */} -
-
-
- -

Privacy

-
-
- -
-
-
-
- -
-

Search History

-

- Delete all your search history on StudentMarket -

-
-
- -
- -
-
- -
-

- Browsing History -

-

- Delete all your browsing history on StudentMarket -

-
-
- -
-
-
-
- {/* Delete Account (Danger Zone) */}
-

Danger Zone

+

Danger Zone !!!

diff --git a/mysql-code/Schema.sql b/mysql-code/Schema.sql index 3f36cf3..df51a58 100644 --- a/mysql-code/Schema.sql +++ b/mysql-code/Schema.sql @@ -61,7 +61,7 @@ CREATE TABLE Review ( ), Date DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (UserID) REFERENCES User (UserID), - FOREIGN KEY (ProductID) REFERENCES Product (ProductID) + FOREIGN KEY (ProductID) REFERENCES Pprint(item[0])roduct (ProductID) ); -- Transaction Entity (Many-to-One with User, Many-to-One with Product) @@ -77,16 +77,17 @@ CREATE TABLE Transaction ( -- Recommendation Entity (Many-to-One with User, Many-to-One with Product) CREATE TABLE Recommendation ( - RecommendationID_PK INT PRIMARY KEY, + RecommendationID_PK INT AUTO_INCREMENT PRIMARY KEY, UserID INT, RecommendedProductID INT, + Date DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (UserID) REFERENCES User (UserID), FOREIGN KEY (RecommendedProductID) REFERENCES Product (ProductID) ); -- History Entity (Many-to-One with User, Many-to-One with Product) CREATE TABLE History ( - HistoryID INT PRIMARY KEY, + HistoryID INT AUTO_INCREMENT PRIMARY KEY, UserID INT, ProductID INT, Date DATETIME DEFAULT CURRENT_TIMESTAMP, diff --git a/recommondation-engine/__pycache__/app.cpython-313.pyc b/recommondation-engine/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7d88e62118d3a8eafce7c80036b6c7642567999e GIT binary patch literal 5045 zcmd5=OKcm*8J>MF-CYgGmYl*E_A@Lzfq9iqnFaw{_9sQi2QpO5dyh<#h*5S+ z+AuX;O_^Fw%PLs2=An?ss#(Zd>6#`^B!Fb#zcmcvLp0?S5;0L%*e!uFt|?PXCaENy zWRh%>JI(4yMu$<1PuQNUkyf%-4AZjNzP{e)Z5A7bW&6eyd{wpbStYH;Sevaj$Qu}% z6-*V|;-28 zPSN$AK_gwrsk&wMoY$-ih08tXlw7`Gp171PWO}fgHgY*bhuNU_302R)X7wof{K@=0 z4!~HB@Ib=s0kMcS+d4iP|6u%f+p)E_W93k1In+*mty0B<+K*Qd*Um4FRs6``QDP%o z&;{+h%fA6dVV8-zPB>MI$<9rXbJ!N#dP=5t-u+Xxu_Od7$Z2j1?g(^}))|>@MHku- z(pi~JGUPaQPKMKyxgbh%j^z8e6R-t3Gqw*QAA+kV$PCnqeT>fUtcA?~+gb=cNEc-R zwCII7JX2(kogjPaW9VMlJK?LBH{v8yZvkr+C&)2v3$nM-V(i(f_MmZSTQkVkcLhd5 zyVQ$tqdkd7(d1Bk=-i}qPO;RCf#*jCrQwP3F*pBcZ>>T8y>?+JA?4F2n?^Y+JvtywlJaMyp*XbZm4*_7F{wW(y#>uuHucsck5{UN zh2OhLXZqS)Qr<0&4-HRB7sp2uQmrmkpA06J#$oz~g3?){jH~YsW4L{*UAJl8oOL^& zpBR@fNds>*Pjtqu{Zw>p%jVMsY!aJRuc_&RrP|_*YNf9z*{scGHNXufZ{%$rY*N*y zZ7!WP02F*SU$uLD0Cv{)rmKgcsiqw;)hwVyDxFmTNo=uJHE;XssgyQt`(`v<$);ut zDxSB6Dju3P=akwEHs)-PrCzgAL<5uD!TP>KjQ5f`_heKn1zM)^)l)X{2`Fxbzqtuw z5q%wKfA0s2!{tEh^*5K^d@r>)e3$k9H1STN)cV{8d#cEjd-HT&) z+PaEjd0*F!*$=K3ePyw|wExWN=<3V-%CVYDqX z+}C3jN_WIHf?JQnHCm>3#Wnh|xW)jku`*M`HNdbMuJM3ytjs&OMr0k=*d|kvLTm(HJ#J{poZC(K6+! z_yh-a_I?-Yl>fV^)67N*XCRoe^>YVxx_07I^puM_GOF8+&P=h;&j}NJJ=KU0A>cy5 zXHNs7G}$iOL&@~1ng+m^xFkiG(@s`g=M^&bG|+jLPHO;*DcL=sndn*^ZCLH1oFSOEd5%cU@YbUzB2g=RtMuyG`qP zo6V_;j>&1-f`T)^87!O2V@(|w>itmr*zxj{EFNDW^1eEKV@X?#Xdbqdf zf9OS#&X4APF?ZufD^IM2pCfhMH;>=!T0O8H9xnPTJlcQsli81ZZg)Pj*7?kOXWxpv zGQ4uW)cMk9ldE`ja;7)1W|EkuEmn_}pC;N3te^8Cu&infaX z_~hrxAC7z;Deb#>TO3^zN58Ewq}?NvK;Mu14-fdz7e`tKc=n6$1tEQl=ZN%q&#}y{ z_NNA3VQ#&`K|0-R^CEP0Y<}gtmv%sD;cYrMMa;|z zGS|4_GPBb=pQIfNVH`h+?Q3d327?j%1RY$DrataJc^k|w@AWS4d4^avxl>L6%Z7JF z=NoGP8wRe^M>nm6-ib@pcco^_d_SK^fK5Z(u;nKJ4gWK3T~y!`_u$yj!oWx(IW#dT zjU*<=rEOTyEjy3!@kY5ieW~tA#iUpJRzCa15U)wEC@pcJOD7N4n_6peF0{{ z2SI>;0Uo|);TK6p#6~uISxH~ThaejhY@S&A$RL1}j&bAZwpq)z8V4}0j zpCZTisKTfCQV#sG6?*;-+It5Dze4*TumaWbpiQD$ Y9t=`Zs^#Gz)j`Fntrt3|uruaA0K7#&Hvj+t literal 0 HcmV?d00001 diff --git a/recommondation-engine/example1.py b/recommondation-engine/app.py similarity index 73% rename from recommondation-engine/example1.py rename to recommondation-engine/app.py index 621469d..95da178 100644 --- a/recommondation-engine/example1.py +++ b/recommondation-engine/app.py @@ -4,6 +4,7 @@ import mysql.connector from sklearn.metrics.pairwise import cosine_similarity import numpy as np import logging +from unittest import result def database(): db_connection = mysql.connector.connect( @@ -83,27 +84,57 @@ def get_user_history(user_id): return final -def get_recommendations(user_id, top_n=40): +def get_recommendations(user_id, top_n=10): try: # Get all products and user history with their category vectors all_products = get_all_products() user_history = get_user_history(user_id) - # if not user_history: # # Cold start: return popular products # return get_popular_products(top_n) - # Calculate similarity between all products and user history user_profile = np.mean(user_history, axis=0) # Average user preferences similarities = cosine_similarity([user_profile], all_products) - # finds the indices of the top N products that have the highest # cosine similarity with the user's profile and sorted from most similar to least similar. product_indices = similarities[0].argsort()[-top_n:][::-1] print("product", product_indices) + # Get the recommended product IDs + recommended_products = [all_products[i][0] for i in product_indices] # Product IDs + + # Upload the recommendations to the database + history_upload(user_id, product_indices) # Pass the indices directly to history_upload + # Return recommended product IDs - return [all_products[i][0] for i in product_indices] # Product IDs + return recommended_products except Exception as e: logging.error(f"Recommendation error for user {user_id}: {str(e)}") # return get_popular_products(top_n) # Fallback to popular products + +def history_upload(userID, anrr): + db_con = database() + cursor = db_con.cursor() + + try: + for item in anrr: + #Product ID starts form index 1 + item_value = item + 1 + print(item_value) + # Use parameterized queries to prevent SQL injection + cursor.execute(f"INSERT INTO Recommendation (UserID, RecommendedProductID) VALUES ({userID}, {item_value});") + + # Commit the changes + db_con.commit() + + # If you need results, you'd typically fetch them after a SELECT query + # results = cursor.fetchall() + # print(results) + + except Exception as e: + print(f"Error: {e}") + db_con.rollback() + finally: + # Close the cursor and connection + cursor.close() + db_con.close() diff --git a/recommondation-engine/server.py b/recommondation-engine/server.py index c371a3c..83dbe4d 100644 --- a/recommondation-engine/server.py +++ b/recommondation-engine/server.py @@ -1,6 +1,8 @@ from flask import Flask, request, jsonify from flask_cors import CORS -from example1 import get_recommendations +from app import get_recommendations +from app import history_upload + import time @@ -18,8 +20,7 @@ def handle_session_data(): if not user_id or not email or is_authenticated is None: return jsonify({'error': 'Invalid data'}), 400 - print(get_recommendations(user_id)) - time.sleep(2) + get_recommendations(user_id) print(f"Received session data: User ID: {user_id}, Email: {email}, Authenticated: {is_authenticated}") return jsonify({'message': 'Session data received successfully'}) From 75c7675601cfcf22bc915c0b04a4087477d8e288 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Fri, 4 Apr 2025 00:02:04 -0600 Subject: [PATCH 14/37] added Review Feature --- backend/controllers/history.js | 53 +++ backend/controllers/product.js | 35 +- backend/controllers/recommendation.js | 101 +++++- backend/controllers/review.js | 133 +++++++ backend/index.js | 5 + backend/routes/history.js | 8 + backend/routes/review.js | 9 + frontend/public/image8.jpg | Bin 0 -> 97546 bytes frontend/src/pages/Home.jsx | 158 ++++++-- frontend/src/pages/ProductDetail.jsx | 505 ++++++++++++++++++++++---- frontend/src/pages/SearchPage.jsx | 20 +- mysql-code/Init-Data.sql | 14 + mysql-code/Schema.sql | 24 +- 13 files changed, 925 insertions(+), 140 deletions(-) create mode 100644 backend/controllers/history.js create mode 100644 backend/controllers/review.js create mode 100644 backend/routes/history.js create mode 100644 backend/routes/review.js create mode 100644 frontend/public/image8.jpg diff --git a/backend/controllers/history.js b/backend/controllers/history.js new file mode 100644 index 0000000..f2af282 --- /dev/null +++ b/backend/controllers/history.js @@ -0,0 +1,53 @@ +const db = require("../utils/database"); + +// TODO: Get the recommondaed product given the userID +exports.HistoryByUserId = async (req, res) => { + const { id } = req.body; + try { + const [data] = await db.execute( + ` + WITH RankedImages AS ( + SELECT + P.ProductID, + P.Name AS ProductName, + P.Price, + P.Date AS DateUploaded, + U.Name AS SellerName, + I.URL AS ProductImage, + C.Name AS Category, + ROW_NUMBER() OVER (PARTITION BY P.ProductID ORDER BY I.URL) AS RowNum + FROM Product P + JOIN Image_URL I ON P.ProductID = I.ProductID + JOIN User U ON P.UserID = U.UserID + JOIN Category C ON P.CategoryID = C.CategoryID + JOIN History H ON H.ProductID = P.ProductID + WHERE U.UserID = ? + ) + SELECT + ProductID, + ProductName, + Price, + DateUploaded, + SellerName, + ProductImage, + Category + FROM RankedImages + WHERE RowNum = 1; + `, + [id], + ); + + console.log(data); + res.json({ + success: true, + message: "Products fetched successfully", + data, + }); + } catch (error) { + console.error("Error finding products:", error); + return res.status(500).json({ + found: false, + error: "Database error occurred", + }); + } +}; diff --git a/backend/controllers/product.js b/backend/controllers/product.js index 1fcf06b..c8f9885 100644 --- a/backend/controllers/product.js +++ b/backend/controllers/product.js @@ -24,18 +24,31 @@ exports.addToFavorite = async (req, res) => { exports.getAllProducts = async (req, res) => { try { const [data, fields] = await db.execute(` + WITH RankedImages AS ( + SELECT + P.ProductID, + P.Name AS ProductName, + P.Price, + P.Date AS DateUploaded, + U.Name AS SellerName, + I.URL AS ProductImage, + C.Name AS Category, + ROW_NUMBER() OVER (PARTITION BY P.ProductID ORDER BY I.URL) AS RowNum + FROM Product P + JOIN Image_URL I ON P.ProductID = I.ProductID + JOIN User U ON P.UserID = U.UserID + JOIN Category C ON P.CategoryID = C.CategoryID + ) SELECT - P.ProductID, - P.Name AS ProductName, - P.Price, - P.Date AS DateUploaded, - U.Name AS SellerName, - I.URL AS ProductImage, - C.Name AS Category - FROM Product P - JOIN Image_URL I ON P.ProductID = I.ProductID - JOIN User U ON P.UserID = U.UserID - JOIN Category C ON P.CategoryID = C.CategoryID; + ProductID, + ProductName, + Price, + DateUploaded, + SellerName, + ProductImage, + Category + FROM RankedImages + WHERE RowNum = 1; `); console.log(data); diff --git a/backend/controllers/recommendation.js b/backend/controllers/recommendation.js index 488b089..5518d46 100644 --- a/backend/controllers/recommendation.js +++ b/backend/controllers/recommendation.js @@ -6,20 +6,34 @@ exports.RecommondationByUserId = async (req, res) => { try { const [data, fields] = await db.execute( ` + WITH RankedImages AS ( + SELECT + P.ProductID, + P.Name AS ProductName, + P.Price, + P.Date AS DateUploaded, + U.Name AS SellerName, + I.URL AS ProductImage, + C.Name AS Category, + ROW_NUMBER() OVER (PARTITION BY P.ProductID ORDER BY I.URL) AS RowNum + FROM Product P + JOIN Image_URL I ON P.ProductID = I.ProductID + JOIN User U ON P.UserID = U.UserID + JOIN Category C ON P.CategoryID = C.CategoryID + JOIN Recommendation R ON P.ProductID = R.RecommendedProductID + WHERE R.UserID = ? + ) SELECT - P.ProductID, - P.Name AS ProductName, - P.Price, - P.Date AS DateUploaded, - U.Name AS SellerName, - I.URL AS ProductImage, - C.Name AS Category - FROM Product P - JOIN Image_URL I ON P.ProductID = I.ProductID - JOIN User U ON P.UserID = U.UserID - JOIN Category C ON P.CategoryID = C.CategoryID - JOIN Recommendation R ON P.ProductID = R.RecommendedProductID - Where R.UserID = ?;`, + ProductID, + ProductName, + Price, + DateUploaded, + SellerName, + ProductImage, + Category + FROM RankedImages + WHERE RowNum = 1; + `, [id], ); @@ -37,3 +51,64 @@ exports.RecommondationByUserId = async (req, res) => { }); } }; + +// Add this to your existing controller file +exports.submitReview = async (req, res) => { + const { productId, reviewerName, rating, comment } = req.body; + + // Validate required fields + if (!productId || !reviewerName || !rating || !comment) { + return res.status(400).json({ + success: false, + message: "Missing required fields", + }); + } + + try { + // Insert the review into the database + const [result] = await db.execute( + ` + INSERT INTO Review ( + ProductID, + ReviewerName, + Rating, + Comment, + ReviewDate + ) VALUES (?, ?, ?, ?, NOW()) + `, + [productId, reviewerName, rating, comment], + ); + + // Get the inserted review id + const reviewId = result.insertId; + + // Fetch the newly created review to return to client + const [newReview] = await db.execute( + ` + SELECT + ReviewID as id, + ProductID, + ReviewerName, + Rating, + Comment, + ReviewDate + FROM Review + WHERE ReviewID = ? + `, + [reviewId], + ); + + res.status(201).json({ + success: true, + message: "Review submitted successfully", + data: newReview[0], + }); + } catch (error) { + console.error("Error submitting review:", error); + return res.status(500).json({ + success: false, + message: "Database error occurred", + error: error.message, + }); + } +}; diff --git a/backend/controllers/review.js b/backend/controllers/review.js new file mode 100644 index 0000000..5de7d1e --- /dev/null +++ b/backend/controllers/review.js @@ -0,0 +1,133 @@ +const db = require("../utils/database"); + +exports.getreview = async (req, res) => { + const { id } = req.params; + console.log("Received Product ID:", id); + + try { + const [data] = await db.execute( + ` + SELECT + R.ReviewID, + R.UserID, + R.ProductID, + R.Comment, + R.Rating, + R.Date AS ReviewDate, + U.Name AS ReviewerName, + P.Name AS ProductName + FROM Review R + JOIN User U ON R.UserID = U.UserID + JOIN Product P ON R.ProductID = P.ProductID + WHERE R.ProductID = ? + + UNION + + SELECT + R.ReviewID, + R.UserID, + R.ProductID, + R.Comment, + R.Rating, + R.Date AS ReviewDate, + U.Name AS ReviewerName, + P.Name AS ProductName + FROM Review R + JOIN User U ON R.UserID = U.UserID + JOIN Product P ON R.ProductID = P.ProductID + WHERE P.UserID = ( + SELECT UserID + FROM Product + WHERE ProductID = ? + ) + AND R.UserID != P.UserID; + `, + [id, id], + ); + + // Log raw data for debugging + console.log("Raw Database Result:", data); + + console.log(data); + res.json({ + success: true, + message: "Products fetched successfully", + data, + }); + } catch (error) { + console.error("Full Error Details:", error); + return res.status(500).json({ + success: false, + message: "Database error occurred", + error: error.message, + }); + } +}; + +// Add this to your existing controller file +exports.submitReview = async (req, res) => { + const { productId, userId, rating, comment } = req.body; + + // Validate required fields + if (!productId || !userId || !rating || !comment) { + return res.status(400).json({ + success: false, + message: "Missing required fields", + }); + } + + // Validate rating is between 1 and 5 + if (rating < 1 || rating > 5) { + return res.status(400).json({ + success: false, + message: "Rating must be between 1 and 5", + }); + } + + try { + // Insert the review into the database + const [result] = await db.execute( + ` + INSERT INTO Review ( + ProductID, + UserID, + Rating, + Comment + ) VALUES (?, ?, ?, ?) + `, + [productId, userId, rating, comment], + ); + + // Get the inserted review id + const reviewId = result.insertId; + + // Fetch the newly created review to return to client + const [newReview] = await db.execute( + ` + SELECT + ReviewID as id, + ProductID, + UserID, + Rating, + Comment, + Date as ReviewDate + FROM Review + WHERE ReviewID = ? + `, + [reviewId], + ); + + res.status(201).json({ + success: false, + message: "Review submitted successfully", + data: newReview[0], + }); + } catch (error) { + console.error("Error submitting review:", error); + return res.status(500).json({ + success: false, + message: "Database error occurred", + error: error.message, + }); + } +}; diff --git a/backend/index.js b/backend/index.js index c3df135..c7b4328 100644 --- a/backend/index.js +++ b/backend/index.js @@ -7,6 +7,9 @@ const userRouter = require("./routes/user"); const productRouter = require("./routes/product"); const searchRouter = require("./routes/search"); const recommendedRouter = require("./routes/recommendation"); +const history = require("./routes/history"); +const review = require("./routes/review"); + const { generateEmailTransporter } = require("./utils/mail"); const { cleanupExpiredCodes, @@ -38,6 +41,8 @@ app.use("/api/user", userRouter); //prefix with /api/user app.use("/api/product", productRouter); //prefix with /api/product app.use("/api/search_products", searchRouter); //prefix with /api/product app.use("/api/Engine", recommendedRouter); //prefix with /api/ +app.use("/api/get", history); //prefix with /api/ +app.use("/api/review", review); //prefix with /api/ // Set up a scheduler to run cleanup every hour setInterval(cleanupExpiredCodes, 60 * 60 * 1000); diff --git a/backend/routes/history.js b/backend/routes/history.js new file mode 100644 index 0000000..8d78efa --- /dev/null +++ b/backend/routes/history.js @@ -0,0 +1,8 @@ +// routes/product.js +const express = require("express"); +const { HistoryByUserId } = require("../controllers/history"); +const router = express.Router(); + +router.post("/history", HistoryByUserId); + +module.exports = router; diff --git a/backend/routes/review.js b/backend/routes/review.js new file mode 100644 index 0000000..de3504f --- /dev/null +++ b/backend/routes/review.js @@ -0,0 +1,9 @@ +// routes/product.js +const express = require("express"); +const { getreview, submitReview } = require("../controllers/review"); +const router = express.Router(); + +router.get("/:id", getreview); +router.post("/add", submitReview); + +module.exports = router; diff --git a/frontend/public/image8.jpg b/frontend/public/image8.jpg new file mode 100644 index 0000000000000000000000000000000000000000..eb1e1a1e993f07a1aab13022361bf9136d083644 GIT binary patch literal 97546 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

i*1r<0dM#X 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 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index d55af11..92f3f3c 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -6,6 +6,7 @@ const Home = () => { const navigate = useNavigate(); const [listings, setListings] = useState([]); const [recommended, setRecommended] = useState([]); + const [history, sethistory] = useState([]); const [error, setError] = useState(null); useEffect(() => { @@ -38,7 +39,6 @@ const Home = () => { price: product.Price, category: product.Category, // Ensure this gets the category name image: product.ProductImage, // Use the alias for image URL - condition: "New", // Modify based on actual data seller: product.SellerName, // Fetch seller name properly datePosted: product.DateUploaded, // Use the actual date isFavorite: false, // Default state @@ -73,7 +73,6 @@ const Home = () => { price: product.Price, category: product.Category, // Ensure this gets the category name image: product.ProductImage, // Use the alias for image URL - condition: "New", // Modify based on actual data seller: product.SellerName, // Fetch seller name properly datePosted: product.DateUploaded, // Use the actual date isFavorite: false, // Default state @@ -90,6 +89,49 @@ const Home = () => { fetchProducts(); }, []); + useEffect(() => { + const fetchrecomProducts = async () => { + // Get the user's data from localStorage + const storedUser = JSON.parse(sessionStorage.getItem("user")); + console.log(storedUser); + try { + const response = await fetch("http://localhost:3030/api/get/history", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: storedUser.ID, + }), + }); + if (!response.ok) throw new Error("Failed to fetch products"); + + const data = await response.json(); + console.log(data); + if (data.success) { + sethistory( + data.data.map((product) => ({ + id: product.ProductID, + title: product.ProductName, // Use the alias from SQL + price: product.Price, + category: product.Category, // Ensure this gets the category name + image: product.ProductImage, // Use the alias for image URL + seller: product.SellerName, // Fetch seller name properly + datePosted: product.DateUploaded, // Use the actual date + isFavorite: false, // Default state + })), + ); + } else { + throw new Error(data.message || "Error fetching products"); + } + } catch (error) { + console.error("Error fetching products:", error); + setError(error.message); + } + }; + fetchrecomProducts(); + }, []); + // Toggle favorite status const toggleFavorite = (id, e) => { e.preventDefault(); // Prevent navigation when clicking the heart icon @@ -138,26 +180,6 @@ const Home = () => {

- {/* Categories */} - {/*
-

Categories

-
- {categories.map((category) => ( - - ))} -
-
*/} - {/* Recent Listings */}

@@ -219,8 +241,6 @@ const Home = () => {
{recommended.category} - - {recommended.condition}
@@ -311,8 +331,6 @@ const Home = () => {
{listing.category} - - {listing.condition}
@@ -341,6 +359,94 @@ const Home = () => {
+ + {/* Recent Listings */} +
+

History

+ +
+ {/* Left Button - Overlaid on products */} + + + {/* Scrollable Listings Container */} +
+ {history.map((history) => ( + +
+ {history.title} + +
+ +
+

+ {history.title} +

+ + ${history.price} + + +
+ + {history.category} +
+ +
+ + {history.datePosted} + + + {history.seller} + +
+
+ + ))} +
+ + {/* Right Button - Overlaid on products */} + +
+

); }; diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx index aca2887..b64a998 100644 --- a/frontend/src/pages/ProductDetail.jsx +++ b/frontend/src/pages/ProductDetail.jsx @@ -1,50 +1,145 @@ -import { useState, useEffect } from "react"; -import { useParams, Link } from "react-router-dom"; -import { Heart, ArrowLeft, Tag, User, Calendar } from "lucide-react"; +import { useState, useEffect, setErrors } from "react"; +import { useParams, Link, isSession } from "react-router-dom"; +import { Heart, ArrowLeft, Tag, User, Calendar, Star } from "lucide-react"; const ProductDetail = () => { const { id } = useParams(); const [product, setProduct] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [loading, setLoading] = useState({ + product: true, + reviews: true, + }); + const [error, setError] = useState({ + product: null, + reviews: null, + }); const [isFavorite, setIsFavorite] = useState(false); const [showContactForm, setShowContactForm] = useState(false); - const [message, setMessage] = useState(""); const [currentImage, setCurrentImage] = useState(0); + const [reviews, setReviews] = useState([]); + const [showReviewForm, setShowReviewForm] = useState(false); - // Fetch product details + const [reviewForm, setReviewForm] = useState({ + rating: 3, + comment: "", + name: "", + }); + + // Add this function to handle review input changes + const handleReviewInputChange = (e) => { + const { id, value } = e.target; + setReviewForm((prev) => ({ + ...prev, + [id]: value, + })); + }; + + // Add this function to handle star rating selection + const handleRatingChange = (rating) => { + setReviewForm((prev) => ({ + ...prev, + rating, + })); + }; + + const handleSubmitReview = async () => { + try { + // Ensure userId is present + if (!userData.userId) { + throw new Error("User ID is missing. Unable to update profile."); + } + + setIsLoading(true); + setError(null); + + const response = await fetch(`http://localhost:3030/api/review/add`, { + method: "POST", // or "PUT" if your backend supports it + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(userData), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || "Failed to update profile"); + } + + console.log("Profile updated successfully:", result); + alert("Profile updated successfully!"); + } catch (error) { + console.error("Error updating profile:", error); + setError( + error.message || "An error occurred while updating your profile.", + ); + } finally { + setIsLoading(false); + } + }; + + // Fetch product data useEffect(() => { const fetchProduct = async () => { try { - setLoading(true); + setLoading((prev) => ({ ...prev, product: true })); const response = await fetch(`http://localhost:3030/api/product/${id}`); if (!response.ok) { - throw new Error("Failed to fetch product"); + throw new Error(`HTTP error! Status: ${response.status}`); } const result = await response.json(); - console.log(result); if (result.success) { setProduct(result.data); - setError(null); + setError((prev) => ({ ...prev, product: null })); } else { throw new Error(result.message || "Error fetching product"); } } catch (error) { console.error("Error fetching product:", error); - setError(error.message); - setProduct(null); + setError((prev) => ({ ...prev, product: error.message })); } finally { - setLoading(false); + setLoading((prev) => ({ ...prev, product: false })); } }; fetchProduct(); }, [id]); - // Handle favorite toggle + // Fetch reviews data + useEffect(() => { + const fetchReviews = async () => { + try { + setLoading((prev) => ({ ...prev, reviews: true })); + const response = await fetch(`http://localhost:3030/api/review/${id}`); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const result = await response.json(); + + if (result.success) { + setReviews(result.data || []); + setError((prev) => ({ ...prev, reviews: null })); + } else { + throw new Error(result.message || "Error fetching reviews"); + } + } catch (error) { + console.error("Error fetching reviews:", error); + setError((prev) => ({ ...prev, reviews: error.message })); + setReviews([]); + } finally { + setLoading((prev) => ({ ...prev, reviews: false })); + } + }; + + fetchReviews(); + }, [id]); + + // Handle favorite toggle with error handling const toggleFavorite = async () => { try { const response = await fetch( @@ -61,28 +156,67 @@ const ProductDetail = () => { }, ); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + const result = await response.json(); if (result.success) { setIsFavorite(!isFavorite); + } else { + throw new Error(result.message || "Failed to toggle favorite"); } } catch (error) { console.error("Error toggling favorite:", error); + alert(`Failed to add to favorites: ${error.message}`); } }; - // Handle message submission + // Handle form input changes + const handleContactInputChange = (e) => { + const { id, value } = e.target; + setContactForm((prev) => ({ + ...prev, + [id]: value, + })); + }; + + // Handle message submission with improved validation const handleSendMessage = (e) => { e.preventDefault(); + + // Basic validation + if (!contactForm.email || !contactForm.phone) { + alert("Please fill in all required fields"); + return; + } + + // Email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(contactForm.email)) { + alert("Please enter a valid email address"); + return; + } + // TODO: Implement actual message sending logic - console.log("Message sent:", message); - setMessage(""); - setShowContactForm(false); - alert("Message sent to seller!"); + try { + // Mock API call + console.log("Message sent:", contactForm); + setContactForm({ + email: "", + phone: "", + message: "Hi, is this item still available?", + }); + setShowContactForm(false); + alert("Message sent to seller!"); + } catch (error) { + alert(`Failed to send message: ${error.message}`); + } }; // Image navigation const nextImage = () => { - if (product && product.images) { + if (product?.images?.length > 0) { setCurrentImage((prev) => prev === product.images.length - 1 ? 0 : prev + 1, ); @@ -90,7 +224,7 @@ const ProductDetail = () => { }; const prevImage = () => { - if (product && product.images) { + if (product?.images?.length > 0) { setCurrentImage((prev) => prev === 0 ? product.images.length - 1 : prev - 1, ); @@ -101,8 +235,22 @@ const ProductDetail = () => { setCurrentImage(index); }; - // Render loading state - if (loading) { + // Function to render stars based on rating + const renderStars = (rating) => { + const stars = []; + for (let i = 1; i <= 5; i++) { + stars.push( + , + ); + } + return stars; + }; + + // Render loading state for the entire page + if (loading.product) { return (
@@ -110,13 +258,30 @@ const ProductDetail = () => { ); } - // Render error state - if (error) { + // Render error state for product + if (error.product) { return (

Error Loading Product

-

{error}

+

{error.product}

+ + Back to Listings + +
+
+ ); + } + + // Safety check for product + if (!product) { + return ( +
+
+

Product Not Found

{
{product.images && product.images.length > 0 ? ( - {product.Name} + <> + {product.Name} { + e.target.onerror = null; + e.target.src = + "https://via.placeholder.com/400x300?text=Image+Not+Available"; + }} + /> + {product.images.length > 1 && ( +
+ +
+ {currentImage + 1}/{product.images.length} +
+
+ )} + ) : (
No Image Available @@ -170,6 +358,11 @@ const ProductDetail = () => { src={image} alt={`${product.Name} - view ${index + 1}`} className="w-full h-auto object-cover" + onError={(e) => { + e.target.onerror = null; + e.target.src = + "https://via.placeholder.com/100x100?text=Error"; + }} />
))} @@ -181,11 +374,14 @@ const ProductDetail = () => {

- {product.Name} + {product.Name || "Unnamed Product"}

- ${product.Price} + $ + {typeof product.Price === "number" + ? product.Price.toFixed(2) + : product.Price}
-
- - {product.Category} -
+ {product.Category && ( +
+ + {product.Category} +
+ )} -
- - Posted on {product.Date} -
+ {product.Date && ( +
+ + Posted on {product.Date} +
+ )}
-

{product.Description}

+

+ {product.Description || "No description available"} +

+ +
+ +
+
+ )} +
); }; diff --git a/frontend/src/pages/SearchPage.jsx b/frontend/src/pages/SearchPage.jsx index 905eb70..2fbcd48 100644 --- a/frontend/src/pages/SearchPage.jsx +++ b/frontend/src/pages/SearchPage.jsx @@ -96,12 +96,13 @@ const SearchPage = () => { return (
+ {/* Filter sidebar */}
@@ -110,9 +111,8 @@ const SearchPage = () => {
-
-
+

Price Range

@@ -126,7 +126,7 @@ const SearchPage = () => { min: Number(e.target.value), })) } - className="w-full p-2 border rounded text-gray-700" + className="w-full p-2 border text-gray-700" /> { max: Number(e.target.value), })) } - className="w-full p-2 border rounded text-gray-700" + className="w-full p-2 border text-gray-700" />
@@ -146,13 +146,13 @@ const SearchPage = () => {
@@ -160,6 +160,7 @@ const SearchPage = () => {
+ {/* Main content */}

{filteredProducts.length} Results @@ -170,18 +171,17 @@ const SearchPage = () => { )}

-
{filteredProducts.map((listing) => ( {listing.title}

diff --git a/mysql-code/Init-Data.sql b/mysql-code/Init-Data.sql index b75516e..a61e04c 100644 --- a/mysql-code/Init-Data.sql +++ b/mysql-code/Init-Data.sql @@ -321,6 +321,9 @@ INSERT INTO Image_URL (URL, ProductID) VALUES ('/image1.avif', 1), + ('/image2.avif', 1), + ('/image3.avif', 1), + ('/image8.jpg', 1), ('/image1.avif', 2), ('/image1.avif', 3), ('/image1.avif', 4), @@ -442,3 +445,14 @@ VALUES (3, 1, 8, '2024-10-14 12:20:00', 'Pending'), (4, 2, 10, '2024-10-13 17:10:00', 'Completed'), (5, 2, 4, '2024-10-12 14:30:00', 'Completed'); + +INSERT INTO + Review (UserID, ProductID, Comment, Rating, Date) +VALUES + ( + 1, + 1, + 'This is a great fake product! Totally recommend it.', + 5, + NOW () + ); diff --git a/mysql-code/Schema.sql b/mysql-code/Schema.sql index df51a58..9764c48 100644 --- a/mysql-code/Schema.sql +++ b/mysql-code/Schema.sql @@ -51,7 +51,7 @@ CREATE TABLE Image_URL ( -- Fixed Review Entity (Many-to-One with User, Many-to-One with Product) CREATE TABLE Review ( - ReviewID INT PRIMARY KEY, + ReviewID INT AUTO_INCREMENT PRIMARY KEY, UserID INT, ProductID INT, Comment TEXT, @@ -61,7 +61,7 @@ CREATE TABLE Review ( ), Date DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (UserID) REFERENCES User (UserID), - FOREIGN KEY (ProductID) REFERENCES Pprint(item[0])roduct (ProductID) + FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ); -- Transaction Entity (Many-to-One with User, Many-to-One with Product) @@ -270,16 +270,16 @@ CREATE TABLE AuthVerification ( -- -- WHERE -- -- CategoryID = 6; -- -- -- REVIEW OPERATIONS --- -- INSERT INTO --- -- Review (ReviewID, UserID, ProductID, Comment, Rating) --- -- VALUES --- -- ( --- -- 1, --- -- 1, --- -- 1, --- -- 'Great product, very satisfied with the purchase!', --- -- 5 --- -- ); +-- INSERT INTO +-- Review (ReviewID, UserID, ProductID, Comment, Rating) +-- VALUES +-- ( +-- 1, +-- 1, +-- 1, +-- 'Great product, very satisfied with the purchase!', +-- 5 +-- ); -- -- -- TRANSACTION OPERATIONS -- -- INSERT INTO -- -- Transaction (TransactionID, UserID, ProductID, PaymentStatus) From d8ed58f572111adffce691421203b0e5b58cbdfb Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Fri, 4 Apr 2025 00:21:10 -0600 Subject: [PATCH 15/37] Update recommendation.js --- backend/controllers/recommendation.js | 61 --------------------------- 1 file changed, 61 deletions(-) diff --git a/backend/controllers/recommendation.js b/backend/controllers/recommendation.js index 5518d46..63c9516 100644 --- a/backend/controllers/recommendation.js +++ b/backend/controllers/recommendation.js @@ -51,64 +51,3 @@ exports.RecommondationByUserId = async (req, res) => { }); } }; - -// Add this to your existing controller file -exports.submitReview = async (req, res) => { - const { productId, reviewerName, rating, comment } = req.body; - - // Validate required fields - if (!productId || !reviewerName || !rating || !comment) { - return res.status(400).json({ - success: false, - message: "Missing required fields", - }); - } - - try { - // Insert the review into the database - const [result] = await db.execute( - ` - INSERT INTO Review ( - ProductID, - ReviewerName, - Rating, - Comment, - ReviewDate - ) VALUES (?, ?, ?, ?, NOW()) - `, - [productId, reviewerName, rating, comment], - ); - - // Get the inserted review id - const reviewId = result.insertId; - - // Fetch the newly created review to return to client - const [newReview] = await db.execute( - ` - SELECT - ReviewID as id, - ProductID, - ReviewerName, - Rating, - Comment, - ReviewDate - FROM Review - WHERE ReviewID = ? - `, - [reviewId], - ); - - res.status(201).json({ - success: true, - message: "Review submitted successfully", - data: newReview[0], - }); - } catch (error) { - console.error("Error submitting review:", error); - return res.status(500).json({ - success: false, - message: "Database error occurred", - error: error.message, - }); - } -}; From 10f0469b56944826af171d1f87e2962d001b1fd0 Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Sat, 12 Apr 2025 11:27:27 -0600 Subject: [PATCH 16/37] added review functionality --- backend/controllers/product.js | 1 + frontend/src/pages/Home.jsx | 24 ++++++ frontend/src/pages/ProductDetail.jsx | 108 +++++++-------------------- frontend/src/pages/Settings.jsx | 54 ++++++++++++++ recommondation-engine/app.py | 34 +++++++-- recommondation-engine/server.py | 7 +- 6 files changed, 139 insertions(+), 89 deletions(-) diff --git a/backend/controllers/product.js b/backend/controllers/product.js index c8f9885..b031c3f 100644 --- a/backend/controllers/product.js +++ b/backend/controllers/product.js @@ -14,6 +14,7 @@ exports.addToFavorite = async (req, res) => { success: true, message: "Product added to favorites successfully", }); + console.log(result); } catch (error) { console.error("Error adding favorite product:", error); return res.json({ error: "Could not add favorite product" }); diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 92f3f3c..555744e 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -8,6 +8,29 @@ const Home = () => { const [recommended, setRecommended] = useState([]); const [history, sethistory] = useState([]); const [error, setError] = useState(null); + const storedUser = JSON.parse(sessionStorage.getItem("user")); + + const handleLinkClick = async (id) => { + // Example: append to localStorage or call analytics + const response = await fetch( + "http://localhost:3030/api/product/add_fav_product", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userID: storedUser.ID, + productsID: id, + }), + }, + ); + + if (!response.ok) throw new Error("Failed to fetch products"); + + console.log(response); + console.log(`Add Product -> History: ${id}`); + }; useEffect(() => { const fetchrecomProducts = async () => { @@ -298,6 +321,7 @@ const Home = () => { handleLinkClick(listing.id)} className="bg-white border border-gray-200 hover:shadow-md transition-shadow w-70 flex-shrink-0 relative" >
diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx index b64a998..b8dbc47 100644 --- a/frontend/src/pages/ProductDetail.jsx +++ b/frontend/src/pages/ProductDetail.jsx @@ -14,7 +14,7 @@ const ProductDetail = () => { reviews: null, }); const [isFavorite, setIsFavorite] = useState(false); - const [showContactForm, setShowContactForm] = useState(false); + const [contactForm, showContactForm, setShowContactForm] = useState(false); const [currentImage, setCurrentImage] = useState(0); const [reviews, setReviews] = useState([]); const [showReviewForm, setShowReviewForm] = useState(false); @@ -417,81 +417,15 @@ const ProductDetail = () => {

- - - {showContactForm && ( -
-

- Contact Seller -

-
-
- - -
-
- - -
-
- - +
+ + {/* Simplified Image Upload */} +
+ + + {/* Simple file input */} + -
- {editingProduct.images.length > 0 && - editingProduct.images.map((img, idx) => ( -
- {`Preview - +
+ ))} + {editingProduct.images.length > 0 && ( + -
- ))} -
+ )} +
+

+ )}
{/* Actions */} -
+
diff --git a/frontend/src/pages/Favorites.jsx b/frontend/src/pages/Favorites.jsx index 868eaf1..55b191d 100644 --- a/frontend/src/pages/Favorites.jsx +++ b/frontend/src/pages/Favorites.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { Link } from "react-router-dom"; -import { Heart, Tag, Trash2, Filter, ChevronDown } from "lucide-react"; +import { Heart, Tag, Trash2 } from "lucide-react"; const Favorites = () => { const [favorites, setFavorites] = useState([]); diff --git a/frontend/src/pages/MyListings.jsx b/frontend/src/pages/MyListings.jsx deleted file mode 100644 index 6cafcc7..0000000 --- a/frontend/src/pages/MyListings.jsx +++ /dev/null @@ -1,550 +0,0 @@ -import { useState, useEffect } from "react"; -import { Link, useParams, useNavigate } from "react-router-dom"; -import { ArrowLeft, Plus, X, Save, Trash } from "lucide-react"; - -const ItemForm = () => { - const { id } = useParams(); // If id exists, we are editing, otherwise creating - const navigate = useNavigate(); - const isEditing = !!id; - - const [formData, setFormData] = useState({ - title: "", - price: "", - category: "", - condition: "", - shortDescription: "", - description: "", - images: [], - status: "active", - }); - - const [originalData, setOriginalData] = useState(null); - const [errors, setErrors] = useState({}); - const [imagePreviewUrls, setImagePreviewUrls] = useState([]); - const [isLoading, setIsLoading] = useState(isEditing); - const [isSubmitting, setIsSubmitting] = useState(false); - const [showDeleteModal, setShowDeleteModal] = useState(false); - - // Categories with icons - const categories = [ - "Electronics", - "Furniture", - "Books", - "Kitchen", - "Collectibles", - "Clothing", - "Sports & Outdoors", - "Tools", - "Toys & Games", - "Other", - ]; - - // Condition options - const conditions = ["New", "Like New", "Good", "Fair", "Poor"]; - - // Status options - const statuses = ["active", "inactive", "sold", "pending"]; - - // Fetch item data if editing - useEffect(() => { - if (isEditing) { - // This would be an API call in a real app - // Simulating API call with timeout - setTimeout(() => { - // Sample data for item being edited - const itemData = { - id: parseInt(id), - title: "Dell XPS 13 Laptop - 2023 Model", - price: 850, - category: "Electronics", - condition: "Like New", - shortDescription: - "Dell XPS 13 laptop in excellent condition. Intel Core i7, 16GB RAM, 512GB SSD. Includes charger and original box.", - description: - "Selling my Dell XPS 13 laptop. Only 6 months old and in excellent condition. Intel Core i7 processor, 16GB RAM, 512GB SSD. Battery life is still excellent (around 10 hours of regular use). Comes with original charger and box. Selling because I'm upgrading to a MacBook for design work.\n\nSpecs:\n- Intel Core i7 11th Gen\n- 16GB RAM\n- 512GB NVMe SSD\n- 13.4\" FHD+ Display (1920x1200)\n- Windows 11 Pro\n- Backlit Keyboard\n- Thunderbolt 4 ports", - images: ["/image1.avif", "/image2.avif", "/image3.avif"], - status: "active", - datePosted: "2023-03-02", - }; - - setFormData(itemData); - setOriginalData(itemData); - setImagePreviewUrls(itemData.images); - setIsLoading(false); - }, 1000); - } - }, [id, isEditing]); - - const handleChange = (e) => { - const { name, value } = e.target; - setFormData({ - ...formData, - [name]: value, - }); - - // Clear error when field is edited - if (errors[name]) { - setErrors({ - ...errors, - [name]: null, - }); - } - }; - - const handleImageChange = (e) => { - e.preventDefault(); - - const files = Array.from(e.target.files); - - if (formData.images.length + files.length > 5) { - setErrors({ - ...errors, - images: "Maximum 5 images allowed", - }); - return; - } - - // Create preview URLs for the images - const newImagePreviewUrls = [...imagePreviewUrls]; - const newImages = [...formData.images]; - - files.forEach((file) => { - const reader = new FileReader(); - reader.onloadend = () => { - newImagePreviewUrls.push(reader.result); - setImagePreviewUrls(newImagePreviewUrls); - }; - reader.readAsDataURL(file); - newImages.push(file); - }); - - setFormData({ - ...formData, - images: newImages, - }); - - // Clear error if any - if (errors.images) { - setErrors({ - ...errors, - images: null, - }); - } - }; - - const removeImage = (index) => { - const newImages = [...formData.images]; - const newImagePreviewUrls = [...imagePreviewUrls]; - - newImages.splice(index, 1); - newImagePreviewUrls.splice(index, 1); - - setFormData({ - ...formData, - images: newImages, - }); - setImagePreviewUrls(newImagePreviewUrls); - }; - - const validateForm = () => { - const newErrors = {}; - - if (!formData.title.trim()) newErrors.title = "Title is required"; - if (!formData.price) newErrors.price = "Price is required"; - if (isNaN(formData.price) || formData.price <= 0) - newErrors.price = "Price must be a positive number"; - if (!formData.category) newErrors.category = "Category is required"; - if (!formData.condition) newErrors.condition = "Condition is required"; - if (!formData.shortDescription.trim()) - newErrors.shortDescription = "Short description is required"; - if (!formData.description.trim()) - newErrors.description = "Description is required"; - if (formData.images.length === 0) - newErrors.images = "At least one image is required"; - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleSubmit = (e) => { - e.preventDefault(); - - if (!validateForm()) { - // Scroll to the first error - const firstErrorField = Object.keys(errors)[0]; - document - .getElementsByName(firstErrorField)[0] - ?.scrollIntoView({ behavior: "smooth" }); - return; - } - - setIsSubmitting(true); - - // Simulate API call to post/update the item - setTimeout(() => { - console.log("Form submitted:", formData); - setIsSubmitting(false); - - // Show success and redirect to listings - alert(`Item successfully ${isEditing ? "updated" : "created"}!`); - navigate("/selling"); - }, 1500); - }; - - const handleDelete = () => { - setIsSubmitting(true); - - // Simulate API call to delete the item - setTimeout(() => { - console.log("Item deleted:", id); - setIsSubmitting(false); - setShowDeleteModal(false); - - // Show success and redirect to listings - alert("Item successfully deleted!"); - navigate("/selling"); - }, 1500); - }; - - // Show loading state if necessary - if (isLoading) { - return ( -
-
- - - Back to listings - -
-
-
-
-
-
-
- ); - } - - return ( -
- {/* Breadcrumb & Back Link */} -
- - - Back to listings - -
- -
-

- {isEditing ? "Edit Item" : "Create New Listing"} -

- - {isEditing && ( - - )} -
- - - {/* Title */} -
- - - {errors.title && ( -

{errors.title}

- )} -
- - {/* Price, Category, Status (side by side on larger screens) */} -
-
- - - {errors.price && ( -

{errors.price}

- )} -
- -
- - - {errors.category && ( -

{errors.category}

- )} -
- - {isEditing && ( -
- - -
- )} -
- - {/* Condition */} -
- -
- {conditions.map((condition) => ( - - ))} -
- {errors.condition && ( -

{errors.condition}

- )} -
- - {/* Short Description */} -
- - -

- {formData.shortDescription.length}/150 characters -

- {errors.shortDescription && ( -

{errors.shortDescription}

- )} -
- - {/* Full Description */} -
- - -

- Use blank lines to separate paragraphs. -

- {errors.description && ( -

{errors.description}

- )} -
- - {/* Image Upload */} -
- - - {/* Image Preview Area */} -
- {imagePreviewUrls.map((url, index) => ( -
- {`Preview - -
- ))} - - {/* Upload Button (only show if less than 5 images) */} - {formData.images.length < 5 && ( - - )} -
- {errors.images && ( -

{errors.images}

- )} -
- - {/* Submit Button */} -
- -
- - - {/* Delete Confirmation Modal */} - {showDeleteModal && ( -
-
-

- Delete Listing -

-

- Are you sure you want to delete {formData.title}? - This action cannot be undone. -

-
- - -
-
-
- )} -
- ); -}; - -export default ItemForm; diff --git a/frontend/src/pages/SearchPage.jsx b/frontend/src/pages/SearchPage.jsx index 0bcd006..0885ab1 100644 --- a/frontend/src/pages/SearchPage.jsx +++ b/frontend/src/pages/SearchPage.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from "react"; -import { Filter, Grid, Heart, Tag, X } from "lucide-react"; +import { useState, useEffect } from "react"; +import { X } from "lucide-react"; import { useLocation, Link } from "react-router-dom"; import axios from "axios"; diff --git a/frontend/src/pages/Selling.jsx b/frontend/src/pages/Selling.jsx index 3495698..99de591 100644 --- a/frontend/src/pages/Selling.jsx +++ b/frontend/src/pages/Selling.jsx @@ -1,152 +1,221 @@ -import { useState } from "react"; -import { Pencil, Trash2, Plus } from "lucide-react"; +import { useState, useEffect } from "react"; import ProductForm from "../components/ProductForm"; const Selling = () => { - const [products, setProducts] = useState([ - { - id: 1, - name: "Green Sofa", - price: 299, - status: "Active", - images: [], - }, - { - id: 2, - name: "Wooden Table", - price: 150, - status: "Inactive", - images: [], - }, - ]); + // State to store user's products + const [products, setProducts] = useState([]); + // State to control when editing form is shown + const [showForm, setShowForm] = useState(false); + // State to store the product being edited (or empty for new product) + const [editingProduct, setEditingProduct] = useState({ + name: "", + price: "", + description: "", + categories: [], + status: "Unsold", + images: [], + }); - const [editingProduct, setEditingProduct] = useState(null); - const [view, setView] = useState("list"); // "list" or "form" + // Simulate fetching products from API/database on component mount + useEffect(() => { + // This would be replaced with a real API call + const fetchProducts = async () => { + // Mock data + const mockProducts = [ + { + id: "1", + name: "Vintage Camera", + price: "299.99", + description: "A beautiful vintage film camera in excellent condition", + categories: ["Electronics", "Art & Collectibles"], + status: "Unsold", + images: ["/public/Pictures/Dell1.jpg"], + }, + { + id: "2", + name: "Leather Jacket", + price: "149.50", + description: "Genuine leather jacket, worn only a few times", + categories: ["Clothing"], + status: "Unsold", + images: [], + }, + ]; - const handleEdit = (product) => { - setEditingProduct({ ...product }); - setView("form"); - }; + setProducts(mockProducts); + }; - const handleAddNew = () => { - setEditingProduct({ - id: null, - name: "", - price: "", - status: "Active", - images: [], - }); - setView("form"); - }; + fetchProducts(); + }, []); - const handleDelete = (id) => { - setProducts((prev) => prev.filter((p) => p.id !== id)); - }; - - const handleSave = () => { - if (!editingProduct.name || !editingProduct.price) { - alert("Please enter a name and price."); - return; - } - if (editingProduct.images.length < 1) { - alert("Please upload at least one image."); - return; - } - - if (editingProduct.id === null) { + // Handle creating or updating a product + const handleSaveProduct = () => { + if (editingProduct.id) { + // Update existing product + setProducts( + products.map((p) => (p.id === editingProduct.id ? editingProduct : p)), + ); + } else { + // Create new product const newProduct = { ...editingProduct, - id: Date.now(), + id: Date.now().toString(), // Generate a temporary ID }; - setProducts((prev) => [newProduct, ...prev]); - } else { - setProducts((prev) => - prev.map((p) => (p.id === editingProduct.id ? editingProduct : p)), - ); + setProducts([...products, newProduct]); } - setEditingProduct(null); - setView("list"); + // Reset form and hide it + setShowForm(false); + setEditingProduct({ + name: "", + price: "", + description: "", + categories: [], + status: "Unsold", + images: [], + }); }; - const handleCancel = () => { - setEditingProduct(null); - setView("list"); + // Handle product deletion + const handleDeleteProduct = (productId) => { + if (window.confirm("Are you sure you want to delete this product?")) { + setProducts(products.filter((p) => p.id !== productId)); + } + }; + + // Handle editing a product + const handleEditProduct = (product) => { + setEditingProduct({ + ...product, + images: product.images || [], // Ensure images array exists + }); + setShowForm(true); + }; + + // Handle adding a new product + const handleAddProduct = () => { + setEditingProduct({ + name: "", + price: "", + description: "", + categories: [], + status: "Unsold", + images: [], + }); + setShowForm(true); }; return ( -
- {view === "list" && ( - <> -
-

My Listings

- -
+
+
+

My Listings

+ {!showForm && ( + + )} +
-
    - {products.map((product) => ( -
  • -
    -
    - {product.images.length > 0 ? ( - Product - ) : ( - No Image - )} -
    -
    -

    {product.name}

    -

    ${product.price}

    -

    - {product.status} -

    -
    -
    -
    - - -
    -
  • - ))} -
- - )} - - {view === "form" && ( + {showForm ? ( setShowForm(false)} /> + ) : ( + <> + {products.length === 0 ? ( +
+

+ You don't have any listings yet +

+ +
+ ) : ( +
+ {products.map((product) => ( +
+
+ {product.images && product.images.length > 0 ? ( + {product.name} + ) : ( +
No image
+ )} +
+ +
+
+

+ {product.name} +

+ + {product.status} + +
+ +

+ ${product.price} +

+ + {product.categories && product.categories.length > 0 && ( +
+ {product.categories.map((category) => ( + + {category} + + ))} +
+ )} + +

+ {product.description} +

+ +
+ + +
+
+
+ ))} +
+ )} + )}
); diff --git a/frontend/src/pages/Transactions.jsx b/frontend/src/pages/Transactions.jsx index 800f0b7..c38b5fc 100644 --- a/frontend/src/pages/Transactions.jsx +++ b/frontend/src/pages/Transactions.jsx @@ -1,13 +1,8 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { Tag, Book, Laptop, Sofa, Utensils, Gift, Heart } from 'lucide-react'; +import { useState } from "react"; +import { Link } from "react-router-dom"; const Transactions = () => { - return ( -
- -
- ); + return
; }; -export default Transactions; \ No newline at end of file +export default Transactions; From fdf63f4e6ae95917e80377ec74b454d331cbbc3a Mon Sep 17 00:00:00 2001 From: Mann Patel <130435633+MannPatel0@users.noreply.github.com> Date: Mon, 14 Apr 2025 22:18:56 -0600 Subject: [PATCH 26/37] code clean up --- frontend/src/pages/Home.jsx | 10 +++++----- frontend/src/pages/SearchPage.jsx | 2 +- frontend/src/pages/Settings.jsx | 2 +- package-lock.json | 6 ------ .../__pycache__/app.cpython-313.pyc | Bin 5732 -> 0 bytes .../__pycache__/example1.cpython-313.pyc | Bin 3997 -> 0 bytes .../__pycache__/server.cpython-313.pyc | Bin 1595 -> 0 bytes 7 files changed, 7 insertions(+), 13 deletions(-) delete mode 100644 package-lock.json delete mode 100644 recommondation-engine/__pycache__/app.cpython-313.pyc delete mode 100644 recommondation-engine/__pycache__/example1.cpython-313.pyc delete mode 100644 recommondation-engine/__pycache__/server.cpython-313.pyc diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 315bf3b..90b7009 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -10,9 +10,12 @@ const Home = () => { const [recommended, setRecommended] = useState([]); const [history, sethistory] = useState([]); const [error, setError] = useState(null); - const storedUser = JSON.parse(sessionStorage.getItem("user")); const [showAlert, setShowAlert] = useState(false); + //After user data storing the session. + const storedUser = JSON.parse(sessionStorage.getItem("user")); + + const toggleFavorite = async (id) => { const response = await fetch( "http://localhost:3030/api/product/addFavorite", @@ -63,8 +66,6 @@ const Home = () => { useEffect(() => { const fetchrecomProducts = async () => { - // Get the user's data from localStorage - console.log(storedUser); try { const response = await fetch( "http://localhost:3030/api/engine/recommended", @@ -95,6 +96,7 @@ const Home = () => { isFavorite: false, // Default state })), ); + reloadPage(); } else { throw new Error(data.message || "Error fetching products"); } @@ -104,9 +106,7 @@ const Home = () => { } }; fetchrecomProducts(); - //reloadPage(); }, []); - reloadPage(); useEffect(() => { const fetchProducts = async () => { diff --git a/frontend/src/pages/SearchPage.jsx b/frontend/src/pages/SearchPage.jsx index 0885ab1..f2fd9cc 100644 --- a/frontend/src/pages/SearchPage.jsx +++ b/frontend/src/pages/SearchPage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; -import { X } from "lucide-react"; import { useLocation, Link } from "react-router-dom"; +import { X } from "lucide-react"; import axios from "axios"; const SearchPage = () => { diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 42751fd..9dc30f1 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { User, Lock, Trash2, History, Search, Shield } from "lucide-react"; +import { User, Lock, Trash2, History, Shield } from "lucide-react"; import FloatingAlert from "../components/FloatingAlert"; // adjust path if needed const Settings = () => { diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index acfb825..0000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Campus-Plug", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/recommondation-engine/__pycache__/app.cpython-313.pyc b/recommondation-engine/__pycache__/app.cpython-313.pyc deleted file mode 100644 index 585d78d02bba7ebf7bec25974be353f56330e8cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5732 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 TYM*pbP1F#z`EnB#vRD5XK$FJb diff --git a/recommondation-engine/__pycache__/example1.cpython-313.pyc b/recommondation-engine/__pycache__/example1.cpython-313.pyc deleted file mode 100644 index e8fb9638b1295d56befa8c760ee3b31a69fe1853..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3997 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 S4l)U*<*$QG2Q$jJzWxQ4-+VFv diff --git a/recommondation-engine/__pycache__/server.cpython-313.pyc b/recommondation-engine/__pycache__/server.cpython-313.pyc deleted file mode 100644 index 4f9faf72f3a6b5e3b07c6c4d8c2c15b08c6db95f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1595 zcmZ`(&2Jk;6rcU_u46l!k2YzND%k|1#Hg*4Hea<0C~1S^RHRilmY|7PZSAqM)ZR5S zyAkmLrGQi)(?eBrfr?YO<;bx_`~lkCT#%hzCPUlQg4P=U%^2P+I>R#?QYaEMdr9ebT$5s;vOO=b*i z`evBpS4i9x$XRTjiCkRkZbG+rl~egBqY9T<)i(%c8Up0s^r>R(9RTzmKwqA2zi4u* zr25qkHE@~ukqu^pkJpSs9HMQ6s)eA<{ z!4O3;#MrP*kH2|CT_*}+9@<-08mF3O=q`~z$quM}M522DhQS8Ct*fRvacs zf;FKnX>uyl%!|pqM1yTCDfnfI>Dt@O_di>NaoupVK53d>YYS!M4*oYVDc}Z29rENYQ=N{ ziC05yB(-yz#q0>_ER$d0!;L+|WWej(2*@xIdDZjL$|cgC0Q0($^JD`rIQAAaZ6l@I zklrI?p<*y$ZtFQCt)z8ZH}**Bipm;HL1P=Hl}6p`vY3)2fJ)+8Oey-$DN7WUu-)gB z6Sy&jb_d3K78b|UY;RBD)(2qMoYrc(8w3_pUu1Bnaa?tE1mgW?)x0N<8x2O-+b0P zSdKq>&mEes^v)b$w>RbnW6!0K+ckO6f8hUm;PK4kaW`?x-MHi4%DJ8S?JW{ zZ71vTTNar;e8EEYY9!zZ89J2ckOlSH9~Zl$eygP{gb|&$6xY=AP}4k4FBCihS&j|s ziS{_;m|uY!(E_Nyu6reKrJWpn1HDR)bns%GkSfP8%+Db51VnxUL%;e0rR7hoUG7J~ z@2a2re_{B019u19!0>+GzO*~wGE;xCGBa1>0DG}Yx#}3;gQa4H8#v;{d-1#Ry>R*6 S{q^1WQ~qL=1N Date: Tue, 15 Apr 2025 00:18:19 -0600 Subject: [PATCH 27/37] fav product from prodDetail page --- backend/controllers/product.js | 73 ++++++++++++++++------------ backend/index.js | 19 ++++---- frontend/src/components/Navbar.jsx | 4 +- frontend/src/pages/Home.jsx | 4 -- frontend/src/pages/ProductDetail.jsx | 70 +++++++++++++------------- frontend/src/pages/SearchPage.jsx | 3 ++ 6 files changed, 91 insertions(+), 82 deletions(-) diff --git a/backend/controllers/product.js b/backend/controllers/product.js index c5a790c..d594f89 100644 --- a/backend/controllers/product.js +++ b/backend/controllers/product.js @@ -47,14 +47,29 @@ exports.getFavorites = async (req, res) => { const [favorites] = await db.execute( ` SELECT - p.*, - u.Name AS SellerName, - i.URL AS image_url - FROM Favorites f - JOIN Product p ON f.ProductID = p.ProductID - JOIN User u ON p.UserID = u.UserID - LEFT JOIN Image_URL i ON p.ProductID = i.ProductID - WHERE f.UserID = ? + p.ProductID, + p.Name, + p.Description, + p.Price, + p.CategoryID, + p.UserID, + p.Date, + u.Name AS SellerName, + MIN(i.URL) AS image_url + FROM Favorites f + JOIN Product p ON f.ProductID = p.ProductID + JOIN User u ON p.UserID = u.UserID + LEFT JOIN Image_URL i ON p.ProductID = i.ProductID + WHERE f.UserID = ? + GROUP BY + p.ProductID, + p.Name, + p.Description, + p.Price, + p.CategoryID, + p.UserID, + p.Date, + u.Name; `, [userID], ); @@ -73,31 +88,25 @@ exports.getFavorites = async (req, res) => { exports.getAllProducts = async (req, res) => { try { const [data, fields] = await db.execute(` - WITH RankedImages AS ( - SELECT - P.ProductID, - P.Name AS ProductName, - P.Price, - P.Date AS DateUploaded, - U.Name AS SellerName, - I.URL AS ProductImage, - C.Name AS Category, - ROW_NUMBER() OVER (PARTITION BY P.ProductID ORDER BY I.URL) AS RowNum - FROM Product P - JOIN Image_URL I ON P.ProductID = I.ProductID - JOIN User U ON P.UserID = U.UserID - JOIN Category C ON P.CategoryID = C.CategoryID - ) SELECT - ProductID, - ProductName, - Price, - DateUploaded, - SellerName, - ProductImage, - Category - FROM RankedImages - WHERE RowNum = 1; + P.ProductID, + P.Name AS ProductName, + P.Price, + P.Date AS DateUploaded, + U.Name AS SellerName, + MIN(I.URL) AS ProductImage, + C.Name AS Category +FROM Product P +JOIN Image_URL I ON P.ProductID = I.ProductID +JOIN User U ON P.UserID = U.UserID +JOIN Category C ON P.CategoryID = C.CategoryID +GROUP BY + P.ProductID, + P.Name, + P.Price, + P.Date, + U.Name, + C.Name; `); res.json({ diff --git a/backend/index.js b/backend/index.js index 0d0dbcb..7afa3fe 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,6 +1,6 @@ const express = require("express"); const cors = require("cors"); -//Get the db connection + const db = require("./utils/database"); const userRouter = require("./routes/user"); @@ -33,19 +33,20 @@ transporter console.error("Email connection failed:", error); }); -//Check database connection checkDatabaseConnection(db); //Routes -app.use("/api/user", userRouter); //prefix with /api/user -app.use("/api/product", productRouter); //prefix with /api/product -app.use("/api/search", searchRouter); //prefix with /api/product -app.use("/api/engine", recommendedRouter); //prefix with /api/ -app.use("/api/history", history); //prefix with /api/ -app.use("/api/review", review); //prefix with /api/ +app.use("/api/user", userRouter); +app.use("/api/product", productRouter); +app.use("/api/search", searchRouter); +app.use("/api/engine", recommendedRouter); +app.use("/api/history", history); +app.use("/api/review", review); + // Set up a scheduler to run cleanup every hour -setInterval(cleanupExpiredCodes, 60 * 60 * 1000); +clean_up_time = 30*60*1000; +setInterval(cleanupExpiredCodes, clean_up_time); app.listen(3030, () => { console.log(`Running Backend on http://localhost:3030/`); diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 827399e..711ad26 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -47,8 +47,8 @@ const Navbar = ({ onLogout, userName }) => {
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 90b7009..f4b82e6 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -34,7 +34,6 @@ const Home = () => { if (data.success) { setShowAlert(true); } - console.log(response); console.log(`Add Product -> History: ${id}`); }; @@ -82,7 +81,6 @@ const Home = () => { if (!response.ok) throw new Error("Failed to fetch products"); const data = await response.json(); - console.log(data); if (data.success) { setRecommended( data.data.map((product) => ({ @@ -145,7 +143,6 @@ const Home = () => { useEffect(() => { const fetchrecomProducts = async () => { // Get the user's data from localStorage - console.log(storedUser); try { const response = await fetch( "http://localhost:3030/api/history/getHistory", @@ -162,7 +159,6 @@ const Home = () => { if (!response.ok) throw new Error("Failed to fetch products"); const data = await response.json(); - console.log(data); if (data.success) { sethistory( data.data.map((product) => ({ diff --git a/frontend/src/pages/ProductDetail.jsx b/frontend/src/pages/ProductDetail.jsx index 711d1f3..eb1cd01 100644 --- a/frontend/src/pages/ProductDetail.jsx +++ b/frontend/src/pages/ProductDetail.jsx @@ -10,6 +10,8 @@ import { Phone, Mail, } from "lucide-react"; +import FloatingAlert from "../components/FloatingAlert"; // adjust path if needed + const ProductDetail = () => { const { id } = useParams(); @@ -29,8 +31,32 @@ const ProductDetail = () => { const [currentImage, setCurrentImage] = useState(0); const [reviews, setReviews] = useState([]); const [showReviewForm, setShowReviewForm] = useState(false); + const [showAlert, setShowAlert] = useState(false); const storedUser = JSON.parse(sessionStorage.getItem("user")); + + const toggleFavorite = async (id) => { + const response = await fetch( + "http://localhost:3030/api/product/addFavorite", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + userID: storedUser.ID, + productID: id, + }), + }, + ); + const data = await response.json(); + if (data.success) { + setShowAlert(true); + } + console.log(`Add Product -> History: ${id}`); + }; + + const [reviewForm, setReviewForm] = useState({ rating: 3, comment: "", @@ -68,7 +94,7 @@ const ProductDetail = () => { userId: storedUser.ID, }; - const response = await fetch(`http://localhost:3030/api/review/add`, { + const response = await fetch(`http://localhost:3030/api/review/addReview`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reviewData), @@ -182,38 +208,6 @@ const ProductDetail = () => { fetchReviews(); }, [id]); - // Handle favorite toggle with error handling - const toggleFavorite = async () => { - try { - const response = await fetch( - "http://localhost:3030/api/product/add_to_favorite", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - userID: 1, // Replace with actual user ID - productsID: id, - }), - }, - ); - - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - - const result = await response.json(); - if (result.success) { - setIsFavorite(!isFavorite); - } else { - throw new Error(result.message || "Failed to toggle favorite"); - } - } catch (error) { - console.error("Error toggling favorite:", error); - alert(`Failed to add to favorites: ${error.message}`); - } - }; // Image navigation const nextImage = () => { @@ -296,6 +290,7 @@ const ProductDetail = () => { // Render product details return ( +
{ Back
+ {showAlert && ( + setShowAlert(false)} + /> + )}
@@ -370,7 +371,6 @@ const ProductDetail = () => {
)}
-
@@ -378,7 +378,7 @@ const ProductDetail = () => { {product.Name || "Unnamed Product"}
@@ -70,7 +70,7 @@ const Navbar = ({ onLogout, userName }) => { {/* Favorites Button */} diff --git a/frontend/src/components/ProductForm.jsx b/frontend/src/components/ProductForm.jsx index f594d24..9885d07 100644 --- a/frontend/src/components/ProductForm.jsx +++ b/frontend/src/components/ProductForm.jsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { X, ChevronLeft, Plus, Trash2 } from "lucide-react"; const ProductForm = ({ editingProduct, @@ -50,63 +51,64 @@ const ProductForm = ({ }; return ( -
+
{/* Back Button */} -

+

{editingProduct?.id ? "Edit Your Product" : "List a New Product"}

-
+
{/* Product Name */}
-
{/* Price */}
-
- {/* Categories - Dropdown with Add button */} + {/* Categories */}
-