added Review Feature

This commit is contained in:
Mann Patel
2025-04-04 00:02:04 -06:00
parent 643b9e357c
commit 75c7675601
13 changed files with 925 additions and 140 deletions

View File

@@ -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",
});
}
};

View File

@@ -24,18 +24,31 @@ exports.addToFavorite = async (req, res) => {
exports.getAllProducts = async (req, res) => { exports.getAllProducts = async (req, res) => {
try { try {
const [data, fields] = await db.execute(` 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 SELECT
P.ProductID, ProductID,
P.Name AS ProductName, ProductName,
P.Price, Price,
P.Date AS DateUploaded, DateUploaded,
U.Name AS SellerName, SellerName,
I.URL AS ProductImage, ProductImage,
C.Name AS Category Category
FROM Product P FROM RankedImages
JOIN Image_URL I ON P.ProductID = I.ProductID WHERE RowNum = 1;
JOIN User U ON P.UserID = U.UserID
JOIN Category C ON P.CategoryID = C.CategoryID;
`); `);
console.log(data); console.log(data);

View File

@@ -6,20 +6,34 @@ exports.RecommondationByUserId = async (req, res) => {
try { try {
const [data, fields] = await db.execute( 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 SELECT
P.ProductID, ProductID,
P.Name AS ProductName, ProductName,
P.Price, Price,
P.Date AS DateUploaded, DateUploaded,
U.Name AS SellerName, SellerName,
I.URL AS ProductImage, ProductImage,
C.Name AS Category Category
FROM Product P FROM RankedImages
JOIN Image_URL I ON P.ProductID = I.ProductID WHERE RowNum = 1;
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], [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,
});
}
};

View File

@@ -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,
});
}
};

View File

@@ -7,6 +7,9 @@ const userRouter = require("./routes/user");
const productRouter = require("./routes/product"); const productRouter = require("./routes/product");
const searchRouter = require("./routes/search"); const searchRouter = require("./routes/search");
const recommendedRouter = require("./routes/recommendation"); const recommendedRouter = require("./routes/recommendation");
const history = require("./routes/history");
const review = require("./routes/review");
const { generateEmailTransporter } = require("./utils/mail"); const { generateEmailTransporter } = require("./utils/mail");
const { const {
cleanupExpiredCodes, 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/product", productRouter); //prefix with /api/product
app.use("/api/search_products", searchRouter); //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/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 // Set up a scheduler to run cleanup every hour
setInterval(cleanupExpiredCodes, 60 * 60 * 1000); setInterval(cleanupExpiredCodes, 60 * 60 * 1000);

View File

@@ -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;

9
backend/routes/review.js Normal file
View File

@@ -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;

BIN
frontend/public/image8.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -6,6 +6,7 @@ const Home = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [listings, setListings] = useState([]); const [listings, setListings] = useState([]);
const [recommended, setRecommended] = useState([]); const [recommended, setRecommended] = useState([]);
const [history, sethistory] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
@@ -38,7 +39,6 @@ const Home = () => {
price: product.Price, price: product.Price,
category: product.Category, // Ensure this gets the category name category: product.Category, // Ensure this gets the category name
image: product.ProductImage, // Use the alias for image URL image: product.ProductImage, // Use the alias for image URL
condition: "New", // Modify based on actual data
seller: product.SellerName, // Fetch seller name properly seller: product.SellerName, // Fetch seller name properly
datePosted: product.DateUploaded, // Use the actual date datePosted: product.DateUploaded, // Use the actual date
isFavorite: false, // Default state isFavorite: false, // Default state
@@ -73,7 +73,6 @@ const Home = () => {
price: product.Price, price: product.Price,
category: product.Category, // Ensure this gets the category name category: product.Category, // Ensure this gets the category name
image: product.ProductImage, // Use the alias for image URL image: product.ProductImage, // Use the alias for image URL
condition: "New", // Modify based on actual data
seller: product.SellerName, // Fetch seller name properly seller: product.SellerName, // Fetch seller name properly
datePosted: product.DateUploaded, // Use the actual date datePosted: product.DateUploaded, // Use the actual date
isFavorite: false, // Default state isFavorite: false, // Default state
@@ -90,6 +89,49 @@ const Home = () => {
fetchProducts(); 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 // Toggle favorite status
const toggleFavorite = (id, e) => { const toggleFavorite = (id, e) => {
e.preventDefault(); // Prevent navigation when clicking the heart icon e.preventDefault(); // Prevent navigation when clicking the heart icon
@@ -138,26 +180,6 @@ const Home = () => {
</div> </div>
</div> </div>
{/* Categories */}
{/* <div className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 mb-4">Categories</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{categories.map((category) => (
<button
key={category.id}
className="flex flex-col items-center justify-center p-4 bg-white border border-gray-200 hover:border-green-500 hover:shadow-sm"
>
<div className="flex items-center justify-center w-12 h-12 bg-green-50 text-green-600 rounded-full mb-2">
{category.icon}
</div>
<span className="text-sm font-medium text-gray-700">
{category.name}
</span>
</button>
))}
</div>
</div> */}
{/* Recent Listings */} {/* Recent Listings */}
<div className="relative py-4"> <div className="relative py-4">
<h2 className="text-xl font-semibold text-gray-800 mb-4"> <h2 className="text-xl font-semibold text-gray-800 mb-4">
@@ -219,8 +241,6 @@ const Home = () => {
<div className="flex items-center text-sm text-gray-500 mt-2"> <div className="flex items-center text-sm text-gray-500 mt-2">
<Tag className="h-4 w-4 mr-1" /> <Tag className="h-4 w-4 mr-1" />
<span>{recommended.category}</span> <span>{recommended.category}</span>
<span className="mx-2"></span>
<span>{recommended.condition}</span>
</div> </div>
<div className="flex justify-between items-center pt-2 border-t border-gray-100 mt-3"> <div className="flex justify-between items-center pt-2 border-t border-gray-100 mt-3">
@@ -311,8 +331,6 @@ const Home = () => {
<div className="flex items-center text-sm text-gray-500 mt-2"> <div className="flex items-center text-sm text-gray-500 mt-2">
<Tag className="h-4 w-4 mr-1" /> <Tag className="h-4 w-4 mr-1" />
<span>{listing.category}</span> <span>{listing.category}</span>
<span className="mx-2"></span>
<span>{listing.condition}</span>
</div> </div>
<div className="flex justify-between items-center pt-2 border-t border-gray-100 mt-3"> <div className="flex justify-between items-center pt-2 border-t border-gray-100 mt-3">
@@ -341,6 +359,94 @@ const Home = () => {
</button> </button>
</div> </div>
</div> </div>
{/* Recent Listings */}
<div className="relative py-4">
<h2 className="text-xl font-semibold text-gray-800 mb-4">History</h2>
<div className="relative">
{/* Left Button - Overlaid on products */}
<button
onClick={() =>
document
.getElementById("HistoryContainer")
.scrollBy({ left: -400, behavior: "smooth" })
}
className="absolute left-0 top-1/2 transform -translate-y-1/2 bg-gray-800 bg-opacity-70 text-white p-4 rounded-full z-20 hidden md:flex items-center justify-center w-12 h-12"
>
</button>
{/* Scrollable Listings Container */}
<div
id="HistoryContainer"
className="overflow-x-auto whitespace-nowrap flex space-x-6 scroll-smooth scrollbar-hide px-10 pl-0"
>
{history.map((history) => (
<Link
key={history.id}
to={`/product/${history.id}`}
className="bg-white border border-gray-200 hover:shadow-md transition-shadow w-70 flex-shrink-0 relative"
>
<div className="relative">
<img
src={history.image}
alt={history.title}
className="w-full h-48 object-cover"
/>
<button
onClick={(e) => toggleFavorite(history.id, e)}
className="absolute top-2 right-2 p-2 bg-white rounded-full shadow-sm"
>
<Heart
className={`h-6 w-6 ${
history.isFavorite
? "text-red-500 fill-red-500"
: "text-gray-400"
}`}
/>
</button>
</div>
<div className="p-4">
<h3 className="text-lg font-medium text-gray-800 leading-tight">
{history.title}
</h3>
<span className="font-semibold text-green-600 block mt-1">
${history.price}
</span>
<div className="flex items-center text-sm text-gray-500 mt-2">
<Tag className="h-4 w-4 mr-1" />
<span>{history.category}</span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-100 mt-3">
<span className="text-xs text-gray-500">
{history.datePosted}
</span>
<span className="text-sm font-medium text-gray-700">
{history.seller}
</span>
</div>
</div>
</Link>
))}
</div>
{/* Right Button - Overlaid on products */}
<button
onClick={() =>
document
.getElementById("HistoryContainer")
.scrollBy({ left: 400, behavior: "smooth" })
}
className="absolute right-0 top-1/2 transform -translate-y-1/2 bg-gray-800 bg-opacity-70 text-white p-4 rounded-full z-20 hidden md:flex items-center justify-center w-12 h-12"
>
</button>
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -1,50 +1,145 @@
import { useState, useEffect } from "react"; import { useState, useEffect, setErrors } from "react";
import { useParams, Link } from "react-router-dom"; import { useParams, Link, isSession } from "react-router-dom";
import { Heart, ArrowLeft, Tag, User, Calendar } from "lucide-react"; import { Heart, ArrowLeft, Tag, User, Calendar, Star } from "lucide-react";
const ProductDetail = () => { const ProductDetail = () => {
const { id } = useParams(); const { id } = useParams();
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState({
const [error, setError] = useState(null); product: true,
reviews: true,
});
const [error, setError] = useState({
product: null,
reviews: null,
});
const [isFavorite, setIsFavorite] = useState(false); const [isFavorite, setIsFavorite] = useState(false);
const [showContactForm, setShowContactForm] = useState(false); const [showContactForm, setShowContactForm] = useState(false);
const [message, setMessage] = useState("");
const [currentImage, setCurrentImage] = useState(0); 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(() => { useEffect(() => {
const fetchProduct = async () => { const fetchProduct = async () => {
try { try {
setLoading(true); setLoading((prev) => ({ ...prev, product: true }));
const response = await fetch(`http://localhost:3030/api/product/${id}`); const response = await fetch(`http://localhost:3030/api/product/${id}`);
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to fetch product"); throw new Error(`HTTP error! Status: ${response.status}`);
} }
const result = await response.json(); const result = await response.json();
console.log(result);
if (result.success) { if (result.success) {
setProduct(result.data); setProduct(result.data);
setError(null); setError((prev) => ({ ...prev, product: null }));
} else { } else {
throw new Error(result.message || "Error fetching product"); throw new Error(result.message || "Error fetching product");
} }
} catch (error) { } catch (error) {
console.error("Error fetching product:", error); console.error("Error fetching product:", error);
setError(error.message); setError((prev) => ({ ...prev, product: error.message }));
setProduct(null);
} finally { } finally {
setLoading(false); setLoading((prev) => ({ ...prev, product: false }));
} }
}; };
fetchProduct(); fetchProduct();
}, [id]); }, [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 () => { const toggleFavorite = async () => {
try { try {
const response = await fetch( 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(); const result = await response.json();
if (result.success) { if (result.success) {
setIsFavorite(!isFavorite); setIsFavorite(!isFavorite);
} else {
throw new Error(result.message || "Failed to toggle favorite");
} }
} catch (error) { } catch (error) {
console.error("Error toggling favorite:", 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) => { const handleSendMessage = (e) => {
e.preventDefault(); 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 // TODO: Implement actual message sending logic
console.log("Message sent:", message); try {
setMessage(""); // Mock API call
setShowContactForm(false); console.log("Message sent:", contactForm);
alert("Message sent to seller!"); 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 // Image navigation
const nextImage = () => { const nextImage = () => {
if (product && product.images) { if (product?.images?.length > 0) {
setCurrentImage((prev) => setCurrentImage((prev) =>
prev === product.images.length - 1 ? 0 : prev + 1, prev === product.images.length - 1 ? 0 : prev + 1,
); );
@@ -90,7 +224,7 @@ const ProductDetail = () => {
}; };
const prevImage = () => { const prevImage = () => {
if (product && product.images) { if (product?.images?.length > 0) {
setCurrentImage((prev) => setCurrentImage((prev) =>
prev === 0 ? product.images.length - 1 : prev - 1, prev === 0 ? product.images.length - 1 : prev - 1,
); );
@@ -101,8 +235,22 @@ const ProductDetail = () => {
setCurrentImage(index); setCurrentImage(index);
}; };
// Render loading state // Function to render stars based on rating
if (loading) { const renderStars = (rating) => {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<Star
key={i}
className={`h-4 w-4 ${i <= rating ? "text-yellow-400 fill-yellow-400" : "text-gray-300"}`}
/>,
);
}
return stars;
};
// Render loading state for the entire page
if (loading.product) {
return ( return (
<div className="flex justify-center items-center h-screen"> <div className="flex justify-center items-center h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-green-500"></div> <div className="animate-spin rounded-full h-32 w-32 border-t-2 border-green-500"></div>
@@ -110,13 +258,30 @@ const ProductDetail = () => {
); );
} }
// Render error state // Render error state for product
if (error) { if (error.product) {
return ( return (
<div className="flex justify-center items-center h-screen"> <div className="flex justify-center items-center h-screen">
<div className="text-center"> <div className="text-center">
<h2 className="text-2xl text-red-500 mb-4">Error Loading Product</h2> <h2 className="text-2xl text-red-500 mb-4">Error Loading Product</h2>
<p className="text-gray-600">{error}</p> <p className="text-gray-600">{error.product}</p>
<Link
to="/"
className="mt-4 inline-block bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
>
Back to Listings
</Link>
</div>
</div>
);
}
// Safety check for product
if (!product) {
return (
<div className="flex justify-center items-center h-screen">
<div className="text-center">
<h2 className="text-2xl text-red-500 mb-4">Product Not Found</h2>
<Link <Link
to="/" to="/"
className="mt-4 inline-block bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600" className="mt-4 inline-block bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
@@ -145,12 +310,35 @@ const ProductDetail = () => {
<div className="md:w-3/5"> <div className="md:w-3/5">
<div className="bg-white border border-gray-200 mb-4 relative"> <div className="bg-white border border-gray-200 mb-4 relative">
{product.images && product.images.length > 0 ? ( {product.images && product.images.length > 0 ? (
<img <>
src={product.images[currentImage]} <img
alt={product.Name} src={product.images[currentImage]}
className="w-full h-auto object-contain cursor-pointer" alt={product.Name}
onClick={nextImage} className="w-full h-auto object-contain cursor-pointer"
/> onClick={nextImage}
onError={(e) => {
e.target.onerror = null;
e.target.src =
"https://via.placeholder.com/400x300?text=Image+Not+Available";
}}
/>
{product.images.length > 1 && (
<div className="absolute inset-x-0 bottom-0 flex justify-between p-2">
<button
onClick={(e) => {
e.stopPropagation();
prevImage();
}}
className="bg-white/70 p-1 rounded-full"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="text-sm bg-white/70 px-2 py-1 rounded">
{currentImage + 1}/{product.images.length}
</div>
</div>
)}
</>
) : ( ) : (
<div className="w-full h-96 flex items-center justify-center bg-gray-200 text-gray-500"> <div className="w-full h-96 flex items-center justify-center bg-gray-200 text-gray-500">
No Image Available No Image Available
@@ -170,6 +358,11 @@ const ProductDetail = () => {
src={image} src={image}
alt={`${product.Name} - view ${index + 1}`} alt={`${product.Name} - view ${index + 1}`}
className="w-full h-auto object-cover" className="w-full h-auto object-cover"
onError={(e) => {
e.target.onerror = null;
e.target.src =
"https://via.placeholder.com/100x100?text=Error";
}}
/> />
</div> </div>
))} ))}
@@ -181,11 +374,14 @@ const ProductDetail = () => {
<div className="bg-white border border-gray-200 p-6 mb-6"> <div className="bg-white border border-gray-200 p-6 mb-6">
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<h1 className="text-2xl font-bold text-gray-800"> <h1 className="text-2xl font-bold text-gray-800">
{product.Name} {product.Name || "Unnamed Product"}
</h1> </h1>
<button <button
onClick={toggleFavorite} onClick={toggleFavorite}
className="p-2 hover:bg-gray-100" className="p-2 hover:bg-gray-100 rounded-full"
aria-label={
isFavorite ? "Remove from favorites" : "Add to favorites"
}
> >
<Heart <Heart
className={`h-6 w-6 ${isFavorite ? "text-red-500 fill-red-500" : "text-gray-400"}`} className={`h-6 w-6 ${isFavorite ? "text-red-500 fill-red-500" : "text-gray-400"}`}
@@ -194,22 +390,31 @@ const ProductDetail = () => {
</div> </div>
<div className="text-2xl font-bold text-green-600 mb-4"> <div className="text-2xl font-bold text-green-600 mb-4">
${product.Price} $
{typeof product.Price === "number"
? product.Price.toFixed(2)
: product.Price}
</div> </div>
<div className="flex flex-wrap gap-x-4 gap-y-2 mb-6 text-sm"> <div className="flex flex-wrap gap-x-4 gap-y-2 mb-6 text-sm">
<div className="flex items-center text-gray-600"> {product.Category && (
<Tag className="h-4 w-4 mr-1" /> <div className="flex items-center text-gray-600">
<span>{product.Category}</span> <Tag className="h-4 w-4 mr-1" />
</div> <span>{product.Category}</span>
</div>
)}
<div className="flex items-center text-gray-600"> {product.Date && (
<Calendar className="h-4 w-4 mr-1" /> <div className="flex items-center text-gray-600">
<span>Posted on {product.Date}</span> <Calendar className="h-4 w-4 mr-1" />
</div> <span>Posted on {product.Date}</span>
</div>
)}
</div> </div>
<div className="bg-gray-50 p-4 mb-6 border border-gray-200"> <div className="bg-gray-50 p-4 mb-6 border border-gray-200">
<p className="text-gray-700">{product.Description}</p> <p className="text-gray-700">
{product.Description || "No description available"}
</p>
</div> </div>
<button <button
@@ -227,50 +432,61 @@ const ProductDetail = () => {
<form onSubmit={handleSendMessage}> <form onSubmit={handleSendMessage}>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="email" className="block text-gray-700 mb-1"> <label htmlFor="email" className="block text-gray-700 mb-1">
Email Email <span className="text-red-500">*</span>
</label> </label>
<input <input
type="email" type="email"
id="email" id="email"
value={message} value={contactForm.email}
onChange={(e) => setMessage(e.target.value)} onChange={handleContactInputChange}
className="w-full p-3 border border-gray-300 focus:outline-none focus:border-green-500" className="w-full p-3 border border-gray-300 focus:outline-none focus:border-green-500"
required required
/> />
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="phone" className="block text-gray-700 mb-1"> <label htmlFor="phone" className="block text-gray-700 mb-1">
Phone Number Phone Number <span className="text-red-500">*</span>
</label> </label>
<input <input
type="tel" type="tel"
id="phone" id="phone"
value={contactForm.phone}
onChange={handleContactInputChange}
className="w-full p-3 border border-gray-300 focus:outline-none focus:border-green-500" className="w-full p-3 border border-gray-300 focus:outline-none focus:border-green-500"
required required
/> />
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label <label
htmlFor="contactMessage" htmlFor="message"
className="block text-gray-700 mb-1" className="block text-gray-700 mb-1"
> >
Message (Optional) Message
</label> </label>
<input <textarea
type="text" id="message"
id="contactMessage" value={contactForm.message}
value={message} onChange={handleContactInputChange}
onChange={(e) => setMessage(e.target.value)}
placeholder="Hi, is this item still available?" placeholder="Hi, is this item still available?"
className="w-full p-3 border border-gray-300 focus:outline-none focus:border-green-500" className="w-full p-3 border border-gray-300 focus:outline-none focus:border-green-500"
rows="3"
/> />
</div> </div>
<button <div className="flex justify-between">
type="submit" <button
className="bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4" type="submit"
> className="bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4"
Send Contact Info >
</button> Send Contact Info
</button>
<button
type="button"
onClick={() => setShowContactForm(false)}
className="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4"
>
Cancel
</button>
</div>
</form> </form>
</div> </div>
)} )}
@@ -287,14 +503,21 @@ const ProductDetail = () => {
{product.SellerName || "Unknown Seller"} {product.SellerName || "Unknown Seller"}
</h3> </h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Member since {product.SellerName} Member since {product.SellerJoinDate || "N/A"}
</p> </p>
</div> </div>
</div> </div>
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
<div> <div>
<span className="font-medium">Rating:</span>{" "} <span className="font-medium">Rating:</span>{" "}
{product.seller ? `${product.seller.rating}/5` : "N/A"} {product.seller?.rating ? (
<div className="flex items-center">
{renderStars(product.seller.rating)}
<span className="ml-1">{product.seller.rating}/5</span>
</div>
) : (
"N/A"
)}
</div> </div>
</div> </div>
</div> </div>
@@ -302,12 +525,158 @@ const ProductDetail = () => {
</div> </div>
</div> </div>
{/* <div className="mt-8"> {/* Reviews Section */}
<h2 className="text-xl font-bold text-gray-800 mb-4">Description</h2> <div className="mt-8">
<h2 className="text-xl font-bold text-gray-800 mb-4">Reviews</h2>
<div className="bg-white border border-gray-200 p-6"> <div className="bg-white border border-gray-200 p-6">
<div className="text-gray-700">{product.Description}</div> {loading.reviews ? (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-green-500"></div>
</div>
) : error.reviews ? (
<div className="text-red-500 mb-4">
Error loading reviews: {error.reviews}
</div>
) : reviews.length === 0 ? (
<div className="text-gray-500 text-center py-6">
No reviews yet for this product
</div>
) : (
<div className="space-y-6">
{reviews.map((review) => (
<div
key={review.id || `review-${Math.random()}`}
className="border-b border-gray-200 pb-6 last:border-0 last:pb-0"
>
<div className="flex justify-between mb-2">
<div className="font-medium text-gray-800">
{review.ReviewerName || "Anonymous"}
</div>
<div className="text-sm text-gray-500">
{review.ReviewDate
? new Date(review.ReviewDate).toLocaleDateString()
: "Unknown date"}
</div>
</div>
<div className="flex items-center mb-2">
{renderStars(review.Rating || 0)}
<span className="ml-2 text-sm text-gray-600">
{review.Rating || 0}/5
</span>
</div>
<div className="text-gray-700">
{review.Comment || "No comment provided"}
</div>
</div>
))}
</div>
)}
</div> </div>
</div> */}
<div className="mt-4">
<button
onClick={() => setShowReviewForm(true)}
className="bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4 rounded"
>
Write a Review
</button>
</div>
{/* Review Popup Form */}
{showReviewForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-gray-800">
Write a Review
</h3>
<button
onClick={() => setShowReviewForm(false)}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<form onSubmit={handleSubmitReview}>
<div className="mb-4">
<label htmlFor="name" className="block text-gray-700 mb-1">
Your Name <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={reviewForm.name}
onChange={handleReviewInputChange}
className="w-full p-3 border border-gray-300 rounded focus:outline-none focus:border-green-500"
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 mb-1">
Rating <span className="text-red-500">*</span>
</label>
<div className="flex items-center">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => handleRatingChange(star)}
className="focus:outline-none mr-1"
>
<Star
className={`h-6 w-6 ${
star <= reviewForm.rating
? "text-yellow-400 fill-yellow-400"
: "text-gray-300"
}`}
/>
</button>
))}
<span className="ml-2 text-gray-600">
{reviewForm.rating}/5
</span>
</div>
</div>
<div className="mb-4">
<label htmlFor="comment" className="block text-gray-700 mb-1">
Your Review <span className="text-red-500">*</span>
</label>
<textarea
id="comment"
value={reviewForm.comment}
onChange={handleReviewInputChange}
className="w-full p-3 border border-gray-300 rounded focus:outline-none focus:border-green-500"
rows="4"
required
></textarea>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => setShowReviewForm(false)}
className="px-4 py-2 border border-gray-300 rounded text-gray-700 hover:bg-gray-100"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
>
Submit Review
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -96,12 +96,13 @@ const SearchPage = () => {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row gap-6"> <div className="flex flex-col md:flex-row gap-6">
{/* Filter sidebar */}
<div <div
className={` className={`
fixed inset-0 z-50 bg-white transform transition-transform duration-300 fixed inset-0 z-50 bg-white transform transition-transform duration-300
${isFilterOpen ? "translate-x-0" : "translate-x-full"} ${isFilterOpen ? "translate-x-0" : "translate-x-full"}
md:translate-x-0 md:relative md:block md:w-72 md:translate-x-0 md:relative md:block md:w-72
overflow-y-auto shadow-lg rounded-lg overflow-y-auto shadow-md
`} `}
> >
<div className="md:hidden flex justify-between items-center p-4 border-b"> <div className="md:hidden flex justify-between items-center p-4 border-b">
@@ -110,9 +111,8 @@ const SearchPage = () => {
<X className="text-gray-600" /> <X className="text-gray-600" />
</button> </button>
</div> </div>
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="bg-gray-50 rounded-lg p-3"> <div className="bg-gray-50 p-3">
<h3 className="font-semibold text-gray-700 mb-3">Price Range</h3> <h3 className="font-semibold text-gray-700 mb-3">Price Range</h3>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex space-x-2"> <div className="flex space-x-2">
@@ -126,7 +126,7 @@ const SearchPage = () => {
min: Number(e.target.value), min: Number(e.target.value),
})) }))
} }
className="w-full p-2 border rounded text-gray-700" className="w-full p-2 border text-gray-700"
/> />
<input <input
type="number" type="number"
@@ -138,7 +138,7 @@ const SearchPage = () => {
max: Number(e.target.value), max: Number(e.target.value),
})) }))
} }
className="w-full p-2 border rounded text-gray-700" className="w-full p-2 border text-gray-700"
/> />
</div> </div>
</div> </div>
@@ -146,13 +146,13 @@ const SearchPage = () => {
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
onClick={applyFilters} onClick={applyFilters}
className="w-full bg-green-500 text-white p-3 rounded-lg hover:bg-green-600 transition-colors" className="w-full bg-green-500 text-white p-3 hover:bg-green-600 transition-colors"
> >
Apply Filters Apply Filters
</button> </button>
<button <button
onClick={resetFilters} onClick={resetFilters}
className="w-full bg-gray-200 text-gray-700 p-3 rounded-lg hover:bg-gray-300 transition-colors" className="w-full bg-gray-200 text-gray-700 p-3 hover:bg-gray-300 transition-colors"
> >
Reset Reset
</button> </button>
@@ -160,6 +160,7 @@ const SearchPage = () => {
</div> </div>
</div> </div>
{/* Main content */}
<div className="flex-1 mt-4 md:mt-0"> <div className="flex-1 mt-4 md:mt-0">
<h2 className="text-2xl font-bold text-gray-800"> <h2 className="text-2xl font-bold text-gray-800">
{filteredProducts.length} Results {filteredProducts.length} Results
@@ -170,18 +171,17 @@ const SearchPage = () => {
</span> </span>
)} )}
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
{filteredProducts.map((listing) => ( {filteredProducts.map((listing) => (
<Link <Link
key={listing.id} key={listing.id}
to={`/product/${listing.id}`} to={`/product/${listing.id}`}
className="bg-white border border-gray-200 rounded-lg hover:shadow-md transition-shadow block" className="bg-white border border-gray-200 hover:shadow-md transition-shadow block"
> >
<img <img
src={listing.image} src={listing.image}
alt={listing.title} alt={listing.title}
className="w-full h-48 object-cover rounded-t-lg" className="w-full h-48 object-cover"
/> />
<div className="p-4"> <div className="p-4">
<h3 className="text-lg font-medium text-gray-800"> <h3 className="text-lg font-medium text-gray-800">

View File

@@ -321,6 +321,9 @@ INSERT INTO
Image_URL (URL, ProductID) Image_URL (URL, ProductID)
VALUES VALUES
('/image1.avif', 1), ('/image1.avif', 1),
('/image2.avif', 1),
('/image3.avif', 1),
('/image8.jpg', 1),
('/image1.avif', 2), ('/image1.avif', 2),
('/image1.avif', 3), ('/image1.avif', 3),
('/image1.avif', 4), ('/image1.avif', 4),
@@ -442,3 +445,14 @@ VALUES
(3, 1, 8, '2024-10-14 12:20:00', 'Pending'), (3, 1, 8, '2024-10-14 12:20:00', 'Pending'),
(4, 2, 10, '2024-10-13 17:10:00', 'Completed'), (4, 2, 10, '2024-10-13 17:10:00', 'Completed'),
(5, 2, 4, '2024-10-12 14:30: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 ()
);

View File

@@ -51,7 +51,7 @@ CREATE TABLE Image_URL (
-- Fixed Review Entity (Many-to-One with User, Many-to-One with Product) -- Fixed Review Entity (Many-to-One with User, Many-to-One with Product)
CREATE TABLE Review ( CREATE TABLE Review (
ReviewID INT PRIMARY KEY, ReviewID INT AUTO_INCREMENT PRIMARY KEY,
UserID INT, UserID INT,
ProductID INT, ProductID INT,
Comment TEXT, Comment TEXT,
@@ -61,7 +61,7 @@ CREATE TABLE Review (
), ),
Date DATETIME DEFAULT CURRENT_TIMESTAMP, Date DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (UserID) REFERENCES User (UserID), 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) -- Transaction Entity (Many-to-One with User, Many-to-One with Product)
@@ -270,16 +270,16 @@ CREATE TABLE AuthVerification (
-- -- WHERE -- -- WHERE
-- -- CategoryID = 6; -- -- CategoryID = 6;
-- -- -- REVIEW OPERATIONS -- -- -- REVIEW OPERATIONS
-- -- INSERT INTO -- INSERT INTO
-- -- Review (ReviewID, UserID, ProductID, Comment, Rating) -- Review (ReviewID, UserID, ProductID, Comment, Rating)
-- -- VALUES -- VALUES
-- -- ( -- (
-- -- 1, -- 1,
-- -- 1, -- 1,
-- -- 1, -- 1,
-- -- 'Great product, very satisfied with the purchase!', -- 'Great product, very satisfied with the purchase!',
-- -- 5 -- 5
-- -- ); -- );
-- -- -- TRANSACTION OPERATIONS -- -- -- TRANSACTION OPERATIONS
-- -- INSERT INTO -- -- INSERT INTO
-- -- Transaction (TransactionID, UserID, ProductID, PaymentStatus) -- -- Transaction (TransactionID, UserID, ProductID, PaymentStatus)