Compare commits
87 Commits
estherBran
...
mannBranch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
505f6cd134 | ||
|
|
53686bd71d | ||
|
|
635ba76ed4 | ||
|
|
5228bf73c9 | ||
|
|
6d2f736541 | ||
|
|
7670bb2b99 | ||
|
|
3c7a1a876a | ||
|
|
46bd77025f | ||
|
|
eff9d9d91b | ||
|
|
89f5032212 | ||
|
|
691980bf7c | ||
|
|
3ea45b5400 | ||
|
|
644db7707c | ||
|
|
8347689f6c | ||
|
|
f82e111e39 | ||
|
|
d381ef7973 | ||
|
|
1c8a7522e2 | ||
|
|
77a35810fd | ||
|
|
25f2c3d8af | ||
|
|
51cd90a409 | ||
|
|
444b436983 | ||
|
|
bcf849611f | ||
|
|
f223f3717d | ||
|
|
0c08dbc5ce | ||
|
|
6ef4a22e9f | ||
|
|
f7e4b49ac8 | ||
|
|
26cd50ab6f | ||
|
|
7a2250369e | ||
|
|
d169c9ba58 | ||
|
|
90789b6fd4 | ||
|
|
dee6e3ce10 | ||
|
|
e97f80aee1 | ||
|
|
fd43001374 | ||
|
|
121316a8d4 | ||
|
|
649dad75cb | ||
|
|
47786b04f4 | ||
|
|
fa43c91cc5 | ||
|
|
5b4332a847 | ||
|
|
067a5c3b0e | ||
|
|
a8417a3697 | ||
|
|
d1aed0602d | ||
|
|
e5493ad59f | ||
|
|
06e045fbff | ||
|
|
fdf63f4e6a | ||
|
|
b142610d50 | ||
|
|
635f73c1be | ||
|
|
521c3af00b | ||
|
|
63c594e041 | ||
|
|
a52f6ce563 | ||
|
|
0c82952927 | ||
|
|
2ef05ac3af | ||
|
|
3bdb8877a6 | ||
|
|
814c24c83f | ||
|
|
0f8bb622a4 | ||
|
|
0e32389482 | ||
|
|
10f0469b56 | ||
|
|
d8ed58f572 | ||
|
|
75c7675601 | ||
|
|
643b9e357c | ||
|
|
3537e698b1 | ||
|
|
ac099da486 | ||
|
|
99f12319d5 | ||
|
|
e7580c36f5 | ||
|
|
a1ca7304eb | ||
|
|
755069d279 | ||
|
|
ff8b7f2081 | ||
|
|
c3cab2776d | ||
|
|
71a90265d9 | ||
|
|
2e77ef49f4 | ||
|
|
91ec43627a | ||
|
|
7a87fc1e49 | ||
|
|
f52693dfc2 | ||
|
|
d78b0c32e0 | ||
|
|
e7a6e1dd8b | ||
|
|
fc125cc76a | ||
|
|
c2e13d56f2 | ||
|
|
148fe95a11 | ||
|
|
d53a487aea | ||
|
|
30482760dd | ||
|
|
ee6df0f674 | ||
|
|
904c908249 | ||
|
|
48668be540 | ||
|
|
c829b30350 | ||
|
|
01d5e1b67b | ||
|
|
8000ed18bf | ||
|
|
656801238c | ||
|
|
6a7fa61fcd |
2
.gitignore
vendored
@@ -2,8 +2,6 @@
|
||||
*/node_modules
|
||||
.DS_Store
|
||||
|
||||
*~/backend/index.js
|
||||
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
43
README.md
@@ -1,25 +1,42 @@
|
||||
### Some ground rules
|
||||
1. Add both node_modules from Slient and Server to your `gitignore` file
|
||||
2. Make a brach with the following naming conventionp, refix it with your name `name-some branch name`.
|
||||
1. Add both node_modules from Slient and Server to your ```gitignore``` file
|
||||
2. Make a brach with the following naming conventionp, prefix it with your name ```Your-Name Branch-Name```.
|
||||
---
|
||||
|
||||
### `frontend`
|
||||
- Use React Js and vite as the node manger
|
||||
### Frontend
|
||||
1. `cd frontend` into the dir and then type command
|
||||
```Bash
|
||||
npm install
|
||||
1. npm install #Installs the needed packages
|
||||
2. npm run dev #Start The Server
|
||||
```
|
||||
2. **Start The Server**, `cd frontend` into the dir and then type command
|
||||
---
|
||||
|
||||
### Backend
|
||||
1. `cd backend` into the dir and then type command
|
||||
```Bash
|
||||
npm run dev
|
||||
1. npm install #Installs the needed packages
|
||||
2. npm run dev #Start The Server
|
||||
```
|
||||
### `backend`
|
||||
1. Install the needed lib with the command bellow
|
||||
---
|
||||
|
||||
### Recommendation
|
||||
1. `cd recommendation-engine` into the dir and then type command
|
||||
```Bash
|
||||
npm install
|
||||
1. python3 server.py #Start The Server
|
||||
```
|
||||
2. **Start The Server**, `cd backend` into the dir and then type command
|
||||
---
|
||||
### Recommendation system
|
||||
1. Install the dependencies
|
||||
```Bash
|
||||
npm run dev
|
||||
pip install mysql.connector
|
||||
```
|
||||
|
||||
|
||||
### Database
|
||||
- MySql Version 9.2.0
|
||||
1. MySql Version 9.2.0
|
||||
2. To Create the DataBase use the command bellow:
|
||||
```Bash
|
||||
1. mysql -u root
|
||||
2. \. PathToYour/Schema.sql
|
||||
3. \. PathToYour/Init-Data.sql
|
||||
```
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
-- MySql Version 9.2.0
|
||||
CREATE DATABASE Marketplace;
|
||||
|
||||
USE Marketplace;
|
||||
|
||||
-- User Entity
|
||||
CREATE TABLE User (
|
||||
UserID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(100) NOT NULL,
|
||||
Email VARCHAR(100) UNIQUE NOT NULL,
|
||||
UCID VARCHAR(20) UNIQUE NOT NULL,
|
||||
Password VARCHAR(255) NOT NULL,
|
||||
Phone VARCHAR(20),
|
||||
Address VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE TABLE UserRole (
|
||||
UserID INT,
|
||||
Client BOOLEAN DEFAULT True,
|
||||
Admin BOOLEAN DEFAULT FALSE,
|
||||
PRIMARY KEY (UserID),
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Category Entity (must be created before Product or else error)
|
||||
CREATE TABLE Category (
|
||||
CategoryID INT PRIMARY KEY,
|
||||
Name VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
-- Product Entity
|
||||
CREATE TABLE Product (
|
||||
ProductID INT PRIMARY KEY,
|
||||
Name VARCHAR(255) NOT NULL,
|
||||
Price DECIMAL(10, 2) NOT NULL,
|
||||
StockQuantity INT,
|
||||
UserID INT,
|
||||
Description TEXT,
|
||||
CategoryID INT NOT NULL,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID),
|
||||
FOREIGN KEY (CategoryID) REFERENCES Category (CategoryID)
|
||||
);
|
||||
|
||||
-- Fixed Image_URL table
|
||||
CREATE TABLE Image_URL (
|
||||
URL VARCHAR(255),
|
||||
ProductID INT,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID)
|
||||
);
|
||||
|
||||
-- Fixed Review Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Review (
|
||||
ReviewID INT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
Comment TEXT,
|
||||
Rating INT CHECK (
|
||||
Rating >= 1
|
||||
AND Rating <= 5
|
||||
),
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID),
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID)
|
||||
);
|
||||
|
||||
-- Transaction Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Transaction (
|
||||
TransactionID INT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PaymentStatus VARCHAR(50),
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID),
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID)
|
||||
);
|
||||
|
||||
-- Recommendation Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Recommendation (
|
||||
RecommendationID_PK INT PRIMARY KEY,
|
||||
UserID INT,
|
||||
RecommendedProductID INT,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID),
|
||||
FOREIGN KEY (RecommendedProductID) REFERENCES Product (ProductID)
|
||||
);
|
||||
|
||||
-- History Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE History (
|
||||
HistoryID INT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID),
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID)
|
||||
);
|
||||
|
||||
-- Favorites Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Favorites (
|
||||
FavoriteID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID),
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID)
|
||||
);
|
||||
|
||||
-- Product-Category Junction Table (Many-to-Many)
|
||||
CREATE TABLE Product_Category (
|
||||
ProductID INT,
|
||||
CategoryID INT,
|
||||
PRIMARY KEY (ProductID, CategoryID),
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID),
|
||||
FOREIGN KEY (CategoryID) REFERENCES Category (CategoryID)
|
||||
);
|
||||
|
||||
-- Login Authentication table
|
||||
CREATE TABLE AuthVerification (
|
||||
UserID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Email VARCHAR(100) UNIQUE NOT NULL,
|
||||
VerificationCode VARCHAR(6) NOT NULL,
|
||||
Authenticated BOOLEAN DEFAULT FALSE,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert sample categories
|
||||
INSERT INTO
|
||||
Category (CategoryID, Name)
|
||||
VALUES
|
||||
(1, 'Electronics'),
|
||||
(2, 'Clothing'),
|
||||
(3, 'Books'),
|
||||
(4, 'Home & Garden'),
|
||||
(5, 'Sports & Outdoors');
|
||||
|
||||
-- USER CRUD OPERATIONS
|
||||
-- Create User (INSERT)
|
||||
INSERT INTO
|
||||
User (Name, Email, UCID, Password, Phone, Address)
|
||||
VALUES
|
||||
(
|
||||
'John Doe',
|
||||
'john@example.com',
|
||||
'UC123456',
|
||||
'hashed_password_here',
|
||||
'555-123-4567',
|
||||
'123 Main St'
|
||||
);
|
||||
|
||||
-- Set user role
|
||||
INSERT INTO
|
||||
UserRole (UserID, Client, Admin)
|
||||
VALUES
|
||||
(LAST_INSERT_ID (), TRUE, FALSE);
|
||||
|
||||
-- Read User (SELECT)
|
||||
SELECT
|
||||
u.*,
|
||||
ur.Client,
|
||||
ur.Admin
|
||||
FROM
|
||||
User u
|
||||
JOIN UserRole ur ON u.UserID = ur.UserID
|
||||
WHERE
|
||||
u.UserID = 1;
|
||||
|
||||
-- Update User (UPDATE)
|
||||
UPDATE User
|
||||
SET
|
||||
Name = 'John Smith',
|
||||
Phone = '555-987-6543',
|
||||
Address = '456 Elm St'
|
||||
WHERE
|
||||
UserID = 1;
|
||||
|
||||
-- Update User Role
|
||||
UPDATE UserRole
|
||||
SET
|
||||
Admin = TRUE
|
||||
WHERE
|
||||
UserID = 1;
|
||||
|
||||
-- PRODUCT CRUD OPERATIONS
|
||||
-- Create Product (INSERT)
|
||||
INSERT INTO
|
||||
Product (
|
||||
ProductID,
|
||||
Name,
|
||||
Price,
|
||||
StockQuantity,
|
||||
UserID,
|
||||
Description,
|
||||
CategoryID
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1,
|
||||
'Smartphone',
|
||||
599.99,
|
||||
50,
|
||||
1,
|
||||
'Latest model smartphone with amazing features',
|
||||
1
|
||||
);
|
||||
|
||||
-- Add product images with the placeholder URL
|
||||
INSERT INTO
|
||||
Image_URL (URL, ProductID)
|
||||
VALUES
|
||||
('https://picsum.photos/id/237/200/300', 1),
|
||||
('https://picsum.photos/id/237/200/300', 1);
|
||||
|
||||
-- Create another product for recommendations
|
||||
INSERT INTO
|
||||
Product (
|
||||
ProductID,
|
||||
Name,
|
||||
Price,
|
||||
StockQuantity,
|
||||
UserID,
|
||||
Description,
|
||||
CategoryID
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
2,
|
||||
'Tablet',
|
||||
799.99,
|
||||
30,
|
||||
1,
|
||||
'High-performance tablet',
|
||||
1
|
||||
);
|
||||
|
||||
-- Add placeholder images for the second product
|
||||
INSERT INTO
|
||||
Image_URL (URL, ProductID)
|
||||
VALUES
|
||||
('https://picsum.photos/id/237/200/300', 2),
|
||||
('https://picsum.photos/id/237/200/300', 2);
|
||||
|
||||
-- Read Product (SELECT)
|
||||
SELECT
|
||||
p.*,
|
||||
c.Name as CategoryName,
|
||||
u.Name as SellerName,
|
||||
i.URL as ImageURL
|
||||
FROM
|
||||
Product p
|
||||
JOIN Category c ON p.CategoryID = c.CategoryID
|
||||
JOIN User u ON p.UserID = u.UserID
|
||||
LEFT JOIN Image_URL i ON p.ProductID = i.ProductID
|
||||
WHERE
|
||||
p.ProductID = 1;
|
||||
|
||||
-- Update Product (UPDATE)
|
||||
UPDATE Product
|
||||
SET
|
||||
Name = 'Premium Smartphone',
|
||||
Price = 649.99,
|
||||
StockQuantity = 45,
|
||||
Description = 'Updated description with new features'
|
||||
WHERE
|
||||
ProductID = 1;
|
||||
|
||||
-- CATEGORY CRUD OPERATIONS
|
||||
-- Create Category (INSERT)
|
||||
INSERT INTO
|
||||
Category (CategoryID, Name)
|
||||
VALUES
|
||||
(6, 'Toys & Games');
|
||||
|
||||
-- Read Category (SELECT)
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
Category
|
||||
WHERE
|
||||
CategoryID = 6;
|
||||
|
||||
-- Update Category (UPDATE)
|
||||
UPDATE Category
|
||||
SET
|
||||
Name = 'Toys & Children''s Games'
|
||||
WHERE
|
||||
CategoryID = 6;
|
||||
|
||||
-- REVIEW OPERATIONS
|
||||
INSERT INTO
|
||||
Review (ReviewID, UserID, ProductID, Comment, Rating)
|
||||
VALUES
|
||||
(
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
'Great product, very satisfied with the purchase!',
|
||||
5
|
||||
);
|
||||
|
||||
-- TRANSACTION OPERATIONS
|
||||
INSERT INTO
|
||||
Transaction (TransactionID, UserID, ProductID, PaymentStatus)
|
||||
VALUES
|
||||
(1, 1, 1, 'Completed');
|
||||
|
||||
-- HISTORY OPERATIONS
|
||||
INSERT INTO
|
||||
History (HistoryID, UserID, ProductID)
|
||||
VALUES
|
||||
(1, 1, 1);
|
||||
|
||||
-- Read History (SELECT)
|
||||
SELECT
|
||||
h.*,
|
||||
p.Name as ProductName
|
||||
FROM
|
||||
History h
|
||||
JOIN Product p ON h.ProductID = p.ProductID
|
||||
WHERE
|
||||
h.UserID = 1
|
||||
ORDER BY
|
||||
h.Date DESC;
|
||||
|
||||
-- FAVORITES OPERATIONS
|
||||
INSERT INTO
|
||||
Favorites (UserID, ProductID)
|
||||
VALUES
|
||||
(1, 1);
|
||||
|
||||
-- Read Favorites (SELECT)
|
||||
SELECT
|
||||
f.*,
|
||||
p.Name as ProductName,
|
||||
p.Price
|
||||
FROM
|
||||
Favorites f
|
||||
JOIN Product p ON f.ProductID = p.ProductID
|
||||
WHERE
|
||||
f.UserID = 1;
|
||||
|
||||
-- RECOMMENDATION OPERATIONS
|
||||
INSERT INTO
|
||||
Recommendation (RecommendationID_PK, UserID, RecommendedProductID)
|
||||
VALUES
|
||||
(1, 1, 2);
|
||||
|
||||
-- Read Recommendations (SELECT)
|
||||
SELECT
|
||||
r.*,
|
||||
p.Name as RecommendedProductName,
|
||||
p.Price,
|
||||
p.Description
|
||||
FROM
|
||||
Recommendation r
|
||||
JOIN Product p ON r.RecommendedProductID = p.ProductID
|
||||
WHERE
|
||||
r.UserID = 1;
|
||||
|
||||
-- Authentication Operations
|
||||
-- Create verification code
|
||||
INSERT INTO
|
||||
AuthVerification (Email, VerificationCode)
|
||||
VALUES
|
||||
('new_user@example.com', '123456');
|
||||
|
||||
-- Update authentication status
|
||||
UPDATE AuthVerification
|
||||
SET
|
||||
Authenticated = TRUE
|
||||
WHERE
|
||||
Email = 'new_user@example.com'
|
||||
AND VerificationCode = '123456';
|
||||
|
||||
-- Get top-selling products
|
||||
SELECT
|
||||
p.ProductID,
|
||||
p.Name,
|
||||
COUNT(t.TransactionID) as SalesCount,
|
||||
SUM(p.Price) as TotalRevenue
|
||||
FROM
|
||||
Product p
|
||||
JOIN Transaction t ON p.ProductID = t.ProductID
|
||||
WHERE
|
||||
t.PaymentStatus = 'Completed'
|
||||
GROUP BY
|
||||
p.ProductID,
|
||||
p.Name
|
||||
ORDER BY
|
||||
SalesCount DESC
|
||||
LIMIT
|
||||
10;
|
||||
|
||||
-- Get highest-rated products
|
||||
SELECT
|
||||
p.ProductID,
|
||||
p.Name,
|
||||
AVG(r.Rating) as AverageRating,
|
||||
COUNT(r.ReviewID) as ReviewCount
|
||||
FROM
|
||||
Product p
|
||||
JOIN Review r ON p.ProductID = r.ProductID
|
||||
GROUP BY
|
||||
p.ProductID,
|
||||
p.Name
|
||||
HAVING
|
||||
ReviewCount >= 5
|
||||
ORDER BY
|
||||
AverageRating DESC
|
||||
LIMIT
|
||||
10;
|
||||
|
||||
-- Get user purchase history with product details
|
||||
SELECT
|
||||
t.TransactionID,
|
||||
t.Date,
|
||||
p.Name,
|
||||
p.Price,
|
||||
t.PaymentStatus
|
||||
FROM
|
||||
Transaction t
|
||||
JOIN Product p ON t.ProductID = p.ProductID
|
||||
WHERE
|
||||
t.UserID = 1
|
||||
ORDER BY
|
||||
t.Date DESC;
|
||||
70
backend/controllers/category.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const db = require("../utils/database");
|
||||
|
||||
exports.getAllCategoriesWithPagination = async (req, res) => {
|
||||
const limit = +req.query?.limit;
|
||||
const page = +req.query?.page;
|
||||
const offset = (page - 1) * limit;
|
||||
try {
|
||||
const [data, _] = await db.execute(
|
||||
"SELECT * FROM Category C ORDER BY C.CategoryID ASC LIMIT ? OFFSET ?",
|
||||
[limit.toString(), offset.toString()],
|
||||
);
|
||||
|
||||
const [result] = await db.execute("SELECT COUNT(*) AS count FROM Category");
|
||||
const { count: total } = result[0];
|
||||
return res.json({ data, total });
|
||||
} catch (error) {
|
||||
res.json({ error: "Cannot fetch categories from database!" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.addCategory = async (req, res) => {
|
||||
const { name } = req.body;
|
||||
|
||||
try {
|
||||
const [result] = await db.execute(
|
||||
"INSERT INTO Category (Name) VALUES (?)",
|
||||
[name],
|
||||
);
|
||||
res.json({ message: "Adding new category successfully!" });
|
||||
} catch (error) {
|
||||
res.json({ error: "Cannot add new category!" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeCategory = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const [result] = await db.execute(
|
||||
`DELETE FROM Category WHERE CategoryID = ?`,
|
||||
[id],
|
||||
);
|
||||
res.json({ message: "Delete category successfully!" });
|
||||
} catch (error) {
|
||||
res.json({ error: "Cannot remove category from database!" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getAllCategory = async (req, res) => {
|
||||
try {
|
||||
const [data, fields] = await db.execute(`SELECT * FROM Category`);
|
||||
|
||||
const formattedData = {};
|
||||
data.forEach((row) => {
|
||||
formattedData[row.CategoryID] = row.Name;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Categories fetched successfully",
|
||||
data: formattedData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching categories:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Database error occurred",
|
||||
});
|
||||
}
|
||||
};
|
||||
90
backend/controllers/history.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const db = require("../utils/database");
|
||||
|
||||
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 H.UserID = ?
|
||||
)
|
||||
SELECT
|
||||
ProductID,
|
||||
ProductName,
|
||||
Price,
|
||||
DateUploaded,
|
||||
SellerName,
|
||||
ProductImage,
|
||||
Category
|
||||
FROM RankedImages
|
||||
WHERE RowNum = 1;
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.AddHistory = async (req, res) => {
|
||||
const { userID, productID } = req.body;
|
||||
console.log(userID);
|
||||
try {
|
||||
// Use parameterized query to prevent SQL injection
|
||||
const [result] = await db.execute(
|
||||
`INSERT INTO History (UserID, ProductID) VALUES (?, ?)`,
|
||||
[userID, productID],
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Product added to history successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error adding favorite product:", error);
|
||||
return res.json({ error: "Could not add favorite product" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.DelHistory = async (req, res) => {
|
||||
const { userID, productID } = req.body;
|
||||
console.log(userID);
|
||||
try {
|
||||
// Use parameterized query to prevent SQL injection
|
||||
const [result] = await db.execute(`DELETE FROM History WHERE UserID=?`, [
|
||||
userID,
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Product deleted from History successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error adding favorite product:", error);
|
||||
return res.json({ error: "Could not add favorite product" });
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,83 @@
|
||||
const db = require("../utils/database");
|
||||
|
||||
exports.addToFavorite = async (req, res) => {
|
||||
const { userID, productsID } = req.body;
|
||||
exports.addProduct = async (req, res) => {
|
||||
const { userID, name, price, qty, description, category, images } = req.body;
|
||||
|
||||
try {
|
||||
// Use parameterized query to prevent SQL injection
|
||||
const [result] = await db.execute(
|
||||
"INSERT INTO Favorites (UserID, ProductID) VALUES (?, ?)",
|
||||
[userID, productsID]
|
||||
`INSERT INTO Product (Name, Price, StockQuantity, UserID, Description, CategoryID) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[name, price, qty, userID, description, category],
|
||||
);
|
||||
|
||||
const productID = result.insertId;
|
||||
if (images && images.length > 0) {
|
||||
const imageInsertPromises = images.map((imagePath) =>
|
||||
db.execute(`INSERT INTO Image_URL (URL, ProductID) VALUES (?, ?)`, [
|
||||
imagePath,
|
||||
productID,
|
||||
]),
|
||||
);
|
||||
|
||||
await Promise.all(imageInsertPromises); //perallel
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Product and images added successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error adding product or images:", error);
|
||||
console.log(error);
|
||||
return res.json({ error: "Could not add product or images" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeProduct = async (req, res) => {
|
||||
const { userID, productID } = req.body;
|
||||
console.log(userID);
|
||||
|
||||
try {
|
||||
// First delete images
|
||||
await db.execute(`DELETE FROM Image_URL WHERE ProductID = ?`, [productID]);
|
||||
await db.execute(`DELETE FROM History WHERE ProductID = ?`, [productID]);
|
||||
await db.execute(`DELETE FROM Favorites WHERE ProductID = ?`, [productID]);
|
||||
await db.execute(`DELETE FROM Product_Category WHERE ProductID = ?`, [
|
||||
productID,
|
||||
]);
|
||||
await db.execute(`DELETE FROM Product_Category WHERE ProductID = ?`, [
|
||||
productID,
|
||||
]);
|
||||
await db.execute(`DELETE FROM Transaction WHERE ProductID = ?`, [
|
||||
productID,
|
||||
]);
|
||||
await db.execute(
|
||||
`DELETE FROM Recommendation WHERE RecommendedProductID = ?`,
|
||||
[productID],
|
||||
);
|
||||
|
||||
// Then delete the product
|
||||
await db.execute(`DELETE FROM Product WHERE UserID = ? AND ProductID = ?`, [
|
||||
userID,
|
||||
productID,
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Product removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error removing product:", error);
|
||||
return res.json({ error: "Could not remove product" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.addFavorite = async (req, res) => {
|
||||
const { userID, productID } = req.body;
|
||||
console.log(userID);
|
||||
try {
|
||||
const [result] = await db.execute(
|
||||
`INSERT INTO Favorites (UserID, ProductID) VALUES (?, ?)`,
|
||||
[userID, productID],
|
||||
);
|
||||
|
||||
res.json({
|
||||
@@ -20,18 +90,199 @@ exports.addToFavorite = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
//Get all products
|
||||
exports.getAllProducts = async (req, res) => {
|
||||
exports.removeFavorite = async (req, res) => {
|
||||
const { userID, productID } = req.body;
|
||||
console.log(userID);
|
||||
try {
|
||||
const [data, fields] = await db.execute("SELECT * FROM Product");
|
||||
// Use parameterized query to prevent SQL injection
|
||||
const [result] = await db.execute(
|
||||
`DELETE FROM Favorites WHERE UserID = ? AND ProductID = ?`,
|
||||
[userID, productID],
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Product added to favorites successfully",
|
||||
message: "Product removed from favorites successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error removing favorite product:", error);
|
||||
return res.json({ error: "Could not remove favorite product" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.updateProduct = async (req, res) => {
|
||||
const { productId } = req.params;
|
||||
const { name, description, price, category, images } = req.body;
|
||||
|
||||
console.log(productId);
|
||||
|
||||
const connection = await db.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// Step 1: Check if the product exists
|
||||
const [checkProduct] = await connection.execute(
|
||||
"SELECT * FROM Product WHERE ProductID = ?",
|
||||
[productId],
|
||||
);
|
||||
if (checkProduct.length === 0) {
|
||||
await connection.rollback();
|
||||
return res.status(404).json({ error: "Product not found" });
|
||||
}
|
||||
|
||||
// Step 2: Update the product
|
||||
await connection.execute(
|
||||
`
|
||||
UPDATE Product
|
||||
SET Name = ?, Description = ?, Price = ?, CategoryID = ?
|
||||
WHERE ProductID = ?
|
||||
`,
|
||||
[name, description, price, category, productId],
|
||||
);
|
||||
|
||||
// Step 3: Delete existing images
|
||||
await connection.execute(`DELETE FROM Image_URL WHERE ProductID = ?`, [
|
||||
productId,
|
||||
]);
|
||||
|
||||
// Step 4: Insert new image URLs
|
||||
for (const imageUrl of images) {
|
||||
await connection.execute(
|
||||
`INSERT INTO Image_URL (ProductID, URL) VALUES (?, ?)`,
|
||||
[productId, imageUrl],
|
||||
);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true, message: "Product updated successfully" });
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
console.error("Update product error:", error);
|
||||
res.status(500).json({ error: "Failed to update product" });
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
|
||||
exports.myProduct = async (req, res) => {
|
||||
const { userID } = req.body;
|
||||
|
||||
try {
|
||||
const [result] = await db.execute(
|
||||
`
|
||||
SELECT
|
||||
p.ProductID,
|
||||
p.Name,
|
||||
p.Description,
|
||||
p.Price,
|
||||
p.CategoryID,
|
||||
p.UserID,
|
||||
p.Date,
|
||||
u.Name AS SellerName,
|
||||
MIN(i.URL) AS image_url
|
||||
FROM Product p
|
||||
JOIN User u ON p.UserID = u.UserID
|
||||
LEFT JOIN Image_URL i ON p.ProductID = i.ProductID
|
||||
WHERE p.UserID = ?
|
||||
GROUP BY
|
||||
p.ProductID,
|
||||
p.Name,
|
||||
p.Description,
|
||||
p.Price,
|
||||
p.CategoryID,
|
||||
p.UserID,
|
||||
p.Date,
|
||||
u.Name;
|
||||
`,
|
||||
[userID],
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrieving favorites:", error);
|
||||
res.status(500).json({ error: "Could not retrieve favorite products" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getFavorites = async (req, res) => {
|
||||
const { userID } = req.body;
|
||||
|
||||
try {
|
||||
const [favorites] = await db.execute(
|
||||
`
|
||||
SELECT
|
||||
p.ProductID,
|
||||
p.Name,
|
||||
p.Description,
|
||||
p.Price,
|
||||
p.CategoryID,
|
||||
p.UserID,
|
||||
p.Date,
|
||||
u.Name AS SellerName,
|
||||
MIN(i.URL) AS image_url
|
||||
FROM Favorites f
|
||||
JOIN Product p ON f.ProductID = p.ProductID
|
||||
JOIN User u ON p.UserID = u.UserID
|
||||
LEFT JOIN Image_URL i ON p.ProductID = i.ProductID
|
||||
WHERE f.UserID = ?
|
||||
GROUP BY
|
||||
p.ProductID,
|
||||
p.Name,
|
||||
p.Description,
|
||||
p.Price,
|
||||
p.CategoryID,
|
||||
p.UserID,
|
||||
p.Date,
|
||||
u.Name;
|
||||
`,
|
||||
[userID],
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
favorites: favorites,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrieving favorites:", error);
|
||||
res.status(500).json({ error: "Could not retrieve favorite products" });
|
||||
}
|
||||
};
|
||||
|
||||
// Get all products along with their image URLs
|
||||
exports.getAllProducts = async (req, res) => {
|
||||
try {
|
||||
const [data, fields] = await db.execute(`
|
||||
SELECT
|
||||
P.ProductID,
|
||||
P.Name AS ProductName,
|
||||
P.Price,
|
||||
P.Date AS DateUploaded,
|
||||
U.Name AS SellerName,
|
||||
MIN(I.URL) AS ProductImage,
|
||||
C.Name AS Category
|
||||
FROM Product P
|
||||
JOIN Image_URL I ON P.ProductID = I.ProductID
|
||||
JOIN User U ON P.UserID = U.UserID
|
||||
JOIN Category C ON P.CategoryID = C.CategoryID
|
||||
GROUP BY
|
||||
P.ProductID,
|
||||
P.Name,
|
||||
P.Price,
|
||||
P.Date,
|
||||
U.Name,
|
||||
C.Name;
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Products fetched successfully",
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error finding user:", error);
|
||||
console.error("Error finding products:", error);
|
||||
return res.status(500).json({
|
||||
found: false,
|
||||
error: "Database error occurred",
|
||||
@@ -39,32 +290,117 @@ exports.getAllProducts = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// db_con.query(
|
||||
// "SELECT ProductID FROM product WHERE ProductID = ?",
|
||||
// [productID],
|
||||
// (err, results) => {
|
||||
// if (err) {
|
||||
// console.error("Error checking product:", err);
|
||||
// return res.json({ error: "Database error" });
|
||||
// }
|
||||
exports.getProductById = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
console.log("Received Product ID:", id);
|
||||
|
||||
// if (results.length === 0) {
|
||||
// return res.json({ error: "Product does not exist" });
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
try {
|
||||
const [data] = await db.execute(
|
||||
`
|
||||
SELECT p.*,U.Name AS SellerName,U.Email as SellerEmail,U.Phone as SellerPhone, i.URL AS image_url
|
||||
FROM Product p
|
||||
LEFT JOIN Image_URL i ON p.ProductID = i.ProductID
|
||||
JOIN User U ON p.UserID = U.UserID
|
||||
WHERE p.ProductID = ?
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
// db_con.query(
|
||||
// "INSERT INTO Favorites (UserID, ProductID) VALUES (?, ?)",
|
||||
// [userID, productID],
|
||||
// (err, result) => {
|
||||
// if (err) {
|
||||
// console.error("Error adding favorite product:", err);
|
||||
// return res.json({ error: "Could not add favorite product" });
|
||||
// }
|
||||
// res.json({
|
||||
// success: true,
|
||||
// message: "Product added to favorites successfully",
|
||||
// });
|
||||
// },
|
||||
// );
|
||||
// Log raw data for debugging
|
||||
console.log("Raw Database Result:", data);
|
||||
|
||||
if (data.length === 0) {
|
||||
console.log("No product found with ID:", id);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "Product not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Collect all image URLs
|
||||
const images = data
|
||||
.map((row) => row.image_url)
|
||||
.filter((url) => url !== null);
|
||||
|
||||
// Create product object with all details from first row and collected images
|
||||
const product = {
|
||||
...data[0], // Base product details
|
||||
images: images, // Collected image URLs
|
||||
};
|
||||
|
||||
// Log processed product for debugging
|
||||
console.log("Processed Product:", product);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Product fetched successfully",
|
||||
data: product,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Full Error Details:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Database error occurred",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.getProductWithPagination = async (req, res) => {
|
||||
const limit = +req.query.limit;
|
||||
const page = +req.query.page;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
try {
|
||||
const [data, fields] = await db.execute(
|
||||
`
|
||||
SELECT
|
||||
P.ProductID,
|
||||
P.Name AS ProductName,
|
||||
P.Price,
|
||||
P.Date AS DateUploaded,
|
||||
U.Name AS SellerName,
|
||||
MIN(I.URL) AS ProductImage,
|
||||
C.Name AS Category
|
||||
FROM Product P
|
||||
LEFT JOIN Image_URL I ON P.ProductID = I.ProductID
|
||||
LEFT JOIN User U ON P.UserID = U.UserID
|
||||
LEFT JOIN Category C ON P.CategoryID = C.CategoryID
|
||||
GROUP BY
|
||||
P.ProductID,
|
||||
P.Name,
|
||||
P.Price,
|
||||
P.Date,
|
||||
U.Name,
|
||||
C.Name
|
||||
ORDER BY P.ProductID ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`,
|
||||
[limit.toString(), offset.toString()],
|
||||
);
|
||||
|
||||
const [result] = await db.execute(
|
||||
`SELECT COUNT(*) AS totalProd FROM Product`,
|
||||
);
|
||||
const { totalProd } = result[0];
|
||||
|
||||
return res.json({ totalProd, products: data });
|
||||
} catch (error) {
|
||||
res.json({ error: "Error fetching products!" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeAnyProduct = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
console.log(id);
|
||||
try {
|
||||
const [result] = await db.execute(
|
||||
`DELETE FROM Product WHERE ProductID = ?`,
|
||||
[id],
|
||||
);
|
||||
res.json({ message: "Delete product successfully!" });
|
||||
} catch (error) {
|
||||
res.json({ error: "Cannot remove product from database!" });
|
||||
}
|
||||
};
|
||||
|
||||
53
backend/controllers/recommendation.js
Normal file
@@ -0,0 +1,53 @@
|
||||
const db = require("../utils/database");
|
||||
|
||||
// TODO: Get the recommondaed product given the userID
|
||||
exports.RecommondationByUserId = async (req, res) => {
|
||||
const { id } = req.body;
|
||||
try {
|
||||
const [data, fields] = await db.execute(
|
||||
`
|
||||
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
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
302
backend/controllers/review.js
Normal file
@@ -0,0 +1,302 @@
|
||||
const db = require("../utils/database");
|
||||
|
||||
/**
|
||||
* Get reviews for a specific product
|
||||
* Returns both reviews for the product and reviews by the product owner for other products
|
||||
*/
|
||||
exports.getReviews = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
console.log("Received Product ID:", id);
|
||||
|
||||
try {
|
||||
// First query: Get reviews for this specific product
|
||||
const [productReviews] = 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,
|
||||
'product' AS ReviewType
|
||||
FROM Review R
|
||||
JOIN User U ON R.UserID = U.UserID
|
||||
JOIN Product P ON R.ProductID = P.ProductID
|
||||
WHERE R.ProductID = ?`,
|
||||
[id],
|
||||
);
|
||||
|
||||
// // Second query: Get reviews written by the product owner for other products
|
||||
// const [sellerReviews] = 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,
|
||||
// 'seller' AS ReviewType
|
||||
// FROM Review R
|
||||
// JOIN User U ON R.UserID = U.UserID
|
||||
// JOIN Product P ON R.ProductID = P.ProductID
|
||||
// WHERE R.UserID = (
|
||||
// SELECT UserID
|
||||
// FROM Product
|
||||
// WHERE ProductID = ?
|
||||
// )
|
||||
// AND R.ProductID != ?`,
|
||||
// [id, id],
|
||||
// );
|
||||
|
||||
// Combine the results
|
||||
const combinedReviews = [...productReviews];
|
||||
|
||||
// Log data for debugging
|
||||
console.log("Combined Reviews:", combinedReviews);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Reviews fetched successfully",
|
||||
data: combinedReviews,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Full Error Details:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Database error occurred",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Submit a new review for a product
|
||||
*/
|
||||
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 {
|
||||
// Check if user has already reviewed this product
|
||||
const [existingReview] = await db.execute(
|
||||
`SELECT ReviewID FROM Review WHERE ProductID = ? AND UserID = ?`,
|
||||
[productId, userId],
|
||||
);
|
||||
|
||||
if (existingReview.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "You have already reviewed this product",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is trying to review their own product
|
||||
const [productOwner] = await db.execute(
|
||||
`SELECT UserID FROM Product WHERE ProductID = ?`,
|
||||
[productId],
|
||||
);
|
||||
|
||||
if (productOwner.length > 0 && productOwner[0].UserID === userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "You cannot review your own product",
|
||||
});
|
||||
}
|
||||
|
||||
// Insert the review into the database
|
||||
const [result] = await db.execute(
|
||||
`INSERT INTO Review (
|
||||
ProductID,
|
||||
UserID,
|
||||
Rating,
|
||||
Comment,
|
||||
Date
|
||||
) VALUES (?, ?, ?, ?, NOW())`,
|
||||
[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
|
||||
R.ReviewID,
|
||||
R.ProductID,
|
||||
R.UserID,
|
||||
R.Rating,
|
||||
R.Comment,
|
||||
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.ReviewID = ?`,
|
||||
[reviewId],
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true, // Fixed from false to 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// /**
|
||||
// * Update an existing review
|
||||
// */
|
||||
// exports.updateReview = async (req, res) => {
|
||||
// const { reviewId } = req.params;
|
||||
// const { rating, comment } = req.body;
|
||||
// const userId = req.body.userId; // Assuming you have middleware that validates the user
|
||||
|
||||
// // Validate required fields
|
||||
// if (!reviewId || !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 {
|
||||
// // Check if review exists and belongs to the user
|
||||
// const [existingReview] = await db.execute(
|
||||
// `SELECT ReviewID, UserID FROM Review WHERE ReviewID = ?`,
|
||||
// [reviewId],
|
||||
// );
|
||||
|
||||
// if (existingReview.length === 0) {
|
||||
// return res.status(404).json({
|
||||
// success: false,
|
||||
// message: "Review not found",
|
||||
// });
|
||||
// }
|
||||
|
||||
// if (existingReview[0].UserID !== userId) {
|
||||
// return res.status(403).json({
|
||||
// success: false,
|
||||
// message: "You can only update your own reviews",
|
||||
// });
|
||||
// }
|
||||
|
||||
// // Update the review
|
||||
// await db.execute(
|
||||
// `UPDATE Review
|
||||
// SET Rating = ?, Comment = ?, Date = NOW()
|
||||
// WHERE ReviewID = ?`,
|
||||
// [rating, comment, reviewId],
|
||||
// );
|
||||
|
||||
// // Fetch the updated review
|
||||
// const [updatedReview] = await db.execute(
|
||||
// `SELECT
|
||||
// R.ReviewID,
|
||||
// R.ProductID,
|
||||
// R.UserID,
|
||||
// R.Rating,
|
||||
// R.Comment,
|
||||
// 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.ReviewID = ?`,
|
||||
// [reviewId],
|
||||
// );
|
||||
|
||||
// res.json({
|
||||
// success: true,
|
||||
// message: "Review updated successfully",
|
||||
// data: updatedReview[0],
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Error updating review:", error);
|
||||
// return res.status(500).json({
|
||||
// success: false,
|
||||
// message: "Database error occurred",
|
||||
// error: error.message,
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
|
||||
// /**
|
||||
// * Delete a review
|
||||
// */
|
||||
// exports.deleteReview = async (req, res) => {
|
||||
// const { reviewId } = req.params;
|
||||
// const userId = req.body.userId; // Assuming you have middleware that validates the user
|
||||
|
||||
// try {
|
||||
// // Check if review exists and belongs to the user
|
||||
// const [existingReview] = await db.execute(
|
||||
// `SELECT ReviewID, UserID FROM Review WHERE ReviewID = ?`,
|
||||
// [reviewId],
|
||||
// );
|
||||
|
||||
// if (existingReview.length === 0) {
|
||||
// return res.status(404).json({
|
||||
// success: false,
|
||||
// message: "Review not found",
|
||||
// });
|
||||
// }
|
||||
|
||||
// if (existingReview[0].UserID !== userId) {
|
||||
// return res.status(403).json({
|
||||
// success: false,
|
||||
// message: "You can only delete your own reviews",
|
||||
// });
|
||||
// }
|
||||
|
||||
// // Delete the review
|
||||
// await db.execute(`DELETE FROM Review WHERE ReviewID = ?`, [reviewId]);
|
||||
|
||||
// res.json({
|
||||
// success: true,
|
||||
// message: "Review deleted successfully",
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error("Error deleting review:", error);
|
||||
// return res.status(500).json({
|
||||
// success: false,
|
||||
// message: "Database error occurred",
|
||||
// error: error.message,
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
164
backend/controllers/search.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const db = require("../utils/database");
|
||||
|
||||
exports.searchProductsByName = async (req, res) => {
|
||||
const { name } = req.query;
|
||||
|
||||
if (name.length === 0) {
|
||||
console.log("Searching for products with no name", name);
|
||||
}
|
||||
|
||||
console.log("Searching for products with name:", name);
|
||||
|
||||
try {
|
||||
// Modify SQL to return all products when no search term is provided
|
||||
const sql = `
|
||||
SELECT p.*, i.URL as image
|
||||
FROM Product p
|
||||
LEFT JOIN Image_URL i ON p.ProductID = i.ProductID
|
||||
${name ? "WHERE p.Name LIKE ?" : ""}
|
||||
ORDER BY p.ProductID
|
||||
`;
|
||||
|
||||
const params = name ? [`%${name}%`] : [];
|
||||
console.log("Executing SQL:", sql);
|
||||
console.log("With parameters:", params);
|
||||
|
||||
const [data] = await db.execute(sql, params);
|
||||
|
||||
console.log("Raw Database Result:", data);
|
||||
|
||||
if (data.length === 0) {
|
||||
console.log("No products found matching:", name);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "No products found matching your search",
|
||||
});
|
||||
}
|
||||
|
||||
// Group products by ProductID to handle multiple images per product
|
||||
const productsMap = new Map();
|
||||
|
||||
data.forEach((row) => {
|
||||
if (!productsMap.has(row.ProductID)) {
|
||||
const product = {
|
||||
ProductID: row.ProductID,
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
Price: row.Price,
|
||||
images: row.image,
|
||||
};
|
||||
productsMap.set(row.ProductID, product);
|
||||
} else if (row.image_url) {
|
||||
productsMap.get(row.ProductID).images.push(row.image_url);
|
||||
}
|
||||
});
|
||||
|
||||
const products = Array.from(productsMap.values());
|
||||
|
||||
console.log("Processed Products:", products);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Products fetched successfully",
|
||||
data: products,
|
||||
count: products.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Database Error:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "Database error occurred",
|
||||
error: error.message || "Unknown database error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// exports.searchProductsByName = async (req, res) => {
|
||||
// const { name } = req.query;
|
||||
|
||||
// // Add better validation and error handling
|
||||
// if (!name || typeof name !== "string") {
|
||||
// return res.status(400).json({
|
||||
// success: false,
|
||||
// message: "Valid search term is required",
|
||||
// });
|
||||
// }
|
||||
|
||||
// console.log("Searching for products with name:", name);
|
||||
|
||||
// try {
|
||||
// // Log the SQL query and parameters for debugging
|
||||
// const sql = `
|
||||
// SELECT p.*, i.URL AS image_url
|
||||
// FROM Product p
|
||||
// LEFT JOIN Image_URL i ON p.ProductID = i.ProductID
|
||||
// WHERE p.Name LIKE ?
|
||||
// `;
|
||||
// const params = [`%${name}%`];
|
||||
// console.log("Executing SQL:", sql);
|
||||
// console.log("With parameters:", params);
|
||||
|
||||
// const [data] = await db.execute(sql, params);
|
||||
|
||||
// // Log raw data for debugging
|
||||
// console.log("Raw Database Result:", data);
|
||||
|
||||
// if (data.length === 0) {
|
||||
// console.log("No products found matching:", name);
|
||||
// return res.status(404).json({
|
||||
// success: false,
|
||||
// message: "No products found matching your search",
|
||||
// });
|
||||
// }
|
||||
|
||||
// // Group products by ProductID to handle multiple images per product
|
||||
// const productsMap = new Map();
|
||||
|
||||
// data.forEach((row) => {
|
||||
// if (!productsMap.has(row.ProductID)) {
|
||||
// // Create a clean object without circular references
|
||||
// const product = {
|
||||
// ProductID: row.ProductID,
|
||||
// Name: row.Name,
|
||||
// Description: row.Description,
|
||||
// Price: row.Price,
|
||||
// // Add any other product fields you need
|
||||
// images: row.image_url ? [row.image_url] : [],
|
||||
// };
|
||||
// productsMap.set(row.ProductID, product);
|
||||
// } else if (row.image_url) {
|
||||
// // Add additional image to existing product
|
||||
// productsMap.get(row.ProductID).images.push(row.image_url);
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Convert map to array of products
|
||||
// const products = Array.from(productsMap.values());
|
||||
|
||||
// // Log processed products for debugging
|
||||
// console.log("Processed Products:", products);
|
||||
|
||||
// res.json({
|
||||
// success: true,
|
||||
// message: "Products fetched successfully",
|
||||
// data: products,
|
||||
// count: products.length,
|
||||
// });
|
||||
// } catch (error) {
|
||||
// // Enhanced error logging
|
||||
// console.error("Database Error Details:", {
|
||||
// message: error.message,
|
||||
// code: error.code,
|
||||
// errno: error.errno,
|
||||
// sqlState: error.sqlState,
|
||||
// sqlMessage: error.sqlMessage,
|
||||
// sql: error.sql,
|
||||
// });
|
||||
|
||||
// return res.status(500).json({
|
||||
// success: false,
|
||||
// message: "Database error occurred",
|
||||
// error: error.message || "Unknown database error",
|
||||
// });
|
||||
// }
|
||||
// };
|
||||
225
backend/controllers/transaction.js
Normal file
@@ -0,0 +1,225 @@
|
||||
const db = require("../utils/database");
|
||||
|
||||
exports.getTransactionWithPagination = async (req, res) => {
|
||||
const limit = +req.query?.limit;
|
||||
const page = +req.query?.page;
|
||||
const offset = (page - 1) * limit;
|
||||
try {
|
||||
const [data, _] = await db.execute(
|
||||
`SELECT T.TransactionID, DATE_FORMAT(T.Date, '%b-%d-%Y %h:%i %p') as Date, T.PaymentStatus, U.Name as UserName, P.Name as ProductName
|
||||
FROM Transaction T
|
||||
LEFT JOIN User U ON T.UserID = U.UserID
|
||||
LEFT JOIN Product P ON T.ProductID = P.ProductID
|
||||
ORDER BY T.TransactionID ASC LIMIT ? OFFSET ?`,
|
||||
[limit.toString(), offset.toString()],
|
||||
);
|
||||
|
||||
const [result] = await db.execute(
|
||||
"SELECT COUNT(*) AS count FROM Transaction",
|
||||
);
|
||||
const { count: total } = result[0];
|
||||
return res.json({ data, total });
|
||||
} catch (error) {
|
||||
res.json({ error: "Cannot fetch transactions from database!" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.removeTransation = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const [result] = await db.execute(
|
||||
"DELETE FROM Transaction WHERE TransactionID = ?;",
|
||||
[id.toString()],
|
||||
);
|
||||
return res.json({ message: "Remove transaction successfully!" });
|
||||
} catch (error) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Cannot remove transactions from database!" });
|
||||
}
|
||||
};
|
||||
// Create a new transaction
|
||||
exports.createTransaction = async (req, res) => {
|
||||
const { userID, productID, date, paymentStatus } = req.body;
|
||||
|
||||
try {
|
||||
// Check if the transaction already exists for the same user and product
|
||||
const [existingTransaction] = await db.execute(
|
||||
`SELECT TransactionID FROM Transaction WHERE UserID = ? AND ProductID = ?`,
|
||||
[userID, productID],
|
||||
);
|
||||
|
||||
if (existingTransaction.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "Transaction already exists for this user and product",
|
||||
});
|
||||
}
|
||||
|
||||
// Format the date
|
||||
const formattedDate = new Date(date)
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace("T", " ");
|
||||
|
||||
// Insert the new transaction
|
||||
const [result] = await db.execute(
|
||||
`INSERT INTO Transaction (UserID, ProductID, Date, PaymentStatus)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[userID, productID, formattedDate, paymentStatus],
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Transaction created successfully",
|
||||
transactionID: result.insertId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating transaction:", error);
|
||||
res.status(500).json({ error: "Could not create transaction" });
|
||||
}
|
||||
};
|
||||
|
||||
// Get all transactions for a given product
|
||||
exports.getTransactionsByProduct = async (req, res) => {
|
||||
const { productID } = req.params;
|
||||
|
||||
try {
|
||||
const [transactions] = await db.execute(
|
||||
`SELECT
|
||||
T.TransactionID,
|
||||
T.UserID,
|
||||
T.ProductID,
|
||||
T.Date,
|
||||
T.PaymentStatus,
|
||||
P.Name AS ProductName,
|
||||
MIN(I.URL) AS Image_URL
|
||||
FROM Transaction T
|
||||
JOIN Product P ON T.ProductID = P.ProductID
|
||||
LEFT JOIN Image_URL I ON P.ProductID = I.ProductID
|
||||
GROUP BY T.TransactionID, T.UserID, T.ProductID, T.Date, T.PaymentStatus, P.Name`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
transactions,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching transactions by product:", error);
|
||||
res.status(500).json({ error: "Could not retrieve transactions" });
|
||||
}
|
||||
};
|
||||
|
||||
// Get all transactions for a given user
|
||||
exports.getTransactionsByUser = async (req, res) => {
|
||||
const { userID } = req.body;
|
||||
|
||||
try {
|
||||
const [transactions] = await db.execute(
|
||||
`SELECT
|
||||
T.TransactionID,
|
||||
T.UserID,
|
||||
T.ProductID,
|
||||
T.Date,
|
||||
T.PaymentStatus,
|
||||
P.Name AS ProductName,
|
||||
I.URL AS Image_URL
|
||||
FROM Transaction T
|
||||
JOIN Product P ON T.ProductID = P.ProductID
|
||||
LEFT JOIN Image_URL I ON P.ProductID = I.ProductID
|
||||
WHERE T.UserID = ?`,
|
||||
[userID],
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
transactions,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching transactions by user:", error);
|
||||
res.status(500).json({ error: "Could not retrieve transactions" });
|
||||
}
|
||||
};
|
||||
|
||||
// Get all transactions in the system
|
||||
exports.getAllTransactions = async (req, res) => {
|
||||
try {
|
||||
const [transactions] = await db.execute(
|
||||
`SELECT
|
||||
T.TransactionID,
|
||||
T.UserID,
|
||||
T.ProductID,
|
||||
T.Date,
|
||||
T.PaymentStatus,
|
||||
P.Name AS ProductName,
|
||||
MIN(I.URL) AS Image_URL
|
||||
FROM Transaction T
|
||||
JOIN Product P ON T.ProductID = P.ProductID
|
||||
LEFT JOIN Image_URL I ON P.ProductID = I.ProductID
|
||||
GROUP BY T.TransactionID, T.UserID, T.ProductID, T.Date, T.PaymentStatus, P.Name`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
transactions,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching all transactions:", error);
|
||||
res.status(500).json({ error: "Could not retrieve transactions" });
|
||||
}
|
||||
};
|
||||
|
||||
// Update the payment status of a transaction
|
||||
exports.updatePaymentStatus = async (req, res) => {
|
||||
const { transactionID, paymentStatus } = req.body;
|
||||
|
||||
try {
|
||||
const [result] = await db.execute(
|
||||
`UPDATE Transaction
|
||||
SET PaymentStatus = ?
|
||||
WHERE TransactionID = ?`,
|
||||
[paymentStatus, transactionID],
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "Transaction not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Payment status updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating payment status:", error);
|
||||
res.status(500).json({ error: "Could not update payment status" });
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a transaction
|
||||
exports.deleteTransaction = async (req, res) => {
|
||||
const { transactionID } = req.body;
|
||||
|
||||
try {
|
||||
const [result] = await db.execute(
|
||||
`DELETE FROM Transaction
|
||||
WHERE TransactionID = ?`,
|
||||
[transactionID],
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, message: "Transaction not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Transaction deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error deleting transaction:", error);
|
||||
res.status(500).json({ error: "Could not delete transaction" });
|
||||
}
|
||||
};
|
||||
@@ -134,6 +134,62 @@ exports.completeSignUp = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.doLogin = async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Input validation
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
found: false,
|
||||
error: "Email and password are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Query to find user with matching email
|
||||
const query = "SELECT * FROM User WHERE email = ?";
|
||||
const [data, fields] = await db.execute(query, [email]);
|
||||
|
||||
// Check if user was found
|
||||
if (data && data.length > 0) {
|
||||
const user = data[0];
|
||||
|
||||
// Verify password match
|
||||
if (user.Password === password) {
|
||||
// Consider using bcrypt for secure password comparison
|
||||
// Return user data without password
|
||||
return res.json({
|
||||
found: true,
|
||||
userID: user.UserID,
|
||||
name: user.Name,
|
||||
email: user.Email,
|
||||
UCID: user.UCID,
|
||||
phone: user.Phone,
|
||||
address: user.Address,
|
||||
});
|
||||
} else {
|
||||
// Password doesn't match
|
||||
return res.json({
|
||||
found: false,
|
||||
error: "Invalid email or password",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// User not found
|
||||
return res.json({
|
||||
found: false,
|
||||
error: "Invalid email or password",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error logging in:", error);
|
||||
return res.status(500).json({
|
||||
found: false,
|
||||
error: "Database error occurred",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.getAllUser = async (req, res) => {
|
||||
try {
|
||||
const [users, fields] = await db.execute("SELECT * FROM User;");
|
||||
@@ -174,6 +230,7 @@ exports.findUserByEmail = async (req, res) => {
|
||||
UCID: user.UCID,
|
||||
phone: user.Phone,
|
||||
address: user.Address,
|
||||
password: user.Password,
|
||||
// Include any other fields your user might have
|
||||
// Make sure the field names match exactly with your database column names
|
||||
});
|
||||
@@ -194,37 +251,47 @@ exports.findUserByEmail = async (req, res) => {
|
||||
};
|
||||
|
||||
exports.updateUser = async (req, res) => {
|
||||
const { userId, ...updateData } = req.body;
|
||||
|
||||
try {
|
||||
const userId = req.body?.userId;
|
||||
const name = req.body?.name;
|
||||
const email = req.body?.email;
|
||||
const phone = req.body?.phone;
|
||||
const UCID = req.body?.UCID;
|
||||
const address = req.body?.address;
|
||||
const password = req.body?.password;
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "User ID is required" });
|
||||
}
|
||||
|
||||
//query dynamically based on provided fields
|
||||
// Build updateData manually
|
||||
const updateData = {};
|
||||
if (name) updateData.name = name;
|
||||
if (email) updateData.email = email;
|
||||
if (phone) updateData.phone = phone;
|
||||
if (UCID) updateData.UCID = UCID;
|
||||
if (address) updateData.address = address;
|
||||
if (password) updateData.password = password;
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return res.status(400).json({ error: "No valid fields to update" });
|
||||
}
|
||||
|
||||
const updateFields = [];
|
||||
const values = [];
|
||||
|
||||
Object.entries(updateData).forEach(([key, value]) => {
|
||||
// Only include fields that are actually in the User table
|
||||
if (["Name", "Email", "Password", "Phone", "UCID"].includes(key)) {
|
||||
updateFields.push(`${key} = ?`);
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
return res.status(400).json({ error: "No valid fields to update" });
|
||||
}
|
||||
|
||||
// Add userId to values array
|
||||
values.push(userId);
|
||||
|
||||
try {
|
||||
const query = `UPDATE User SET ${updateFields.join(", ")} WHERE UserID = ?`;
|
||||
const query = `UPDATE User SET ${updateFields.join(", ")} WHERE userId = ?`;
|
||||
const [updateResult] = await db.execute(query, values);
|
||||
|
||||
if (updateResult.affectedRows === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: "User updated successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
@@ -261,3 +328,38 @@ exports.deleteUser = async (req, res) => {
|
||||
return res.status(500).json({ error: "Could not delete user!" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.getUsersWithPagination = async (req, res) => {
|
||||
const limit = +req.query.limit;
|
||||
const page = +req.query.page;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
try {
|
||||
const [users, fields] = await db.execute(
|
||||
"SELECT * FROM User LIMIT ? OFFSET ?",
|
||||
[limit.toString(), offset.toString()]
|
||||
);
|
||||
|
||||
const [result] = await db.execute("SELECT COUNT(*) AS count FROM User");
|
||||
const { count: total } = result[0];
|
||||
|
||||
res.json({ users, total });
|
||||
} catch (error) {
|
||||
console.error("Errors: ", error);
|
||||
return res.status(500).json({ error: "\nCould not fetch users!" });
|
||||
}
|
||||
};
|
||||
|
||||
exports.isAdmin = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const [result] = await db.execute(
|
||||
"SELECT R.Admin FROM marketplace.userrole R WHERE R.UserID = ?",
|
||||
[id]
|
||||
);
|
||||
const { Admin } = result[0];
|
||||
res.json({ isAdmin: Admin });
|
||||
} catch (error) {
|
||||
res.json({ error: "Cannot verify admin status!" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
//Get the db connection
|
||||
|
||||
const db = require("./utils/database");
|
||||
|
||||
const userRouter = require("./routes/user");
|
||||
const productRouter = require("./routes/product");
|
||||
const searchRouter = require("./routes/search");
|
||||
const recommendedRouter = require("./routes/recommendation");
|
||||
const history = require("./routes/history");
|
||||
const review = require("./routes/review");
|
||||
|
||||
const categoryRouter = require("./routes/category");
|
||||
|
||||
const transactionRouter = require("./routes/transaction");
|
||||
|
||||
const { generateEmailTransporter } = require("./utils/mail");
|
||||
const {
|
||||
cleanupExpiredCodes,
|
||||
@@ -28,15 +37,22 @@ transporter
|
||||
console.error("Email connection failed:", error);
|
||||
});
|
||||
|
||||
//Check database connection
|
||||
checkDatabaseConnection(db);
|
||||
|
||||
//Routes
|
||||
app.use("/api/user", userRouter); //prefix with /api/user
|
||||
app.use("/api/product", productRouter); //prefix with /api/product
|
||||
app.use("/api/user", userRouter);
|
||||
app.use("/api/product", productRouter);
|
||||
app.use("/api/search", searchRouter);
|
||||
app.use("/api/engine", recommendedRouter);
|
||||
app.use("/api/history", history);
|
||||
app.use("/api/review", review);
|
||||
app.use("/api/transaction", transactionRouter);
|
||||
app.use("/api/category", categoryRouter);
|
||||
app.use("/api/transaction", transactionRouter);
|
||||
|
||||
// Set up a scheduler to run cleanup every hour
|
||||
setInterval(cleanupExpiredCodes, 60 * 60 * 1000);
|
||||
clean_up_time = 30 * 60 * 1000;
|
||||
setInterval(cleanupExpiredCodes, clean_up_time);
|
||||
|
||||
app.listen(3030, () => {
|
||||
console.log(`Running Backend on http://localhost:3030/`);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
func cosine(x, y []int) float64 {
|
||||
var dotProduct, normX, normY float64
|
||||
for i := 1; i < len(x); i++ {
|
||||
dotProduct += float64(x[i] * y[i])
|
||||
normX += float64(x[i] * x[i])
|
||||
normY += float64(y[i] * y[i])
|
||||
}
|
||||
return dotProduct / (math.Sqrt(normX) * math.Sqrt(normY))
|
||||
}
|
||||
|
||||
func main() {
|
||||
history := [][]int{
|
||||
{1, 0, 1, 0},
|
||||
{0, 1, 0, 1},
|
||||
{1, 0, 1, 1},
|
||||
}
|
||||
|
||||
productCategory := [][]int{
|
||||
{1, 1, 0, 0},
|
||||
{0, 0, 0, 1},
|
||||
{0, 0, 1, 0},
|
||||
{0, 0, 1, 1},
|
||||
{0, 1, 0, 0},
|
||||
|
||||
{0, 1, 1, 1},
|
||||
{1, 1, 1, 0},
|
||||
{0, 0, 0, 1},
|
||||
{1, 1, 1, 1},
|
||||
}
|
||||
|
||||
// Calculate similarity between first search and each product
|
||||
for i, product := range productCategory {
|
||||
sim := cosine(history[0], product)
|
||||
fmt.Printf("Similarity with product %d: %f\n", i, sim)
|
||||
}
|
||||
}
|
||||
16
backend/routes/category.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const express = require("express");
|
||||
const {
|
||||
getAllCategoriesWithPagination,
|
||||
addCategory,
|
||||
removeCategory,
|
||||
getAllCategory,
|
||||
} = require("../controllers/category");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/getCategories", getAllCategoriesWithPagination);
|
||||
router.post("/addCategory", addCategory);
|
||||
router.delete("/:id", removeCategory);
|
||||
router.get("/", getAllCategory);
|
||||
|
||||
module.exports = router;
|
||||
14
backend/routes/history.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// routes/product.js
|
||||
const express = require("express");
|
||||
const {
|
||||
HistoryByUserId,
|
||||
DelHistory,
|
||||
AddHistory,
|
||||
} = require("../controllers/history");
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/getHistory", HistoryByUserId);
|
||||
router.post("/delHistory", DelHistory);
|
||||
router.post("/addHistory", AddHistory);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +1,42 @@
|
||||
// routes/product.js
|
||||
const express = require("express");
|
||||
const { addToFavorite, getAllProducts } = require("../controllers/product");
|
||||
|
||||
const {
|
||||
addFavorite,
|
||||
getFavorites,
|
||||
removeFavorite,
|
||||
getAllProducts,
|
||||
getProductById,
|
||||
addProduct,
|
||||
removeProduct,
|
||||
removeAnyProduct,
|
||||
getProductWithPagination,
|
||||
myProduct,
|
||||
updateProduct,
|
||||
} = require("../controllers/product");
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/add_fav_product", addToFavorite);
|
||||
// Add detailed logging middleware
|
||||
router.use((req, res, next) => {
|
||||
console.log(`Incoming ${req.method} request to ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
router.get("/get_product", getAllProducts);
|
||||
router.post("/addFavorite", addFavorite);
|
||||
router.post("/getFavorites", getFavorites);
|
||||
router.post("/delFavorite", removeFavorite);
|
||||
|
||||
router.post("/delProduct", removeProduct);
|
||||
router.post("/myProduct", myProduct);
|
||||
router.post("/addProduct", addProduct);
|
||||
router.get("/getProduct", getAllProducts);
|
||||
|
||||
//Remove product
|
||||
router.delete("/any/:id", removeAnyProduct);
|
||||
//Get products with pagination
|
||||
router.get("/getProductWithPagination", getProductWithPagination);
|
||||
|
||||
router.get("/:id", getProductById); // Simplified route
|
||||
|
||||
router.put("/update/:productId", updateProduct);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
8
backend/routes/recommendation.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// routes/product.js
|
||||
const express = require("express");
|
||||
const { RecommondationByUserId } = require("../controllers/recommendation");
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/recommended", RecommondationByUserId);
|
||||
|
||||
module.exports = router;
|
||||
9
backend/routes/review.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// routes/product.js
|
||||
const express = require("express");
|
||||
const { getReviews, submitReview } = require("../controllers/review");
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/:id", getReviews);
|
||||
router.post("/addReview", submitReview);
|
||||
|
||||
module.exports = router;
|
||||
14
backend/routes/search.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// routes/product.js
|
||||
const express = require("express");
|
||||
const { searchProductsByName } = require("../controllers/search");
|
||||
const router = express.Router();
|
||||
|
||||
// Add detailed logging middleware
|
||||
router.use((req, res, next) => {
|
||||
console.log(`Incoming ${req.method} request to ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
router.get("/getProduct", searchProductsByName);
|
||||
|
||||
module.exports = router;
|
||||
42
backend/routes/transaction.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// routes/transaction.js
|
||||
const express = require("express");
|
||||
const {
|
||||
createTransaction,
|
||||
getTransactionsByProduct,
|
||||
getTransactionsByUser,
|
||||
getAllTransactions,
|
||||
updatePaymentStatus,
|
||||
deleteTransaction,
|
||||
getTransactionWithPagination,
|
||||
removeTransation,
|
||||
} = require("../controllers/transaction");
|
||||
const router = express.Router();
|
||||
|
||||
// logging middleware
|
||||
router.use((req, res, next) => {
|
||||
console.log(`Incoming ${req.method} ${req.originalUrl}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Create a new transaction
|
||||
router.post("/createTransaction", createTransaction);
|
||||
|
||||
// Get all transactions for a specific product
|
||||
router.get("/getTransactionsByProduct/:productID", getTransactionsByProduct);
|
||||
|
||||
// Get all transactions for a specific user
|
||||
router.post("/getTransactionsByUser", getTransactionsByUser);
|
||||
|
||||
// Get all transactions in the system
|
||||
router.post("/getAllTransactions", getAllTransactions);
|
||||
|
||||
// Update payment status on a transaction
|
||||
router.patch("/updatePaymentStatus", updatePaymentStatus);
|
||||
|
||||
// Delete a transaction
|
||||
router.delete("/deleteTransaction", deleteTransaction);
|
||||
|
||||
router.get("/getTransactions", getTransactionWithPagination);
|
||||
router.delete("/:id", removeTransation);
|
||||
|
||||
module.exports = router;
|
||||
@@ -7,6 +7,9 @@ const {
|
||||
findUserByEmail,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
doLogin,
|
||||
isAdmin,
|
||||
getUsersWithPagination,
|
||||
} = require("../controllers/user");
|
||||
|
||||
const router = express.Router();
|
||||
@@ -26,10 +29,19 @@ router.get("/fetch_all_users", getAllUser);
|
||||
//Fetch One user Data with all fields:
|
||||
router.post("/find_user", findUserByEmail);
|
||||
|
||||
//Fetch One user Data with all fields:
|
||||
router.post("/do_login", doLogin);
|
||||
|
||||
//Update A uses Data:
|
||||
router.post("/update", updateUser);
|
||||
|
||||
//Delete A uses Data:
|
||||
router.post("/delete", deleteUser);
|
||||
|
||||
//Check admin status
|
||||
router.get("/isAdmin/:id", isAdmin);
|
||||
|
||||
//Fetch user with pagination
|
||||
router.get("/getUserWithPagination", getUsersWithPagination);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
const mysql = require("mysql2");
|
||||
|
||||
//Create a pool of connections to allow multiple query happen at the same time
|
||||
const pool = mysql.createPool({
|
||||
host: "localhost",
|
||||
user: "root",
|
||||
database: "marketplace",
|
||||
password: "12345678",
|
||||
database: "Marketplace",
|
||||
});
|
||||
|
||||
//Export a promise for promise-based query
|
||||
// const pool = mysql.createPool(
|
||||
// "singlestore://mann-619d0:<mann-619d0 Password>@svc-3482219c-a389-4079-b18b-d50662524e8a-shared-dml.aws-virginia-6.svc.singlestore.com:3333/db_mann_48ba9?ssl={}",
|
||||
// );
|
||||
|
||||
module.exports = pool.promise();
|
||||
|
||||
@@ -18,7 +18,7 @@ async function sendVerificationEmail(email, verificationCode) {
|
||||
|
||||
// Clean up expired verification codes (run this periodically)
|
||||
function cleanupExpiredCodes() {
|
||||
db_con.query(
|
||||
db.query(
|
||||
"DELETE FROM AuthVerification WHERE Date < DATE_SUB(NOW(), INTERVAL 15 MINUTE) AND Authenticated = 0",
|
||||
(err, result) => {
|
||||
if (err) {
|
||||
|
||||
125
frontend/package-lock.json
generated
@@ -9,9 +9,11 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"axios": "^1.8.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1770,6 +1772,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.20",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
|
||||
@@ -1824,6 +1832,17 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
|
||||
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -1898,7 +1917,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -1993,6 +2011,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -2153,6 +2183,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
@@ -2182,7 +2221,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
@@ -2283,7 +2321,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -2293,7 +2330,6 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -2331,7 +2367,6 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -2344,7 +2379,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -2732,6 +2766,26 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -2748,6 +2802,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -2780,7 +2849,6 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -2831,7 +2899,6 @@
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
|
||||
"integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
@@ -2856,7 +2923,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
@@ -2931,7 +2997,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3002,7 +3067,6 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3015,7 +3079,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -3031,7 +3094,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -3895,12 +3957,32 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -4251,6 +4333,12 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4282,6 +4370,15 @@
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-icons": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.9",
|
||||
"axios": "^1.8.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB |
BIN
frontend/public/Uploads/Acoustic-Guitar.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/public/Uploads/Backpack.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/Uploads/Basketball.jpg
Normal file
|
After Width: | Height: | Size: 774 KiB |
BIN
frontend/public/Uploads/Bluetooth-Speaker.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/Uploads/CS-Textbook.jpg
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/Uploads/Calculator.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
frontend/public/Uploads/Calculus-Textbook.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/Uploads/Calculus-Textbook2.jpg
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
frontend/public/Uploads/Calculus-Textbook3.jpg
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
frontend/public/Uploads/Controller.jpg
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
BIN
frontend/public/Uploads/Desk-Lamp.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
frontend/public/Uploads/Dorm-Desk.jpg
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
frontend/public/Uploads/HP-Calculator.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
frontend/public/Uploads/HP-Laptop1.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
frontend/public/Uploads/HP-Laptop2.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/Uploads/Lab-Coat.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/Uploads/Mini-Fridge.jpg
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
frontend/public/Uploads/Mountain-Bike.jpg
Normal file
|
After Width: | Height: | Size: 577 KiB |
BIN
frontend/public/Uploads/Physics-Textbook.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
frontend/public/Uploads/University-Hoodie.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/public/Uploads/Winter-Jacket.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/Uploads/Wireless-Mouse.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
frontend/public/Uploads/Yoga-Mat.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 923 KiB |
|
Before Width: | Height: | Size: 413 KiB |
@@ -4,6 +4,7 @@ import {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Home from "./pages/Home";
|
||||
@@ -12,7 +13,10 @@ import Selling from "./pages/Selling";
|
||||
import Transactions from "./pages/Transactions";
|
||||
import Favorites from "./pages/Favorites";
|
||||
import ProductDetail from "./pages/ProductDetail";
|
||||
import ItemForm from "./pages/MyListings";
|
||||
import SearchPage from "./pages/SearchPage";
|
||||
import Dashboard from "./pages/Dashboard"; // The single consolidated dashboard component
|
||||
import DashboardNav from "./components/DashboardNav";
|
||||
import { verifyIsAdmin } from "./api/admin";
|
||||
|
||||
function App() {
|
||||
// Authentication state - initialize from localStorage if available
|
||||
@@ -30,6 +34,23 @@ function App() {
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Product recommendation states
|
||||
const [isGeneratingRecommendations, setIsGeneratingRecommendations] =
|
||||
useState(false);
|
||||
const [recommendations, setRecommendations] = useState([]);
|
||||
|
||||
// Admin state
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [showAdminDashboard, setShowAdminDashboard] = useState(false);
|
||||
|
||||
// Check URL to determine if we're in admin mode
|
||||
useEffect(() => {
|
||||
// If URL contains /admin, set showAdminDashboard to true
|
||||
if (window.location.pathname.includes("/admin")) {
|
||||
setShowAdminDashboard(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// New verification states
|
||||
const [verificationStep, setVerificationStep] = useState("initial"); // 'initial', 'code-sent', 'verifying'
|
||||
const [tempUserData, setTempUserData] = useState(null);
|
||||
@@ -50,6 +71,93 @@ function App() {
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
sendSessionDataToServer();
|
||||
}
|
||||
}, [isAuthenticated, user]);
|
||||
|
||||
// Generate product recommendations when user logs in
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
generateProductRecommendations();
|
||||
}
|
||||
}, [isAuthenticated, user]);
|
||||
|
||||
// Generate product recommendations
|
||||
const generateProductRecommendations = async () => {
|
||||
try {
|
||||
setIsGeneratingRecommendations(true);
|
||||
|
||||
// Add a short delay to simulate calculation time
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
console.log("Generating product recommendations for user:", user.ID);
|
||||
|
||||
// Make API call to get recommendations
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/recommendations/generate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: user.ID,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate recommendations");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
console.log(
|
||||
"Recommendations generated successfully:",
|
||||
result.recommendations,
|
||||
);
|
||||
setRecommendations(result.recommendations);
|
||||
|
||||
// Store recommendations in session storage for access across the app
|
||||
sessionStorage.setItem(
|
||||
"userRecommendations",
|
||||
JSON.stringify(result.recommendations),
|
||||
);
|
||||
} else {
|
||||
console.error("Error generating recommendations:", result.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error generating product recommendations:", err);
|
||||
} finally {
|
||||
setIsGeneratingRecommendations(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const userInfo = sessionStorage.getItem("user")
|
||||
? JSON.parse(sessionStorage.getItem("user"))
|
||||
: "";
|
||||
const id = userInfo?.ID;
|
||||
verifyIsAdmin(id).then((data) => {
|
||||
setIsAdmin(data.isAdmin);
|
||||
});
|
||||
}, [user]);
|
||||
|
||||
const handleShowAdminDashboard = () => {
|
||||
setShowAdminDashboard(true);
|
||||
// Update URL without reloading page
|
||||
window.history.pushState({}, "", "/admin");
|
||||
};
|
||||
|
||||
const handleCloseAdminDashboard = () => {
|
||||
setShowAdminDashboard(false);
|
||||
// Update URL without reloading page
|
||||
window.history.pushState({}, "", "/");
|
||||
};
|
||||
|
||||
// Send verification code
|
||||
const sendVerificationCode = async (userData) => {
|
||||
try {
|
||||
@@ -70,7 +178,7 @@ function App() {
|
||||
email: userData.email,
|
||||
// Add any other required fields
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -119,7 +227,7 @@ function App() {
|
||||
email: tempUserData.email,
|
||||
code: code,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -163,7 +271,7 @@ function App() {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(userData),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -176,6 +284,7 @@ function App() {
|
||||
if (result.success) {
|
||||
// Create user object from API response
|
||||
const newUser = {
|
||||
ID: result.userID || result.ID,
|
||||
name: result.name || userData.name,
|
||||
email: result.email || userData.email,
|
||||
UCID: result.UCID || userData.ucid,
|
||||
@@ -189,11 +298,18 @@ function App() {
|
||||
sessionStorage.setItem("isAuthenticated", "true");
|
||||
sessionStorage.setItem("user", JSON.stringify(newUser));
|
||||
|
||||
// After successful signup, send session data to server
|
||||
sendSessionDataToServer();
|
||||
|
||||
// Reset verification steps
|
||||
setVerificationStep("initial");
|
||||
setTempUserData(null);
|
||||
|
||||
console.log("Signup completed successfully");
|
||||
|
||||
// Generate recommendations for the new user
|
||||
generateProductRecommendations();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
setError(result.message || "Failed to complete signup");
|
||||
@@ -222,11 +338,12 @@ function App() {
|
||||
setError("Email and password are required");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
} else if (!formValues.email.endsWith("@ucalgary.ca")) {
|
||||
setError("Please use your UCalgary email address (@ucalgary.ca)");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// else if (!formValues.email.endsWith("@ucalgary.ca")) {
|
||||
// setError("Please use your UCalgary email address (@ucalgary.ca)");
|
||||
// setIsLoading(false);
|
||||
// return;
|
||||
// }
|
||||
try {
|
||||
if (isSignUp) {
|
||||
// Handle Sign Up with verification
|
||||
@@ -239,7 +356,7 @@ function App() {
|
||||
UCID: formValues.ucid,
|
||||
phone: formValues.phone,
|
||||
password: formValues.password, // This will be needed for the final signup
|
||||
address: "NOT_GIVEN",
|
||||
address: formValues.address, // Add this line
|
||||
client: 1,
|
||||
admin: 0,
|
||||
};
|
||||
@@ -255,7 +372,7 @@ function App() {
|
||||
|
||||
// Make API call to localhost:3030/find_user
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/user/find_user",
|
||||
"http://localhost:3030/api/user/do_login",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -265,7 +382,7 @@ function App() {
|
||||
email: formValues.email,
|
||||
password: formValues.password,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -291,9 +408,11 @@ function App() {
|
||||
sessionStorage.setItem("isAuthenticated", "true");
|
||||
sessionStorage.setItem("user", JSON.stringify(userObj));
|
||||
|
||||
sessionStorage.getItem("user");
|
||||
|
||||
console.log("Login successful for:", userData.email);
|
||||
|
||||
// Start generating recommendations with a slight delay
|
||||
// This will happen in the useEffect, but we set a loading state to show to the user
|
||||
setIsGeneratingRecommendations(true);
|
||||
} else {
|
||||
// Show error message for invalid credentials
|
||||
setError("Invalid email or password");
|
||||
@@ -327,11 +446,13 @@ function App() {
|
||||
setUser(null);
|
||||
setVerificationStep("initial");
|
||||
setTempUserData(null);
|
||||
setRecommendations([]);
|
||||
setShowAdminDashboard(false);
|
||||
|
||||
// Clear localStorage
|
||||
//
|
||||
sessionStorage.removeItem("user");
|
||||
sessionStorage.removeItem("isAuthenticated");
|
||||
sessionStorage.removeItem("userRecommendations");
|
||||
|
||||
console.log("User logged out");
|
||||
};
|
||||
@@ -355,6 +476,51 @@ function App() {
|
||||
setError("");
|
||||
};
|
||||
|
||||
const sendSessionDataToServer = async () => {
|
||||
try {
|
||||
// Retrieve data from sessionStorage
|
||||
const user = JSON.parse(sessionStorage.getItem("user"));
|
||||
|
||||
if (!user || !isAuthenticated) {
|
||||
console.log("User is not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the data to send
|
||||
const requestData = {
|
||||
userId: user.ID, // or user.ID depending on your user structure
|
||||
email: user.email,
|
||||
isAuthenticated,
|
||||
};
|
||||
|
||||
// Send data to Python server (replace with your actual server URL)
|
||||
const response = await fetch("http://0.0.0.0:5000/api/user/session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
// Check the response
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log("Server response:", result);
|
||||
} else {
|
||||
console.error("Failed to send session data to the server");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending session data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading overlay component
|
||||
const LoadingOverlay = () => (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-green-500 border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Login component
|
||||
const LoginComponent = () => (
|
||||
<div className="flex h-screen bg-white">
|
||||
@@ -479,6 +645,25 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSignUp && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="address"
|
||||
className="block mb-1 text-sm font-medium text-gray-800"
|
||||
>
|
||||
Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address"
|
||||
name="address"
|
||||
placeholder="Your address"
|
||||
className="w-full px-4 py-2 border border-gray-300 bg-white text-gray-800 focus:outline-none focus:border-green-500 focus:ring-1 focus:ring-green-500"
|
||||
required={isSignUp}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
@@ -603,16 +788,39 @@ function App() {
|
||||
|
||||
return (
|
||||
<Router>
|
||||
{/* If admin dashboard should be shown */}
|
||||
{showAdminDashboard ? (
|
||||
<div className="flex">
|
||||
<DashboardNav handleCloseAdminDashboard={handleCloseAdminDashboard} />
|
||||
<Routes>
|
||||
{/* Single admin route for consolidated dashboard */}
|
||||
<Route path="/admin/*" element={<Dashboard />} />
|
||||
{/* Any other path in admin mode should go to dashboard */}
|
||||
<Route path="*" element={<Navigate to="/admin" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
) : (
|
||||
/* Normal user interface */
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Show loading overlay when generating recommendations */}
|
||||
{isGeneratingRecommendations && <LoadingOverlay />}
|
||||
|
||||
{/* Only show navbar when authenticated */}
|
||||
{isAuthenticated && (
|
||||
<Navbar onLogout={handleLogout} userName={user?.name} />
|
||||
<Navbar
|
||||
isAdmin={isAdmin}
|
||||
onLogout={handleLogout}
|
||||
userName={user?.name}
|
||||
handleShowAdminDashboard={handleShowAdminDashboard}
|
||||
/>
|
||||
)}
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" /> : <LoginComponent />}
|
||||
element={
|
||||
isAuthenticated ? <Navigate to="/" /> : <LoginComponent />
|
||||
}
|
||||
/>
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
@@ -620,7 +828,7 @@ function App() {
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<Home />
|
||||
<Home recommendations={recommendations} />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -633,6 +841,16 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<SearchPage />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
@@ -653,27 +871,6 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Add new selling routes */}
|
||||
<Route
|
||||
path="/selling/create"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<ItemForm />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/selling/edit/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<ItemForm />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/transactions"
|
||||
element={
|
||||
@@ -701,6 +898,7 @@ function App() {
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
)}
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
119
frontend/src/api/admin.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// api.js
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: "http://localhost:3030/api",
|
||||
});
|
||||
|
||||
// Users
|
||||
export const getUsers = async (page, limit = 10) => {
|
||||
try {
|
||||
const { data } = await client.get(
|
||||
`/user/getUserWithPagination?page=${page}&limit=${limit}`,
|
||||
);
|
||||
return { users: data.users, total: data.total };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeUser = async (id) => {
|
||||
try {
|
||||
const { data } = await client.post(`/user/delete`, { userId: id });
|
||||
return { message: data.message };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyIsAdmin = async (id) => {
|
||||
try {
|
||||
const { data } = await client.get(`/user/isAdmin/${id}`);
|
||||
return { isAdmin: data.isAdmin };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Products
|
||||
export const getProducts = async (page, limit = 10) => {
|
||||
try {
|
||||
const { data } = await client.get(
|
||||
`/product/getProductWithPagination?limit=${limit}&page=${page}`,
|
||||
);
|
||||
return { products: data.products, total: data.totalProd };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeProduct = async (id) => {
|
||||
try {
|
||||
const { data } = await client.delete(`/product/any/${id}`);
|
||||
return { message: data.message };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Categories
|
||||
export const getCategories = async (page, limit = 10) => {
|
||||
try {
|
||||
const { data } = await client.get(
|
||||
`/category/getCategories?page=${page}&limit=${limit}`,
|
||||
);
|
||||
return { data: data.data, total: data.total };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const addCategory = async (name) => {
|
||||
try {
|
||||
const { data } = await client.post(`/category/addCategory`, { name });
|
||||
return { message: data.message };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeCategory = async (id) => {
|
||||
try {
|
||||
const { data } = await client.delete(`/category/${id}`);
|
||||
return { message: data.message };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Transactions
|
||||
export const getTransactions = async (page, limit = 10) => {
|
||||
try {
|
||||
const { data } = await client.get(
|
||||
`/transaction/getTransactions?limit=${limit}&page=${page}`,
|
||||
);
|
||||
return { transactions: data.data, total: data.total };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeTransaction = async (id) => {
|
||||
try {
|
||||
const { data } = await client.delete(`/transaction/${id}`);
|
||||
return { message: data.message };
|
||||
} catch (error) {
|
||||
return handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Shared Error Handler
|
||||
const handleError = (error) => {
|
||||
const { response } = error;
|
||||
if (response?.data) return response.data;
|
||||
return { error: error.message || error };
|
||||
};
|
||||
|
||||
// Optional: export client if you want to use it elsewhere
|
||||
export default client;
|
||||
65
frontend/src/components/CategoryForm.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import { MdAddBox } from "react-icons/md";
|
||||
import { addCategory } from "../api/admin";
|
||||
|
||||
export default function CategoryForm({ visible, onAddCategory }) {
|
||||
const [category, setCategory] = useState("");
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!category.trim()) {
|
||||
document.getElementById("noti").innerHTML = "Category name is missing!";
|
||||
document
|
||||
.getElementById("noti")
|
||||
.classList.add("bg-red-200", "text-red-500");
|
||||
document.getElementById("noti").classList.remove("opacity-0");
|
||||
return;
|
||||
}
|
||||
addCategory(category)
|
||||
.then((message) => {
|
||||
document
|
||||
.getElementById("noti")
|
||||
.classList.remove("opacity-0", "bg-red-200", "text-red-500");
|
||||
document
|
||||
.getElementById("noti")
|
||||
.classList.add("bg-green-200", "text-green-800");
|
||||
document.getElementById("noti").innerHTML = `${message.message}`;
|
||||
setCategory("");
|
||||
onAddCategory();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = ({ target }) => {
|
||||
setCategory(target.value);
|
||||
if (target.value.trim())
|
||||
document.getElementById("noti").classList.add("opacity-0");
|
||||
};
|
||||
|
||||
if (!visible) return;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} action="" className="flex p-2 items-center">
|
||||
<label htmlFor="category" className="text-green-700">
|
||||
Category:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-green-700 ml-2 rounded-sm focus:bg-green-100 text-green-900"
|
||||
name="category"
|
||||
id="category"
|
||||
onChange={handleChange}
|
||||
value={category}
|
||||
/>
|
||||
<button type="submit" className="text-2xl pl-1 text-green-700">
|
||||
<MdAddBox className="text-3xl" />
|
||||
</button>
|
||||
<p
|
||||
id="noti"
|
||||
className="text-red-500 bg-red-200 px-2 rounded-sm opacity-0 mx-2"
|
||||
></p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/DashboardNav.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { FaArrowLeft } from "react-icons/fa";
|
||||
|
||||
export default function DashboardNav({ handleCloseAdminDashboard }) {
|
||||
return (
|
||||
<div className="w-48 min-w-[12rem] bg-gray-100 text-emerald-600 flex flex-col p-4 shadow-md">
|
||||
<button
|
||||
onClick={handleCloseAdminDashboard}
|
||||
className="flex items-center gap-2 text-sm font-medium hover:text-emerald-700 underline underline-offset-4 transition"
|
||||
>
|
||||
<FaArrowLeft className="text-xs mt-[1px]" />
|
||||
Back to User Page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/FloatingAlert.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
// components/FloatingAlert.jsx
|
||||
import { useEffect } from "react";
|
||||
|
||||
const FloatingAlert = ({ message, onClose, duration = 3000 }) => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose, duration]);
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 bg-emerald-500 text-white px-4 py-2 rounded-xl shadow-lg z-50 text-center">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default FloatingAlert;
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import UserDropdown from "./UserDropdown";
|
||||
import { Search, Heart } from "lucide-react";
|
||||
|
||||
const Navbar = ({ onLogout, userName }) => {
|
||||
const Navbar = ({ onLogout, userName, isAdmin, handleShowAdminDashboard }) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -12,8 +13,14 @@ const Navbar = ({ onLogout, userName }) => {
|
||||
|
||||
const handleSearchSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
console.log("Searching for:", searchQuery);
|
||||
// TODO: Implement search functionality
|
||||
|
||||
// if (!searchQuery.trim()) return;
|
||||
|
||||
// Navigate to search page with query
|
||||
navigate({
|
||||
pathname: "/search",
|
||||
search: `?name=${encodeURIComponent(searchQuery)}`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -28,7 +35,7 @@ const Navbar = ({ onLogout, userName }) => {
|
||||
alt="Campus Plug"
|
||||
className="h-8 px-2"
|
||||
/>
|
||||
<span className="hidden md:block text-green-600 font-bold text-xl">
|
||||
<span className="hidden md:block text-emerald-600 font-bold text-xl">
|
||||
Campus Plug
|
||||
</span>
|
||||
</Link>
|
||||
@@ -40,14 +47,20 @@ const Navbar = ({ onLogout, userName }) => {
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for books, electronics, furniture..."
|
||||
className="w-full p-2 pl-10 pr-4 border border-gray-300 focus:outline-none focus:border-green-500 focus:ring-1 focus:ring-green-500"
|
||||
placeholder="Search for anything..."
|
||||
className="w-full p-2 pl-10 pr-4 border border-gray-300 focus:outline-none focus:border-[#ed7f30]-500 focus:ring-1 focus:ring-[#ed7f30]-500"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-y-0 right-0 flex items-center px-3 text-gray-500 hover:text-[#ed7f30]-500"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -57,12 +70,18 @@ const Navbar = ({ onLogout, userName }) => {
|
||||
{/* Favorites Button */}
|
||||
<Link
|
||||
to="/favorites"
|
||||
className="p-2 text-gray-600 hover:text-green-600"
|
||||
className="p-2 text-gray-600 hover:text-[#ed7f30]-600"
|
||||
>
|
||||
<Heart className="h-6 w-6" />
|
||||
</Link>
|
||||
|
||||
{/* User Profile */}
|
||||
<UserDropdown onLogout={onLogout} userName={userName} />
|
||||
<UserDropdown
|
||||
isAdmin={isAdmin}
|
||||
onLogout={onLogout}
|
||||
userName={userName}
|
||||
handleShowAdminDashboard={handleShowAdminDashboard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
99
frontend/src/components/Pagination.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
|
||||
export default function Pagination({ pageNum, onChange }) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const pages = [];
|
||||
for (let i = 1; i <= pageNum; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
const handleClick = (page) => {
|
||||
setCurrentPage(page);
|
||||
onChange(page);
|
||||
};
|
||||
|
||||
const handleTogglePage = (type) => {
|
||||
let current = currentPage;
|
||||
if (type == "next")
|
||||
current = current + 1 <= pageNum ? current + 1 : current;
|
||||
else current = current - 1 >= 1 ? current - 1 : current;
|
||||
setCurrentPage(current);
|
||||
onChange(current);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav aria-label="Page navigation" className="flex justify-end">
|
||||
<ul className="flex items-center -space-x-px h-8 text-sm mt-4 pr-0 font-bold">
|
||||
<li>
|
||||
<NavLink
|
||||
onClick={() => {
|
||||
handleTogglePage("previous");
|
||||
}}
|
||||
className=" flex items-center justify-center px-3 h-8 ms-0 leading-tight border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 text-white bg-green-700 border border-gray-300 hover:bg-green-600 hover:text-white"
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<svg
|
||||
className="w-2.5 h-2.5 rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 6 10"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M5 1 1 5l4 4"
|
||||
/>
|
||||
</svg>
|
||||
</NavLink>
|
||||
</li>
|
||||
{pages.map((page) => (
|
||||
<li key={page}>
|
||||
<NavLink
|
||||
className={`${
|
||||
currentPage == page ? "bg-green-600" : "bg-green-700"
|
||||
} +
|
||||
" flex items-center justify-center px-3 h-8 leading-tight text-white border border-gray-300 hover:bg-green-600 hover:text-white"`}
|
||||
onClick={() => {
|
||||
handleClick(page);
|
||||
}}
|
||||
>
|
||||
{page}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<NavLink
|
||||
onClick={() => {
|
||||
handleTogglePage("next");
|
||||
}}
|
||||
className="flex items-center justify-center px-3 h-8 leading-tight border border-gray-300 rounded-e-lg text-white bg-green-700 border border-gray-300 hover:bg-green-600 hover:text-white"
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<svg
|
||||
className="w-2.5 h-2.5 rtl:rotate-180"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 6 10"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="m1 9 4-4-4-4"
|
||||
/>
|
||||
</svg>
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { User, Settings, ShoppingBag, DollarSign, LogOut } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { User, Settings, ShoppingBag, DollarSign, LogOut } from "lucide-react";
|
||||
import { RiAdminLine } from "react-icons/ri";
|
||||
|
||||
const UserDropdown = ({ onLogout, userName }) => {
|
||||
const UserDropdown = ({
|
||||
onLogout,
|
||||
userName,
|
||||
isAdmin,
|
||||
handleShowAdminDashboard,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Use passed userName or fallback to default
|
||||
const displayName = userName || 'User';
|
||||
const displayName = userName || "User";
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen(!isOpen);
|
||||
@@ -22,9 +28,9 @@ const UserDropdown = ({ onLogout, userName }) => {
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -39,7 +45,7 @@ const UserDropdown = ({ onLogout, userName }) => {
|
||||
}
|
||||
|
||||
// Navigate to login page (this may be redundant as App.jsx should handle redirection)
|
||||
navigate('/login');
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -48,8 +54,8 @@ const UserDropdown = ({ onLogout, userName }) => {
|
||||
className="flex items-center focus:outline-none"
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-green-600" />
|
||||
<div className="h-8 w-8 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -89,6 +95,20 @@ const UserDropdown = ({ onLogout, userName }) => {
|
||||
Settings
|
||||
</Link>
|
||||
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={() => {
|
||||
handleShowAdminDashboard();
|
||||
}}
|
||||
>
|
||||
<RiAdminLine className="h-4 w-4 mr-2 text-gray-500" />
|
||||
Admin
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={handleLogout}
|
||||
|
||||
362
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
getUsers,
|
||||
removeUser,
|
||||
getTransactions,
|
||||
removeTransaction,
|
||||
getProducts,
|
||||
removeProduct,
|
||||
getCategories,
|
||||
removeCategory,
|
||||
} from "../api/admin";
|
||||
import { MdDelete } from "react-icons/md";
|
||||
import { IoAddCircleSharp } from "react-icons/io5";
|
||||
import { FaHome } from "react-icons/fa";
|
||||
import Pagination from "../components/Pagination";
|
||||
import CategoryForm from "../components/CategoryForm";
|
||||
import DashboardNav from "../components/DashboardNav";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// Spinner Component
|
||||
const Spinner = () => (
|
||||
<div className="flex justify-center items-center h-40 w-full">
|
||||
<div className="w-12 h-12 border-4 border-green-500 border-dashed rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Empty State Component
|
||||
const EmptyState = () => (
|
||||
<div className="flex flex-col items-center justify-center bg-gray-50 py-10 rounded-lg w-full">
|
||||
<svg
|
||||
className="w-16 h-16 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
></path>
|
||||
</svg>
|
||||
<p className="mt-4 text-lg font-medium text-gray-600">No data found</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
No records are currently available.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Generic Dashboard Component
|
||||
const Dashboard = ({
|
||||
fetchDataFn,
|
||||
deleteFn,
|
||||
columns,
|
||||
idKey,
|
||||
refreshKey = 0,
|
||||
headerAction = null,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageLimit = 10;
|
||||
|
||||
const fetchItems = useCallback(
|
||||
(page = 1, limit = 10) => {
|
||||
setLoading(true);
|
||||
fetchDataFn(page, limit)
|
||||
.then((res) => {
|
||||
const data =
|
||||
res.users || res.products || res.transactions || res.data || [];
|
||||
setItems(data);
|
||||
setTotal(res.total);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching data:", error);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[fetchDataFn],
|
||||
);
|
||||
|
||||
const handleRemove = (id) => {
|
||||
if (window.confirm("Are you sure you want to delete this item?")) {
|
||||
setLoading(true);
|
||||
deleteFn(id)
|
||||
.then(() => fetchItems(currentPage))
|
||||
.catch((error) => {
|
||||
console.error("Error removing item:", error);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems(currentPage);
|
||||
}, [fetchItems, currentPage, refreshKey]);
|
||||
|
||||
const onChangePage = (page) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div className="w-full mt-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center mb-4 w-full">
|
||||
<h2 className="text-xl font-semibold text-gray-700 mb-2 md:mb-0">
|
||||
Total: <span className="text-green-600">{total}</span>
|
||||
</h2>
|
||||
{headerAction && <div>{headerAction}</div>}
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="w-full overflow-x-auto bg-white rounded-lg shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200 table-fixed">
|
||||
<thead className="bg-green-600">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.label}
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-white uppercase tracking-wider"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-center text-xs font-medium text-white uppercase tracking-wider w-20"
|
||||
>
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item[idKey]}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={`${item[idKey]}-${col.key}`}
|
||||
className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{item[col.key] || "—"}
|
||||
</td>
|
||||
))}
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleRemove(item[idKey])}
|
||||
className="text-red-500 hover:text-red-700 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<MdDelete size={20} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
|
||||
{total > pageLimit && (
|
||||
<div className="mt-4 w-full">
|
||||
<Pagination
|
||||
pageNum={Math.ceil(total / pageLimit)}
|
||||
onChange={onChangePage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main Admin Tabs
|
||||
export default function AdminDashboardTabs() {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [tabData, setTabData] = useState({
|
||||
users: { loaded: false, data: null },
|
||||
products: { loaded: false, data: null },
|
||||
transactions: { loaded: false, data: null },
|
||||
categories: { loaded: false, data: null },
|
||||
});
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [categoryRefreshKey, setCategoryRefreshKey] = useState(0);
|
||||
const toggleForm = () => setVisible((v) => !v);
|
||||
|
||||
// Preload all tab data
|
||||
useEffect(() => {
|
||||
const tabKeys = ["users", "products", "transactions", "categories"];
|
||||
const loadTabData = async (index) => {
|
||||
if (index === tabKeys.length) return;
|
||||
|
||||
setTabData((prev) => ({
|
||||
...prev,
|
||||
[tabKeys[index]]: { ...prev[tabKeys[index]], loaded: true },
|
||||
}));
|
||||
|
||||
// Load next tab after a short delay
|
||||
setTimeout(() => loadTabData(index + 1), 100);
|
||||
};
|
||||
|
||||
loadTabData(0);
|
||||
}, []);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
title: "Users",
|
||||
icon: "👥",
|
||||
key: "users",
|
||||
component: () => (
|
||||
<Dashboard
|
||||
fetchDataFn={getUsers}
|
||||
deleteFn={removeUser}
|
||||
idKey="UserID"
|
||||
columns={[
|
||||
{ label: "ID", key: "UserID" },
|
||||
{ label: "UCID", key: "UCID" },
|
||||
{ label: "Name", key: "Name" },
|
||||
{ label: "Email", key: "Email" },
|
||||
{ label: "Phone", key: "Phone" },
|
||||
{ label: "Address", key: "Address" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
icon: "📦",
|
||||
key: "products",
|
||||
component: () => (
|
||||
<Dashboard
|
||||
fetchDataFn={getProducts}
|
||||
deleteFn={removeProduct}
|
||||
idKey="ProductID"
|
||||
columns={[
|
||||
{ label: "ID", key: "ProductID" },
|
||||
{ label: "Name", key: "ProductName" },
|
||||
{ label: "Price", key: "Price" },
|
||||
{ label: "Category", key: "Category" },
|
||||
{ label: "Seller", key: "SellerName" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Transactions",
|
||||
icon: "💰",
|
||||
key: "transactions",
|
||||
component: () => (
|
||||
<Dashboard
|
||||
fetchDataFn={getTransactions}
|
||||
deleteFn={removeTransaction}
|
||||
idKey="TransactionID"
|
||||
columns={[
|
||||
{ label: "ID", key: "TransactionID" },
|
||||
{ label: "User", key: "UserName" },
|
||||
{ label: "Product", key: "ProductName" },
|
||||
{ label: "Date", key: "Date" },
|
||||
{ label: "Status", key: "PaymentStatus" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Categories",
|
||||
icon: "🏷️",
|
||||
key: "categories",
|
||||
component: () => (
|
||||
<>
|
||||
<Dashboard
|
||||
fetchDataFn={getCategories}
|
||||
deleteFn={removeCategory}
|
||||
idKey="CategoryID"
|
||||
columns={[
|
||||
{ label: "ID", key: "CategoryID" },
|
||||
{ label: "Name", key: "Name" },
|
||||
]}
|
||||
refreshKey={categoryRefreshKey}
|
||||
headerAction={
|
||||
<button
|
||||
onClick={toggleForm}
|
||||
className="flex items-center bg-green-500 rounded-md px-4 py-2 text-white text-sm hover:bg-green-600 transition-colors font-medium shadow"
|
||||
>
|
||||
<IoAddCircleSharp className="mr-1" size={18} />
|
||||
Add Category
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CategoryForm
|
||||
visible={visible}
|
||||
onAddCategory={() => {
|
||||
setCategoryRefreshKey((prev) => prev + 1);
|
||||
toggleForm();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen bg-gray-50">
|
||||
<div className="w-full px-4 py-8">
|
||||
<div className="mb-8 flex justify-between items-center w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-800">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Mobile Tabs */}
|
||||
<div className="md:hidden w-full mb-4">
|
||||
<select
|
||||
className="w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 p-2"
|
||||
value={activeTab}
|
||||
onChange={(e) => setActiveTab(parseInt(e.target.value))}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<option key={tab.key} value={index}>
|
||||
{tab.icon} {tab.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Desktop Tabs */}
|
||||
<div className="hidden md:flex space-x-1 border-b border-gray-200 w-full overflow-x-auto">
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`px-6 py-3 font-medium text-sm rounded-t-lg transition-colors ${
|
||||
index === activeTab
|
||||
? "text-green-700 bg-white border-l border-t border-r border-gray-200 border-b-0"
|
||||
: "text-gray-600 hover:text-green-700 bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setActiveTab(index)}
|
||||
>
|
||||
<span className="inline-block mr-2">{tab.icon}</span>
|
||||
{tab.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 md:p-6 rounded-lg shadow-sm border border-gray-200 mt-4 w-full">
|
||||
<div className="w-full">
|
||||
{tabData[tabs[activeTab].key].loaded && tabs[activeTab].component()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Heart, Tag, Trash2, Filter, ChevronDown } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Heart, Trash2 } from "lucide-react";
|
||||
|
||||
const Favorites = () => {
|
||||
const [favorites, setFavorites] = useState([
|
||||
{
|
||||
id: 0,
|
||||
title: 'Dell XPS 16 Laptop',
|
||||
price: 850,
|
||||
category: 'Electronics',
|
||||
image: '/image1.avif',
|
||||
condition: 'Like New',
|
||||
seller: 'Michael T.',
|
||||
datePosted: '5d ago',
|
||||
dateAdded: '2023-03-08',
|
||||
},
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [sortBy, setSortBy] = useState("dateAdded");
|
||||
const storedUser = JSON.parse(sessionStorage.getItem("user"));
|
||||
|
||||
]);
|
||||
function reloadPage() {
|
||||
const docTimestamp = new Date(performance.timing.domLoading).getTime();
|
||||
const now = Date.now();
|
||||
if (now > docTimestamp) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [sortBy, setSortBy] = useState('dateAdded');
|
||||
const [filterCategory, setFilterCategory] = useState('All');
|
||||
|
||||
// Function to remove item from favorites
|
||||
const removeFromFavorites = (id) => {
|
||||
setFavorites(favorites.filter(item => item.id !== id));
|
||||
const mapCategory = (id) => {
|
||||
return id || "Other";
|
||||
};
|
||||
|
||||
// Available categories for filtering
|
||||
const categories = ['All', 'Electronics', 'Textbooks', 'Furniture', 'Kitchen', 'Other'];
|
||||
const removeFromFavorites = async (itemID) => {
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/product/delFavorite",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userID: storedUser.ID,
|
||||
productID: itemID,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Sort favorites based on selected sort option
|
||||
const sortedFavorites = [...favorites].sort((a, b) => {
|
||||
if (sortBy === 'dateAdded') {
|
||||
return new Date(b.dateAdded) - new Date(a.dateAdded);
|
||||
} else if (sortBy === 'priceHigh') {
|
||||
return b.price - a.price;
|
||||
} else if (sortBy === 'priceLow') {
|
||||
return a.price - b.price;
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
reloadPage();
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error("Failed to remove from favorites");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFavorites = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/product/getFavorites",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ userID: storedUser.ID }),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const favoritesData = data.favorites;
|
||||
|
||||
if (!Array.isArray(favoritesData)) {
|
||||
console.error("Expected an array but got:", favoritesData);
|
||||
return;
|
||||
}
|
||||
|
||||
const transformed = favoritesData.map((item) => ({
|
||||
id: item.ProductID,
|
||||
name: item.Name,
|
||||
price: parseFloat(item.Price),
|
||||
categories: [mapCategory(item.Category)],
|
||||
image: item.image_url || "/default-image.jpg",
|
||||
description: item.Description || "",
|
||||
seller: item.SellerName,
|
||||
datePosted: formatDatePosted(item.Date),
|
||||
dateAdded: item.Date || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
setFavorites(transformed);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch favorites:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFavorites();
|
||||
}, []);
|
||||
|
||||
const formatDatePosted = (dateString) => {
|
||||
const postedDate = new Date(dateString);
|
||||
const today = new Date();
|
||||
const diffInMs = today - postedDate;
|
||||
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
||||
return `${diffInDays}d ago`;
|
||||
};
|
||||
|
||||
const sortedFavorites = [...favorites].sort((a, b) => {
|
||||
if (sortBy === "dateAdded")
|
||||
return new Date(b.dateAdded) - new Date(a.dateAdded);
|
||||
if (sortBy === "priceHigh") return b.price - a.price;
|
||||
if (sortBy === "priceLow") return a.price - b.price;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Filter favorites based on selected category
|
||||
const filteredFavorites = filterCategory === 'All'
|
||||
? sortedFavorites
|
||||
: sortedFavorites.filter(item => item.category === filterCategory);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">My Favorites</h1>
|
||||
<button
|
||||
className="flex items-center text-gray-600 hover:text-gray-800"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="h-5 w-5 mr-1" />
|
||||
<span>Filter & Sort</span>
|
||||
<ChevronDown className={`h-4 w-4 ml-1 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters and Sorting */}
|
||||
{showFilters && (
|
||||
<div className="bg-white border border-gray-200 p-4 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sort by
|
||||
</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
>
|
||||
<option value="dateAdded">Recently Added</option>
|
||||
<option value="priceHigh">Price (High to Low)</option>
|
||||
<option value="priceLow">Price (Low to High)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Favorites List */}
|
||||
{filteredFavorites.length === 0 ? (
|
||||
{sortedFavorites.length === 0 ? (
|
||||
<div className="bg-white border border-gray-200 p-8 text-center">
|
||||
<Heart className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-medium text-gray-700 mb-2">No favorites yet</h3>
|
||||
<h3 className="text-xl font-medium text-gray-700 mb-2">
|
||||
No favorites yet
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Items you save will appear here. Start browsing to add items to your favorites.
|
||||
Items you save will appear here. Start browsing to add items to your
|
||||
favorites.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-block bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4"
|
||||
className="inline-block bg-emerald-500 hover:bg-emerald-600 text-white font-medium py-2 px-4"
|
||||
>
|
||||
Browse Listings
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredFavorites.map((item) => (
|
||||
<div key={item.id} className="bg-white border border-gray-200 hover:shadow-md transition-shadow relative">
|
||||
<button
|
||||
onClick={() => removeFromFavorites(item.id)}
|
||||
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-sm text-red-500 hover:bg-red-50"
|
||||
title="Remove from favorites"
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sortedFavorites.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="border-2 border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<Link to={`/product/${item.id}`}>
|
||||
<img src={item.image} alt={item.title} className="w-full h-48 object-cover" />
|
||||
<Link to={`/product/${product.id}`}>
|
||||
<div className="h-48 bg-gray-200 flex items-center justify-center">
|
||||
{product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400">No image</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="text-lg font-medium text-gray-800 leading-tight">
|
||||
{item.title}
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{product.name}
|
||||
</h3>
|
||||
<span className="font-semibold text-green-600">${item.price}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
removeFromFavorites(product.id);
|
||||
}}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500 mb-3">
|
||||
<Tag className="h-4 w-4 mr-1" />
|
||||
<span>{item.category}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{item.condition}</span>
|
||||
</div>
|
||||
<p className="text-emerald-600 font-bold mt-1">
|
||||
${product.price.toFixed(2)}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center pt-2 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500">Listed {item.datePosted}</span>
|
||||
<span className="text-sm font-medium text-gray-700">{item.seller}</span>
|
||||
{product.categories.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{product.categories.map((category) => (
|
||||
<span
|
||||
key={category}
|
||||
className="text-xs bg-gray-100 text-gray-600 px-2 py-1"
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-gray-500 text-sm mt-2 line-clamp-2">
|
||||
{product.description}
|
||||
</p>
|
||||
|
||||
<p className="text-gray-400 text-xs mt-2">
|
||||
Posted {product.datePosted}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -153,13 +192,66 @@ const Favorites = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show count if there are favorites */}
|
||||
{filteredFavorites.length > 0 && (
|
||||
{sortedFavorites.length > 0 && (
|
||||
<div className="mt-6 text-sm text-gray-500">
|
||||
Showing {filteredFavorites.length} {filteredFavorites.length === 1 ? 'item' : 'items'}
|
||||
{filterCategory !== 'All' && ` in ${filterCategory}`}
|
||||
Showing {sortedFavorites.length}{" "}
|
||||
{sortedFavorites.length === 1 ? "item" : "items"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="bg-gray-800 text-white py-6 mt-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="mb-4 md:mb-0">
|
||||
<h3 className="text-lg font-semibold mb-2">Campus Marketplace</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Your trusted university trading platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Quick Links</h4>
|
||||
<ul className="text-sm text-gray-400">
|
||||
<li className="mb-1">
|
||||
<Link to="/" className="hover:text-white transition">
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-1">
|
||||
<Link to="/selling" className="hover:text-white transition">
|
||||
Sell an Item
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-1">
|
||||
<Link
|
||||
to="/favorites"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
My Favorites
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Contact</h4>
|
||||
<ul className="text-sm text-gray-400">
|
||||
<li className="mb-1">support@campusmarket.com</li>
|
||||
<li className="mb-1">University of Calgary</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 mt-6 pt-6 text-center text-sm text-gray-400">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Campus Marketplace. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,35 +1,149 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Tag, Book, Laptop, Sofa, Utensils, Gift, Heart } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Tag, ChevronLeft, ChevronRight, Bookmark, Loader } from "lucide-react";
|
||||
|
||||
import FloatingAlert from "../components/FloatingAlert"; // adjust path if needed
|
||||
|
||||
const Home = () => {
|
||||
const navigate = useNavigate();
|
||||
const [listings, setListings] = useState([]);
|
||||
const [recommended, setRecommended] = useState([]);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState({
|
||||
recommendations: true,
|
||||
listings: true,
|
||||
history: true,
|
||||
});
|
||||
const recommendationsFetched = useRef(false);
|
||||
const historyFetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
//After user data storing the session.
|
||||
const storedUser = JSON.parse(sessionStorage.getItem("user"));
|
||||
|
||||
const toggleFavorite = async (id) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/product/get_product"
|
||||
"http://localhost:3030/api/product/addFavorite",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userID: storedUser.ID,
|
||||
productID: id,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setShowAlert(true);
|
||||
// Close alert after 3 seconds
|
||||
setTimeout(() => setShowAlert(false), 3000);
|
||||
}
|
||||
console.log(`Add Product -> Favorites: ${id}`);
|
||||
} catch (error) {
|
||||
console.error("Error adding favorite:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const addHistory = async (id) => {
|
||||
try {
|
||||
await fetch("http://localhost:3030/api/history/addHistory", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userID: storedUser.ID,
|
||||
productID: id,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error adding to history:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch recommended products
|
||||
useEffect(() => {
|
||||
const fetchRecommendedProducts = async () => {
|
||||
// Skip if already fetched or no user data
|
||||
if (recommendationsFetched.current || !storedUser || !storedUser.ID)
|
||||
return;
|
||||
|
||||
setIsLoading((prev) => ({ ...prev, recommendations: true }));
|
||||
try {
|
||||
recommendationsFetched.current = true; // Mark as fetched before the API call
|
||||
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/engine/recommended",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: storedUser.ID,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch recommendations");
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setRecommended(
|
||||
data.data.map((product) => ({
|
||||
id: product.ProductID,
|
||||
title: product.ProductName,
|
||||
price: product.Price,
|
||||
category: product.Category,
|
||||
image: product.ProductImage,
|
||||
seller: product.SellerName,
|
||||
datePosted: product.DateUploaded,
|
||||
isFavorite: false,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
throw new Error(data.message || "Error fetching recommendations");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching recommendations:", error);
|
||||
setError(error.message);
|
||||
// Reset the flag if there's an error so it can try again
|
||||
recommendationsFetched.current = false;
|
||||
} finally {
|
||||
setIsLoading((prev) => ({ ...prev, recommendations: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecommendedProducts();
|
||||
}, [storedUser]); // Keep dependency
|
||||
|
||||
// Fetch all products
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
setIsLoading((prev) => ({ ...prev, listings: true }));
|
||||
try {
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/product/getProduct",
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch products");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setListings(
|
||||
data.data.map((product) => ({
|
||||
id: product.ProductID,
|
||||
title: product.Name,
|
||||
title: product.ProductName,
|
||||
price: product.Price,
|
||||
category: product.CategoryID,
|
||||
image: product.ImageURL,
|
||||
condition: "New", // Modify based on actual data
|
||||
seller: "Unknown", // Modify if seller info is available
|
||||
datePosted: "Just now",
|
||||
category: product.Category,
|
||||
image: product.ProductImage,
|
||||
seller: product.SellerName,
|
||||
datePosted: product.DateUploaded,
|
||||
isFavorite: false,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
throw new Error(data.message || "Error fetching products");
|
||||
@@ -37,143 +151,316 @@ const Home = () => {
|
||||
} catch (error) {
|
||||
console.error("Error fetching products:", error);
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setIsLoading((prev) => ({ ...prev, listings: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
// Toggle favorite status
|
||||
const toggleFavorite = (id, e) => {
|
||||
e.preventDefault(); // Prevent navigation when clicking the heart icon
|
||||
setListings((prevListings) =>
|
||||
prevListings.map((listing) =>
|
||||
listing.id === id
|
||||
? { ...listing, isFavorite: !listing.isFavorite }
|
||||
: listing
|
||||
)
|
||||
// Fetch user history
|
||||
useEffect(() => {
|
||||
const fetchUserHistory = async () => {
|
||||
// Skip if already fetched or no user data
|
||||
if (historyFetched.current || !storedUser || !storedUser.ID) return;
|
||||
|
||||
setIsLoading((prev) => ({ ...prev, history: true }));
|
||||
try {
|
||||
historyFetched.current = true; // Mark as fetched before the API call
|
||||
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/history/getHistory",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: storedUser.ID,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch history");
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setHistory(
|
||||
data.data.map((product) => ({
|
||||
id: product.ProductID,
|
||||
title: product.ProductName,
|
||||
price: product.Price,
|
||||
category: product.Category,
|
||||
image: product.ProductImage,
|
||||
seller: product.SellerName,
|
||||
datePosted: product.DateUploaded,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
throw new Error(data.message || "Error fetching history");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching history:", error);
|
||||
setError(error.message);
|
||||
// Reset the flag if there's an error so it can try again
|
||||
historyFetched.current = false;
|
||||
} finally {
|
||||
setIsLoading((prev) => ({ ...prev, history: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserHistory();
|
||||
}, [storedUser]); // Keep dependency
|
||||
|
||||
const handleSelling = () => {
|
||||
navigate("/selling");
|
||||
};
|
||||
|
||||
// Loading indicator component
|
||||
const LoadingSection = () => (
|
||||
<div className="flex justify-center items-center h-48">
|
||||
<Loader className="animate-spin text-emerald-600 h-8 w-8" />
|
||||
</div>
|
||||
);
|
||||
|
||||
// Product card component to reduce duplication
|
||||
const ProductCard = ({ product, addToHistory = false }) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
to={`/product/${product.id}`}
|
||||
onClick={addToHistory ? () => addHistory(product.id) : undefined}
|
||||
className="bg-white border border-gray-200 hover:shadow-md transition-shadow w-70 flex-shrink-0 relative"
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.title}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleFavorite(product.id);
|
||||
}}
|
||||
className="absolute top-0 right-0 p-2 rounded-bl-md bg-emerald-600 hover:bg-emerald-500 transition shadow-sm"
|
||||
>
|
||||
<Bookmark className="text-white w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-medium text-gray-800 leading-tight">
|
||||
{product.title}
|
||||
</h3>
|
||||
<span className="font-semibold text-emerald-600 block mt-1">
|
||||
${product.price}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500 mt-2">
|
||||
<Tag className="h-4 w-4 mr-1" />
|
||||
<span>{product.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">{product.datePosted}</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{product.seller}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
// Scrollable product list component to reduce duplication
|
||||
const ScrollableProductList = ({
|
||||
containerId,
|
||||
products,
|
||||
children,
|
||||
isLoading,
|
||||
addToHistory = false,
|
||||
}) => (
|
||||
<div className="relative py-4">
|
||||
{children}
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById(containerId)
|
||||
.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"
|
||||
>
|
||||
<ChevronLeft size={24} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={containerId}
|
||||
className="overflow-x-auto whitespace-nowrap flex space-x-6 scroll-smooth scrollbar-hide px-10 pl-0 rounded min-h-[250px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoadingSection />
|
||||
) : products.length > 0 ? (
|
||||
products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
addToHistory={addToHistory}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-center items-center w-full h-48 text-gray-500">
|
||||
No products available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById(containerId)
|
||||
.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"
|
||||
>
|
||||
<ChevronRight size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<div className="flex-grow">
|
||||
{/* Hero Section with School Background */}
|
||||
<div className="relative py-12 px-4 mb-8 shadow-sm">
|
||||
{/* Background Image - Positioned at bottom */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden bg-black bg-opacity-100">
|
||||
<img
|
||||
src="../public/Ucalgary.png"
|
||||
alt="University of Calgary"
|
||||
className="w-full h-full object-cover object-bottom opacity-50"
|
||||
className="w-full h-full object-cover object-bottom opacity-45"
|
||||
/>
|
||||
{/* Dark overlay for better text readability */}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-2xl mx-auto text-center relative z-1">
|
||||
<h1 className="text-3xl font-bold text-white mb-4">
|
||||
Buy and Sell on Campus
|
||||
</h1>
|
||||
<p className="text-white mb-6">
|
||||
The marketplace exclusively for university students. Find everything
|
||||
you need or sell what you don't.
|
||||
The marketplace exclusively for university students. Find
|
||||
everything you need or sell what you don't.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSelling}
|
||||
className="bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-6 focus:outline-none focus:ring-2 focus:ring-green-400 transition-colors"
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2 px-6 focus:outline-none focus:ring-2 focus:ring-emerald-400 transition-colors"
|
||||
>
|
||||
Post an Item
|
||||
</button>
|
||||
</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> */}
|
||||
{/* Floating Alert */}
|
||||
{showAlert && (
|
||||
<FloatingAlert
|
||||
message="Product added to favorites!"
|
||||
onClose={() => setShowAlert(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recent Listings */}
|
||||
<div>
|
||||
{/* Recommendations Section */}
|
||||
<ScrollableProductList
|
||||
containerId="RecomContainer"
|
||||
products={recommended}
|
||||
isLoading={isLoading.recommendations}
|
||||
addToHistory={true}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
Recommended For You
|
||||
</h2>
|
||||
</ScrollableProductList>
|
||||
|
||||
{/* Recent Listings Section */}
|
||||
<ScrollableProductList
|
||||
containerId="listingsContainer"
|
||||
products={listings}
|
||||
isLoading={isLoading.listings}
|
||||
addToHistory={true}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
Recent Listings
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{listings.map((listing) => (
|
||||
<Link
|
||||
key={listing.id}
|
||||
to={`/product/${listing.id}`}
|
||||
className="bg-white border border-gray-200 hover:shadow-md transition-shadow"
|
||||
</ScrollableProductList>
|
||||
|
||||
{/* History Section */}
|
||||
{(history.length > 0 || isLoading.history) && (
|
||||
<ScrollableProductList
|
||||
containerId="HistoryContainer"
|
||||
products={history}
|
||||
isLoading={isLoading.history}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={listing.image}
|
||||
alt={listing.title}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => toggleFavorite(listing.id, e)}
|
||||
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-sm"
|
||||
>
|
||||
<Heart
|
||||
className={`h-5 w-5 ${
|
||||
listing.isFavorite
|
||||
? "text-red-500 fill-red-500"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
Your Browsing History
|
||||
</h2>
|
||||
</ScrollableProductList>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="text-lg font-medium text-gray-800 leading-tight">
|
||||
{listing.title}
|
||||
</h3>
|
||||
<span className="font-semibold text-green-600">
|
||||
${listing.price}
|
||||
</span>
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-800 text-white py-6 mt-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="mb-4 md:mb-0">
|
||||
<h3 className="text-lg font-semibold mb-2">Campus Marketplace</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Your trusted university trading platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500 mb-3">
|
||||
<Tag className="h-4 w-4 mr-1" />
|
||||
<span>{listing.category}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{listing.condition}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500">
|
||||
{listing.datePosted}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{listing.seller}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Quick Links</h4>
|
||||
<ul className="text-sm text-gray-400">
|
||||
<li className="mb-1">
|
||||
<Link to="/" className="hover:text-white transition">
|
||||
Home
|
||||
</Link>
|
||||
))}
|
||||
</li>
|
||||
<li className="mb-1">
|
||||
<Link to="/selling" className="hover:text-white transition">
|
||||
Sell an Item
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-1">
|
||||
<Link
|
||||
to="/favorites"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
My Favorites
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Contact</h4>
|
||||
<ul className="text-sm text-gray-400">
|
||||
<li className="mb-1">support@campusmarket.com</li>
|
||||
<li className="mb-1">University of Calgary</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 mt-6 pt-6 text-center text-sm text-gray-400">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Campus Marketplace. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, Plus, X, Save, Trash } from "lucide-react";
|
||||
|
||||
const ItemForm = () => {
|
||||
const { id } = useParams(); // If id exists, we are editing, otherwise creating
|
||||
const navigate = useNavigate();
|
||||
const isEditing = !!id;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
price: "",
|
||||
category: "",
|
||||
condition: "",
|
||||
shortDescription: "",
|
||||
description: "",
|
||||
images: [],
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState(null);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [imagePreviewUrls, setImagePreviewUrls] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(isEditing);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// Categories with icons
|
||||
const categories = [
|
||||
"Electronics",
|
||||
"Furniture",
|
||||
"Books",
|
||||
"Kitchen",
|
||||
"Collectibles",
|
||||
"Clothing",
|
||||
"Sports & Outdoors",
|
||||
"Tools",
|
||||
"Toys & Games",
|
||||
"Other",
|
||||
];
|
||||
|
||||
// Condition options
|
||||
const conditions = ["New", "Like New", "Good", "Fair", "Poor"];
|
||||
|
||||
// Status options
|
||||
const statuses = ["active", "inactive", "sold", "pending"];
|
||||
|
||||
// Fetch item data if editing
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
// This would be an API call in a real app
|
||||
// Simulating API call with timeout
|
||||
setTimeout(() => {
|
||||
// Sample data for item being edited
|
||||
const itemData = {
|
||||
id: parseInt(id),
|
||||
title: "Dell XPS 13 Laptop - 2023 Model",
|
||||
price: 850,
|
||||
category: "Electronics",
|
||||
condition: "Like New",
|
||||
shortDescription:
|
||||
"Dell XPS 13 laptop in excellent condition. Intel Core i7, 16GB RAM, 512GB SSD. Includes charger and original box.",
|
||||
description:
|
||||
"Selling my Dell XPS 13 laptop. Only 6 months old and in excellent condition. Intel Core i7 processor, 16GB RAM, 512GB SSD. Battery life is still excellent (around 10 hours of regular use). Comes with original charger and box. Selling because I'm upgrading to a MacBook for design work.\n\nSpecs:\n- Intel Core i7 11th Gen\n- 16GB RAM\n- 512GB NVMe SSD\n- 13.4\" FHD+ Display (1920x1200)\n- Windows 11 Pro\n- Backlit Keyboard\n- Thunderbolt 4 ports",
|
||||
images: ["/image1.avif", "/image2.avif", "/image3.avif"],
|
||||
status: "active",
|
||||
datePosted: "2023-03-02",
|
||||
};
|
||||
|
||||
setFormData(itemData);
|
||||
setOriginalData(itemData);
|
||||
setImagePreviewUrls(itemData.images);
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
}
|
||||
}, [id, isEditing]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
|
||||
// Clear error when field is edited
|
||||
if (errors[name]) {
|
||||
setErrors({
|
||||
...errors,
|
||||
[name]: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageChange = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const files = Array.from(e.target.files);
|
||||
|
||||
if (formData.images.length + files.length > 5) {
|
||||
setErrors({
|
||||
...errors,
|
||||
images: "Maximum 5 images allowed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URLs for the images
|
||||
const newImagePreviewUrls = [...imagePreviewUrls];
|
||||
const newImages = [...formData.images];
|
||||
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
newImagePreviewUrls.push(reader.result);
|
||||
setImagePreviewUrls(newImagePreviewUrls);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
newImages.push(file);
|
||||
});
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
images: newImages,
|
||||
});
|
||||
|
||||
// Clear error if any
|
||||
if (errors.images) {
|
||||
setErrors({
|
||||
...errors,
|
||||
images: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (index) => {
|
||||
const newImages = [...formData.images];
|
||||
const newImagePreviewUrls = [...imagePreviewUrls];
|
||||
|
||||
newImages.splice(index, 1);
|
||||
newImagePreviewUrls.splice(index, 1);
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
images: newImages,
|
||||
});
|
||||
setImagePreviewUrls(newImagePreviewUrls);
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.title.trim()) newErrors.title = "Title is required";
|
||||
if (!formData.price) newErrors.price = "Price is required";
|
||||
if (isNaN(formData.price) || formData.price <= 0)
|
||||
newErrors.price = "Price must be a positive number";
|
||||
if (!formData.category) newErrors.category = "Category is required";
|
||||
if (!formData.condition) newErrors.condition = "Condition is required";
|
||||
if (!formData.shortDescription.trim())
|
||||
newErrors.shortDescription = "Short description is required";
|
||||
if (!formData.description.trim())
|
||||
newErrors.description = "Description is required";
|
||||
if (formData.images.length === 0)
|
||||
newErrors.images = "At least one image is required";
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
// Scroll to the first error
|
||||
const firstErrorField = Object.keys(errors)[0];
|
||||
document
|
||||
.getElementsByName(firstErrorField)[0]
|
||||
?.scrollIntoView({ behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate API call to post/update the item
|
||||
setTimeout(() => {
|
||||
console.log("Form submitted:", formData);
|
||||
setIsSubmitting(false);
|
||||
|
||||
// Show success and redirect to listings
|
||||
alert(`Item successfully ${isEditing ? "updated" : "created"}!`);
|
||||
navigate("/selling");
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate API call to delete the item
|
||||
setTimeout(() => {
|
||||
console.log("Item deleted:", id);
|
||||
setIsSubmitting(false);
|
||||
setShowDeleteModal(false);
|
||||
|
||||
// Show success and redirect to listings
|
||||
alert("Item successfully deleted!");
|
||||
navigate("/selling");
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Show loading state if necessary
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center mb-6">
|
||||
<Link to="/selling" className="text-green-600 hover:text-green-700">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to listings
|
||||
</Link>
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3 mb-8"></div>
|
||||
<div className="h-64 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="h-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Breadcrumb & Back Link */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/selling"
|
||||
className="flex items-center text-green-600 hover:text-green-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
<span>Back to listings</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-800">
|
||||
{isEditing ? "Edit Item" : "Create New Listing"}
|
||||
</h1>
|
||||
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="bg-red-500 hover:bg-red-600 text-white font-medium py-2 px-4 flex items-center"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash className="h-5 w-5 mr-1" />
|
||||
Delete Item
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-white border border-gray-200 p-6 rounded-md"
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="title"
|
||||
className="block text-gray-700 font-medium mb-2"
|
||||
>
|
||||
Title <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-4 py-2 border ${errors.title ? "border-red-500" : "border-gray-300"} focus:outline-none focus:border-green-500`}
|
||||
placeholder="e.g., Dell XPS 13 Laptop - 2023 Model"
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price, Category, Status (side by side on larger screens) */}
|
||||
<div className="flex flex-col md:flex-row gap-6 mb-6">
|
||||
<div className="w-full md:w-1/3">
|
||||
<label
|
||||
htmlFor="price"
|
||||
className="block text-gray-700 font-medium mb-2"
|
||||
>
|
||||
Price ($) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
name="price"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-4 py-2 border ${errors.price ? "border-red-500" : "border-gray-300"} focus:outline-none focus:border-green-500`}
|
||||
placeholder="e.g., 850"
|
||||
min="0"
|
||||
step="0.01"
|
||||
/>
|
||||
{errors.price && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.price}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/3">
|
||||
<label
|
||||
htmlFor="category"
|
||||
className="block text-gray-700 font-medium mb-2"
|
||||
>
|
||||
Category <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
value={formData.category}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-4 py-2 border ${errors.category ? "border-red-500" : "border-gray-300"} focus:outline-none focus:border-green-500 bg-white`}
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.category && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.category}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="w-full md:w-1/3">
|
||||
<label
|
||||
htmlFor="status"
|
||||
className="block text-gray-700 font-medium mb-2"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-green-500 bg-white"
|
||||
>
|
||||
{statuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Condition */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-700 font-medium mb-2">
|
||||
Condition <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{conditions.map((condition) => (
|
||||
<label
|
||||
key={condition}
|
||||
className={`px-4 py-2 border cursor-pointer ${
|
||||
formData.condition === condition
|
||||
? "bg-green-50 border-green-500 text-green-700"
|
||||
: "border-gray-300 text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="condition"
|
||||
value={condition}
|
||||
checked={formData.condition === condition}
|
||||
onChange={handleChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
{condition}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{errors.condition && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.condition}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Short Description */}
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="shortDescription"
|
||||
className="block text-gray-700 font-medium mb-2"
|
||||
>
|
||||
Short Description <span className="text-red-500">*</span>
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">
|
||||
(Brief summary that appears in listings)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="shortDescription"
|
||||
name="shortDescription"
|
||||
value={formData.shortDescription}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-4 py-2 border ${errors.shortDescription ? "border-red-500" : "border-gray-300"} focus:outline-none focus:border-green-500`}
|
||||
placeholder="e.g., Dell XPS 13 laptop in excellent condition. Intel Core i7, 16GB RAM, 512GB SSD."
|
||||
maxLength="150"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{formData.shortDescription.length}/150 characters
|
||||
</p>
|
||||
{errors.shortDescription && (
|
||||
<p className="text-red-500 text-sm">{errors.shortDescription}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full Description */}
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-gray-700 font-medium mb-2"
|
||||
>
|
||||
Full Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-4 py-2 border ${errors.description ? "border-red-500" : "border-gray-300"} focus:outline-none focus:border-green-500 h-40`}
|
||||
placeholder="Describe your item in detail. Include specs, condition, reason for selling, etc."
|
||||
></textarea>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Use blank lines to separate paragraphs.
|
||||
</p>
|
||||
{errors.description && (
|
||||
<p className="text-red-500 text-sm">{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-gray-700 font-medium mb-2">
|
||||
Images <span className="text-red-500">*</span>
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">
|
||||
(Up to 5 images)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Image Preview Area */}
|
||||
<div className="flex flex-wrap gap-4 mb-4">
|
||||
{imagePreviewUrls.map((url, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative w-24 h-24 border border-gray-300"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(index)}
|
||||
className="absolute -top-2 -right-2 bg-white rounded-full p-1 shadow-md border border-gray-300"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Upload Button (only show if less than 5 images) */}
|
||||
{formData.images.length < 5 && (
|
||||
<label className="w-24 h-24 border-2 border-dashed border-gray-300 flex flex-col items-center justify-center text-gray-500 cursor-pointer hover:bg-gray-50">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
<Plus className="h-6 w-6 mb-1" />
|
||||
<span className="text-xs">Add Image</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{errors.images && (
|
||||
<p className="text-red-500 text-sm">{errors.images}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="mt-8">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`w-full py-3 px-4 text-white font-medium flex items-center justify-center ${
|
||||
isSubmitting ? "bg-gray-400" : "bg-green-500 hover:bg-green-600"
|
||||
}`}
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{isSubmitting
|
||||
? "Saving..."
|
||||
: isEditing
|
||||
? "Save Changes"
|
||||
: "Create Listing"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-md max-w-md w-full">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Delete Listing
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Are you sure you want to delete <strong>{formData.title}</strong>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 font-medium rounded-md hover:bg-gray-50"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white font-medium rounded-md hover:bg-red-700 flex items-center"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemForm;
|
||||
@@ -1,298 +1,650 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import {
|
||||
Heart,
|
||||
ArrowLeft,
|
||||
Tag,
|
||||
User,
|
||||
Calendar,
|
||||
Share,
|
||||
Flag,
|
||||
Star,
|
||||
Phone,
|
||||
Mail,
|
||||
Bookmark,
|
||||
} from "lucide-react";
|
||||
import FloatingAlert from "../components/FloatingAlert"; // adjust path if needed
|
||||
|
||||
const ProductDetail = () => {
|
||||
const { id } = useParams();
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [showContactForm, setShowContactForm] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [product, setProduct] = useState(null);
|
||||
const [loading, setLoading] = useState({
|
||||
product: true,
|
||||
reviews: true,
|
||||
submitting: false,
|
||||
});
|
||||
const [error, setError] = useState({
|
||||
product: null,
|
||||
reviews: null,
|
||||
submit: null,
|
||||
});
|
||||
const [showContactOptions, setShowContactOptions] = useState(false);
|
||||
const [currentImage, setCurrentImage] = useState(0);
|
||||
const [reviews, setReviews] = useState([]);
|
||||
const [showReviewForm, setShowReviewForm] = useState(false);
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
const [showAlert1, setShowAlert1] = useState(false);
|
||||
|
||||
// Sample data for demonstration
|
||||
const product = [
|
||||
const storedUser = JSON.parse(sessionStorage.getItem("user"));
|
||||
|
||||
const toggleFavorite = async (id) => {
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/product/addFavorite",
|
||||
{
|
||||
id: 0,
|
||||
title: "Dell XPS 13 Laptop - 2023 Model",
|
||||
price: 850,
|
||||
shortDescription:
|
||||
"Dell XPS 13 laptop in excellent condition. Intel Core i7, 16GB RAM, 512GB SSD. Includes charger and original box.",
|
||||
description:
|
||||
"Selling my Dell XPS 13 laptop. Only 6 months old and in excellent condition. Intel Core i7 processor, 16GB RAM, 512GB SSD. Battery life is still excellent (around 10 hours of regular use). Comes with original charger and box. Selling because I'm upgrading to a MacBook for design work.\n\nSpecs:\n- Intel Core i7 11th Gen\n- 16GB RAM\n- 512GB NVMe SSD\n- 13.4\" FHD+ Display (1920x1200)\n- Windows 11 Pro\n- Backlit Keyboard\n- Thunderbolt 4 ports",
|
||||
condition: "Like New",
|
||||
category:
|
||||
"Electronics, Electronics, Electronics, Electronics , Electronics , Electronics, Electronicss",
|
||||
datePosted: "2023-03-02",
|
||||
images: [
|
||||
"/image1.avif",
|
||||
"/image2.avif",
|
||||
"/image3.avif",
|
||||
"/image3.avif",
|
||||
"/image3.avif",
|
||||
],
|
||||
seller: {
|
||||
name: "Michael T.",
|
||||
rating: 4.8,
|
||||
memberSince: "January 2022",
|
||||
avatar: "/Profile.jpg",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userID: storedUser.ID,
|
||||
productID: id,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
console.log(product[id]);
|
||||
|
||||
const toggleFavorite = () => {
|
||||
setIsFavorite(!isFavorite);
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setShowAlert(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = (e) => {
|
||||
e.preventDefault();
|
||||
// TODO: this would send the message to the seller
|
||||
console.log("Message sent:", message);
|
||||
setMessage("");
|
||||
setShowContactForm(false);
|
||||
// Show confirmation or success message
|
||||
alert("Message sent to seller!");
|
||||
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,
|
||||
}));
|
||||
};
|
||||
|
||||
// Function to split description into paragraphs
|
||||
const formatDescription = (text) => {
|
||||
return text.split("\n\n").map((paragraph, index) => (
|
||||
<p key={index} className="mb-4">
|
||||
{paragraph.split("\n").map((line, i) => (
|
||||
<span key={i}>
|
||||
{line}
|
||||
{i < paragraph.split("\n").length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
));
|
||||
// Add this function to handle star rating selection
|
||||
const handleRatingChange = (rating) => {
|
||||
setReviewForm((prev) => ({
|
||||
...prev,
|
||||
rating,
|
||||
}));
|
||||
};
|
||||
|
||||
// image navigation
|
||||
const handleSubmitReview = async (e) => {
|
||||
e.preventDefault(); // Prevent form default behavior
|
||||
|
||||
try {
|
||||
setLoading((prev) => ({ ...prev, submitting: true }));
|
||||
setError((prev) => ({ ...prev, submit: null }));
|
||||
|
||||
const reviewData = {
|
||||
productId: id,
|
||||
rating: reviewForm.rating,
|
||||
comment: reviewForm.comment,
|
||||
userId: storedUser.ID,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`http://localhost:3030/api/review/addReview`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(reviewData),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Check if API returned an error message even with 200 status
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "Failed to submit review");
|
||||
}
|
||||
|
||||
alert("Review submitted successfully!");
|
||||
|
||||
setReviewForm({
|
||||
rating: 3,
|
||||
comment: "",
|
||||
name: "",
|
||||
});
|
||||
setShowReviewForm(false);
|
||||
|
||||
try {
|
||||
setLoading((prev) => ({ ...prev, reviews: true }));
|
||||
const reviewsResponse = await fetch(
|
||||
`http://localhost:3030/api/review/${id}`,
|
||||
);
|
||||
const reviewsResult = await reviewsResponse.json();
|
||||
|
||||
if (reviewsResult.success) {
|
||||
setReviews(reviewsResult.data || []);
|
||||
setError((prev) => ({ ...prev, reviews: null }));
|
||||
} else {
|
||||
throw new Error(reviewsResult.message || "Error fetching reviews");
|
||||
}
|
||||
} catch (reviewsError) {
|
||||
console.error("Error fetching reviews:", reviewsError);
|
||||
setError((prev) => ({ ...prev, reviews: reviewsError.message }));
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, reviews: false }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting review:", error);
|
||||
alert(`Error: ${error.message}`);
|
||||
setError((prev) => ({
|
||||
...prev,
|
||||
submit: error.message,
|
||||
}));
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, submitting: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch product data
|
||||
useEffect(() => {
|
||||
const fetchProduct = async () => {
|
||||
try {
|
||||
setLoading((prev) => ({ ...prev, product: true }));
|
||||
const response = await fetch(`http://localhost:3030/api/product/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setProduct(result.data);
|
||||
setError((prev) => ({ ...prev, product: null }));
|
||||
} else {
|
||||
throw new Error(result.message || "Error fetching product");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching product:", error);
|
||||
setError((prev) => ({ ...prev, product: error.message }));
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, product: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchProduct();
|
||||
}, [id]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Image navigation
|
||||
const nextImage = () => {
|
||||
if (product?.images?.length > 0) {
|
||||
setCurrentImage((prev) =>
|
||||
prev === product.images.length - 1 ? 0 : prev + 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const prevImage = () => {
|
||||
if (product?.images?.length > 0) {
|
||||
setCurrentImage((prev) =>
|
||||
prev === 0 ? product.images.length - 1 : prev - 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const selectImage = (index) => {
|
||||
setCurrentImage(index);
|
||||
};
|
||||
|
||||
// Function to render stars based on rating
|
||||
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 (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* Breadcrumb & Back Link */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<div className="animate-spin h-32 w-32 border-t-2 border-emerald-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render error state for product
|
||||
if (error.product) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl text-red-500 mb-4">Error Loading Product</h2>
|
||||
<p className="text-gray-600">{error.product}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center text-green-600 hover:text-green-700"
|
||||
className="mt-4 inline-block bg-emerald-600 text-white px-4 py-2 hover:bg-emerald-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
<span>Back to listings</span>
|
||||
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
|
||||
to="/"
|
||||
className="mt-4 inline-block bg-emerald-600 text-white px-4 py-2 hover:bg-emerald-700"
|
||||
>
|
||||
Back to Listings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render product details
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* <div className="mb-6">
|
||||
<Link
|
||||
to="/search"
|
||||
className="flex items-center text-emerald-700 hover:text-emerald-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
<span>Back</span>
|
||||
</Link>
|
||||
</div> */}
|
||||
{showAlert && (
|
||||
<FloatingAlert
|
||||
message="Product added to favorites!"
|
||||
onClose={() => setShowAlert(false)}
|
||||
/>
|
||||
)}
|
||||
{showAlert1 && (
|
||||
<FloatingAlert
|
||||
message="Product added to transaction!"
|
||||
onClose={() => setShowAlert1(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{/* Left Column - Images */}
|
||||
<div className="md:w-3/5">
|
||||
{/* Main Image */}
|
||||
<div className="bg-white border border-gray-200 mb-4 relative">
|
||||
{product.images && product.images.length > 0 ? (
|
||||
<>
|
||||
<img
|
||||
src={product[id].images[currentImage]}
|
||||
alt={product[id].title}
|
||||
src={product.images[currentImage]}
|
||||
alt={product.Name}
|
||||
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";
|
||||
}}
|
||||
/>
|
||||
{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"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="text-sm bg-white/70 px-2 py-1 ">
|
||||
{currentImage + 1}/{product.images.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-96 flex items-center justify-center bg-gray-200 text-gray-500">
|
||||
No Image Available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail Images */}
|
||||
{product[id].images.length > 1 && (
|
||||
{product.images && product.images.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{product[id].images.map((image, index) => (
|
||||
{product.images.map((image, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-white border ${currentImage === index ? "border-green-500" : "border-gray-200"} min-w-[100px] cursor-pointer`}
|
||||
className={`bg-white border ${currentImage === index ? "border-emerald-600 border-2" : "border-gray-200"} min-w-[100px] cursor-pointer`}
|
||||
onClick={() => selectImage(index)}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={`${product[id].title} - view ${index + 1}`}
|
||||
alt={`${product.Name} - view ${index + 1}`}
|
||||
className="w-full h-auto object-cover"
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src =
|
||||
"https://via.placeholder.com/100x100?text=Error";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Details */}
|
||||
<div className="md:w-2/5">
|
||||
{/* Product Info Card */}
|
||||
<div className="bg-white border border-gray-200 p-6 mb-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{product[id].title}
|
||||
{product.Name || "Unnamed Product"}
|
||||
</h1>
|
||||
<button
|
||||
onClick={toggleFavorite}
|
||||
className="p-2 hover:bg-gray-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
toggleFavorite(product.ProductID);
|
||||
}}
|
||||
className="top-0 p-2 rounded-bl-md bg-emerald-700 hover:bg-emerald-600 transition shadow-sm"
|
||||
>
|
||||
<Heart
|
||||
className={`h-6 w-6 ${isFavorite ? "text-red-500 fill-red-500" : "text-gray-400"}`}
|
||||
/>
|
||||
<Bookmark className="text-white w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-2xl font-bold text-green-600 mb-4">
|
||||
${product[id].price}
|
||||
<div className="text-2xl font-bold text-emerald-700 mb-4">
|
||||
$
|
||||
{typeof product.Price === "number"
|
||||
? product.Price.toFixed(2)
|
||||
: product.Price}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 mb-6 text-sm">
|
||||
{product.Category && (
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Tag className="h-4 w-4 mr-1" />
|
||||
<span>{product[id].category}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<span className="font-medium">Condition:</span>
|
||||
<span className="ml-1">{product[id].condition}</span>
|
||||
<span>{product.Category}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{product.Date && (
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
<span>Posted on {product[id].datePosted}</span>
|
||||
<span>Posted on {product.Date}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Short Description */}
|
||||
<div className="bg-gray-50 p-4 mb-6 border border-gray-200">
|
||||
<p className="text-gray-700">{product[id].shortDescription}</p>
|
||||
<p className="text-gray-700">
|
||||
{product.Description || "No description available"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowContactForm(!showContactForm)}
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Create a transaction record
|
||||
const transactionData = {
|
||||
userID: storedUser.ID, // User ID from session storage
|
||||
productID: product.ProductID, // Product ID from the product details
|
||||
date: new Date().toISOString(), // Current date in ISO format
|
||||
paymentStatus: "Pending", // Default payment status
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/transaction/createTransaction",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(transactionData),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setShowAlert1(true);
|
||||
}
|
||||
// Toggle contact options visibility
|
||||
setShowContactOptions(!showContactOptions);
|
||||
} catch (error) {
|
||||
console.error("Error creating transaction:", error);
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}}
|
||||
className="w-full bg-green-500 hover:bg-green-600 text-white font-medium py-3 px-4 mb-3"
|
||||
>
|
||||
Contact Seller
|
||||
</button>
|
||||
|
||||
{/* TODO:Contact Form */}
|
||||
{showContactForm && (
|
||||
<div className="border border-gray-200 p-4 mb-4">
|
||||
<h3 className="font-medium text-gray-800 mb-2">
|
||||
Contact Seller
|
||||
</h3>
|
||||
<form onSubmit={handleSendMessage}>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="email" className="block text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={product[id].User.email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="phone" className="block text-gray-700 mb-1">
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label
|
||||
htmlFor="contactMessage"
|
||||
className="block text-gray-700 mb-1"
|
||||
{showContactOptions && (
|
||||
<div className="absolute z-10 w-full bg-white border border-gray-200 shadow-md">
|
||||
{product.SellerPhone && (
|
||||
<a
|
||||
href={`tel:${product.SellerPhone}`}
|
||||
className="flex items-center gap-2 p-3 hover:bg-gray-50 border-b border-gray-100"
|
||||
>
|
||||
Message (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="contactMessage"
|
||||
value={contactMessage}
|
||||
onChange={(e) => setContactMessage(e.target.value)}
|
||||
placeholder="Hi, is this item still available?"
|
||||
className="w-full p-3 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4"
|
||||
>
|
||||
Send Contact Info
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<Phone className="h-5 w-5 text-emerald-600" />
|
||||
<span>Call Seller</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Seller Info */}
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<div className="flex items-center mb-3">
|
||||
<div className="mr-3">
|
||||
{product[id].seller.avatar ? (
|
||||
<img
|
||||
src={product[id].seller.avatar}
|
||||
alt="Seller"
|
||||
className="h-12 w-12 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-gray-600" />
|
||||
{product.SellerEmail && (
|
||||
<a
|
||||
href={`mailto:${product.SellerEmail}`}
|
||||
className="flex items-center gap-2 p-3 hover:bg-gray-50"
|
||||
>
|
||||
<Mail className="h-5 w-5 text-emerald-600" />
|
||||
<span>Email Seller</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
{/* Seller Info */}
|
||||
<div className="flex items-center mb-3">
|
||||
<div className="mr-3">
|
||||
<div className="h-12 w-12 bg-gray-200 flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800">
|
||||
{product[id].seller.name}
|
||||
{product.SellerName || "Unknown Seller"}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Member since {product[id].seller.memberSince}
|
||||
Member since {product.SellerJoinDate || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">Rating:</span>{" "}
|
||||
{product[id].seller.rating}/5
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Section */}
|
||||
{/* Reviews Section */}
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Description</h2>
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Reviews</h2>
|
||||
|
||||
<div className="bg-white border border-gray-200 p-6">
|
||||
{loading.reviews ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-t-2 border-emerald-600"></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}
|
||||
</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">
|
||||
{formatDescription(product[id].description)}
|
||||
{review.Comment || "No comment provided"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setShowReviewForm(true)}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white font-medium py-2 px-4"
|
||||
>
|
||||
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 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 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 focus:outline-none focus:border-emerald-600"
|
||||
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 text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
disabled={loading.submitting}
|
||||
>
|
||||
{loading.submitting ? "Submitting..." : "Submit Review"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
257
frontend/src/pages/SearchPage.jsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { X } from "lucide-react";
|
||||
import axios from "axios";
|
||||
|
||||
const SearchPage = () => {
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const nameParam = queryParams.get("name") || "";
|
||||
const initialSearchQuery = location.state?.query || nameParam || "";
|
||||
|
||||
const [products, setProducts] = useState([]);
|
||||
const [filteredProducts, setFilteredProducts] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearchQuery);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [priceRange, setPriceRange] = useState({ min: 0, max: 1000 });
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts(initialSearchQuery);
|
||||
}, [initialSearchQuery]);
|
||||
|
||||
const fetchProducts = async (query) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`http://localhost:3030/api/search/getProduct`,
|
||||
{
|
||||
params: { name: query },
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
const transformedProducts = response.data.data.map((product) => ({
|
||||
id: product.ProductID,
|
||||
title: product.Name,
|
||||
description: product.Description || "",
|
||||
price: product.Price || 0,
|
||||
category: product.Category || "Uncategorized",
|
||||
condition: product.Condition || "Used",
|
||||
image: product.images,
|
||||
seller: product.SellerName || "Unknown Seller",
|
||||
isFavorite: false,
|
||||
}));
|
||||
|
||||
setProducts(transformedProducts);
|
||||
setFilteredProducts(transformedProducts);
|
||||
} else {
|
||||
setError(response.data.message || "Failed to fetch products");
|
||||
setProducts([]);
|
||||
setFilteredProducts([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching products:", err);
|
||||
setError(err.response?.data?.message || "Error connecting to the server");
|
||||
setProducts([]);
|
||||
setFilteredProducts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = (id, e) => {
|
||||
e.preventDefault();
|
||||
setProducts((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id
|
||||
? { ...product, isFavorite: !product.isFavorite }
|
||||
: product,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const filterProducts = () => {
|
||||
let result = products;
|
||||
result = result.filter(
|
||||
(product) =>
|
||||
product.price >= priceRange.min && product.price <= priceRange.max,
|
||||
);
|
||||
setFilteredProducts(result);
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
filterProducts();
|
||||
setIsFilterOpen(false);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setPriceRange({ min: 0, max: 1000 });
|
||||
setFilteredProducts(products);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Filter sidebar */}
|
||||
<div
|
||||
className={`
|
||||
fixed inset-0 z-50 bg-white transform transition-transform duration-300
|
||||
${isFilterOpen ? "translate-x-0" : "translate-x-full"}
|
||||
md:translate-x-0 md:relative md:block md:w-72
|
||||
overflow-y-auto shadow-md
|
||||
`}
|
||||
>
|
||||
<div className="md:hidden flex justify-between items-center p-4 border-b">
|
||||
<h3 className="text-lg font-semibold">Filters</h3>
|
||||
<button onClick={() => setIsFilterOpen(false)}>
|
||||
<X className="text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-gray-50 p-3">
|
||||
<h3 className="font-semibold text-gray-700 mb-3">Price Range</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={priceRange.min}
|
||||
onChange={(e) =>
|
||||
setPriceRange((prev) => ({
|
||||
...prev,
|
||||
min: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="w-full p-2 border text-gray-700"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={priceRange.max}
|
||||
onChange={(e) =>
|
||||
setPriceRange((prev) => ({
|
||||
...prev,
|
||||
max: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="w-full p-2 border text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="w-full bg-emerald-500 text-white p-3 hover:bg-emerald-600 transition-colors"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="w-full bg-gray-200 text-gray-700 p-3 hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 mt-4 md:mt-0">
|
||||
<h2 className="text-2xl font-bold text-gray-800">
|
||||
{filteredProducts.length} Results
|
||||
{searchQuery && (
|
||||
<span className="text-lg font-normal text-gray-600">
|
||||
{" "}
|
||||
for "{searchQuery}"
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
|
||||
{filteredProducts.map((listing) => (
|
||||
<Link
|
||||
key={listing.id}
|
||||
to={`/product/${listing.id}`}
|
||||
className="bg-white border border-gray-200 hover:shadow-md transition-shadow block"
|
||||
>
|
||||
<img
|
||||
src={listing.image}
|
||||
alt={listing.title}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-medium text-gray-800">
|
||||
{listing.title}
|
||||
</h3>
|
||||
<p className="text-emerald-600 font-semibold">
|
||||
${Number(listing.price).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Footer - Added here */}
|
||||
<footer className="bg-gray-800 text-white py-6 mt-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="mb-4 md:mb-0">
|
||||
<h3 className="text-lg font-semibold mb-2">Campus Marketplace</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Your trusted university trading platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Quick Links</h4>
|
||||
<ul className="text-sm text-gray-400">
|
||||
<li className="mb-1">
|
||||
<Link to="/" className="hover:text-white transition">
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-1">
|
||||
<Link to="/selling" className="hover:text-white transition">
|
||||
Sell an Item
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-1">
|
||||
<Link
|
||||
to="/favorites"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
My Favorites
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Contact</h4>
|
||||
<ul className="text-sm text-gray-400">
|
||||
<li className="mb-1">support@campusmarket.com</li>
|
||||
<li className="mb-1">University of Calgary</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 mt-6 pt-6 text-center text-sm text-gray-400">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Campus Marketplace. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
||||
@@ -1,11 +1,697 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Tag, Book, Laptop, Sofa, Utensils, Gift, Heart } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { X, ChevronLeft, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
const Selling = () => {
|
||||
return (
|
||||
<div>
|
||||
const [products, setProducts] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const storedUser = JSON.parse(sessionStorage.getItem("user"));
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [categoryMapping, setCategoryMapping] = useState({});
|
||||
const [selectedCategory, setSelectedCategory] = useState("");
|
||||
const [originalProduct, setOriginalProduct] = useState(null);
|
||||
|
||||
const [editingProduct, setEditingProduct] = useState({
|
||||
name: "",
|
||||
price: "",
|
||||
description: "",
|
||||
categories: [],
|
||||
images: [],
|
||||
});
|
||||
|
||||
function reloadPage() {
|
||||
var doctTimestamp = new Date(performance.timing.domLoading).getTime();
|
||||
var now = Date.now();
|
||||
if (now > doctTimestamp) {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch categories from API
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:3030/api/category");
|
||||
if (!response.ok) throw new Error("Failed to fetch categories");
|
||||
|
||||
const responseJson = await response.json();
|
||||
const data = responseJson.data;
|
||||
|
||||
// Create an array of category names for the dropdown
|
||||
const categoryNames = [];
|
||||
const mapping = {};
|
||||
|
||||
// Process the data properly to avoid rendering objects
|
||||
Object.entries(data).forEach(([id, name]) => {
|
||||
// Make sure each category name is a string
|
||||
const categoryName = String(name);
|
||||
categoryNames.push(categoryName);
|
||||
mapping[categoryName] = parseInt(id);
|
||||
});
|
||||
|
||||
setCategories(categoryNames);
|
||||
setCategoryMapping(mapping);
|
||||
} catch (error) {
|
||||
console.error("Error fetching categories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
// Simulate fetching products from API/database on component mount
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
// Replace with your actual API endpoint
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/product/myProduct",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userID: storedUser.ID,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const datajson = await response.json();
|
||||
setProducts(datajson.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching products:", error);
|
||||
// You might want to set an error state here
|
||||
}
|
||||
};
|
||||
|
||||
fetchProducts();
|
||||
}, []); // Add userId to dependency array if it might change
|
||||
|
||||
// When editing a product, save the original product properly
|
||||
const handleEditProduct = (product) => {
|
||||
// Save the original product completely
|
||||
setOriginalProduct(product);
|
||||
|
||||
// Convert category ID to category name if needed
|
||||
const categoryName = getCategoryNameById(product.CategoryID);
|
||||
|
||||
setEditingProduct({
|
||||
...product,
|
||||
categories: categoryName ? [categoryName] : [],
|
||||
images: product.images || [], // Ensure images array exists
|
||||
});
|
||||
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
// Then update the handleSaveProduct function to properly merge values
|
||||
const handleSaveProduct = async () => {
|
||||
if (!(editingProduct.categories || []).length) {
|
||||
alert("Please select at least one category");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const imagePaths = [];
|
||||
|
||||
// Handle images properly
|
||||
if (editingProduct.images && editingProduct.images.length > 0) {
|
||||
// If there are new images uploaded (File objects)
|
||||
const newImages = editingProduct.images.filter(
|
||||
(img) => img instanceof File,
|
||||
);
|
||||
newImages.forEach((file) => {
|
||||
const simulatedPath = `/public/uploads/${file.name}`;
|
||||
imagePaths.push(simulatedPath);
|
||||
});
|
||||
|
||||
// Also include any existing image URLs that are strings, not File objects
|
||||
const existingImages = editingProduct.images.filter(
|
||||
(img) => typeof img === "string",
|
||||
);
|
||||
if (existingImages.length > 0) {
|
||||
imagePaths.push(...existingImages);
|
||||
}
|
||||
} else if (originalProduct?.image_url) {
|
||||
// If no new images but there was an original image URL
|
||||
imagePaths.push(originalProduct.image_url);
|
||||
}
|
||||
|
||||
const categoryName = (editingProduct.categories || [])[0];
|
||||
const categoryID =
|
||||
categoryMapping[categoryName] || originalProduct?.CategoryID || 1;
|
||||
|
||||
// Create payload with proper fallback to original values
|
||||
const payload = {
|
||||
name:
|
||||
editingProduct.Name ||
|
||||
editingProduct.name ||
|
||||
originalProduct?.Name ||
|
||||
"",
|
||||
price: parseFloat(
|
||||
editingProduct.Price ||
|
||||
editingProduct.price ||
|
||||
originalProduct?.Price ||
|
||||
0,
|
||||
),
|
||||
qty: 1,
|
||||
userID: storedUser.ID,
|
||||
description:
|
||||
editingProduct.Description ||
|
||||
editingProduct.description ||
|
||||
originalProduct?.Description ||
|
||||
"",
|
||||
category: categoryID,
|
||||
images:
|
||||
imagePaths.length > 0
|
||||
? imagePaths
|
||||
: originalProduct?.image_url
|
||||
? [originalProduct.image_url]
|
||||
: [],
|
||||
};
|
||||
|
||||
console.log("Sending payload:", payload);
|
||||
|
||||
const endpoint = editingProduct.ProductID
|
||||
? `http://localhost:3030/api/product/update/${editingProduct.ProductID}`
|
||||
: "http://localhost:3030/api/product/addProduct";
|
||||
|
||||
const method = editingProduct.ProductID ? "PUT" : "POST";
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new Error(
|
||||
`${editingProduct.ProductID ? "Failed to update" : "Failed to add"} product: ${errorData}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Product saved:", data);
|
||||
|
||||
// Reset form and hide it
|
||||
setShowForm(false);
|
||||
setEditingProduct({
|
||||
name: "",
|
||||
price: "",
|
||||
description: "",
|
||||
categories: [],
|
||||
images: [],
|
||||
});
|
||||
|
||||
setOriginalProduct(null); // reset original as well
|
||||
|
||||
reloadPage();
|
||||
} catch (error) {
|
||||
console.error("Error saving product:", error);
|
||||
alert(`Error saving product: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle product deletion
|
||||
const handleDeleteProduct = async (productId) => {
|
||||
try {
|
||||
// Replace with your actual API endpoint
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/product/delProduct",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userID: storedUser.ID,
|
||||
productID: productId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
reloadPage();
|
||||
console.log("deleteproodidt");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching products:", error);
|
||||
// You might want to set an error state here
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get category name from ID
|
||||
const getCategoryNameById = (categoryId) => {
|
||||
if (!categoryId || !categoryMapping) return null;
|
||||
|
||||
// Find the category name by ID
|
||||
for (const [name, id] of Object.entries(categoryMapping)) {
|
||||
if (id === categoryId) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Handle adding a new product
|
||||
const handleAddProduct = () => {
|
||||
setEditingProduct({
|
||||
name: "",
|
||||
price: "",
|
||||
description: "",
|
||||
categories: [],
|
||||
images: [],
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const addCategory = () => {
|
||||
if (
|
||||
selectedCategory &&
|
||||
!(editingProduct.categories || []).includes(selectedCategory)
|
||||
) {
|
||||
setEditingProduct((prev) => ({
|
||||
...prev,
|
||||
categories: [...(prev.categories || []), selectedCategory],
|
||||
}));
|
||||
setSelectedCategory("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeCategory = (categoryToRemove) => {
|
||||
setEditingProduct((prev) => ({
|
||||
...prev,
|
||||
categories: (prev.categories || []).filter(
|
||||
(cat) => cat !== categoryToRemove,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const markAsSold = async () => {
|
||||
// This would call an API to move the product to the transaction table
|
||||
try {
|
||||
// API call would go here
|
||||
console.log(
|
||||
"Moving product to transaction table:",
|
||||
editingProduct.ProductID,
|
||||
);
|
||||
|
||||
// Toggle the sold status in the UI
|
||||
setEditingProduct((prev) => ({
|
||||
...prev,
|
||||
isSold: !prev.isSold,
|
||||
}));
|
||||
|
||||
// You would add your API call here to update the backend
|
||||
} catch (error) {
|
||||
console.error("Error marking product as sold:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 max-w-6xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">My Listings</h1>
|
||||
{!showForm && (
|
||||
<button
|
||||
onClick={handleAddProduct}
|
||||
className="bg-emerald-600 text-white px-4 py-2 hover:bg-emerald-700"
|
||||
>
|
||||
+ Add New Product
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm ? (
|
||||
<div className="bg-white border border-gray-200 shadow-md p-6">
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="mb-4 text-emerald-600 hover:text-emerald-800 flex items-center gap-1"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
<span>Back to Listings</span>
|
||||
</button>
|
||||
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-6 border-b border-gray-200 pb-3">
|
||||
{editingProduct?.ProductID
|
||||
? "Edit Your Product"
|
||||
: "List a New Product"}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Product Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Product Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingProduct.Name || editingProduct.name || ""}
|
||||
onChange={(e) =>
|
||||
setEditingProduct({
|
||||
...editingProduct,
|
||||
Name: e.target.value,
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Price ($)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingProduct.Price || editingProduct.price || ""}
|
||||
onChange={(e) =>
|
||||
setEditingProduct({
|
||||
...editingProduct,
|
||||
Price: e.target.value,
|
||||
price: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sold Status */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center mt-2">
|
||||
{editingProduct.isSold && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Sold
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Categories
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 focus:border-emerald-500 focus:outline-none"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a category
|
||||
</option>
|
||||
{categories
|
||||
.filter(
|
||||
(cat) => !(editingProduct.categories || []).includes(cat),
|
||||
)
|
||||
.map((category, index) => (
|
||||
<option key={index} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCategory}
|
||||
disabled={!selectedCategory}
|
||||
className="px-3 py-2 bg-emerald-600 text-white hover:bg-emerald-700 disabled:bg-gray-300 disabled:cursor-not-allowed flex items-center gap-1"
|
||||
>
|
||||
<Plus size={16} />
|
||||
<span>Add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Categories */}
|
||||
{(editingProduct.categories || []).length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{(editingProduct.categories || []).map((category, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 bg-emerald-100 text-emerald-800"
|
||||
>
|
||||
{category}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCategory(category)}
|
||||
className="ml-1 text-emerald-600 hover:text-emerald-800"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Please select at least one category
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={
|
||||
editingProduct.Description || editingProduct.description || ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setEditingProduct({
|
||||
...editingProduct,
|
||||
Description: e.target.value,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
rows="4"
|
||||
className="w-full px-3 py-2 border border-gray-300 focus:border-emerald-500 focus:outline-none"
|
||||
placeholder="Describe your product in detail..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Product Images <span className="text-gray-500">(Max 5)</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files).slice(0, 5);
|
||||
setEditingProduct((prev) => ({
|
||||
...prev,
|
||||
images: [...(prev.images || []), ...files].slice(0, 5),
|
||||
}));
|
||||
}}
|
||||
className="hidden"
|
||||
id="image-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="image-upload"
|
||||
className="block w-full p-3 border border-gray-300 bg-gray-50 text-center cursor-pointer hover:bg-gray-100"
|
||||
>
|
||||
<span className="text-emerald-600 font-medium">
|
||||
Click to upload images
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Image previews */}
|
||||
{(editingProduct.images || []).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
{editingProduct.images.length}{" "}
|
||||
{editingProduct.images.length === 1 ? "image" : "images"}{" "}
|
||||
selected
|
||||
</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingProduct((prev) => ({ ...prev, images: [] }))
|
||||
}
|
||||
className="text-sm text-red-600 hover:text-red-800 flex items-center gap-1"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Clear all</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{editingProduct.images.map((img, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="relative w-20 h-20 border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={URL.createObjectURL(img)}
|
||||
alt={`Product ${idx + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = [...editingProduct.images];
|
||||
updated.splice(idx, 1);
|
||||
setEditingProduct((prev) => ({
|
||||
...prev,
|
||||
images: updated,
|
||||
}));
|
||||
}}
|
||||
className="absolute top-0 right-0 bg-white bg-opacity-80 w-6 h-6 flex items-center justify-center text-gray-700 hover:text-red-600"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show current image if editing */}
|
||||
{editingProduct.image_url && (
|
||||
<div className="mt-3">
|
||||
<p className="text-sm text-gray-600 mb-2">Current image:</p>
|
||||
<div className="relative w-20 h-20 border border-gray-200 overflow-hidden">
|
||||
<img
|
||||
src={editingProduct.image_url}
|
||||
alt="Current product"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex justify-end gap-3 border-t border-gray-200 pt-4">
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="bg-gray-100 text-gray-700 px-4 py-2 hover:bg-gray-200 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{editingProduct.ProductID && (
|
||||
<button
|
||||
onClick={markAsSold}
|
||||
className={`px-4 py-2 rounded-md transition-colors ${
|
||||
editingProduct.isSold
|
||||
? "bg-green-600 text-white hover:bg-green-700"
|
||||
: "bg-red-600 text-white hover:bg-red-700"
|
||||
}`}
|
||||
>
|
||||
Mark as {editingProduct.isSold ? "Available" : "Sold"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSaveProduct}
|
||||
className="bg-emerald-600 text-white px-6 py-2 hover:bg-emerald-700 rounded-md"
|
||||
>
|
||||
{editingProduct.ProductID ? "Update Product" : "Add Product"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-10 bg-gray-50">
|
||||
<p className="text-gray-500 mb-4">
|
||||
You don't have any listings yet
|
||||
</p>
|
||||
<button
|
||||
onClick={handleAddProduct}
|
||||
className="bg-emerald-600 text-white px-4 py-2 hover:bg-emerald-700"
|
||||
>
|
||||
Create Your First Listing
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{products.map((product) => (
|
||||
<Link
|
||||
key={product.ProductID}
|
||||
to={`/product/${product.ProductID}`}
|
||||
>
|
||||
<div className="border-2 border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
|
||||
<div className="h-48 bg-gray-200 flex items-center justify-center">
|
||||
{product.image_url && product.image_url.length > 0 ? (
|
||||
<img
|
||||
src={product.image_url || ""}
|
||||
alt={product.Name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400">No image</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{product.Name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-emerald-600 font-bold mt-1">
|
||||
${product.Price}
|
||||
</p>
|
||||
|
||||
{product.CategoryID && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1">
|
||||
{getCategoryNameById(product.CategoryID) ||
|
||||
product.CategoryID}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-gray-500 text-sm mt-2 line-clamp-2">
|
||||
{product.Description}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleDeleteProduct(product.ProductID);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleEditProduct(product);
|
||||
}}
|
||||
className="text-emerald-600 hover:text-emerald-800 font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { User, Lock, Trash2, History, Search, Shield } from "lucide-react";
|
||||
import { User, Lock, Trash2, History, Shield } from "lucide-react";
|
||||
import FloatingAlert from "../components/FloatingAlert"; // adjust path if needed
|
||||
|
||||
const Settings = () => {
|
||||
const [userData, setUserData] = useState({
|
||||
@@ -9,13 +10,13 @@ const Settings = () => {
|
||||
phone: "",
|
||||
UCID: "",
|
||||
address: "",
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAlert, setShowAlert] = useState(false);
|
||||
const storedUser = JSON.parse(sessionStorage.getItem("user"));
|
||||
|
||||
// Fetch user data when component mounts
|
||||
useEffect(() => {
|
||||
@@ -43,7 +44,7 @@ const Settings = () => {
|
||||
body: JSON.stringify({
|
||||
email: storedUser.email,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
@@ -53,16 +54,13 @@ const Settings = () => {
|
||||
// Update state with fetched data
|
||||
setUserData((prevData) => ({
|
||||
...prevData,
|
||||
userId: data.userId || storedUser.id || "", // Try both sources
|
||||
userId: storedUser.ID, // Try both sources
|
||||
name: data.name || storedUser.name || "",
|
||||
email: data.email || storedUser.email || "",
|
||||
UCID: data.UCID || storedUser.UCID || "",
|
||||
phone: data.phone || storedUser.phone || "",
|
||||
address: data.address || storedUser.address || "",
|
||||
// Reset password fields
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
password: data.password,
|
||||
}));
|
||||
} else {
|
||||
throw new Error(data.error || "Failed to retrieve user data");
|
||||
@@ -70,7 +68,7 @@ const Settings = () => {
|
||||
} catch (error) {
|
||||
console.error("Error fetching user data:", error);
|
||||
setError(
|
||||
error.message || "An error occurred while loading your profile"
|
||||
error.message || "An error occurred while loading your profile",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -88,75 +86,65 @@ const Settings = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleProfileUpdate = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
// TODO: Implement the actual update API call
|
||||
console.log("Profile updated:", userData);
|
||||
const removeHistory = async () => {
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/history/delHistory",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userID: storedUser.ID,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Update localStorage with new user data
|
||||
const storedUser = JSON.parse(localStorage.getItem("user"));
|
||||
const updatedUser = {
|
||||
...storedUser,
|
||||
name: userData.name,
|
||||
phone: userData.phone,
|
||||
UCID: userData.UCID,
|
||||
address: userData.address,
|
||||
if (response.ok) {
|
||||
setShowAlert(true);
|
||||
}
|
||||
};
|
||||
localStorage.setItem("user", JSON.stringify(updatedUser));
|
||||
|
||||
const handleUpdateProfile = 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/user/update", {
|
||||
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);
|
||||
alert("Failed to update profile: " + error.message);
|
||||
setError(
|
||||
error.message || "An error occurred while updating your profile.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordUpdate = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
// Validate passwords match
|
||||
if (userData.newPassword !== userData.confirmPassword) {
|
||||
alert("New passwords do not match!");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Implement the actual password update API call
|
||||
console.log("Password updated");
|
||||
|
||||
// Update password in localStorage
|
||||
const storedUser = JSON.parse(localStorage.getItem("user"));
|
||||
const updatedUser = {
|
||||
...storedUser,
|
||||
password: userData.newPassword,
|
||||
};
|
||||
localStorage.setItem("user", JSON.stringify(updatedUser));
|
||||
|
||||
// Reset password fields
|
||||
setUserData((prevData) => ({
|
||||
...prevData,
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
}));
|
||||
|
||||
alert("Password updated successfully!");
|
||||
} catch (error) {
|
||||
console.error("Error updating password:", error);
|
||||
alert("Failed to update password: " + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteHistory = (type) => {
|
||||
// TODO: Delete the specified history
|
||||
console.log(`Deleting ${type} history`);
|
||||
alert(`${type} history deleted successfully!`);
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to delete your account? This action cannot be undone."
|
||||
"Are you sure you want to delete your account? This action cannot be undone.",
|
||||
)
|
||||
) {
|
||||
try {
|
||||
@@ -202,7 +190,7 @@ const Settings = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500"></div>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -232,7 +220,7 @@ const Settings = () => {
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<form onSubmit={handleProfileUpdate}>
|
||||
<form onSubmit={handleUpdateProfile}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label
|
||||
@@ -246,7 +234,7 @@ const Settings = () => {
|
||||
id="name"
|
||||
value={userData.name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -263,7 +251,7 @@ const Settings = () => {
|
||||
id="email"
|
||||
value={userData.email}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-emerald-500"
|
||||
required
|
||||
readOnly // Email is often used as identifier and not changeable
|
||||
/>
|
||||
@@ -281,7 +269,7 @@ const Settings = () => {
|
||||
id="phone"
|
||||
value={userData.phone}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -297,7 +285,7 @@ const Settings = () => {
|
||||
id="UCID"
|
||||
value={userData.UCID}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-emerald-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -314,14 +302,29 @@ const Settings = () => {
|
||||
id="address"
|
||||
value={userData.address}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
password
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="password"
|
||||
value={userData.password}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4"
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white font-medium py-2 px-4"
|
||||
>
|
||||
Update Profile
|
||||
</button>
|
||||
@@ -329,81 +332,13 @@ const Settings = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Section */}
|
||||
<div className="bg-white border border-gray-200 mb-6">
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center">
|
||||
<Lock className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<h2 className="text-lg font-medium text-gray-800">Password</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<form onSubmit={handlePasswordUpdate}>
|
||||
<div className="space-y-4 mb-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="currentPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="currentPassword"
|
||||
value={userData.currentPassword}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="newPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
value={userData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
value={userData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className="w-full p-2 border border-gray-300 focus:outline-none focus:border-green-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy Section */}
|
||||
{showAlert && (
|
||||
<FloatingAlert
|
||||
message="We Removed Your History! 😉"
|
||||
onClose={() => setShowAlert(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-white border border-gray-200 mb-6">
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="flex items-center">
|
||||
@@ -414,39 +349,18 @@ const Settings = () => {
|
||||
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center pb-4 border-b border-gray-100">
|
||||
<div className="flex items-start">
|
||||
<Search className="h-5 w-5 text-gray-500 mr-2 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800">Search History</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Delete all your search history on StudentMarket
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteHistory("search")}
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium py-2 px-4 flex items-center"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-start">
|
||||
<History className="h-5 w-5 text-gray-500 mr-2 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800">
|
||||
Browsing History
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-800"> History</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Delete all your browsing history on StudentMarket
|
||||
Delete all your history on Market
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteHistory("browsing")}
|
||||
onClick={() => removeHistory()}
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium py-2 px-4 flex items-center"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
@@ -460,7 +374,7 @@ const Settings = () => {
|
||||
{/* Delete Account (Danger Zone) */}
|
||||
<div className="bg-white border border-red-200 mb-6">
|
||||
<div className="border-b border-red-200 p-4 bg-red-50">
|
||||
<h2 className="text-lg font-medium text-red-700">Danger Zone</h2>
|
||||
<h2 className="text-lg font-medium text-red-700">Danger Zone !!!</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
|
||||
@@ -1,11 +1,211 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Tag, Book, Laptop, Sofa, Utensils, Gift, Heart } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Calendar, CreditCard, Trash2 } from "lucide-react";
|
||||
|
||||
const Transactions = () => {
|
||||
return (
|
||||
<div>
|
||||
const [transactions, setTransactions] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTransactions = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"http://localhost:3030/api/transaction/getAllTransactions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userID: 1 }), // replace with actual userID
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const { transactions: txData } = await response.json();
|
||||
if (!Array.isArray(txData)) return;
|
||||
|
||||
setTransactions(
|
||||
txData.map((tx) => ({
|
||||
id: tx.TransactionID,
|
||||
productId: tx.ProductID,
|
||||
name: tx.ProductName || "Unnamed Product",
|
||||
price: tx.Price != null ? parseFloat(tx.Price) : null,
|
||||
image: tx.Image_URL || "/default-image.jpg",
|
||||
date: tx.Date,
|
||||
status: tx.PaymentStatus,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transactions:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTransactions();
|
||||
}, []);
|
||||
|
||||
const deleteTransaction = async (id) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"http://localhost:3030/api/transaction/deleteTransaction",
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ transactionID: id }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setTransactions((prev) => prev.filter((tx) => tx.id !== id));
|
||||
} else {
|
||||
console.error("Delete failed:", data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error deleting transaction:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const d = new Date(dateString);
|
||||
return d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">My Transactions</h1>
|
||||
</div>
|
||||
|
||||
{transactions.length === 0 ? (
|
||||
<div className="bg-white border border-gray-200 p-8 text-center">
|
||||
<CreditCard className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-medium text-gray-700 mb-2">
|
||||
No transactions found
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-4">
|
||||
Once transactions are created, they’ll appear here.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-block bg-emerald-500 hover:bg-emerald-600 text-white font-medium py-2 px-4"
|
||||
>
|
||||
Browse Listings
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="relative border-2 border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
deleteTransaction(tx.id);
|
||||
}}
|
||||
className="absolute bottom-2 right-2 text-red-500 hover:text-red-600 z-10"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
|
||||
<Link to={`/product/${tx.productId}`}>
|
||||
<div className="h-48 bg-gray-200 flex items-center justify-center">
|
||||
{tx.image ? (
|
||||
<img
|
||||
src={tx.image}
|
||||
alt={tx.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-gray-400">No image</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{tx.name}
|
||||
</h3>
|
||||
{tx.price !== null && (
|
||||
<p className="text-emerald-600 font-bold mt-1">
|
||||
${tx.price.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center text-gray-500 text-sm mt-2">
|
||||
<Calendar className="mr-1" size={16} />
|
||||
{formatDate(tx.date)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
Status: <span className="font-medium">{tx.status}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-sm text-gray-500">
|
||||
Showing {transactions.length}{" "}
|
||||
{transactions.length === 1 ? "transaction" : "transactions"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<footer className="bg-gray-800 text-white py-6 mt-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="mb-4 md:mb-0">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Campus Marketplace
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Your trusted university trading platform
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Quick Links</h4>
|
||||
<ul className="text-sm text-gray-400">
|
||||
<li className="mb-1">
|
||||
<Link to="/" className="hover:text-white transition">
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-1">
|
||||
<Link
|
||||
to="/selling"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Sell an Item
|
||||
</Link>
|
||||
</li>
|
||||
<li className="mb-1">
|
||||
<Link
|
||||
to="/favorites"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
My Favorites
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Contact</h4>
|
||||
<ul className="text-sm text-gray-400">
|
||||
<li className="mb-1">support@campusmarket.com</li>
|
||||
<li className="mb-1">University of Calgary</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-700 mt-6 pt-6 text-center text-sm text-gray-400">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Campus Marketplace. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
124
frontend/src/schema.sql
Normal file
@@ -0,0 +1,124 @@
|
||||
-- MySql Version 9.2.0
|
||||
CREATE DATABASE Marketplace;
|
||||
|
||||
USE Marketplace;
|
||||
|
||||
-- User Entity
|
||||
CREATE TABLE User (
|
||||
UserID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(100) NOT NULL,
|
||||
Email VARCHAR(100) UNIQUE NOT NULL,
|
||||
UCID VARCHAR(20) UNIQUE NOT NULL,
|
||||
Password VARCHAR(255) NOT NULL,
|
||||
Phone VARCHAR(20),
|
||||
Address VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE TABLE UserRole (
|
||||
UserID INT,
|
||||
Client BOOLEAN DEFAULT True,
|
||||
Admin BOOLEAN DEFAULT FALSE,
|
||||
PRIMARY KEY (UserID),
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Category Entity (must be created before Product or else error)
|
||||
CREATE TABLE Category (
|
||||
CategoryID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
-- Product Entity
|
||||
CREATE TABLE Product (
|
||||
ProductID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(255) NOT NULL,
|
||||
Price DECIMAL(10, 2) NOT NULL,
|
||||
StockQuantity INT,
|
||||
UserID INT,
|
||||
Description TEXT,
|
||||
CategoryID INT NOT NULL,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (CategoryID) REFERENCES Category (CategoryID) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Fixed Image_URL table
|
||||
CREATE TABLE Image_URL (
|
||||
URL VARCHAR(255),
|
||||
ProductID INT,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Fixed Review Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Review (
|
||||
ReviewID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
Comment TEXT,
|
||||
Rating INT CHECK (
|
||||
Rating >= 1
|
||||
AND Rating <= 5
|
||||
),
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Transaction Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Transaction (
|
||||
TransactionID INT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PaymentStatus VARCHAR(50),
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Recommendation Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Recommendation (
|
||||
RecommendationID_PK INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
RecommendedProductID INT,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (RecommendedProductID) REFERENCES Product (ProductID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- History Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE History (
|
||||
HistoryID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Favorites Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Favorites (
|
||||
FavoriteID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE,
|
||||
UNIQUE (UserID, ProductID)
|
||||
);
|
||||
|
||||
-- Product-Category Junction Table (Many-to-Many)
|
||||
CREATE TABLE Product_Category (
|
||||
ProductID INT,
|
||||
CategoryID INT,
|
||||
PRIMARY KEY (ProductID, CategoryID),
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (CategoryID) REFERENCES Category (CategoryID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Login Authentication table
|
||||
CREATE TABLE AuthVerification (
|
||||
UserID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Email VARCHAR(100) UNIQUE NOT NULL,
|
||||
VerificationCode VARCHAR(6) NOT NULL,
|
||||
Authenticated BOOLEAN DEFAULT FALSE,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
457
mysql-code/Init-Data.sql
Normal file
@@ -0,0 +1,457 @@
|
||||
-- Inserting sample data into the Marketplace database
|
||||
SET
|
||||
FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
TRUNCATE TABLE Product_Category;
|
||||
|
||||
TRUNCATE TABLE Favorites;
|
||||
|
||||
TRUNCATE TABLE History;
|
||||
|
||||
TRUNCATE TABLE Recommendation;
|
||||
|
||||
TRUNCATE TABLE Transaction;
|
||||
|
||||
TRUNCATE TABLE Review;
|
||||
|
||||
TRUNCATE TABLE Image_URL;
|
||||
|
||||
TRUNCATE TABLE Product;
|
||||
|
||||
TRUNCATE TABLE Category;
|
||||
|
||||
TRUNCATE TABLE UserRole;
|
||||
|
||||
TRUNCATE TABLE User;
|
||||
|
||||
TRUNCATE TABLE AuthVerification;
|
||||
|
||||
SET
|
||||
FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- Insert Users
|
||||
INSERT INTO
|
||||
User (
|
||||
UserID,
|
||||
Name,
|
||||
Email,
|
||||
UCID,
|
||||
Password,
|
||||
Phone,
|
||||
Address
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1,
|
||||
'John Doe',
|
||||
'john.doe@example.com',
|
||||
'U123456',
|
||||
'hashedpassword1',
|
||||
'555-123-4567',
|
||||
'123 Main St, Calgary, AB'
|
||||
),
|
||||
(
|
||||
2,
|
||||
'Jane Smith',
|
||||
'jane.smith@example.com',
|
||||
'U234567',
|
||||
'hashedpassword2',
|
||||
'555-234-5678',
|
||||
'456 Oak Ave, Calgary, AB'
|
||||
);
|
||||
|
||||
-- Insert User Roles
|
||||
INSERT INTO
|
||||
UserRole (UserID, Client, Admin)
|
||||
VALUES
|
||||
(1, TRUE, TRUE),
|
||||
(2, TRUE, FALSE);
|
||||
|
||||
-- Insert Categories
|
||||
-- Insert Categories
|
||||
INSERT INTO
|
||||
Category (Name)
|
||||
VALUES
|
||||
('Textbooks'),
|
||||
('Electronics'),
|
||||
('Furniture'),
|
||||
('Clothing'),
|
||||
('Sports Equipment'),
|
||||
('Musical Instruments'),
|
||||
('Art Supplies'),
|
||||
('Kitchen Appliances'),
|
||||
('Gaming'),
|
||||
('Bicycles'),
|
||||
('Computer Accessories'),
|
||||
('Stationery'),
|
||||
('Fitness Equipment'),
|
||||
('Winter Sports'),
|
||||
('Lab Equipment'),
|
||||
('Camping Gear'),
|
||||
('School Supplies'),
|
||||
('Office Furniture'),
|
||||
('Books (-textbook)'),
|
||||
('Math & Science Resources'),
|
||||
('Engineering Tools'),
|
||||
('Backpacks & Bags'),
|
||||
('Audio Equipment'),
|
||||
('Dorm Essentials'),
|
||||
('Smartphones & Tablets'),
|
||||
('Winter Clothing'),
|
||||
('Photography Equipment'),
|
||||
('Event Tickets'),
|
||||
('Software Licenses'),
|
||||
('Transportation (Car Pool)'),
|
||||
('Other');
|
||||
|
||||
-- Insert Products
|
||||
INSERT INTO
|
||||
Product (
|
||||
ProductID,
|
||||
Name,
|
||||
Price,
|
||||
StockQuantity,
|
||||
UserID,
|
||||
Description,
|
||||
CategoryID,
|
||||
Date
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
1,
|
||||
'Calculus Textbook 8th Edition',
|
||||
79.99,
|
||||
5,
|
||||
1,
|
||||
'Like new calculus textbook, minor highlighting',
|
||||
1,
|
||||
'2024-10-15 10:00:00'
|
||||
),
|
||||
(
|
||||
2,
|
||||
'HP Laptop',
|
||||
699.99,
|
||||
1,
|
||||
1,
|
||||
'2023 HP Pavilion, 16GB RAM, 512GB SSD',
|
||||
2,
|
||||
'2024-10-10 14:30:00'
|
||||
),
|
||||
(
|
||||
3,
|
||||
'Dorm Desk',
|
||||
120.00,
|
||||
1,
|
||||
2,
|
||||
'Sturdy desk perfect for studying, minor scratches',
|
||||
3,
|
||||
'2024-10-12 09:15:00'
|
||||
),
|
||||
(
|
||||
4,
|
||||
'University Hoodie',
|
||||
35.00,
|
||||
3,
|
||||
2,
|
||||
'Size L, university logo, worn twice',
|
||||
4,
|
||||
'2024-10-14 16:45:00'
|
||||
),
|
||||
(
|
||||
5,
|
||||
'Basketball',
|
||||
25.50,
|
||||
1,
|
||||
2,
|
||||
'Slightly used indoor basketball',
|
||||
5,
|
||||
'2024-10-11 11:20:00'
|
||||
),
|
||||
(
|
||||
6,
|
||||
'Acoustic Guitar',
|
||||
175.00,
|
||||
1,
|
||||
1,
|
||||
'Beginner acoustic guitar with case',
|
||||
6,
|
||||
'2024-10-09 13:10:00'
|
||||
),
|
||||
(
|
||||
7,
|
||||
'Physics Textbook',
|
||||
65.00,
|
||||
2,
|
||||
2,
|
||||
'University Physics 14th Edition, good condition',
|
||||
1,
|
||||
'2024-10-08 10:30:00'
|
||||
),
|
||||
(
|
||||
8,
|
||||
'Mini Fridge',
|
||||
85.00,
|
||||
1,
|
||||
1,
|
||||
'Small dorm fridge, works perfectly',
|
||||
8,
|
||||
'2024-10-13 15:00:00'
|
||||
),
|
||||
(
|
||||
9,
|
||||
'PlayStation 5 Controller',
|
||||
55.00,
|
||||
1,
|
||||
2,
|
||||
'Extra controller, barely used',
|
||||
9,
|
||||
'2024-10-07 17:20:00'
|
||||
),
|
||||
(
|
||||
10,
|
||||
'Mountain Bike',
|
||||
350.00,
|
||||
1,
|
||||
1,
|
||||
'Trek mountain bike, great condition, new tires',
|
||||
10,
|
||||
'2024-10-06 14:00:00'
|
||||
),
|
||||
(
|
||||
11,
|
||||
'Wireless Mouse',
|
||||
22.99,
|
||||
3,
|
||||
1,
|
||||
'Logitech wireless mouse with battery',
|
||||
11,
|
||||
'2024-10-05 09:30:00'
|
||||
),
|
||||
(
|
||||
12,
|
||||
'Chemistry Lab Coat',
|
||||
30.00,
|
||||
2,
|
||||
2,
|
||||
'Size M, required for chem labs',
|
||||
15,
|
||||
'2024-10-04 13:45:00'
|
||||
),
|
||||
(
|
||||
13,
|
||||
'Graphing Calculator',
|
||||
75.00,
|
||||
1,
|
||||
1,
|
||||
'TI-84 Plus, perfect working condition',
|
||||
12,
|
||||
'2024-10-03 11:15:00'
|
||||
),
|
||||
(
|
||||
14,
|
||||
'Yoga Mat',
|
||||
20.00,
|
||||
1,
|
||||
2,
|
||||
'Thick yoga mat, barely used',
|
||||
13,
|
||||
'2024-10-02 16:00:00'
|
||||
),
|
||||
(
|
||||
15,
|
||||
'Winter Jacket',
|
||||
120.00,
|
||||
1,
|
||||
1,
|
||||
'Columbia winter jacket, size XL, very warm',
|
||||
26,
|
||||
'2024-10-01 10:20:00'
|
||||
),
|
||||
(
|
||||
16,
|
||||
'Computer Science Textbook',
|
||||
70.00,
|
||||
1,
|
||||
2,
|
||||
'Introduction to Algorithms, like new',
|
||||
1,
|
||||
'2024-09-30 14:30:00'
|
||||
),
|
||||
(
|
||||
17,
|
||||
'Desk Lamp',
|
||||
15.00,
|
||||
2,
|
||||
2,
|
||||
'LED desk lamp with adjustable brightness',
|
||||
24,
|
||||
'2024-09-29 12:00:00'
|
||||
),
|
||||
(
|
||||
18,
|
||||
'Scientific Calculator',
|
||||
25.00,
|
||||
1,
|
||||
1,
|
||||
'Casio scientific calculator',
|
||||
12,
|
||||
'2024-09-28 11:30:00'
|
||||
),
|
||||
(
|
||||
19,
|
||||
'Bluetooth Speaker',
|
||||
45.00,
|
||||
1,
|
||||
1,
|
||||
'JBL Bluetooth speaker, great sound',
|
||||
23,
|
||||
'2024-09-27 15:45:00'
|
||||
),
|
||||
(
|
||||
20,
|
||||
'Backpack',
|
||||
40.00,
|
||||
1,
|
||||
2,
|
||||
'North Face backpack, lots of pockets',
|
||||
22,
|
||||
'2024-09-26 09:15:00'
|
||||
);
|
||||
|
||||
INSERT INTO
|
||||
Image_URL (URL, ProductID)
|
||||
VALUES
|
||||
('/Uploads/Dell1.jpg', 1),
|
||||
('/Uploads/Dell2.jpg', 1),
|
||||
('/Uploads/Dell3.jpg', 1),
|
||||
('/Uploads/HP-Laptop1.jpg', 2),
|
||||
('/Uploads/HP-Laptop1.jpg', 2),
|
||||
('/Uploads/Dorm-Desk.jpg', 3),
|
||||
('/Uploads/University-Hoodie.jpg', 4),
|
||||
('/Uploads/Basketball.jpg', 5),
|
||||
('/Uploads/Acoustic-Guitar.jpg', 6),
|
||||
('/Uploads/Physics-Textbook.jpg', 7),
|
||||
('/Uploads/Mini-Fridge.jpg', 8),
|
||||
('/Uploads/Controller.jpg', 9),
|
||||
('/Uploads/Mountain-Bike.jpg', 10),
|
||||
('/Uploads/Wireless-Mouse.jpg', 11),
|
||||
('/Uploads/Lab-Coat.jpg', 12),
|
||||
('/Uploads/Calculator.jpg', 13),
|
||||
('/Uploads/Yoga-Mat.jpg', 14),
|
||||
('/Uploads/Winter-Jacket.jpg', 15),
|
||||
('/Uploads/CS-Textbook.jpg', 16),
|
||||
('/Uploads/Desk-Lamp.jpg', 17),
|
||||
('/Uploads/HP-Calculator.jpg', 18),
|
||||
('/Uploads/Bluetooth-Speaker.jpg', 19),
|
||||
('/Uploads/Backpack.jpg', 20);
|
||||
|
||||
-- Insert Product-Category relationships (products with multiple categories)
|
||||
INSERT INTO
|
||||
Product_Category (ProductID, CategoryID)
|
||||
VALUES
|
||||
(1, 1),
|
||||
(1, 17),
|
||||
(1, 20), -- Calculus book: Textbooks, School Supplies, Math Resources
|
||||
(2, 2),
|
||||
(2, 11),
|
||||
(2, 25), -- Laptop: Electronics, Computer Accessories, Smartphones & Tablets
|
||||
(3, 3),
|
||||
(3, 18),
|
||||
(3, 24), -- Desk: Furniture, Office Furniture, Dorm Essentials
|
||||
(4, 4),
|
||||
(4, 26), -- Hoodie: Clothing, Winter Clothing
|
||||
(5, 5),
|
||||
(5, 13), -- Basketball: Sports Equipment, Fitness Equipment
|
||||
(6, 6),
|
||||
(6, 23), -- Guitar: Musical Instruments, Audio Equipment
|
||||
(7, 1),
|
||||
(7, 15),
|
||||
(7, 20), -- Physics book: Textbooks, Lab Equipment, Math & Science Resources
|
||||
(8, 8),
|
||||
(8, 24), -- Mini Fridge: Kitchen Appliances, Dorm Essentials
|
||||
(9, 9),
|
||||
(9, 2), -- PS5 Controller: Gaming, Electronics
|
||||
(10, 10),
|
||||
(10, 5),
|
||||
(10, 13), -- Mountain Bike: Bicycles, Sports Equipment, Fitness Equipment
|
||||
(11, 11),
|
||||
(11, 2), -- Mouse: Computer Accessories, Electronics
|
||||
(12, 15),
|
||||
(12, 17), -- Lab Coat: Lab Equipment, School Supplies
|
||||
(13, 12),
|
||||
(13, 17),
|
||||
(13, 20), -- Calculator: Stationery, School Supplies, Math & Science Resources
|
||||
(14, 13),
|
||||
(14, 5), -- Yoga Mat: Fitness Equipment, Sports Equipment
|
||||
(15, 26),
|
||||
(15, 4),
|
||||
(15, 14), -- Winter Jacket: Winter Clothing, Clothing, Winter Sports
|
||||
(16, 1),
|
||||
(16, 17),
|
||||
(16, 19), -- CS Book: Textbooks, School Supplies, Books (Non-textbook)
|
||||
(17, 24),
|
||||
(17, 2), -- Desk Lamp: Dorm Essentials, Electronics
|
||||
(18, 12),
|
||||
(18, 17),
|
||||
(18, 20), -- Scientific Calculator: Stationery, School Supplies, Math & Science
|
||||
(19, 23),
|
||||
(19, 2),
|
||||
(19, 24), -- Bluetooth Speaker: Audio Equipment, Electronics, Dorm Essentials
|
||||
(20, 22),
|
||||
(20, 17),
|
||||
(20, 24);
|
||||
|
||||
-- Insert History records
|
||||
INSERT INTO
|
||||
History (HistoryID, UserID, ProductID)
|
||||
VALUES
|
||||
(1, 1, 1),
|
||||
(2, 1, 3),
|
||||
(3, 1, 5),
|
||||
(4, 1, 7),
|
||||
(5, 1, 9),
|
||||
(6, 1, 11),
|
||||
(7, 2, 2),
|
||||
(8, 2, 4),
|
||||
(9, 2, 5),
|
||||
(10, 1, 15),
|
||||
(11, 1, 18);
|
||||
|
||||
-- Insert Favorites
|
||||
INSERT INTO
|
||||
Favorites (UserID, ProductID)
|
||||
VALUES
|
||||
(1, 2), -- User 1 likes HP Laptop
|
||||
(1, 7), -- User 1 likes Physics Textbook
|
||||
(2, 3), -- User 2 likes Dorm Desk
|
||||
(2, 10), -- User 2 likes Mountain Bike
|
||||
(1, 6), -- User 3 likes Acoustic Guitar
|
||||
(1, 5), -- User 4 likes Basketball
|
||||
(2, 8);
|
||||
|
||||
-- User 5 likes Mini Fridge
|
||||
-- Insert Transactions
|
||||
INSERT INTO
|
||||
Transaction (
|
||||
TransactionID,
|
||||
UserID,
|
||||
ProductID,
|
||||
Date,
|
||||
PaymentStatus
|
||||
)
|
||||
VALUES
|
||||
(1, 1, 1, '2024-10-16 10:30:00', 'Completed'),
|
||||
(2, 1, 6, '2024-10-15 15:45:00', 'Completed'),
|
||||
(3, 1, 8, '2024-10-14 12:20:00', 'Pending'),
|
||||
(4, 2, 10, '2024-10-13 17:10:00', 'Completed'),
|
||||
(5, 2, 4, '2024-10-12 14:30:00', 'Completed');
|
||||
|
||||
INSERT INTO
|
||||
Review (UserID, ProductID, Comment, Rating, Date)
|
||||
VALUES
|
||||
(
|
||||
1,
|
||||
1,
|
||||
'This is a great fake product! Totally recommend it.',
|
||||
5,
|
||||
);
|
||||
137
mysql-code/Schema.sql
Normal file
@@ -0,0 +1,137 @@
|
||||
-- MySql Version 9.2.0
|
||||
CREATE DATABASE Marketplace;
|
||||
|
||||
USE Marketplace;
|
||||
|
||||
-- User Entity
|
||||
CREATE TABLE User (
|
||||
UserID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(100) NOT NULL,
|
||||
Email VARCHAR(100) UNIQUE NOT NULL,
|
||||
UCID VARCHAR(20) UNIQUE NOT NULL,
|
||||
Password VARCHAR(255) NOT NULL,
|
||||
Phone VARCHAR(20),
|
||||
Address VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE TABLE UserRole (
|
||||
UserID INT,
|
||||
Client BOOLEAN DEFAULT True,
|
||||
Admin BOOLEAN DEFAULT FALSE,
|
||||
PRIMARY KEY (UserID),
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Category Entity (must be created before Product or else error)
|
||||
CREATE TABLE Category (
|
||||
CategoryID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE Product (
|
||||
ProductID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(255) NOT NULL,
|
||||
Price DECIMAL(10, 2) NOT NULL,
|
||||
StockQuantity INT,
|
||||
UserID INT,
|
||||
Description TEXT,
|
||||
CategoryID INT NOT NULL,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID),
|
||||
FOREIGN KEY (CategoryID) REFERENCES Category (CategoryID)
|
||||
);
|
||||
|
||||
-- Product Entity
|
||||
CREATE TABLE Product (
|
||||
ProductID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Name VARCHAR(255) NOT NULL,
|
||||
Price DECIMAL(10, 2) NOT NULL,
|
||||
StockQuantity INT,
|
||||
UserID INT,
|
||||
Description TEXT,
|
||||
CategoryID INT,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE SET NULL,
|
||||
FOREIGN KEY (CategoryID) REFERENCES Category (CategoryID)
|
||||
);
|
||||
|
||||
-- Fixed Image_URL table
|
||||
CREATE TABLE Image_URL (
|
||||
URL VARCHAR(255),
|
||||
ProductID INT,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Fixed Review Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Review (
|
||||
ReviewID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
Comment TEXT,
|
||||
Rating INT CHECK (
|
||||
Rating >= 1
|
||||
AND Rating <= 5
|
||||
),
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE SET NULL,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Transaction Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Transaction (
|
||||
TransactionID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PaymentStatus VARCHAR(50),
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Recommendation Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Recommendation (
|
||||
RecommendationID_PK INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
RecommendedProductID INT,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (RecommendedProductID) REFERENCES Product (ProductID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- History Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE History (
|
||||
HistoryID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Favorites Entity (Many-to-One with User, Many-to-One with Product)
|
||||
CREATE TABLE Favorites (
|
||||
FavoriteID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
UserID INT,
|
||||
ProductID INT,
|
||||
FOREIGN KEY (UserID) REFERENCES User (UserID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE,
|
||||
UNIQUE (UserID, ProductID)
|
||||
);
|
||||
|
||||
-- Product-Category Junction Table (Many-to-Many)
|
||||
CREATE TABLE Product_Category (
|
||||
ProductID INT,
|
||||
CategoryID INT,
|
||||
PRIMARY KEY (ProductID, CategoryID),
|
||||
FOREIGN KEY (ProductID) REFERENCES Product (ProductID) ON DELETE CASCADE,
|
||||
FOREIGN KEY (CategoryID) REFERENCES Category (CategoryID) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Login Authentication table
|
||||
CREATE TABLE AuthVerification (
|
||||
UserID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Email VARCHAR(100) UNIQUE NOT NULL,
|
||||
VerificationCode VARCHAR(6) NOT NULL,
|
||||
Authenticated BOOLEAN DEFAULT FALSE,
|
||||
Date DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
BIN
recommondation-engine/__pycache__/app.cpython-313.pyc
Normal file
272
recommondation-engine/app.py
Normal file
@@ -0,0 +1,272 @@
|
||||
# pip install mysql.connector
|
||||
import mysql.connector
|
||||
from sklearn.metrics.pairwise import cosine_similarity
|
||||
import numpy as np
|
||||
import logging
|
||||
import random
|
||||
|
||||
def database():
|
||||
db_connection = mysql.connector.connect(
|
||||
host = "localhost",
|
||||
port = "3306",
|
||||
user = "root",
|
||||
database = "Marketplace"
|
||||
)
|
||||
return db_connection
|
||||
|
||||
def delete_user_recommendations(user_id):
|
||||
db_con = database()
|
||||
cursor = db_con.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("DELETE FROM Recommendation WHERE UserID = %s", (user_id,))
|
||||
db_con.commit()
|
||||
print(f"Deleted existing recommendations for user {user_id}")
|
||||
logging.info(f"Deleted existing recommendations for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Error deleting recommendations for user {user_id}: {str(e)}")
|
||||
db_con.rollback()
|
||||
return False
|
||||
finally:
|
||||
cursor.close()
|
||||
db_con.close()
|
||||
|
||||
def get_random_products(count=10, exclude_list=None):
|
||||
"""Get random products from the database, excluding any in the exclude_list"""
|
||||
db_con = database()
|
||||
cursor = db_con.cursor()
|
||||
|
||||
try:
|
||||
if exclude_list and len(exclude_list) > 0:
|
||||
# Convert exclude_list to string for SQL IN clause
|
||||
exclude_str = ', '.join(map(str, exclude_list))
|
||||
cursor.execute(f"SELECT ProductID FROM Product WHERE ProductID NOT IN ({exclude_str}) ORDER BY RAND() LIMIT {count}")
|
||||
else:
|
||||
cursor.execute(f"SELECT ProductID FROM Product ORDER BY RAND() LIMIT {count}")
|
||||
|
||||
random_products = [row[0] for row in cursor.fetchall()]
|
||||
return random_products
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting random products: {str(e)}")
|
||||
return []
|
||||
finally:
|
||||
cursor.close()
|
||||
db_con.close()
|
||||
|
||||
def get_popular_products(count=10):
|
||||
"""Get popular products based on history table frequency"""
|
||||
db_con = database()
|
||||
cursor = db_con.cursor()
|
||||
|
||||
try:
|
||||
# Get products that appear most frequently in history
|
||||
cursor.execute("""
|
||||
SELECT ProductID, COUNT(*) as count
|
||||
FROM History
|
||||
GROUP BY ProductID
|
||||
ORDER BY count DESC
|
||||
LIMIT %s
|
||||
""", (count,))
|
||||
|
||||
popular_products = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
# If not enough popular products, supplement with random ones
|
||||
if len(popular_products) < count:
|
||||
random_products = get_random_products(count - len(popular_products), popular_products)
|
||||
popular_products.extend(random_products)
|
||||
|
||||
return popular_products
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting popular products: {str(e)}")
|
||||
return get_random_products(count) # Fallback to random products
|
||||
finally:
|
||||
cursor.close()
|
||||
db_con.close()
|
||||
|
||||
def has_user_history_or_recommendations(user_id):
|
||||
"""Check if user exists in History or Recommendation table"""
|
||||
db_con = database()
|
||||
cursor = db_con.cursor()
|
||||
|
||||
try:
|
||||
# Check if user has history
|
||||
cursor.execute("SELECT COUNT(*) FROM History WHERE UserID = %s", (user_id,))
|
||||
history_count = cursor.fetchone()[0]
|
||||
|
||||
# Check if user has recommendations
|
||||
cursor.execute("SELECT COUNT(*) FROM Recommendation WHERE UserID = %s", (user_id,))
|
||||
recommendation_count = cursor.fetchone()[0]
|
||||
|
||||
return history_count > 0 or recommendation_count > 0
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error checking user history/recommendations: {str(e)}")
|
||||
return False
|
||||
finally:
|
||||
cursor.close()
|
||||
db_con.close()
|
||||
|
||||
def get_all_products():
|
||||
db_con = database()
|
||||
cursor = db_con.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT CategoryID FROM Category")
|
||||
categories = cursor.fetchall()
|
||||
|
||||
select_clause = "SELECT p.ProductID"
|
||||
for category in categories:
|
||||
category_id = category[0]
|
||||
select_clause += f", MAX(CASE WHEN pc.CategoryID = {category_id} THEN 1 ELSE 0 END) AS `Cat_{category_id}`"
|
||||
|
||||
final_query = f"""
|
||||
{select_clause}
|
||||
FROM Product p
|
||||
LEFT JOIN Product_Category pc ON p.ProductID = pc.ProductID
|
||||
LEFT JOIN Category c ON pc.CategoryID = c.CategoryID
|
||||
GROUP BY p.ProductID;
|
||||
"""
|
||||
|
||||
cursor.execute(final_query)
|
||||
results = cursor.fetchall()
|
||||
|
||||
final = []
|
||||
product_ids = []
|
||||
for row in results:
|
||||
text_list = list(row)
|
||||
product_id = text_list.pop(0) # Save the product ID before removing it
|
||||
final.append(text_list)
|
||||
product_ids.append(product_id)
|
||||
|
||||
cursor.close()
|
||||
db_con.close()
|
||||
return final, product_ids # Return both feature vectors and product IDs
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting all products: {str(e)}")
|
||||
cursor.close()
|
||||
db_con.close()
|
||||
return [], []
|
||||
|
||||
def get_user_history(user_id):
|
||||
db_con = database()
|
||||
cursor = db_con.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT CategoryID FROM Category")
|
||||
categories = cursor.fetchall()
|
||||
|
||||
select_clause = "SELECT p.ProductID"
|
||||
for category in categories:
|
||||
category_id = category[0] # get the uid of the category and then append that to the new column
|
||||
select_clause += f", MAX(CASE WHEN pc.CategoryID = {category_id} THEN 1 ELSE 0 END) AS `Cat_{category_id}`"
|
||||
|
||||
final_query = f"""
|
||||
{select_clause}
|
||||
FROM Product p
|
||||
LEFT JOIN Product_Category pc ON p.ProductID = pc.ProductID
|
||||
LEFT JOIN Category c ON pc.CategoryID = c.CategoryID
|
||||
where p.ProductID in (select ProductID from History where UserID = {user_id})
|
||||
GROUP BY p.ProductID;
|
||||
"""
|
||||
|
||||
cursor.execute(final_query)
|
||||
results = cursor.fetchall()
|
||||
final = []
|
||||
for row in results:
|
||||
text_list = list(row)
|
||||
text_list.pop(0)
|
||||
final.append(text_list)
|
||||
|
||||
cursor.close()
|
||||
db_con.close()
|
||||
return final
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting user history: {str(e)}")
|
||||
cursor.close()
|
||||
db_con.close()
|
||||
return []
|
||||
|
||||
def get_recommendations(user_id, top_n=5):
|
||||
try:
|
||||
# Always delete existing recommendations first
|
||||
delete_user_recommendations(user_id)
|
||||
|
||||
# Check if user has history or recommendations
|
||||
if not has_user_history_or_recommendations(user_id):
|
||||
# Cold start: return random products
|
||||
random_recs = get_random_products(top_n)
|
||||
# Store these random recommendations
|
||||
history_upload(user_id, random_recs)
|
||||
|
||||
# Add 5 more unique random products
|
||||
additional_random = get_random_products(5, random_recs)
|
||||
history_upload(user_id, additional_random)
|
||||
|
||||
return random_recs + additional_random
|
||||
|
||||
# Get all products and user history with their category vectors
|
||||
all_product_features, all_product_ids = get_all_products()
|
||||
user_history = get_user_history(user_id)
|
||||
|
||||
if not user_history:
|
||||
# User exists but has no history yet
|
||||
popular_recs = get_popular_products(top_n)
|
||||
history_upload(user_id, popular_recs)
|
||||
|
||||
# Add 5 more unique random products
|
||||
additional_random = get_random_products(5, popular_recs)
|
||||
history_upload(user_id, additional_random)
|
||||
|
||||
return popular_recs + additional_random
|
||||
|
||||
# Calculate similarity between all products and user history
|
||||
user_profile = np.mean(user_history, axis=0) # Average user preferences
|
||||
similarities = cosine_similarity([user_profile], all_product_features)
|
||||
print(similarities)
|
||||
|
||||
# Get indices of the top N products sorted by similarity
|
||||
product_indices = similarities[0].argsort()[-top_n:][::-1]
|
||||
|
||||
# Get the actual product IDs using the indices
|
||||
recommended_product_ids = [all_product_ids[i] for i in product_indices]
|
||||
|
||||
# Upload the core recommendations to the database
|
||||
history_upload(user_id, recommended_product_ids)
|
||||
|
||||
# Add 5 more unique random products that aren't in the recommendations
|
||||
additional_random = get_random_products(5, recommended_product_ids)
|
||||
history_upload(user_id, additional_random)
|
||||
|
||||
# Return both the similarity-based recommendations and the random ones
|
||||
return recommended_product_ids + additional_random
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Recommendation error for user {user_id}: {str(e)}")
|
||||
# Fallback to random products
|
||||
random_products = get_random_products(top_n + 5)
|
||||
return random_products
|
||||
|
||||
def history_upload(userID, products):
|
||||
"""Upload product recommendations to the database"""
|
||||
db_con = database()
|
||||
cursor = db_con.cursor()
|
||||
|
||||
try:
|
||||
for product_id in products:
|
||||
# Use parameterized queries to prevent SQL injection
|
||||
cursor.execute("INSERT INTO Recommendation (UserID, RecommendedProductID) VALUES (%s, %s)",
|
||||
(userID, product_id))
|
||||
|
||||
# Commit the changes
|
||||
db_con.commit()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error uploading recommendations: {str(e)}")
|
||||
db_con.rollback()
|
||||
finally:
|
||||
# Close the cursor and connection
|
||||
cursor.close()
|
||||
db_con.close()
|
||||
30
recommondation-engine/server.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
from app import get_recommendations
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app) # Enable CORS for all routes
|
||||
|
||||
@app.route('/api/user/session', methods=['POST'])
|
||||
def handle_session_data():
|
||||
try:
|
||||
data = request.get_json()
|
||||
user_id = data.get('userId')
|
||||
email = data.get('email')
|
||||
is_authenticated = data.get('isAuthenticated')
|
||||
|
||||
if not user_id or not email or is_authenticated is None:
|
||||
return jsonify({'error': 'Invalid data'}), 400
|
||||
|
||||
get_recommendations(user_id)
|
||||
|
||||
print(f"Received session data: User ID: {user_id}, Email: {email}, Authenticated: {is_authenticated}")
|
||||
return jsonify({'message': 'Session data received successfully'})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return jsonify({'error': 'Server error'}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||