added Review Feature
This commit is contained in:
53
backend/controllers/history.js
Normal file
53
backend/controllers/history.js
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
133
backend/controllers/review.js
Normal file
133
backend/controllers/review.js
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
8
backend/routes/history.js
Normal file
8
backend/routes/history.js
Normal 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
9
backend/routes/review.js
Normal 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
BIN
frontend/public/image8.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user