Git Migration
This commit is contained in:
commit
af0608ed43
76 changed files with 13328 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.env
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.DS_Store
|
||||
uploads/*
|
||||
5
backend/.gitignore
vendored
Normal file
5
backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.env
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.DS_Store
|
||||
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
RUN mkdir -p public/uploads/products
|
||||
COPY . .
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
92
backend/README.md
Normal file
92
backend/README.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# E-commerce API Backend
|
||||
|
||||
API backend for the Rocks, Bones & Sticks e-commerce platform.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run for development
|
||||
npm run dev
|
||||
|
||||
# Run for production
|
||||
npm start
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
- `POST /api/auth/register` - Register a new user
|
||||
- `POST /api/auth/login-request` - Request a login code
|
||||
- `POST /api/auth/verify` - Verify login code and generate API key
|
||||
- `POST /api/auth/verify-key` - Verify an existing API key
|
||||
- `POST /api/auth/logout` - Logout current user and invalidate API key
|
||||
|
||||
For protected routes, include the API key in the request header:
|
||||
```
|
||||
X-API-Key: your-api-key-here
|
||||
```
|
||||
|
||||
### Products
|
||||
|
||||
- `GET /api/products` - Get all products
|
||||
- `GET /api/products/:id` - Get single product
|
||||
- `GET /api/products/categories/all` - Get all categories
|
||||
- `GET /api/products/tags/all` - Get all tags
|
||||
- `GET /api/products/category/:categoryName` - Get products by category
|
||||
|
||||
|
||||
### Product Admin (Admin Protected)
|
||||
|
||||
These routes require an API key with admin privileges.
|
||||
|
||||
- `POST /api/admin/products` - Create a new product with multiple images
|
||||
- `PUT /api/admin/products/:id` - Update a product
|
||||
- `DELETE /api/admin/products/:id` - Delete a product
|
||||
|
||||
|
||||
### Cart (Protected)
|
||||
|
||||
- `GET /api/cart/:userId` - Get users cart
|
||||
- `POST /api/cart/add` - Add item to cart
|
||||
- `PUT /api/cart/update` - Update cart item quantity
|
||||
- `DELETE /api/cart/clear/:userId` - Clear cart
|
||||
- `POST /api/cart/checkout` - Checkout (create order from cart)
|
||||
|
||||
## Admin Access
|
||||
|
||||
By default, the user with email `john@example.com` is set as an admin for testing purposes. The admin status allows access to protected admin routes.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env` file with the following variables:
|
||||
|
||||
```
|
||||
# Server configuration
|
||||
PORT=4000
|
||||
NODE_ENV=development
|
||||
ENVIRONMENT=beta # Use 'beta' for development, 'prod' for production
|
||||
|
||||
# Database connection
|
||||
DB_HOST=db
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=PLEASECHANGETOSECUREPASSWORD
|
||||
DB_NAME=ecommerce
|
||||
DB_PORT=5432
|
||||
|
||||
# Email configuration (Postmark)
|
||||
EMAIL_HOST=smtp.postmarkapp.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USER=your_postmark_api_token
|
||||
EMAIL_PASS=your_postmark_api_token
|
||||
```
|
||||
|
||||
### Environment-specific Behavior
|
||||
|
||||
Based on the `ENVIRONMENT` variable, the application will use different domain configurations:
|
||||
|
||||
- `beta`: Uses `localhost:3000` for the frontend and `http` protocol
|
||||
- `prod`: Uses `rocks.2many.ca` for the frontend and `https` protocol
|
||||
25
backend/package.json
Normal file
25
backend/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "rocks-2many-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for rocks, bones, and sticks e-commerce store",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"nodemailer": "^6.9.1",
|
||||
"pg": "^8.10.0",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.22"
|
||||
}
|
||||
}
|
||||
38
backend/src/config.js
Normal file
38
backend/src/config.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
const dotenv = require('dotenv');
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const config = {
|
||||
// Server configuration
|
||||
port: process.env.PORT || 4000,
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
environment: process.env.ENVIRONMENT || 'beta',
|
||||
|
||||
// Database configuration
|
||||
db: {
|
||||
host: process.env.DB_HOST || 'db',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
database: process.env.DB_NAME || 'ecommerce',
|
||||
port: process.env.DB_PORT || 5432
|
||||
},
|
||||
|
||||
// Email configuration
|
||||
email: {
|
||||
host: process.env.EMAIL_HOST || 'smtp.postmarkapp.com',
|
||||
port: process.env.EMAIL_PORT || 587,
|
||||
user: process.env.EMAIL_USER || '03c638d8-4a04-9be6',
|
||||
pass: process.env.EMAIL_PASS || '03c638d8-4a04-9be6',
|
||||
reply: process.env.EMAIL_REPLY || 'noreply@2many.ca'
|
||||
},
|
||||
|
||||
// Site configuration (domain and protocol based on environment)
|
||||
site: {
|
||||
domain: process.env.ENVIRONMENT === 'prod' ? 'rocks.2many.ca' : 'localhost:3000',
|
||||
protocol: process.env.ENVIRONMENT === 'prod' ? 'https' : 'http',
|
||||
apiDomain: process.env.ENVIRONMENT === 'prod' ? 'api.rocks.2many.ca' : 'localhost:4000'
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
25
backend/src/db/index.js
Normal file
25
backend/src/db/index.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const { Pool } = require('pg');
|
||||
const config = require('../config')
|
||||
|
||||
// Create a pool instance
|
||||
const pool = new Pool({
|
||||
user: config.db.user,
|
||||
password: config.db.password,
|
||||
host: config.db.host,
|
||||
port: config.db.port,
|
||||
database: config.db.database
|
||||
});
|
||||
|
||||
// Helper function for running queries
|
||||
const query = async (text, params) => {
|
||||
const start = Date.now();
|
||||
const res = await pool.query(text, params);
|
||||
const duration = Date.now() - start;
|
||||
console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||
return res;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
query,
|
||||
pool
|
||||
};
|
||||
226
backend/src/index.js
Normal file
226
backend/src/index.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const config = require('./config');
|
||||
const { query, pool } = require('./db')
|
||||
const authMiddleware = require('./middleware/auth');
|
||||
const adminAuthMiddleware = require('./middleware/adminAuth');
|
||||
const fs = require('fs');
|
||||
|
||||
// routes
|
||||
const productRoutes = require('./routes/products');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const cartRoutes = require('./routes/cart');
|
||||
const productAdminRoutes = require('./routes/productAdmin');
|
||||
const categoryAdminRoutes = require('./routes/categoryAdmin'); // Add category admin routes
|
||||
// Create Express app
|
||||
const app = express();
|
||||
const port = config.port || 4000;
|
||||
|
||||
// Ensure uploads directories exist
|
||||
const uploadsDir = path.join(__dirname, '../public/uploads');
|
||||
const productImagesDir = path.join(uploadsDir, 'products');
|
||||
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(productImagesDir)) {
|
||||
fs.mkdirSync(productImagesDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Configure storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
// Determine destination based on upload type
|
||||
if (req.originalUrl.includes('/product')) {
|
||||
cb(null, productImagesDir);
|
||||
} else {
|
||||
cb(null, uploadsDir);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Create unique filename with original extension
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const fileExt = path.extname(file.originalname);
|
||||
const safeName = path.basename(file.originalname, fileExt)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-');
|
||||
|
||||
cb(null, `${safeName}-${uniqueSuffix}${fileExt}`);
|
||||
}
|
||||
});
|
||||
|
||||
// File filter to only allow images
|
||||
const fileFilter = (req, file, cb) => {
|
||||
// Accept only image files
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed!'), false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create the multer instance
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||
}
|
||||
});
|
||||
|
||||
pool.connect()
|
||||
.then(() => console.log('Connected to PostgreSQL database'))
|
||||
.catch(err => console.error('Database connection error:', err));
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(morgan('dev'));
|
||||
|
||||
// Serve static files - serve the entire public directory
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// More specific static file serving for images
|
||||
app.use('/images', express.static(path.join(__dirname, '../public/uploads')));
|
||||
app.use('/uploads', express.static(path.join(__dirname, '../public/uploads')));
|
||||
app.use('/api/uploads', express.static(path.join(__dirname, '../public/uploads')));
|
||||
app.use('/api/images', express.static(path.join(__dirname, '../public/uploads')));
|
||||
if (!fs.existsSync(path.join(__dirname, '../public/uploads'))) {
|
||||
fs.mkdirSync(path.join(__dirname, '../public/uploads'), { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(__dirname, '../public/uploads/products'))) {
|
||||
fs.mkdirSync(path.join(__dirname, '../public/uploads/products'), { recursive: true });
|
||||
}
|
||||
// For direct access to product images
|
||||
app.use('/products/images', express.static(path.join(__dirname, '../public/uploads/products')));
|
||||
app.use('/api/products/images', express.static(path.join(__dirname, '../public/uploads/products')));
|
||||
// Health check route
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({ status: 'ok', message: 'API is running' });
|
||||
});
|
||||
|
||||
// Upload endpoints
|
||||
// Public upload endpoint (basic)
|
||||
app.post('/api/image/upload', upload.single('image'), (req, res) => {
|
||||
console.log('/api/image/upload');
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'No image file provided'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imagePath: `/uploads/${req.file.filename}`
|
||||
});
|
||||
});
|
||||
|
||||
// Admin-only product image upload
|
||||
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {
|
||||
console.log('/api/image/product', req.file);
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'No image file provided'
|
||||
});
|
||||
}
|
||||
|
||||
// Get the relative path to the image
|
||||
const imagePath = `/uploads/products/${req.file.filename}`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imagePath,
|
||||
filename: req.file.filename
|
||||
});
|
||||
});
|
||||
|
||||
// Upload multiple product images (admin only)
|
||||
app.post('/api/image/products', adminAuthMiddleware(pool, query), upload.array('images', 10), (req, res) => {
|
||||
console.log('/api/image/products', req.files);
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'No image files provided'
|
||||
});
|
||||
}
|
||||
|
||||
// Get the relative paths to the images
|
||||
const imagePaths = req.files.map(file => ({
|
||||
imagePath: `/uploads/products/${file.filename}`,
|
||||
filename: file.filename
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
images: imagePaths
|
||||
});
|
||||
});
|
||||
|
||||
// Delete product image (admin only)
|
||||
app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
|
||||
// Prevent path traversal attacks
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Invalid filename'
|
||||
});
|
||||
}
|
||||
|
||||
const filePath = path.join(__dirname, '../public/uploads/products', filename);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Image not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Image deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting image:', error);
|
||||
res.status(500).json({
|
||||
error: true,
|
||||
message: 'Failed to delete image'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Use routes
|
||||
app.use('/api/products', productRoutes(pool, query));
|
||||
app.use('/api/auth', authRoutes(pool, query));
|
||||
app.use('/api/cart', cartRoutes(pool, query, authMiddleware(pool, query)));
|
||||
app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
|
||||
app.use('/api/admin/categories', categoryAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); // Add category admin routes
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: true,
|
||||
message: err.message || 'An unexpected error occurred'
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on port ${port} in ${config.environment} environment`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
50
backend/src/middleware/adminAuth.js
Normal file
50
backend/src/middleware/adminAuth.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Admin authentication middleware to protect admin routes
|
||||
*/
|
||||
module.exports = (pool, query) => {
|
||||
return async (req, res, next) => {
|
||||
// Get API key from header
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: true,
|
||||
message: 'API key is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify API key and check admin status
|
||||
const result = await query(
|
||||
'SELECT id, email, first_name, last_name, is_admin FROM users WHERE api_key = $1',
|
||||
[apiKey]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({
|
||||
error: true,
|
||||
message: 'Invalid API key'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
if (!result.rows[0].is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Add user to request object
|
||||
req.user = result.rows[0];
|
||||
|
||||
// Continue to next middleware/route handler
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
error: true,
|
||||
message: 'Error authenticating user'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
42
backend/src/middleware/auth.js
Normal file
42
backend/src/middleware/auth.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Authentication middleware to protect routes
|
||||
*/
|
||||
module.exports = (pool, query) => {
|
||||
return async (req, res, next) => {
|
||||
// Get API key from header
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({
|
||||
error: true,
|
||||
message: 'API key is required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify API key
|
||||
const result = await query(
|
||||
'SELECT id, email, first_name, last_name, is_admin FROM users WHERE api_key = $1',
|
||||
[apiKey]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({
|
||||
error: true,
|
||||
message: 'Invalid API key'
|
||||
});
|
||||
}
|
||||
|
||||
// Add user to request object
|
||||
req.user = result.rows[0];
|
||||
|
||||
// Continue to next middleware/route handler
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
error: true,
|
||||
message: 'Error authenticating user'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
59
backend/src/middleware/upload.js
Normal file
59
backend/src/middleware/upload.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
// Create uploads directory if it doesn't exist
|
||||
const uploadDir = path.join(__dirname, '../../public/uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create directories for different image types
|
||||
const productImagesDir = path.join(uploadDir, 'products');
|
||||
if (!fs.existsSync(productImagesDir)) {
|
||||
fs.mkdirSync(productImagesDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Configure storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
// Determine destination based on upload type
|
||||
if (req.path.includes('/products')) {
|
||||
cb(null, productImagesDir);
|
||||
} else {
|
||||
cb(null, uploadDir);
|
||||
}
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Create unique filename with original extension
|
||||
const uniqueId = uuidv4();
|
||||
const fileExt = path.extname(file.originalname);
|
||||
const safeName = path.basename(file.originalname, fileExt)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-');
|
||||
|
||||
cb(null, `${safeName}-${uniqueId}${fileExt}`);
|
||||
}
|
||||
});
|
||||
|
||||
// File filter to only allow images
|
||||
const fileFilter = (req, file, cb) => {
|
||||
// Accept only image files
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed!'), false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create the multer instance
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = upload;
|
||||
271
backend/src/routes/auth.js
Normal file
271
backend/src/routes/auth.js
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const nodemailer = require('nodemailer');
|
||||
const config = require('../config');
|
||||
|
||||
const router = express.Router();
|
||||
const createTransporter = () => {
|
||||
return nodemailer.createTransport({
|
||||
host: config.email.host,
|
||||
port: config.email.port,
|
||||
auth: {
|
||||
user: config.email.user,
|
||||
pass: config.email.pass
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Mock email transporter for development
|
||||
const transporter = createTransporter();
|
||||
|
||||
module.exports = (pool, query) => {
|
||||
// Register new user
|
||||
router.post('/register', async (req, res, next) => {
|
||||
const { email, firstName, lastName } = req.body;
|
||||
|
||||
try {
|
||||
// Check if user already exists
|
||||
const userCheck = await query(
|
||||
'SELECT * FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (userCheck.rows.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'User with this email already exists'
|
||||
});
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const result = await query(
|
||||
'INSERT INTO users (email, first_name, last_name) VALUES ($1, $2, $3) RETURNING id, email',
|
||||
[email, firstName, lastName]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'User registered successfully',
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Request login code
|
||||
router.post('/login-request', async (req, res, next) => {
|
||||
const { email } = req.body;
|
||||
|
||||
try {
|
||||
// Check if user exists
|
||||
const userResult = await query(
|
||||
'SELECT * FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Generate a random 6-digit code
|
||||
// Set expiration time (15 minutes from now)
|
||||
const authCode = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setMinutes(expiresAt.getMinutes() + 15);
|
||||
|
||||
// Create auth record
|
||||
const authResult = await query(
|
||||
'INSERT INTO authentications (code, expires_at) VALUES ($1, $2) RETURNING id',
|
||||
[authCode, expiresAt]
|
||||
);
|
||||
|
||||
const authId = authResult.rows[0].id;
|
||||
|
||||
// Update user with auth record
|
||||
await query(
|
||||
'UPDATE users SET current_auth_id = $1 WHERE id = $2',
|
||||
[authId, user.id]
|
||||
);
|
||||
|
||||
// Send email with code (mock in development)
|
||||
const loginLink = `${config.site.protocol}://${config.site.domain}/verify?code=${authCode}&email=${encodeURIComponent(email)}`;
|
||||
|
||||
|
||||
|
||||
await transporter.sendMail({
|
||||
from: 'noreply@2many.ca',
|
||||
to: email,
|
||||
subject: 'Your Login Code',
|
||||
html: `
|
||||
<h1>Your login code is: ${authCode}</h1>
|
||||
<p>This code will expire in 15 minutes.</p>
|
||||
<p>Or click <a href="${loginLink}">here</a> to log in directly.</p>
|
||||
`
|
||||
});
|
||||
let retObj = {
|
||||
message: 'Login code sent to address: ' + email
|
||||
}
|
||||
|
||||
if(process.env.ENVIRONMENT === "beta"){
|
||||
retObj.code = authCode;
|
||||
}
|
||||
|
||||
res.json(retObj);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Verify login code
|
||||
router.post('/verify', async (req, res, next) => {
|
||||
const { email, code } = req.body;
|
||||
|
||||
try {
|
||||
// Get user and auth record
|
||||
const userResult = await query(
|
||||
`SELECT u.*, a.code, a.expires_at, a.is_used
|
||||
FROM users u
|
||||
JOIN authentications a ON u.current_auth_id = a.id
|
||||
WHERE u.email = $1`,
|
||||
[email]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Invalid request or no login code requested'
|
||||
});
|
||||
}
|
||||
|
||||
const { code: storedCode, expires_at, is_used, id: userId } = userResult.rows[0];
|
||||
|
||||
// Check code
|
||||
if (storedCode !== code) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Invalid code'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (new Date() > new Date(expires_at)) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Code has expired, Please restart login'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already used
|
||||
if (is_used) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Code has already been used' ,
|
||||
data: is_used
|
||||
});
|
||||
}
|
||||
|
||||
// Mark auth as used
|
||||
await query(
|
||||
'UPDATE authentications SET is_used = true WHERE code = $1',
|
||||
[code]
|
||||
);
|
||||
|
||||
// Generate API key
|
||||
const apiKey = uuidv4();
|
||||
|
||||
// Save API key to user
|
||||
await query(
|
||||
'UPDATE users SET api_key = $1, last_login = NOW() WHERE id = $2',
|
||||
[apiKey, userId]
|
||||
);
|
||||
|
||||
// Get user information including admin status
|
||||
const userInfo = await query(
|
||||
'SELECT id, email, first_name, last_name, is_admin FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
userId: userId,
|
||||
isAdmin: userInfo.rows[0].is_admin,
|
||||
apiKey: apiKey
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
//apikey check
|
||||
router.post('/verify-key', async (req, res, next) => {
|
||||
const { apiKey, email } = req.body;
|
||||
|
||||
try {
|
||||
// Verify the API key against email
|
||||
const result = await query(
|
||||
'SELECT id, email, first_name, last_name, is_admin FROM users WHERE api_key = $1 AND email = $2',
|
||||
[apiKey, email]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({
|
||||
error: true,
|
||||
message: 'Invalid API key'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
// Logout
|
||||
router.post('/logout', async (req, res, next) => {
|
||||
const { userId } = req.body;
|
||||
|
||||
try {
|
||||
// Get current auth ID
|
||||
const userResult = await query(
|
||||
'SELECT current_auth_id FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
const authId = userResult.rows[0].current_auth_id;
|
||||
|
||||
// Clear auth ID and API key
|
||||
await query(
|
||||
'UPDATE users SET current_auth_id = NULL, api_key = NULL WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
// Delete auth record
|
||||
if (authId) {
|
||||
await query(
|
||||
'DELETE FROM authentications WHERE id = $1',
|
||||
[authId]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
363
backend/src/routes/cart.js
Normal file
363
backend/src/routes/cart.js
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const router = express.Router();
|
||||
|
||||
module.exports = (pool, query, authMiddleware) => {
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Get user's cart
|
||||
router.get('/:userId', async (req, res, next) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
if (req.user.id !== userId) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'You can only access your own cart'
|
||||
});
|
||||
}
|
||||
// Get cart
|
||||
let cartResult = await query(
|
||||
'SELECT * FROM carts WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
// If no cart exists, create one
|
||||
if (cartResult.rows.length === 0) {
|
||||
cartResult = await query(
|
||||
'INSERT INTO carts (id, user_id) VALUES ($1, $2) RETURNING *',
|
||||
[uuidv4(), userId]
|
||||
);
|
||||
}
|
||||
|
||||
const cartId = cartResult.rows[0].id;
|
||||
|
||||
// Get cart items with product details
|
||||
const cartItemsResult = await query(
|
||||
`SELECT ci.id, ci.quantity, ci.added_at,
|
||||
p.id AS product_id, p.name, p.description, p.price, p.image_url,
|
||||
p.category_id, pc.name AS category_name
|
||||
FROM cart_items ci
|
||||
JOIN products p ON ci.product_id = p.id
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE ci.cart_id = $1`,
|
||||
[cartId]
|
||||
);
|
||||
|
||||
// Calculate total
|
||||
const total = cartItemsResult.rows.reduce((sum, item) => {
|
||||
|
||||
return sum + (parseFloat(item.price) * item.quantity);
|
||||
}, 0);
|
||||
|
||||
res.json({
|
||||
id: cartId,
|
||||
userId,
|
||||
items: cartItemsResult.rows,
|
||||
itemCount: cartItemsResult.rows.length,
|
||||
total
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Add item to cart
|
||||
router.post('/add', async (req, res, next) => {
|
||||
try {
|
||||
const { userId, productId, quantity = 1 } = req.body;
|
||||
if (req.user.id !== userId) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'You can only modify your own cart'
|
||||
});
|
||||
}
|
||||
// Check if product exists
|
||||
const productResult = await query(
|
||||
'SELECT * FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Product not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Get or create cart
|
||||
let cartResult = await query(
|
||||
'SELECT * FROM carts WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (cartResult.rows.length === 0) {
|
||||
cartResult = await query(
|
||||
'INSERT INTO carts (id, user_id) VALUES ($1, $2) RETURNING *',
|
||||
[uuidv4(), userId]
|
||||
);
|
||||
}
|
||||
|
||||
const cartId = cartResult.rows[0].id;
|
||||
|
||||
// Check if item already in cart
|
||||
const existingItemResult = await query(
|
||||
'SELECT * FROM cart_items WHERE cart_id = $1 AND product_id = $2',
|
||||
[cartId, productId]
|
||||
);
|
||||
|
||||
if (existingItemResult.rows.length > 0) {
|
||||
// Update quantity
|
||||
const newQuantity = existingItemResult.rows[0].quantity + quantity;
|
||||
|
||||
await query(
|
||||
'UPDATE cart_items SET quantity = $1 WHERE id = $2',
|
||||
[newQuantity, existingItemResult.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
// Add new item
|
||||
await query(
|
||||
'INSERT INTO cart_items (id, cart_id, product_id, quantity) VALUES ($1, $2, $3, $4)',
|
||||
[uuidv4(), cartId, productId, quantity]
|
||||
);
|
||||
}
|
||||
|
||||
// Get updated cart
|
||||
const updatedCartItems = await query(
|
||||
`SELECT ci.id, ci.quantity, ci.added_at,
|
||||
p.id AS product_id, p.name, p.description, p.price, p.image_url,
|
||||
p.category_id, pc.name AS category_name
|
||||
FROM cart_items ci
|
||||
JOIN products p ON ci.product_id = p.id
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE ci.cart_id = $1`,
|
||||
[cartId]
|
||||
);
|
||||
|
||||
// Calculate total
|
||||
const total = updatedCartItems.rows.reduce((sum, item) => {
|
||||
return sum + (parseFloat(item.price) * item.quantity);
|
||||
}, 0);
|
||||
|
||||
res.json({
|
||||
id: cartId,
|
||||
userId,
|
||||
items: updatedCartItems.rows,
|
||||
itemCount: updatedCartItems.rows.length,
|
||||
total
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update cart item quantity
|
||||
router.put('/update', async (req, res, next) => {
|
||||
try {
|
||||
const { userId, productId, quantity } = req.body;
|
||||
if (req.user.id !== userId) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'You can only modify your own cart'
|
||||
});
|
||||
}
|
||||
// Get cart
|
||||
const cartResult = await query(
|
||||
'SELECT * FROM carts WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (cartResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Cart not found'
|
||||
});
|
||||
}
|
||||
|
||||
const cartId = cartResult.rows[0].id;
|
||||
|
||||
if (quantity <= 0) {
|
||||
// Remove item
|
||||
await query(
|
||||
'DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2',
|
||||
[cartId, productId]
|
||||
);
|
||||
} else {
|
||||
// Update quantity
|
||||
await query(
|
||||
'UPDATE cart_items SET quantity = $1 WHERE cart_id = $2 AND product_id = $3',
|
||||
[quantity, cartId, productId]
|
||||
);
|
||||
}
|
||||
|
||||
// Get updated cart
|
||||
const updatedCartItems = await query(
|
||||
`SELECT ci.id, ci.quantity, ci.added_at,
|
||||
p.id AS product_id, p.name, p.description, p.price, p.image_url,
|
||||
p.category_id, pc.name AS category_name
|
||||
FROM cart_items ci
|
||||
JOIN products p ON ci.product_id = p.id
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE ci.cart_id = $1`,
|
||||
[cartId]
|
||||
);
|
||||
|
||||
// Calculate total
|
||||
const total = updatedCartItems.rows.reduce((sum, item) => {
|
||||
return sum + (parseFloat(item.price) * item.quantity);
|
||||
}, 0);
|
||||
|
||||
res.json({
|
||||
id: cartId,
|
||||
userId,
|
||||
items: updatedCartItems.rows,
|
||||
itemCount: updatedCartItems.rows.length,
|
||||
total
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear cart
|
||||
router.delete('/clear/:userId', async (req, res, next) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
if (req.user.id !== userId) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'You can only modify your own cart'
|
||||
});
|
||||
}
|
||||
// Get cart
|
||||
const cartResult = await query(
|
||||
'SELECT * FROM carts WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (cartResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Cart not found'
|
||||
});
|
||||
}
|
||||
|
||||
const cartId = cartResult.rows[0].id;
|
||||
|
||||
// Delete all items
|
||||
await query(
|
||||
'DELETE FROM cart_items WHERE cart_id = $1',
|
||||
[cartId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
id: cartId,
|
||||
userId,
|
||||
items: [],
|
||||
itemCount: 0,
|
||||
total: 0
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Checkout (create order from cart)
|
||||
router.post('/checkout', async (req, res, next) => {
|
||||
try {
|
||||
const { userId, shippingAddress } = req.body;
|
||||
if (req.user.id !== userId) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'You can only checkout your own cart'
|
||||
});
|
||||
}
|
||||
// Get cart
|
||||
const cartResult = await query(
|
||||
'SELECT * FROM carts WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (cartResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Cart not found'
|
||||
});
|
||||
}
|
||||
|
||||
const cartId = cartResult.rows[0].id;
|
||||
|
||||
// Get cart items
|
||||
const cartItemsResult = await query(
|
||||
`SELECT ci.*, p.price
|
||||
FROM cart_items ci
|
||||
JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.cart_id = $1`,
|
||||
[cartId]
|
||||
);
|
||||
|
||||
if (cartItemsResult.rows.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Cart is empty'
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate total
|
||||
const total = cartItemsResult.rows.reduce((sum, item) => {
|
||||
return sum + (parseFloat(item.price) * item.quantity);
|
||||
}, 0);
|
||||
|
||||
// Begin transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Create order
|
||||
const orderId = uuidv4();
|
||||
await client.query(
|
||||
'INSERT INTO orders (id, user_id, status, total_amount, shipping_address) VALUES ($1, $2, $3, $4, $5)',
|
||||
[orderId, userId, 'pending', total, shippingAddress]
|
||||
);
|
||||
|
||||
// Create order items
|
||||
for (const item of cartItemsResult.rows) {
|
||||
await client.query(
|
||||
'INSERT INTO order_items (id, order_id, product_id, quantity, price_at_purchase) VALUES ($1, $2, $3, $4, $5)',
|
||||
[uuidv4(), orderId, item.product_id, item.quantity, item.price]
|
||||
);
|
||||
|
||||
// Update product stock
|
||||
await client.query(
|
||||
'UPDATE products SET stock_quantity = stock_quantity - $1 WHERE id = $2',
|
||||
[item.quantity, item.product_id]
|
||||
);
|
||||
}
|
||||
|
||||
// Clear cart
|
||||
await client.query(
|
||||
'DELETE FROM cart_items WHERE cart_id = $1',
|
||||
[cartId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Order created successfully',
|
||||
orderId
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
186
backend/src/routes/categoryAdmin.js
Normal file
186
backend/src/routes/categoryAdmin.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const router = express.Router();
|
||||
|
||||
module.exports = (pool, query, authMiddleware) => {
|
||||
// Apply authentication middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Get all categories
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const result = await query('SELECT * FROM product_categories ORDER BY name ASC');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get single category by ID
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await query(
|
||||
'SELECT * FROM product_categories WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Category not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new category
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { name, description, imagePath } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Category name is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if category with same name already exists
|
||||
const existingCategory = await query(
|
||||
'SELECT * FROM product_categories WHERE name = $1',
|
||||
[name]
|
||||
);
|
||||
|
||||
if (existingCategory.rows.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'A category with this name already exists'
|
||||
});
|
||||
}
|
||||
|
||||
// Create new category
|
||||
const result = await query(
|
||||
'INSERT INTO product_categories (id, name, description, image_path) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[uuidv4(), name, description || null, imagePath || null]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Category created successfully',
|
||||
category: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update a category
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, imagePath } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Category name is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if category exists
|
||||
const categoryCheck = await query(
|
||||
'SELECT * FROM product_categories WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (categoryCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Category not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if new name conflicts with existing category
|
||||
if (name !== categoryCheck.rows[0].name) {
|
||||
const nameCheck = await query(
|
||||
'SELECT * FROM product_categories WHERE name = $1 AND id != $2',
|
||||
[name, id]
|
||||
);
|
||||
|
||||
if (nameCheck.rows.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'A category with this name already exists'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update category
|
||||
const result = await query(
|
||||
'UPDATE product_categories SET name = $1, description = $2, image_path = $3 WHERE id = $4 RETURNING *',
|
||||
[name, description || null, imagePath, id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Category updated successfully',
|
||||
category: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a category
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if category exists
|
||||
const categoryCheck = await query(
|
||||
'SELECT * FROM product_categories WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (categoryCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Category not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if category is being used by products
|
||||
const productsUsingCategory = await query(
|
||||
'SELECT COUNT(*) FROM products WHERE category_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (parseInt(productsUsingCategory.rows[0].count) > 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'This category cannot be deleted because it is associated with products. Please reassign those products to a different category first.'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete category
|
||||
await query(
|
||||
'DELETE FROM product_categories WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Category deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
97
backend/src/routes/images.js
Normal file
97
backend/src/routes/images.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const upload = require('../middleware/upload');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const authMiddleware = require('../middleware/auth');
|
||||
const adminAuthMiddleware = require('../middleware/adminAuth');
|
||||
|
||||
module.exports = (pool, query) => {
|
||||
// Apply authentication for all admin upload routes
|
||||
router.use('/admin', adminAuthMiddleware(pool, query));
|
||||
|
||||
// Upload product image (admin only)
|
||||
router.post('/admin/products', upload.single('image'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'No image file provided'
|
||||
});
|
||||
}
|
||||
|
||||
// Get the relative path to the image
|
||||
const imagePath = `/uploads/products/${req.file.filename}`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imagePath,
|
||||
filename: req.file.filename
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Upload multiple product images (admin only)
|
||||
router.post('/admin/products/multiple', upload.array('images', 10), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'No image files provided'
|
||||
});
|
||||
}
|
||||
|
||||
// Get the relative paths to the images
|
||||
const imagePaths = req.files.map(file => ({
|
||||
imagePath: `/uploads/products/${file.filename}`,
|
||||
filename: file.filename
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
images: imagePaths
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete product image (admin only)
|
||||
router.delete('/admin/products/:filename', async (req, res, next) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
|
||||
// Prevent path traversal attacks
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Invalid filename'
|
||||
});
|
||||
}
|
||||
|
||||
const filePath = path.join(__dirname, '../../public/uploads/products', filename);
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Image not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Image deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
437
backend/src/routes/productAdmin.js
Normal file
437
backend/src/routes/productAdmin.js
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const router = express.Router();
|
||||
|
||||
module.exports = (pool, query, authMiddleware) => {
|
||||
// Apply authentication middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Create a new product with multiple images
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
categoryName,
|
||||
price,
|
||||
stockQuantity,
|
||||
weightGrams,
|
||||
lengthCm,
|
||||
widthCm,
|
||||
heightCm,
|
||||
origin,
|
||||
age,
|
||||
materialType,
|
||||
color,
|
||||
images,
|
||||
tags
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !description || !categoryName || !price || !stockQuantity) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Required fields missing: name, description, categoryName, price, and stockQuantity are mandatory'
|
||||
});
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Get category ID by name
|
||||
const categoryResult = await client.query(
|
||||
'SELECT id FROM product_categories WHERE name = $1',
|
||||
[categoryName]
|
||||
);
|
||||
|
||||
if (categoryResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: `Category "${categoryName}" not found`
|
||||
});
|
||||
}
|
||||
|
||||
const categoryId = categoryResult.rows[0].id;
|
||||
|
||||
// Create product
|
||||
const productId = uuidv4();
|
||||
await client.query(
|
||||
`INSERT INTO products (
|
||||
id, name, description, category_id, price, stock_quantity,
|
||||
weight_grams, length_cm, width_cm, height_cm,
|
||||
origin, age, material_type, color
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
|
||||
[
|
||||
productId, name, description, categoryId, price, stockQuantity,
|
||||
weightGrams || null, lengthCm || null, widthCm || null, heightCm || null,
|
||||
origin || null, age || null, materialType || null, color || null
|
||||
]
|
||||
);
|
||||
|
||||
// Add images if provided
|
||||
if (images && images.length > 0) {
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const { path, isPrimary = (i === 0) } = images[i];
|
||||
|
||||
await client.query(
|
||||
'INSERT INTO product_images (product_id, image_path, display_order, is_primary) VALUES ($1, $2, $3, $4)',
|
||||
[productId, path, i, isPrimary]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add tags if provided
|
||||
if (tags && tags.length > 0) {
|
||||
for (const tagName of tags) {
|
||||
// Get tag ID
|
||||
let tagResult = await client.query(
|
||||
'SELECT id FROM tags WHERE name = $1',
|
||||
[tagName]
|
||||
);
|
||||
|
||||
let tagId;
|
||||
|
||||
// If tag doesn't exist, create it
|
||||
if (tagResult.rows.length === 0) {
|
||||
const newTagResult = await client.query(
|
||||
'INSERT INTO tags (name) VALUES ($1) RETURNING id',
|
||||
[tagName]
|
||||
);
|
||||
tagId = newTagResult.rows[0].id;
|
||||
} else {
|
||||
tagId = tagResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Add tag to product
|
||||
await client.query(
|
||||
'INSERT INTO product_tags (product_id, tag_id) VALUES ($1, $2)',
|
||||
[productId, tagId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Get complete product with images and tags
|
||||
const productQuery = `
|
||||
SELECT p.*,
|
||||
pc.name as category_name,
|
||||
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
|
||||
json_agg(json_build_object(
|
||||
'id', pi.id,
|
||||
'path', pi.image_path,
|
||||
'isPrimary', pi.is_primary,
|
||||
'displayOrder', pi.display_order
|
||||
)) FILTER (WHERE pi.id IS NOT NULL) AS images
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_tags pt ON p.id = pt.product_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
LEFT JOIN product_images pi ON p.id = pi.product_id
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id, pc.name
|
||||
`;
|
||||
|
||||
const product = await query(productQuery, [productId]);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Product created successfully',
|
||||
product: product.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update an existing product
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
categoryName,
|
||||
price,
|
||||
stockQuantity,
|
||||
weightGrams,
|
||||
lengthCm,
|
||||
widthCm,
|
||||
heightCm,
|
||||
origin,
|
||||
age,
|
||||
materialType,
|
||||
color,
|
||||
images,
|
||||
tags
|
||||
} = req.body;
|
||||
|
||||
// Begin transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Check if product exists
|
||||
const productCheck = await client.query(
|
||||
'SELECT * FROM products WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Product not found'
|
||||
});
|
||||
}
|
||||
|
||||
// If category is changing, get the new category ID
|
||||
let categoryId = productCheck.rows[0].category_id;
|
||||
if (categoryName) {
|
||||
const categoryResult = await client.query(
|
||||
'SELECT id FROM product_categories WHERE name = $1',
|
||||
[categoryName]
|
||||
);
|
||||
|
||||
if (categoryResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: `Category "${categoryName}" not found`
|
||||
});
|
||||
}
|
||||
|
||||
categoryId = categoryResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Update product
|
||||
const updateFields = [];
|
||||
const updateValues = [];
|
||||
let valueIndex = 1;
|
||||
|
||||
if (name) {
|
||||
updateFields.push(`name = $${valueIndex}`);
|
||||
updateValues.push(name);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (description) {
|
||||
updateFields.push(`description = $${valueIndex}`);
|
||||
updateValues.push(description);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (categoryName) {
|
||||
updateFields.push(`category_id = $${valueIndex}`);
|
||||
updateValues.push(categoryId);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (price) {
|
||||
updateFields.push(`price = $${valueIndex}`);
|
||||
updateValues.push(price);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (stockQuantity !== undefined) {
|
||||
updateFields.push(`stock_quantity = $${valueIndex}`);
|
||||
updateValues.push(stockQuantity);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (weightGrams !== undefined) {
|
||||
updateFields.push(`weight_grams = $${valueIndex}`);
|
||||
updateValues.push(weightGrams);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (lengthCm !== undefined) {
|
||||
updateFields.push(`length_cm = $${valueIndex}`);
|
||||
updateValues.push(lengthCm);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (widthCm !== undefined) {
|
||||
updateFields.push(`width_cm = $${valueIndex}`);
|
||||
updateValues.push(widthCm);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (heightCm !== undefined) {
|
||||
updateFields.push(`height_cm = $${valueIndex}`);
|
||||
updateValues.push(heightCm);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (origin !== undefined) {
|
||||
updateFields.push(`origin = $${valueIndex}`);
|
||||
updateValues.push(origin);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (age !== undefined) {
|
||||
updateFields.push(`age = $${valueIndex}`);
|
||||
updateValues.push(age);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (materialType !== undefined) {
|
||||
updateFields.push(`material_type = $${valueIndex}`);
|
||||
updateValues.push(materialType);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (color !== undefined) {
|
||||
updateFields.push(`color = $${valueIndex}`);
|
||||
updateValues.push(color);
|
||||
valueIndex++;
|
||||
}
|
||||
|
||||
if (updateFields.length > 0) {
|
||||
const updateQuery = `
|
||||
UPDATE products
|
||||
SET ${updateFields.join(', ')}
|
||||
WHERE id = $${valueIndex}
|
||||
`;
|
||||
|
||||
updateValues.push(id);
|
||||
|
||||
await client.query(updateQuery, updateValues);
|
||||
}
|
||||
|
||||
// Update images if provided
|
||||
if (images) {
|
||||
// Remove existing images
|
||||
await client.query(
|
||||
'DELETE FROM product_images WHERE product_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
// Add new images
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const { path, isPrimary = (i === 0) } = images[i];
|
||||
|
||||
await client.query(
|
||||
'INSERT INTO product_images (product_id, image_path, display_order, is_primary) VALUES ($1, $2, $3, $4)',
|
||||
[id, path, i, isPrimary]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update tags if provided
|
||||
if (tags) {
|
||||
// Remove existing tags
|
||||
await client.query(
|
||||
'DELETE FROM product_tags WHERE product_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
// Add new tags
|
||||
for (const tagName of tags) {
|
||||
// Get tag ID
|
||||
let tagResult = await client.query(
|
||||
'SELECT id FROM tags WHERE name = $1',
|
||||
[tagName]
|
||||
);
|
||||
|
||||
let tagId;
|
||||
|
||||
// If tag doesn't exist, create it
|
||||
if (tagResult.rows.length === 0) {
|
||||
const newTagResult = await client.query(
|
||||
'INSERT INTO tags (name) VALUES ($1) RETURNING id',
|
||||
[tagName]
|
||||
);
|
||||
tagId = newTagResult.rows[0].id;
|
||||
} else {
|
||||
tagId = tagResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Add tag to product
|
||||
await client.query(
|
||||
'INSERT INTO product_tags (product_id, tag_id) VALUES ($1, $2)',
|
||||
[id, tagId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Get updated product
|
||||
const productQuery = `
|
||||
SELECT p.*,
|
||||
pc.name as category_name,
|
||||
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
|
||||
json_agg(json_build_object(
|
||||
'id', pi.id,
|
||||
'path', pi.image_path,
|
||||
'isPrimary', pi.is_primary,
|
||||
'displayOrder', pi.display_order
|
||||
)) FILTER (WHERE pi.id IS NOT NULL) AS images
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_tags pt ON p.id = pt.product_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
LEFT JOIN product_images pi ON p.id = pi.product_id
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id, pc.name
|
||||
`;
|
||||
|
||||
const product = await query(productQuery, [id]);
|
||||
|
||||
res.json({
|
||||
message: 'Product updated successfully',
|
||||
product: product.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a product
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if product exists
|
||||
const productCheck = await query(
|
||||
'SELECT * FROM products WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Product not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete product (cascade will handle related records)
|
||||
await query(
|
||||
'DELETE FROM products WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Product deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
41
backend/src/routes/productAdminImages.js
Normal file
41
backend/src/routes/productAdminImages.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Process image paths and prepare them for database storage
|
||||
* @param {Array} images - Array of image objects with paths
|
||||
* @returns {Array} - Processed image objects with proper paths
|
||||
*/
|
||||
const processImagePaths = (images) => {
|
||||
if (!images || !Array.isArray(images)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return images.map((image, index) => {
|
||||
// If the image is already a string (existing path), use it as is
|
||||
if (typeof image === 'string') {
|
||||
return {
|
||||
path: image,
|
||||
isPrimary: index === 0, // First image is primary by default
|
||||
displayOrder: index
|
||||
};
|
||||
}
|
||||
|
||||
// If the image is an object from the upload middleware
|
||||
if (image.path || image.imagePath) {
|
||||
return {
|
||||
path: image.imagePath || image.path,
|
||||
isPrimary: image.isPrimary || index === 0,
|
||||
displayOrder: image.displayOrder || index
|
||||
};
|
||||
}
|
||||
|
||||
// If the image is from the frontend (already has path field)
|
||||
return {
|
||||
path: image.path,
|
||||
isPrimary: image.isPrimary || index === 0,
|
||||
displayOrder: image.displayOrder || index
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
processImagePaths
|
||||
};
|
||||
177
backend/src/routes/products.js
Normal file
177
backend/src/routes/products.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
module.exports = (pool, qry) => {
|
||||
// Get all products
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { category, tag, search, sort, order } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT p.*, pc.name as category_name,
|
||||
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', pi.id,
|
||||
'path', pi.image_path,
|
||||
'isPrimary', pi.is_primary,
|
||||
'displayOrder', pi.display_order
|
||||
) ORDER BY pi.display_order
|
||||
) FILTER (WHERE pi.id IS NOT NULL) AS images
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_tags pt ON p.id = pt.product_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
LEFT JOIN product_images pi ON p.id = pi.product_id
|
||||
`;
|
||||
|
||||
const whereConditions = [];
|
||||
const params = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Filter by category
|
||||
if (category) {
|
||||
params.push(category);
|
||||
whereConditions.push(`pc.name = $${params.length}`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Filter by tag
|
||||
if (tag) {
|
||||
params.push(tag);
|
||||
whereConditions.push(`t.name = $${params.length}`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
whereConditions.push(`(p.name ILIKE $${params.length} OR p.description ILIKE $${params.length})`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Add WHERE clause if we have conditions
|
||||
if (whereConditions.length > 0) {
|
||||
query += ` WHERE ${whereConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
// Group by product id
|
||||
query += ` GROUP BY p.id, pc.name`;
|
||||
|
||||
// Sorting
|
||||
if (sort) {
|
||||
const validSortColumns = ['name', 'price', 'created_at'];
|
||||
const validOrderDirections = ['asc', 'desc'];
|
||||
|
||||
const sortColumn = validSortColumns.includes(sort) ? sort : 'name';
|
||||
const orderDirection = validOrderDirections.includes(order) ? order : 'asc';
|
||||
|
||||
query += ` ORDER BY p.${sortColumn} ${orderDirection}`;
|
||||
} else {
|
||||
// Default sort
|
||||
query += ` ORDER BY p.name ASC`;
|
||||
}
|
||||
|
||||
const result = await qry(query, params);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get single product by ID
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const query = `
|
||||
SELECT p.*, pc.name as category_name,
|
||||
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', pi.id,
|
||||
'path', pi.image_path,
|
||||
'isPrimary', pi.is_primary,
|
||||
'displayOrder', pi.display_order
|
||||
) ORDER BY pi.display_order
|
||||
) FILTER (WHERE pi.id IS NOT NULL) AS images
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_tags pt ON p.id = pt.product_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
LEFT JOIN product_images pi ON p.id = pi.product_id
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id, pc.name
|
||||
`;
|
||||
|
||||
const result = await qry(query, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Product not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all product categories
|
||||
router.get('/categories/all', async (req, res, next) => {
|
||||
try {
|
||||
const result = await qry('SELECT * FROM product_categories');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all tags
|
||||
router.get('/tags/all', async (req, res, next) => {
|
||||
try {
|
||||
const result = await qry('SELECT * FROM tags');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get products by category
|
||||
router.get('/category/:categoryName', async (req, res, next) => {
|
||||
try {
|
||||
const { categoryName } = req.params;
|
||||
|
||||
const query = `
|
||||
SELECT p.*,
|
||||
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', pi.id,
|
||||
'path', pi.image_path,
|
||||
'isPrimary', pi.is_primary,
|
||||
'displayOrder', pi.display_order
|
||||
) ORDER BY pi.display_order
|
||||
) FILTER (WHERE pi.id IS NOT NULL) AS images
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_tags pt ON p.id = pt.product_id
|
||||
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||
LEFT JOIN product_images pi ON p.id = pi.product_id
|
||||
WHERE pc.name = $1
|
||||
GROUP BY p.id
|
||||
`;
|
||||
|
||||
const result = await qry(query, [categoryName]);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
170
db/init/01-schema.sql
Normal file
170
db/init/01-schema.sql
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
-- Create UUID extension for generating UUIDs
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Create users table
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
last_login TIMESTAMP WITH TIME ZONE,
|
||||
current_auth_id UUID, -- Foreign key to authentication table, NULL if logged out
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Create authentication table
|
||||
CREATE TABLE authentications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
code VARCHAR(6) NOT NULL, -- 6-digit authentication code
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL, -- When this authentication code expires
|
||||
is_used BOOLEAN DEFAULT FALSE -- Track if this code has been used
|
||||
);
|
||||
|
||||
-- Add foreign key constraint
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT fk_user_authentication
|
||||
FOREIGN KEY (current_auth_id)
|
||||
REFERENCES authentications (id)
|
||||
ON DELETE SET NULL; -- If auth record is deleted, just set NULL in users table
|
||||
|
||||
-- Create product_categories table
|
||||
CREATE TABLE product_categories (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
-- Insert the three main product categories
|
||||
INSERT INTO product_categories (name, description) VALUES
|
||||
('Rock', 'Natural stone specimens of various types, sizes, and origins'),
|
||||
('Bone', 'Preserved bones from various sources and species'),
|
||||
('Stick', 'Natural wooden sticks and branches of different types and sizes');
|
||||
|
||||
-- Create products table
|
||||
CREATE TABLE products (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
category_id UUID NOT NULL REFERENCES product_categories(id),
|
||||
price DECIMAL(10, 2) NOT NULL,
|
||||
stock_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
weight_grams DECIMAL(10, 2),
|
||||
length_cm DECIMAL(10, 2),
|
||||
width_cm DECIMAL(10, 2),
|
||||
height_cm DECIMAL(10, 2),
|
||||
origin VARCHAR(100),
|
||||
age VARCHAR(100),
|
||||
material_type VARCHAR(100),
|
||||
color VARCHAR(100),
|
||||
image_url VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Create orders table
|
||||
CREATE TABLE orders (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending', -- pending, processing, shipped, delivered, cancelled
|
||||
total_amount DECIMAL(10, 2) NOT NULL,
|
||||
shipping_address TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
-- Create order_items table for order details
|
||||
CREATE TABLE order_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
order_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
price_at_purchase DECIMAL(10, 2) NOT NULL, -- Store price at time of purchase
|
||||
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products (id)
|
||||
);
|
||||
|
||||
-- Create shopping cart table
|
||||
CREATE TABLE carts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create cart items table
|
||||
CREATE TABLE cart_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
cart_id UUID NOT NULL,
|
||||
product_id UUID NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
FOREIGN KEY (cart_id) REFERENCES carts (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products (id),
|
||||
UNIQUE(cart_id, product_id) -- Prevent duplicate products in cart
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX idx_user_email ON users(email);
|
||||
CREATE INDEX idx_auth_code ON authentications(code);
|
||||
-- Create tags table
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(50) NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
-- Create product_tags junction table
|
||||
CREATE TABLE product_tags (
|
||||
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (product_id, tag_id)
|
||||
);
|
||||
|
||||
-- Insert common tags for natural items
|
||||
INSERT INTO tags (name, description) VALUES
|
||||
('Polished', 'Items that have been polished to a smooth finish'),
|
||||
('Raw', 'Items in their natural, unprocessed state'),
|
||||
('Rare', 'Uncommon or hard-to-find specimens'),
|
||||
('Fossil', 'Preserved remains or traces of ancient organisms'),
|
||||
('Decorative', 'Items selected for their aesthetic appeal'),
|
||||
('Educational', 'Items with significant educational value'),
|
||||
('Collectible', 'Items that are part of a recognized collection series');
|
||||
|
||||
CREATE INDEX idx_product_name ON products(name);
|
||||
CREATE INDEX idx_product_category ON products(category_id);
|
||||
CREATE INDEX idx_product_tags_product ON product_tags(product_id);
|
||||
CREATE INDEX idx_product_tags_tag ON product_tags(tag_id);
|
||||
CREATE INDEX idx_orders_user_id ON orders(user_id);
|
||||
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
|
||||
|
||||
-- Create a function to update the updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_modified_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE 'plpgsql';
|
||||
|
||||
-- Create triggers to automatically update the updated_at column
|
||||
CREATE TRIGGER update_users_modtime
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
|
||||
|
||||
CREATE TRIGGER update_products_modtime
|
||||
BEFORE UPDATE ON products
|
||||
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
|
||||
|
||||
CREATE TRIGGER update_orders_modtime
|
||||
BEFORE UPDATE ON orders
|
||||
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
|
||||
|
||||
CREATE TRIGGER update_carts_modtime
|
||||
BEFORE UPDATE ON carts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
|
||||
191
db/init/02-seed.sql
Normal file
191
db/init/02-seed.sql
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
-- Seed data for testing
|
||||
|
||||
-- Insert test users
|
||||
INSERT INTO users (email, first_name, last_name) VALUES
|
||||
('shaivkamat@2many.ca', 'Shaiv', 'Kamat'),
|
||||
('jane@example.com', 'Jane', 'Smith'),
|
||||
('bob@example.com', 'Bob', 'Johnson');
|
||||
|
||||
-- Note: Product categories are already inserted in the schema file, so we're skipping that step
|
||||
|
||||
-- Insert rock products
|
||||
WITH categories AS (
|
||||
SELECT id, name FROM product_categories
|
||||
)
|
||||
-- Insert rock products
|
||||
INSERT INTO products (name, description, category_id, price, stock_quantity, weight_grams, color, material_type, origin, image_url)
|
||||
SELECT
|
||||
'Amethyst Geode',
|
||||
'Beautiful purple amethyst geode with crystal formation',
|
||||
id,
|
||||
49.99,
|
||||
15,
|
||||
450.0,
|
||||
'Purple',
|
||||
'Quartz',
|
||||
'Brazil',
|
||||
'/images/amethyst-geode.jpg'
|
||||
FROM categories WHERE name = 'Rock';
|
||||
|
||||
WITH categories AS (
|
||||
SELECT id, name FROM product_categories
|
||||
)
|
||||
INSERT INTO products (name, description, category_id, price, stock_quantity, weight_grams, color, material_type, origin, image_url)
|
||||
SELECT
|
||||
'Polished Labradorite',
|
||||
'Stunning polished labradorite with iridescent blue flash',
|
||||
id,
|
||||
29.99,
|
||||
25,
|
||||
120.5,
|
||||
'Gray/Blue',
|
||||
'Feldspar',
|
||||
'Madagascar',
|
||||
'/images/labradorite.jpg'
|
||||
FROM categories WHERE name = 'Rock';
|
||||
|
||||
WITH categories AS (
|
||||
SELECT id, name FROM product_categories
|
||||
)
|
||||
INSERT INTO products (name, description, category_id, price, stock_quantity, weight_grams, color, material_type, origin, image_url)
|
||||
SELECT
|
||||
'Raw Turquoise',
|
||||
'Natural turquoise specimen, unpolished',
|
||||
id,
|
||||
19.99,
|
||||
18,
|
||||
85.2,
|
||||
'Turquoise',
|
||||
'Turquoise',
|
||||
'Arizona',
|
||||
'/images/turquoise.jpg'
|
||||
FROM categories WHERE name = 'Rock';
|
||||
|
||||
-- Insert bone products
|
||||
WITH categories AS (
|
||||
SELECT id, name FROM product_categories
|
||||
)
|
||||
INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, material_type, image_url)
|
||||
SELECT
|
||||
'Deer Antler',
|
||||
'Naturally shed deer antler, perfect for display or crafts',
|
||||
id,
|
||||
24.99,
|
||||
8,
|
||||
38.5,
|
||||
'Antler',
|
||||
'/images/deer-antler.jpg'
|
||||
FROM categories WHERE name = 'Bone';
|
||||
|
||||
WITH categories AS (
|
||||
SELECT id, name FROM product_categories
|
||||
)
|
||||
INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, material_type, image_url)
|
||||
SELECT
|
||||
'Fossil Fish',
|
||||
'Well-preserved fossil fish from the Green River Formation',
|
||||
id,
|
||||
89.99,
|
||||
5,
|
||||
22.8,
|
||||
'Fossilized Bone',
|
||||
'/images/fossil-fish.jpg'
|
||||
FROM categories WHERE name = 'Bone';
|
||||
|
||||
-- Insert stick products
|
||||
WITH categories AS (
|
||||
SELECT id, name FROM product_categories
|
||||
)
|
||||
INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, width_cm, material_type, color, image_url)
|
||||
SELECT
|
||||
'Driftwood Piece',
|
||||
'Unique driftwood piece, weathered by the ocean',
|
||||
id,
|
||||
14.99,
|
||||
12,
|
||||
45.6,
|
||||
8.3,
|
||||
'Driftwood',
|
||||
'Tan/Gray',
|
||||
'/images/driftwood.jpg'
|
||||
FROM categories WHERE name = 'Stick';
|
||||
|
||||
WITH categories AS (
|
||||
SELECT id, name FROM product_categories
|
||||
)
|
||||
INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, width_cm, material_type, color, image_url)
|
||||
SELECT
|
||||
'Walking Stick',
|
||||
'Hand-selected natural maple walking stick',
|
||||
id,
|
||||
34.99,
|
||||
10,
|
||||
152.4,
|
||||
3.8,
|
||||
'Maple',
|
||||
'Brown',
|
||||
'/images/walking-stick.jpg'
|
||||
FROM categories WHERE name = 'Stick';
|
||||
|
||||
WITH categories AS (
|
||||
SELECT id, name FROM product_categories
|
||||
)
|
||||
INSERT INTO products (name, description, category_id, price, stock_quantity, length_cm, width_cm, material_type, color, image_url)
|
||||
SELECT
|
||||
'Decorative Branch Set',
|
||||
'Set of 3 decorative birch branches for home decoration',
|
||||
id,
|
||||
19.99,
|
||||
20,
|
||||
76.2,
|
||||
1.5,
|
||||
'Birch',
|
||||
'White',
|
||||
'/images/birch-branches.jpg'
|
||||
FROM categories WHERE name = 'Stick';
|
||||
|
||||
-- Create a cart for testing
|
||||
INSERT INTO carts (user_id)
|
||||
SELECT id FROM users WHERE email = 'jane@example.com';
|
||||
|
||||
-- Add product tags - using a different approach
|
||||
-- Tag: Decorative for Amethyst Geode
|
||||
INSERT INTO product_tags (product_id, tag_id)
|
||||
SELECT p.id, t.id
|
||||
FROM products p, tags t
|
||||
WHERE p.name = 'Amethyst Geode' AND t.name = 'Decorative';
|
||||
|
||||
-- Tag: Polished for Polished Labradorite
|
||||
INSERT INTO product_tags (product_id, tag_id)
|
||||
SELECT p.id, t.id
|
||||
FROM products p, tags t
|
||||
WHERE p.name = 'Polished Labradorite' AND t.name = 'Polished';
|
||||
|
||||
-- Tag: Raw for Raw Turquoise
|
||||
INSERT INTO product_tags (product_id, tag_id)
|
||||
SELECT p.id, t.id
|
||||
FROM products p, tags t
|
||||
WHERE p.name = 'Raw Turquoise' AND t.name = 'Raw';
|
||||
|
||||
-- Tags: Fossil and Educational for Fossil Fish
|
||||
INSERT INTO product_tags (product_id, tag_id)
|
||||
SELECT p.id, t.id
|
||||
FROM products p, tags t
|
||||
WHERE p.name = 'Fossil Fish' AND t.name = 'Fossil';
|
||||
|
||||
INSERT INTO product_tags (product_id, tag_id)
|
||||
SELECT p.id, t.id
|
||||
FROM products p, tags t
|
||||
WHERE p.name = 'Fossil Fish' AND t.name = 'Educational';
|
||||
|
||||
-- Tag: Decorative for Driftwood Piece
|
||||
INSERT INTO product_tags (product_id, tag_id)
|
||||
SELECT p.id, t.id
|
||||
FROM products p, tags t
|
||||
WHERE p.name = 'Driftwood Piece' AND t.name = 'Decorative';
|
||||
|
||||
-- Tag: Collectible for Walking Stick
|
||||
INSERT INTO product_tags (product_id, tag_id)
|
||||
SELECT p.id, t.id
|
||||
FROM products p, tags t
|
||||
WHERE p.name = 'Walking Stick' AND t.name = 'Collectible';
|
||||
5
db/init/03-api-key.sql
Normal file
5
db/init/03-api-key.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Add API key column to users table
|
||||
ALTER TABLE users ADD COLUMN api_key VARCHAR(255) DEFAULT NULL;
|
||||
|
||||
-- Create index for faster API key lookups
|
||||
CREATE INDEX idx_user_api_key ON users(api_key);
|
||||
44
db/init/04-product-images.sql
Normal file
44
db/init/04-product-images.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
-- Create product_images table
|
||||
CREATE TABLE product_images (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
product_id UUID NOT NULL,
|
||||
image_path VARCHAR(255) NOT NULL,
|
||||
display_order INT NOT NULL DEFAULT 0,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX idx_product_images_product_id ON product_images(product_id);
|
||||
|
||||
-- Modify existing products table to remove single image_url field
|
||||
ALTER TABLE products DROP COLUMN IF EXISTS image_url;
|
||||
|
||||
-- Insert test images for existing products
|
||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
||||
SELECT id, '/images/amethyst-geode.jpg', 0, true FROM products WHERE name = 'Amethyst Geode';
|
||||
|
||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
||||
SELECT id, '/images/amethyst-geode-closeup.jpg', 1, false FROM products WHERE name = 'Amethyst Geode';
|
||||
|
||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
||||
SELECT id, '/images/labradorite.jpg', 0, true FROM products WHERE name = 'Polished Labradorite';
|
||||
|
||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
||||
SELECT id, '/images/turquoise.jpg', 0, true FROM products WHERE name = 'Raw Turquoise';
|
||||
|
||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
||||
SELECT id, '/images/deer-antler.jpg', 0, true FROM products WHERE name = 'Deer Antler';
|
||||
|
||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
||||
SELECT id, '/images/fossil-fish.jpg', 0, true FROM products WHERE name = 'Fossil Fish';
|
||||
|
||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
||||
SELECT id, '/images/driftwood.jpg', 0, true FROM products WHERE name = 'Driftwood Piece';
|
||||
|
||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
||||
SELECT id, '/images/walking-stick.jpg', 0, true FROM products WHERE name = 'Walking Stick';
|
||||
|
||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
||||
SELECT id, '/images/birch-branches.jpg', 0, true FROM products WHERE name = 'Decorative Branch Set';
|
||||
10
db/init/05-admin-role.sql
Normal file
10
db/init/05-admin-role.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- Add is_admin column to users table
|
||||
ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Create index for faster admin lookups
|
||||
CREATE INDEX idx_user_is_admin ON users(is_admin);
|
||||
|
||||
-- Set the first user as admin for testing
|
||||
UPDATE users
|
||||
SET is_admin = TRUE
|
||||
WHERE email = 'shaivkamat@2many.ca';
|
||||
1
db/init/06-product-categories.sql
Normal file
1
db/init/06-product-categories.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE product_categories ADD COLUMN image_path VARCHAR(255);
|
||||
67
db/test/test-api.sh
Normal file
67
db/test/test-api.sh
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "Starting backend service..."
|
||||
docker compose up -d db backend
|
||||
|
||||
echo "Waiting for backend to start..."
|
||||
sleep 5
|
||||
|
||||
echo "Testing API health endpoint..."
|
||||
curl -s http://localhost:4000/health | jq
|
||||
|
||||
echo "Fetching product categories..."
|
||||
curl -s http://localhost:4000/api/products/categories/all | jq
|
||||
|
||||
echo "Fetching all products..."
|
||||
curl -s http://localhost:4000/api/products | jq
|
||||
|
||||
echo "Fetching Rock category products..."
|
||||
curl -s http://localhost:4000/api/products/category/Rock | jq
|
||||
|
||||
echo "Testing user registration..."
|
||||
curl -s -X POST http://localhost:4000/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","firstName":"Test","lastName":"User"}' | jq
|
||||
|
||||
echo "Testing login request..."
|
||||
curl -s -X POST http://localhost:4000/api/auth/login-request \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com"}' | jq
|
||||
|
||||
# Save the auth code for the next step
|
||||
AUTH_CODE=$(curl -s -X POST http://localhost:4000/api/auth/login-request \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com"}' | jq -r '.code')
|
||||
|
||||
echo "Testing login verification with code: $AUTH_CODE"
|
||||
RESPONSE=$(curl -s -X POST http://localhost:4000/api/auth/verify \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"test@example.com\",\"code\":\"$AUTH_CODE\"}")
|
||||
|
||||
echo $RESPONSE | jq
|
||||
|
||||
USER_ID=$(echo $RESPONSE | jq -r '.userId')
|
||||
API_KEY=$(echo $RESPONSE | jq -r '.token')
|
||||
|
||||
echo "User ID: $USER_ID"
|
||||
echo "API Key: $API_KEY"
|
||||
|
||||
echo "Verifying API key..."
|
||||
curl -s -X POST http://localhost:4000/api/auth/verify-key \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"apiKey\":\"$API_KEY\"}" | jq
|
||||
|
||||
echo "Testing cart for user: $USER_ID with API key"
|
||||
curl -s http://localhost:4000/api/cart/$USER_ID \
|
||||
-H "X-API-Key: $API_KEY" | jq
|
||||
|
||||
echo "Adding a product to the cart..."
|
||||
PRODUCT_ID=$(curl -s http://localhost:4000/api/products | jq -r '.[0].id')
|
||||
curl -s -X POST http://localhost:4000/api/cart/add \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"userId\":\"$USER_ID\",\"productId\":\"$PRODUCT_ID\",\"quantity\":1}" | jq
|
||||
|
||||
echo "Showing updated cart..."
|
||||
curl -s http://localhost:4000/api/cart/$USER_ID | jq
|
||||
|
||||
echo "Test complete!"
|
||||
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Frontend React application
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:80"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:4000/api
|
||||
- VITE_ENVIRONMENT=beta
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
# Backend Express server
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- /app/node_modules
|
||||
- ./backend/public/uploads:/app/public/uploads # Persist uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./db/init:/docker-entrypoint-initdb.d
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
96
fileStructure.txt
Normal file
96
fileStructure.txt
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
backend/
|
||||
├── public/
|
||||
├── node_modules/
|
||||
├── src/
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js
|
||||
│ │ ├── products.js
|
||||
│ │ ├── productAdminImages.js
|
||||
│ │ ├── images.js
|
||||
│ │ ├── productAdmin.js
|
||||
│ │ └── cart.js
|
||||
│ ├── middleware/
|
||||
│ │ ├── upload.js
|
||||
│ │ ├── auth.js
|
||||
│ │ └── adminAuth.js
|
||||
│ ├── db/
|
||||
│ │ └── index.js
|
||||
│ ├── index.js
|
||||
│ └── config.js
|
||||
│ └── Dockerfile
|
||||
├── package.json
|
||||
├── .env
|
||||
├── README.md
|
||||
└── .gitignore
|
||||
db/
|
||||
├── init/
|
||||
│ ├── 05-admin-role.sql
|
||||
│ ├── 02-seed.sql
|
||||
│ ├── 04-product-images.sql
|
||||
│ ├── 03-api-key.sql
|
||||
│ ├── 01-schema.sql
|
||||
frontend/
|
||||
├── node_modules/
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── pages/
|
||||
│ │ ├── Admin/
|
||||
│ │ │ ├── ProductEditPage.jsx
|
||||
│ │ │ ├── DashboardPage.jsx
|
||||
│ │ │ └── ProductsPage.jsx
|
||||
│ │ ├── ProductsPage.jsx
|
||||
│ │ ├── HomePage.jsx
|
||||
│ │ ├── CartPage.jsx
|
||||
│ │ ├── ProductDetailPage.jsx
|
||||
│ │ ├── VerifyPage.jsx
|
||||
│ │ ├── CheckoutPage.jsx
|
||||
│ │ ├── RegisterPage.jsx
|
||||
│ │ ├── NotFoundPage.jsx
|
||||
│ │ └── LoginPage.jsx
|
||||
│ ├── utils/
|
||||
│ │ └── imageUtils.js
|
||||
│ ├── components/
|
||||
│ │ ├── ImageUploader.jsx
|
||||
│ │ ├── ProductImage.jsx
|
||||
│ │ ├── Footer.jsx
|
||||
│ │ ├── ProtectedRoute.jsx
|
||||
│ │ └── Notifications.jsx
|
||||
│ ├── services/
|
||||
│ │ ├── imageService.js
|
||||
│ │ ├── productService.js
|
||||
│ │ ├── cartService.js
|
||||
│ │ ├── authService.js
|
||||
│ │ └── api.js
|
||||
│ ├── hooks/
|
||||
│ │ ├── apiHooks.js
|
||||
│ │ └── reduxHooks.js
|
||||
│ ├── layouts/
|
||||
│ │ ├── MainLayout.jsx
|
||||
│ │ ├── AuthLayout.jsx
|
||||
│ │ └── AdminLayout.jsx
|
||||
│ ├── theme/
|
||||
│ │ ├── index.js
|
||||
│ │ └── ThemeProvider.jsx
|
||||
│ ├── features/
|
||||
│ │ ├── ui/
|
||||
│ │ │ └── uiSlice.js
|
||||
│ │ ├── cart/
|
||||
│ │ │ └── cartSlice.js
|
||||
│ │ ├── auth/
|
||||
│ │ │ └── authSlice.js
|
||||
│ │ └── store/
|
||||
│ │ └── index.js
|
||||
│ ├── assets/
|
||||
│ ├── App.jsx
|
||||
│ ├── main.jsx
|
||||
│ └── config.js
|
||||
├── package.json
|
||||
├── package-lock.json
|
||||
├── vite.config.js
|
||||
├── Dockerfile
|
||||
├── nginx.conf
|
||||
├── index.html
|
||||
├── README.md
|
||||
├── .env
|
||||
├── setup-frontend.sh
|
||||
└── docker-compose.yml
|
||||
58
frontend/Dockerfile
Normal file
58
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# FROM node:18-alpine as build
|
||||
|
||||
# WORKDIR /app
|
||||
|
||||
# # Copy package files first for better layer caching
|
||||
# COPY package*.json ./
|
||||
|
||||
# # Use npm install instead of npm ci to resolve dependency mismatches
|
||||
# RUN npm install
|
||||
|
||||
# # Copy the rest of the application
|
||||
# COPY . .
|
||||
|
||||
# # Make sure we have a public directory for static assets
|
||||
# RUN mkdir -p public
|
||||
|
||||
# # Build the application
|
||||
# RUN npm run build
|
||||
|
||||
# # Production stage
|
||||
# FROM nginx:alpine
|
||||
|
||||
# # Copy built assets from the build stage
|
||||
# COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# # Copy custom nginx config if it exists, otherwise create a basic one
|
||||
# # COPY nginx.conf /etc/nginx/conf.d/default.conf 2>/dev/null || echo 'server { \
|
||||
# # listen 80; \
|
||||
# # root /usr/share/nginx/html; \
|
||||
# # index index.html; \
|
||||
# # location / { \
|
||||
# # try_files $uri $uri/ /index.html; \
|
||||
# # } \
|
||||
# # location /api/ { \
|
||||
# # proxy_pass http://backend:4000; \
|
||||
# # } \
|
||||
# # }' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
# EXPOSE 80
|
||||
|
||||
# CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
# frontend/Dockerfile.dev
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Don't copy source files - they will be mounted as a volume
|
||||
# COPY . .
|
||||
|
||||
# Expose the Vite dev server port
|
||||
EXPOSE 3000
|
||||
|
||||
# Run the dev server
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
66
frontend/README.md
Normal file
66
frontend/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Rocks, Bones & Sticks Frontend
|
||||
|
||||
React frontend for the Rocks, Bones & Sticks e-commerce platform.
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **React**: UI library
|
||||
- **React Router**: For navigation and routing
|
||||
- **Redux**: For state management
|
||||
- **@reduxjs/toolkit**: Simplified Redux development
|
||||
- **React Query**: For data fetching, caching, and state management
|
||||
- **Material UI**: Component library with theming
|
||||
- **Axios**: HTTP client
|
||||
- **Vite**: Build tool
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── assets/ # Static assets like images, fonts
|
||||
├── components/ # Reusable components
|
||||
├── features/ # Redux slices organized by feature
|
||||
├── hooks/ # Custom hooks
|
||||
├── layouts/ # Layout components
|
||||
├── pages/ # Page components
|
||||
├── services/ # API services
|
||||
├── store/ # Redux store configuration
|
||||
├── theme/ # Material UI theme configuration
|
||||
└── utils/ # Utility functions
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run for development
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env` file with the following variables:
|
||||
|
||||
```
|
||||
VITE_APP_NAME=Rocks, Bones & Sticks
|
||||
VITE_API_URL=http://localhost:4000/api
|
||||
VITE_ENVIRONMENT=beta # Use 'beta' for development, 'prod' for production
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **User Authentication**: Login with email verification code
|
||||
- **Product Browsing**: Filter and search products
|
||||
- **Shopping Cart**: Add, update, remove items
|
||||
- **Checkout Process**: Complete order creation
|
||||
- **Admin Dashboard**: Manage products, view orders and customers
|
||||
- **Responsive Design**: Works on mobile and desktop devices
|
||||
- **Dark Mode Support**: Toggle between light and dark themes
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rocks, Bones & Sticks</title>
|
||||
<meta name="description" content="Your premier source for natural curiosities and unique specimens" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
37
frontend/nginx.conf
Normal file
37
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# Document root
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Handle SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to the backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Static asset caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 10240;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
|
||||
gzip_disable "MSIE [1-6]\.";
|
||||
}
|
||||
3664
frontend/package-lock.json
generated
Normal file
3664
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "rocks-bones-sticks-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fontsource/roboto": "^5.0.8",
|
||||
"@mui/icons-material": "^5.14.19",
|
||||
"@mui/material": "^5.14.19",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@tanstack/react-query": "^5.12.2",
|
||||
"@tanstack/react-query-devtools": "^5.12.2",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.0.2",
|
||||
"react-router-dom": "^6.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"sass": "^1.69.5",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
5
frontend/public/favicon.svg
Normal file
5
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3L4 9v12h16V9l-8-6z" stroke="#673AB7" fill="#EDE7F6" />
|
||||
<path d="M12 9a2 2 0 100 4 2 2 0 000-4z" stroke="#673AB7" fill="#673AB7" />
|
||||
<path d="M8 21v-5a4 4 0 118 0v5" stroke="#673AB7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 392 B |
33
frontend/setup-frontend.sh
Normal file
33
frontend/setup-frontend.sh
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Create React app using Vite
|
||||
npm create vite@latest frontend -- --template react
|
||||
|
||||
# Navigate to frontend directory
|
||||
cd frontend
|
||||
|
||||
# Install core dependencies
|
||||
npm install \
|
||||
react-router-dom \
|
||||
@reduxjs/toolkit \
|
||||
react-redux \
|
||||
@tanstack/react-query \
|
||||
@tanstack/react-query-devtools \
|
||||
axios \
|
||||
@mui/material \
|
||||
@mui/icons-material \
|
||||
@emotion/react \
|
||||
@emotion/styled \
|
||||
@fontsource/roboto
|
||||
|
||||
# Install dev dependencies
|
||||
npm install -D \
|
||||
@types/react \
|
||||
@types/react-dom \
|
||||
@vitejs/plugin-react \
|
||||
sass
|
||||
|
||||
# Create frontend project structure
|
||||
mkdir -p src/{assets,components,features,hooks,layouts,pages,services,store,theme,utils}
|
||||
|
||||
echo "Frontend project setup complete!"
|
||||
85
frontend/src/App.jsx
Normal file
85
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { Routes, Route } from 'react-router-dom';
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { CircularProgress, Box } from '@mui/material';
|
||||
import Notifications from './components/Notifications';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
// Layouts
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import AuthLayout from './layouts/AuthLayout';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
|
||||
// Pages - lazy loaded to reduce initial bundle size
|
||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||
const ProductsPage = lazy(() => import('./pages/ProductsPage'));
|
||||
const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage'));
|
||||
const CartPage = lazy(() => import('./pages/CartPage'));
|
||||
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'));
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
||||
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
|
||||
const VerifyPage = lazy(() => import('./pages/VerifyPage'));
|
||||
const AdminDashboardPage = lazy(() => import('./pages/Admin/DashboardPage'));
|
||||
const AdminProductsPage = lazy(() => import('./pages/Admin/ProductsPage'));
|
||||
const AdminProductEditPage = lazy(() => import('./pages/Admin/ProductEditPage'));
|
||||
const AdminCategoriesPage = lazy(() => import('./pages/Admin/CategoriesPage'));
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||
|
||||
// Loading component for suspense fallback
|
||||
const LoadingComponent = () => (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Notifications />
|
||||
<Routes>
|
||||
{/* Main routes with MainLayout */}
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="products" element={<ProductsPage />} />
|
||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||
<Route path="cart" element={
|
||||
<ProtectedRoute>
|
||||
<CartPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="checkout" element={
|
||||
<ProtectedRoute>
|
||||
<CheckoutPage />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Route>
|
||||
|
||||
{/* Auth routes with AuthLayout */}
|
||||
<Route path="/auth" element={<AuthLayout />}>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="register" element={<RegisterPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Verification route - standalone page */}
|
||||
<Route path="/verify" element={<VerifyPage />} />
|
||||
|
||||
{/* Admin routes with AdminLayout - protected for admins only */}
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute requireAdmin={true} redirectTo="/">
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="products" element={<AdminProductsPage />} />
|
||||
<Route path="products/:id" element={<AdminProductEditPage />} />
|
||||
<Route path="products/new" element={<AdminProductEditPage />} />
|
||||
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all route for 404s */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
86
frontend/src/components/Footer.jsx
Normal file
86
frontend/src/components/Footer.jsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React from 'react';
|
||||
import { Box, Container, Grid, Typography, Link, IconButton } from '@mui/material';
|
||||
import FacebookIcon from '@mui/icons-material/Facebook';
|
||||
import TwitterIcon from '@mui/icons-material/Twitter';
|
||||
import InstagramIcon from '@mui/icons-material/Instagram';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
py: 3,
|
||||
px: 2,
|
||||
mt: 'auto',
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'light'
|
||||
? theme.palette.grey[200]
|
||||
: theme.palette.grey[800],
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Typography variant="h6" color="text.primary" gutterBottom>
|
||||
Rocks, Bones & Sticks
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Your premier source for natural curiosities
|
||||
and unique specimens from my backyards.
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Typography variant="h6" color="text.primary" gutterBottom>
|
||||
Quick Links
|
||||
</Typography>
|
||||
<Link component={RouterLink} to="/" color="inherit" display="block">
|
||||
Home
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/products" color="inherit" display="block">
|
||||
Shop All
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/products?category=Rock" color="inherit" display="block">
|
||||
Rocks
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/products?category=Bone" color="inherit" display="block">
|
||||
Bones
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/products?category=Stick" color="inherit" display="block">
|
||||
Sticks
|
||||
</Link>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Typography variant="h6" color="text.primary" gutterBottom>
|
||||
Connect With Us
|
||||
</Typography>
|
||||
<Box>
|
||||
<IconButton aria-label="facebook" color="primary">
|
||||
<FacebookIcon />
|
||||
</IconButton>
|
||||
<IconButton aria-label="twitter" color="primary">
|
||||
<TwitterIcon />
|
||||
</IconButton>
|
||||
<IconButton aria-label="instagram" color="primary">
|
||||
<InstagramIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Subscribe to our newsletter for updates on new items and promotions.
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box mt={3}>
|
||||
<Typography variant="body2" color="text.secondary" align="center">
|
||||
© {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
225
frontend/src/components/ImageUploader.jsx
Normal file
225
frontend/src/components/ImageUploader.jsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Grid,
|
||||
IconButton,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardActions,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import StarIcon from '@mui/icons-material/Star';
|
||||
import StarBorderIcon from '@mui/icons-material/StarBorder';
|
||||
import { useAuth } from '@hooks/reduxHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
/**
|
||||
* Image uploader component for product images
|
||||
* @param {Object} props - Component props
|
||||
* @param {Array} props.images - Current images
|
||||
* @param {Function} props.onChange - Callback when images change
|
||||
* @param {boolean} props.multiple - Whether to allow multiple images
|
||||
* @param {boolean} props.admin - Whether this is for admin use
|
||||
* @returns {JSX.Element} - Image uploader component
|
||||
*/
|
||||
const ImageUploader = ({
|
||||
images = [],
|
||||
onChange,
|
||||
multiple = true,
|
||||
admin = true
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const { apiKey } = useAuth();
|
||||
|
||||
const handleUpload = async (event) => {
|
||||
const files = event.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (multiple && files.length > 1) {
|
||||
// Upload multiple images
|
||||
const response = await imageUtils.uploadMultipleImages(
|
||||
Array.from(files),
|
||||
{ apiKey, isProductImage: true }
|
||||
);
|
||||
|
||||
// Format new images
|
||||
const newImages = [...images];
|
||||
|
||||
response.images.forEach((img, index) => {
|
||||
newImages.push({
|
||||
path: img.imagePath,
|
||||
isPrimary: images.length === 0 && index === 0,
|
||||
displayOrder: images.length + index
|
||||
});
|
||||
});
|
||||
|
||||
onChange(newImages);
|
||||
} else {
|
||||
// Upload single image
|
||||
const response = await imageUtils.uploadImage(
|
||||
files[0],
|
||||
{ apiKey, isProductImage: true }
|
||||
);
|
||||
|
||||
// Add the new image
|
||||
const newImage = {
|
||||
path: response.imagePath,
|
||||
isPrimary: images.length === 0,
|
||||
displayOrder: images.length
|
||||
};
|
||||
|
||||
onChange([...images, newImage]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Image upload failed:', err);
|
||||
setError(err.message || 'Failed to upload image. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index) => {
|
||||
// Get the image to remove
|
||||
const imageToRemove = images[index];
|
||||
|
||||
// Extract filename if we need to delete from server
|
||||
const filename = imageUtils.getFilenameFromPath(imageToRemove.path);
|
||||
|
||||
// Remove from array first
|
||||
const newImages = [...images];
|
||||
newImages.splice(index, 1);
|
||||
|
||||
// If the removed image was primary, make the first one primary
|
||||
if (imageToRemove.isPrimary && newImages.length > 0) {
|
||||
newImages[0].isPrimary = true;
|
||||
}
|
||||
|
||||
// Update state
|
||||
onChange(newImages);
|
||||
|
||||
// If we have the filename and this is admin mode, delete from server
|
||||
if (admin && filename) {
|
||||
imageUtils.deleteImage(filename, { apiKey })
|
||||
.catch(err => console.error('Failed to delete image from server:', err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrimary = (index) => {
|
||||
// Update all images, setting only one as primary
|
||||
const newImages = images.map((img, i) => ({
|
||||
...img,
|
||||
isPrimary: i === index
|
||||
}));
|
||||
|
||||
onChange(newImages);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
id="image-upload-input"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
|
||||
{/* Upload button */}
|
||||
<label htmlFor="image-upload-input">
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="span"
|
||||
startIcon={<CloudUploadIcon />}
|
||||
disabled={loading}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
Uploading...
|
||||
<CircularProgress size={24} sx={{ ml: 1 }} />
|
||||
</>
|
||||
) : (
|
||||
`Upload Image${multiple ? 's' : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Image grid */}
|
||||
{images.length > 0 ? (
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
{images.map((image, index) => (
|
||||
<Grid item xs={6} sm={4} md={3} key={index}>
|
||||
<Card
|
||||
elevation={2}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: image.isPrimary ? '2px solid' : 'none',
|
||||
borderColor: 'primary.main'
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
sx={{ height: 140, objectFit: 'cover' }}
|
||||
image={imageUtils.getImageUrl(image.path)}
|
||||
alt={`Product image ${index + 1}`}
|
||||
/>
|
||||
<CardActions sx={{ justifyContent: 'space-between', mt: 'auto' }}>
|
||||
<Tooltip title={image.isPrimary ? "Primary Image" : "Set as Primary"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color={image.isPrimary ? "primary" : "default"}
|
||||
onClick={() => handleSetPrimary(index)}
|
||||
disabled={image.isPrimary}
|
||||
>
|
||||
{image.isPrimary ? <StarIcon /> : <StarBorderIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Remove Image">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Typography color="text.secondary" sx={{ mt: 2 }}>
|
||||
No images uploaded yet. Click the button above to upload.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
39
frontend/src/components/Notifications.jsx
Normal file
39
frontend/src/components/Notifications.jsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React from 'react';
|
||||
import { Snackbar, Alert } from '@mui/material';
|
||||
import { useAppSelector, useAppDispatch } from '../hooks/reduxHooks';
|
||||
import { selectNotifications, removeNotification } from '../features/ui/uiSlice';
|
||||
|
||||
const Notifications = () => {
|
||||
const notifications = useAppSelector(selectNotifications);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClose = (id) => {
|
||||
dispatch(removeNotification(id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{notifications.map((notification) => (
|
||||
<Snackbar
|
||||
key={notification.id}
|
||||
open={true}
|
||||
autoHideDuration={notification.duration || 6000}
|
||||
onClose={() => handleClose(notification.id)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => handleClose(notification.id)}
|
||||
severity={notification.type || 'info'}
|
||||
sx={{ width: '100%' }}
|
||||
elevation={6}
|
||||
variant="filled"
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
67
frontend/src/components/ProductImage.jsx
Normal file
67
frontend/src/components/ProductImage.jsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
/**
|
||||
* Component to display product images with fallback handling
|
||||
* @param {Object} props - Component props
|
||||
* @param {Array|Object|string} props.images - Product images array, single image object, or image path
|
||||
* @param {string} props.alt - Alt text for the image
|
||||
* @param {Object} props.sx - Additional styling props
|
||||
* @param {string} props.placeholderImage - Placeholder image to use when no image is available
|
||||
* @returns {JSX.Element} - ProductImage component
|
||||
*/
|
||||
const ProductImage = ({
|
||||
images,
|
||||
alt = 'Product image',
|
||||
sx = {},
|
||||
placeholderImage = '/placeholder.jpg',
|
||||
...rest
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
// Determine which image to use
|
||||
let imageSrc = placeholderImage;
|
||||
|
||||
// Handle different types of image inputs
|
||||
if (Array.isArray(images) && images.length > 0) {
|
||||
// Find primary image in the array
|
||||
const primaryImage = images.find(img => img.isPrimary || img.is_primary) || images[0];
|
||||
const imagePath = primaryImage.path || primaryImage.image_path || primaryImage;
|
||||
imageSrc = imageUtils.getImageUrl(imagePath, placeholderImage);
|
||||
} else if (typeof images === 'object' && images !== null) {
|
||||
// Single image object
|
||||
const imagePath = images.path || images.image_path;
|
||||
imageSrc = imageUtils.getImageUrl(imagePath, placeholderImage);
|
||||
} else if (typeof images === 'string' && images) {
|
||||
// Direct image path
|
||||
imageSrc = imageUtils.getImageUrl(images, placeholderImage);
|
||||
}
|
||||
|
||||
// Handle image load errors
|
||||
const handleError = () => {
|
||||
console.error(`Failed to load image: ${imageSrc}`);
|
||||
if (!imageError) {
|
||||
setImageError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={imageError ? placeholderImage : imageSrc}
|
||||
alt={alt}
|
||||
onError={handleError}
|
||||
sx={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'cover',
|
||||
...sx
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductImage;
|
||||
48
frontend/src/components/ProtectedRoute.jsx
Normal file
48
frontend/src/components/ProtectedRoute.jsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/reduxHooks';
|
||||
import { CircularProgress, Box } from '@mui/material';
|
||||
|
||||
/**
|
||||
* ProtectedRoute component to handle authenticated routes
|
||||
*
|
||||
* @param {Object} props - Component props
|
||||
* @param {ReactNode} props.children - Child components to render when authenticated
|
||||
* @param {boolean} [props.requireAdmin=false] - Whether the route requires admin privileges
|
||||
* @param {string} [props.redirectTo='/auth/login'] - Where to redirect if not authenticated
|
||||
* @returns {ReactNode} The protected route
|
||||
*/
|
||||
const ProtectedRoute = ({
|
||||
children,
|
||||
requireAdmin = false,
|
||||
redirectTo = '/auth/login'
|
||||
}) => {
|
||||
const { isAuthenticated, isAdmin, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login, but save the current location so we can redirect back after login
|
||||
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Check admin privileges if required
|
||||
if (requireAdmin && !isAdmin) {
|
||||
// Redirect to home if not admin
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// Render children if authenticated and has required privileges
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
21
frontend/src/config.js
Normal file
21
frontend/src/config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const config = {
|
||||
// App information
|
||||
appName: import.meta.env.VITE_APP_NAME || 'Rocks, Bones & Sticks',
|
||||
|
||||
// API connection
|
||||
apiUrl: import.meta.env.VITE_API_URL || '/api',
|
||||
|
||||
// Environment
|
||||
environment: import.meta.env.VITE_ENVIRONMENT || 'beta',
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
isProduction: import.meta.env.PROD,
|
||||
|
||||
// Site configuration (domain and protocol based on environment)
|
||||
site: {
|
||||
domain: import.meta.env.VITE_ENVIRONMENT === 'prod' ? 'rocks.2many.ca' : 'localhost:3000',
|
||||
protocol: import.meta.env.VITE_ENVIRONMENT === 'prod' ? 'https' : 'http',
|
||||
apiDomain: import.meta.env.VITE_ENVIRONMENT === 'prod' ? 'api.rocks.2many.ca' : 'localhost:4000'
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
59
frontend/src/features/auth/authSlice.js
Normal file
59
frontend/src/features/auth/authSlice.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const initialState = {
|
||||
user: JSON.parse(localStorage.getItem('user')) || null,
|
||||
apiKey: localStorage.getItem('apiKey') || null,
|
||||
isAdmin: localStorage.getItem('isAdmin') === 'true',
|
||||
isAuthenticated: !!localStorage.getItem('apiKey'),
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
loginStart: (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
},
|
||||
loginSuccess: (state, action) => {
|
||||
state.loading = false;
|
||||
state.isAuthenticated = true;
|
||||
state.user = action.payload.user;
|
||||
state.apiKey = action.payload.apiKey;
|
||||
state.isAdmin = action.payload.isAdmin;
|
||||
localStorage.setItem('apiKey', action.payload.apiKey);
|
||||
localStorage.setItem('isAdmin', action.payload.isAdmin);
|
||||
localStorage.setItem('user', JSON.stringify(action.payload.user));
|
||||
},
|
||||
loginFailed: (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload;
|
||||
},
|
||||
logout: (state) => {
|
||||
state.isAuthenticated = false;
|
||||
state.user = null;
|
||||
state.apiKey = null;
|
||||
state.isAdmin = false;
|
||||
localStorage.removeItem('apiKey');
|
||||
localStorage.removeItem('isAdmin');
|
||||
localStorage.removeItem('user');
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { loginStart, loginSuccess, loginFailed, logout, clearError } = authSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectIsAuthenticated = (state) => state.auth.isAuthenticated;
|
||||
export const selectIsAdmin = (state) => state.auth.isAdmin;
|
||||
export const selectCurrentUser = (state) => state.auth.user;
|
||||
export const selectApiKey = (state) => state.auth.apiKey;
|
||||
export const selectAuthLoading = (state) => state.auth.loading;
|
||||
export const selectAuthError = (state) => state.auth.error;
|
||||
|
||||
export default authSlice.reducer;
|
||||
58
frontend/src/features/cart/cartSlice.js
Normal file
58
frontend/src/features/cart/cartSlice.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const initialState = {
|
||||
id: null,
|
||||
items: [],
|
||||
itemCount: 0,
|
||||
total: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const cartSlice = createSlice({
|
||||
name: 'cart',
|
||||
initialState,
|
||||
reducers: {
|
||||
cartLoading: (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
},
|
||||
cartLoadingFailed: (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload;
|
||||
},
|
||||
updateCart: (state, action) => {
|
||||
state.loading = false;
|
||||
state.id = action.payload.id;
|
||||
state.items = action.payload.items;
|
||||
state.itemCount = action.payload.itemCount;
|
||||
state.total = action.payload.total;
|
||||
},
|
||||
clearCart: (state) => {
|
||||
state.id = null;
|
||||
state.items = [];
|
||||
state.itemCount = 0;
|
||||
state.total = 0;
|
||||
},
|
||||
clearCartError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
cartLoading,
|
||||
cartLoadingFailed,
|
||||
updateCart,
|
||||
clearCart,
|
||||
clearCartError
|
||||
} = cartSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectCartItems = (state) => state.cart.items;
|
||||
export const selectCartItemCount = (state) => state.cart.itemCount;
|
||||
export const selectCartTotal = (state) => state.cart.total;
|
||||
export const selectCartLoading = (state) => state.cart.loading;
|
||||
export const selectCartError = (state) => state.cart.error;
|
||||
|
||||
export default cartSlice.reducer;
|
||||
59
frontend/src/features/ui/uiSlice.js
Normal file
59
frontend/src/features/ui/uiSlice.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
const initialState = {
|
||||
notifications: [],
|
||||
darkMode: localStorage.getItem('darkMode') === 'true',
|
||||
mobileMenuOpen: false,
|
||||
};
|
||||
|
||||
export const uiSlice = createSlice({
|
||||
name: 'ui',
|
||||
initialState,
|
||||
reducers: {
|
||||
addNotification: (state, action) => {
|
||||
state.notifications.push({
|
||||
id: Date.now(),
|
||||
...action.payload,
|
||||
});
|
||||
},
|
||||
removeNotification: (state, action) => {
|
||||
state.notifications = state.notifications.filter(
|
||||
(notification) => notification.id !== action.payload
|
||||
);
|
||||
},
|
||||
clearNotifications: (state) => {
|
||||
state.notifications = [];
|
||||
},
|
||||
toggleDarkMode: (state) => {
|
||||
state.darkMode = !state.darkMode;
|
||||
localStorage.setItem('darkMode', state.darkMode);
|
||||
},
|
||||
setDarkMode: (state, action) => {
|
||||
state.darkMode = action.payload;
|
||||
localStorage.setItem('darkMode', action.payload);
|
||||
},
|
||||
toggleMobileMenu: (state) => {
|
||||
state.mobileMenuOpen = !state.mobileMenuOpen;
|
||||
},
|
||||
closeMobileMenu: (state) => {
|
||||
state.mobileMenuOpen = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
addNotification,
|
||||
removeNotification,
|
||||
clearNotifications,
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
toggleMobileMenu,
|
||||
closeMobileMenu,
|
||||
} = uiSlice.actions;
|
||||
|
||||
// Selectors
|
||||
export const selectNotifications = (state) => state.ui.notifications;
|
||||
export const selectDarkMode = (state) => state.ui.darkMode;
|
||||
export const selectMobileMenuOpen = (state) => state.ui.mobileMenuOpen;
|
||||
|
||||
export default uiSlice.reducer;
|
||||
271
frontend/src/hooks/apiHooks.js
Normal file
271
frontend/src/hooks/apiHooks.js
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import productService, { productAdminService } from '../services/productService';
|
||||
import authService from '../services/authService';
|
||||
import cartService from '../services/cartService';
|
||||
import { useAuth, useCart, useNotification } from './reduxHooks';
|
||||
|
||||
// Product hooks
|
||||
export const useProducts = (params) => {
|
||||
return useQuery({
|
||||
queryKey: ['products', params],
|
||||
queryFn: () => productService.getAllProducts(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useProduct = (id) => {
|
||||
return useQuery({
|
||||
queryKey: ['product', id],
|
||||
queryFn: () => productService.getProductById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCategories = () => {
|
||||
return useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => productService.getAllCategories(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useTags = () => {
|
||||
return useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: () => productService.getAllTags(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useProductsByCategory = (categoryName) => {
|
||||
return useQuery({
|
||||
queryKey: ['products', 'category', categoryName],
|
||||
queryFn: () => productService.getProductsByCategory(categoryName),
|
||||
enabled: !!categoryName,
|
||||
});
|
||||
};
|
||||
|
||||
// Admin product hooks
|
||||
export const useCreateProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (productData) => productAdminService.createProduct(productData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
notification.showNotification('Product created successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to create product',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, productData }) => productAdminService.updateProduct(id, productData),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['product', variables.id] });
|
||||
notification.showNotification('Product updated successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to update product',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteProduct = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id) => productAdminService.deleteProduct(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
notification.showNotification('Product deleted successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete product',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Auth hooks
|
||||
export const useRegister = () => {
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userData) => authService.register(userData),
|
||||
onSuccess: () => {
|
||||
notification.showNotification('Registration successful', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Registration failed',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRequestLoginCode = () => {
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (email) => authService.requestLoginCode(email),
|
||||
onSuccess: () => {
|
||||
notification.showNotification('Login code sent to your email', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to send login code',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useVerifyCode = () => {
|
||||
const { login } = useAuth();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (verifyData) => authService.verifyCode(verifyData),
|
||||
onSuccess: (data) => {
|
||||
login(data.userId, data.apiKey, data.isAdmin);
|
||||
notification.showNotification('Login successful', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Invalid verification code',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useLogout = () => {
|
||||
const { logout, user } = useAuth();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => authService.logout(user?.id),
|
||||
onSuccess: () => {
|
||||
logout();
|
||||
notification.showNotification('Logged out successfully', 'success');
|
||||
},
|
||||
onError: () => {
|
||||
// Force logout even if API call fails
|
||||
logout();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Cart hooks
|
||||
export const useGetCart = (userId) => {
|
||||
const { updateCart } = useCart();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['cart', userId],
|
||||
queryFn: () => cartService.getCart(userId),
|
||||
enabled: !!userId,
|
||||
onSuccess: (data) => {
|
||||
updateCart(data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddToCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { updateCart } = useCart();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (cartItemData) => cartService.addToCart(cartItemData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cart'] });
|
||||
updateCart(data);
|
||||
notification.showNotification('Item added to cart', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to add item to cart',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCartItem = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { updateCart } = useCart();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (updateData) => cartService.updateCartItem(updateData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cart'] });
|
||||
updateCart(data);
|
||||
notification.showNotification('Cart updated', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to update cart',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useClearCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { clearCart } = useCart();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId) => cartService.clearCart(userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cart'] });
|
||||
clearCart();
|
||||
notification.showNotification('Cart cleared', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to clear cart',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCheckout = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { clearCart } = useCart();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (checkoutData) => cartService.checkout(checkoutData),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cart'] });
|
||||
clearCart();
|
||||
notification.showNotification('Order placed successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Checkout failed',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
98
frontend/src/hooks/categoryAdminHooks.js
Normal file
98
frontend/src/hooks/categoryAdminHooks.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { categoryAdminService } from '../services/categoryAdminService';
|
||||
import { useNotification } from './reduxHooks';
|
||||
|
||||
/**
|
||||
* Hook for fetching all categories
|
||||
*/
|
||||
export const useAdminCategories = () => {
|
||||
return useQuery({
|
||||
queryKey: ['admin-categories'],
|
||||
queryFn: () => categoryAdminService.getAllCategories(),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching a single category by ID
|
||||
*/
|
||||
export const useAdminCategory = (id) => {
|
||||
return useQuery({
|
||||
queryKey: ['admin-category', id],
|
||||
queryFn: () => categoryAdminService.getCategoryById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for creating a new category
|
||||
*/
|
||||
export const useCreateCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (categoryData) => categoryAdminService.createCategory(categoryData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-categories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
notification.showNotification('Category created successfully!', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to create category',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for updating a category
|
||||
*/
|
||||
export const useUpdateCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, categoryData }) => categoryAdminService.updateCategory(id, categoryData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-categories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
notification.showNotification('Category updated successfully!', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to update category',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for deleting a category
|
||||
*/
|
||||
export const useDeleteCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id) => categoryAdminService.deleteCategory(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-categories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
notification.showNotification('Category deleted successfully!', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete category',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
};
|
||||
105
frontend/src/hooks/reduxHooks.js
Normal file
105
frontend/src/hooks/reduxHooks.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||
export const useAppDispatch = () => useDispatch();
|
||||
|
||||
export const useAppSelector = useSelector;
|
||||
|
||||
// Create a custom hook for notifications
|
||||
export const useNotification = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return useMemo(() => ({
|
||||
showNotification: (message, type = 'info', duration = 3000) => {
|
||||
const id = Date.now();
|
||||
dispatch({
|
||||
type: 'ui/addNotification',
|
||||
payload: { message, type, duration, id },
|
||||
});
|
||||
|
||||
if (duration !== null) {
|
||||
setTimeout(() => {
|
||||
dispatch({
|
||||
type: 'ui/removeNotification',
|
||||
payload: id,
|
||||
});
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
closeNotification: (id) => {
|
||||
dispatch({
|
||||
type: 'ui/removeNotification',
|
||||
payload: id,
|
||||
});
|
||||
},
|
||||
|
||||
clearNotifications: () => {
|
||||
dispatch({ type: 'ui/clearNotifications' });
|
||||
},
|
||||
}), [dispatch]);
|
||||
};
|
||||
|
||||
// Custom hook for dark mode
|
||||
export const useDarkMode = () => {
|
||||
const darkMode = useAppSelector((state) => state.ui.darkMode);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return [
|
||||
darkMode,
|
||||
() => dispatch({ type: 'ui/toggleDarkMode' }),
|
||||
(value) => dispatch({ type: 'ui/setDarkMode', payload: value }),
|
||||
];
|
||||
};
|
||||
|
||||
// Custom hook for auth state
|
||||
export const useAuth = () => {
|
||||
const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated);
|
||||
const isAdmin = useAppSelector((state) => state.auth.isAdmin);
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const apiKey = useAppSelector((state) => state.auth.apiKey);
|
||||
const loading = useAppSelector((state) => state.auth.loading);
|
||||
const error = useAppSelector((state) => state.auth.error);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
user,
|
||||
apiKey,
|
||||
loading,
|
||||
error,
|
||||
login: (user, apiKey, isAdmin) =>
|
||||
dispatch({
|
||||
type: 'auth/loginSuccess',
|
||||
payload: { user, apiKey, isAdmin }
|
||||
}),
|
||||
logout: () => dispatch({ type: 'auth/logout' }),
|
||||
clearError: () => dispatch({ type: 'auth/clearError' }),
|
||||
};
|
||||
};
|
||||
|
||||
// Custom hook for cart state
|
||||
export const useCart = () => {
|
||||
const items = useAppSelector((state) => state.cart.items);
|
||||
const itemCount = useAppSelector((state) => state.cart.itemCount);
|
||||
const total = useAppSelector((state) => state.cart.total);
|
||||
const loading = useAppSelector((state) => state.cart.loading);
|
||||
const error = useAppSelector((state) => state.cart.error);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return {
|
||||
items,
|
||||
itemCount,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
updateCart: (cartData) =>
|
||||
dispatch({ type: 'cart/updateCart', payload: cartData }),
|
||||
clearCart: () => dispatch({ type: 'cart/clearCart' }),
|
||||
clearError: () => dispatch({ type: 'cart/clearCartError' }),
|
||||
};
|
||||
};
|
||||
214
frontend/src/layouts/AdminLayout.jsx
Normal file
214
frontend/src/layouts/AdminLayout.jsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { Box, Drawer, AppBar, Toolbar, List, Typography, Divider,
|
||||
IconButton, ListItem, ListItemIcon, ListItemText, Container,
|
||||
useMediaQuery, CssBaseline } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import CategoryIcon from '@mui/icons-material/Category';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import Brightness4Icon from '@mui/icons-material/Brightness4';
|
||||
import Brightness7Icon from '@mui/icons-material/Brightness7';
|
||||
import ClassIcon from '@mui/icons-material/Class';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useAuth, useDarkMode } from '../hooks/reduxHooks';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
const AdminLayout = () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, isAdmin, logout } = useAuth();
|
||||
const [darkMode, toggleDarkMode] = useDarkMode();
|
||||
|
||||
// Force drawer closed on mobile
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// Redirect if not admin
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !isAdmin) {
|
||||
navigate('/auth/login');
|
||||
}
|
||||
}, [isAuthenticated, isAdmin, navigate]);
|
||||
|
||||
const handleDrawerOpen = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const mainListItems = [
|
||||
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/admin' },
|
||||
{ text: 'Products', icon: <CategoryIcon />, path: '/admin/products' },
|
||||
{ text: 'Add Product', icon: <AddCircleIcon />, path: '/admin/products/new' },
|
||||
{ text: 'Categories', icon: <ClassIcon />, path: '/admin/categories' },
|
||||
{ text: 'Orders', icon: <ShoppingCartIcon />, path: '/admin/orders' },
|
||||
{ text: 'Customers', icon: <PeopleIcon />, path: '/admin/customers' },
|
||||
{ text: 'Reports', icon: <BarChartIcon />, path: '/admin/reports' },
|
||||
];
|
||||
|
||||
const secondaryListItems = [
|
||||
{ text: 'Visit Site', icon: <HomeIcon />, path: '/' },
|
||||
{ text: darkMode ? 'Light Mode' : 'Dark Mode', icon: darkMode ? <Brightness7Icon /> : <Brightness4Icon />, onClick: toggleDarkMode },
|
||||
{ text: 'Logout', icon: <LogoutIcon />, onClick: handleLogout },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
transition: (theme) => theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
...(open && {
|
||||
marginLeft: drawerWidth,
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
transition: (theme) => theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={handleDrawerOpen}
|
||||
sx={{
|
||||
marginRight: '36px',
|
||||
...(open && { display: 'none' }),
|
||||
}}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography
|
||||
component="h1"
|
||||
variant="h6"
|
||||
color="inherit"
|
||||
noWrap
|
||||
sx={{ flexGrow: 1 }}
|
||||
>
|
||||
Admin Dashboard
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant={isMobile ? 'temporary' : 'permanent'}
|
||||
open={open}
|
||||
onClose={isMobile ? handleDrawerClose : undefined}
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap',
|
||||
width: drawerWidth,
|
||||
transition: (theme) => theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
boxSizing: 'border-box',
|
||||
...(!open && {
|
||||
overflowX: 'hidden',
|
||||
transition: (theme) => theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
width: theme.spacing(7),
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: theme.spacing(9),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
px: [1],
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={handleDrawerClose}>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{mainListItems.map((item) => (
|
||||
<ListItem
|
||||
button
|
||||
key={item.text}
|
||||
component={RouterLink}
|
||||
to={item.path}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{secondaryListItems.map((item) => (
|
||||
<ListItem
|
||||
button
|
||||
key={item.text}
|
||||
component={item.path ? RouterLink : 'button'}
|
||||
to={item.path}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'light'
|
||||
? theme.palette.grey[100]
|
||||
: theme.palette.grey[900],
|
||||
flexGrow: 1,
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
69
frontend/src/layouts/AuthLayout.jsx
Normal file
69
frontend/src/layouts/AuthLayout.jsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Box, Container, Paper, Typography, Button } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
const AuthLayout = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '100vh',
|
||||
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'background.default' : 'grey.100',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm" sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1, py: 4 }}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
sx={{ alignSelf: 'flex-start', mb: 2 }}
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
p: 4,
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<Typography component="h1" variant="h4" gutterBottom>
|
||||
Rocks, Bones & Sticks
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ width: '100%', mt: 2 }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 2,
|
||||
mt: 'auto',
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'light'
|
||||
? theme.palette.grey[200]
|
||||
: theme.palette.grey[800],
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm" sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
© {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
|
||||
</Typography>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
219
frontend/src/layouts/MainLayout.jsx
Normal file
219
frontend/src/layouts/MainLayout.jsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Box, Container, AppBar, Toolbar, Typography, Button,
|
||||
IconButton, Drawer, List, ListItem, ListItemIcon, ListItemText,
|
||||
Divider, Badge, useMediaQuery } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import CategoryIcon from '@mui/icons-material/Category';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import LoginIcon from '@mui/icons-material/Login';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import Brightness4Icon from '@mui/icons-material/Brightness4';
|
||||
import Brightness7Icon from '@mui/icons-material/Brightness7';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth, useCart, useDarkMode } from '../hooks/reduxHooks';
|
||||
import Footer from '../components/Footer';
|
||||
|
||||
const MainLayout = () => {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { isAuthenticated, isAdmin, logout } = useAuth();
|
||||
const { itemCount } = useCart();
|
||||
const [darkMode, toggleDarkMode] = useDarkMode();
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setDrawerOpen(!drawerOpen);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const mainMenu = [
|
||||
{ text: 'Home', icon: <HomeIcon />, path: '/' },
|
||||
{ text: 'Products', icon: <CategoryIcon />, path: '/products' },
|
||||
{ text: 'Cart', icon: <ShoppingCartIcon />, path: '/cart', badge: itemCount > 0 ? itemCount : null },
|
||||
];
|
||||
|
||||
const authMenu = isAuthenticated ?
|
||||
[
|
||||
{ text: 'Logout', icon: <LogoutIcon />, onClick: handleLogout }
|
||||
] : [
|
||||
{ text: 'Login', icon: <LoginIcon />, path: '/auth/login' },
|
||||
{ text: 'Register', icon: <PersonIcon />, path: '/auth/register' }
|
||||
];
|
||||
|
||||
// Add admin menu if user is admin
|
||||
if (isAuthenticated && isAdmin) {
|
||||
mainMenu.push(
|
||||
{ text: 'Admin Dashboard', icon: <DashboardIcon />, path: '/admin' }
|
||||
);
|
||||
}
|
||||
|
||||
const drawer = (
|
||||
<Box sx={{ width: 250 }} role="presentation" onClick={handleDrawerToggle}>
|
||||
<Box sx={{ display: 'flex', p: 2, alignItems: 'center' }}>
|
||||
<Typography variant="h6" component="div">
|
||||
Rocks, Bones & Sticks
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List>
|
||||
{mainMenu.map((item) => (
|
||||
<ListItem
|
||||
button
|
||||
key={item.text}
|
||||
component={item.path ? RouterLink : 'button'}
|
||||
to={item.path}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{item.badge ? (
|
||||
<Badge badgeContent={item.badge} color="primary">
|
||||
{item.icon}
|
||||
</Badge>
|
||||
) : (
|
||||
item.icon
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItem button onClick={toggleDarkMode}>
|
||||
<ListItemIcon>
|
||||
{darkMode ? <Brightness7Icon /> : <Brightness4Icon />}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={darkMode ? "Light Mode" : "Dark Mode"} />
|
||||
</ListItem>
|
||||
{authMenu.map((item) => (
|
||||
<ListItem
|
||||
button
|
||||
key={item.text}
|
||||
component={item.path ? RouterLink : 'button'}
|
||||
to={item.path}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<AppBar position="sticky" color="primary">
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2, display: { sm: 'flex' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
component={RouterLink}
|
||||
to="/"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
display: { xs: 'none', sm: 'block' }
|
||||
}}
|
||||
>
|
||||
Rocks, Bones & Sticks
|
||||
</Typography>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
{!isMobile && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
{mainMenu.map((item) => (
|
||||
<Button
|
||||
key={item.text}
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to={item.path}
|
||||
sx={{ ml: 1 }}
|
||||
startIcon={item.badge ? (
|
||||
<Badge badgeContent={item.badge} color="secondary">
|
||||
{item.icon}
|
||||
</Badge>
|
||||
) : item.icon}
|
||||
>
|
||||
{item.text}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<IconButton
|
||||
sx={{ ml: 1 }}
|
||||
onClick={toggleDarkMode}
|
||||
color="inherit"
|
||||
>
|
||||
{darkMode ? <Brightness7Icon /> : <Brightness4Icon />}
|
||||
</IconButton>
|
||||
|
||||
{authMenu.map((item) => (
|
||||
item.path ? (
|
||||
<Button
|
||||
key={item.text}
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to={item.path}
|
||||
sx={{ ml: 1 }}
|
||||
startIcon={item.icon}
|
||||
>
|
||||
{item.text}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key={item.text}
|
||||
color="inherit"
|
||||
onClick={item.onClick}
|
||||
sx={{ ml: 1 }}
|
||||
startIcon={item.icon}
|
||||
>
|
||||
{item.text}
|
||||
</Button>
|
||||
)
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
anchor="left"
|
||||
open={drawerOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
|
||||
<Box component="main" sx={{ flexGrow: 1 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 3, pb: 6 }}>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Footer />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
42
frontend/src/main.jsx
Normal file
42
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
import App from './App';
|
||||
import { store } from './store';
|
||||
import ThemeProvider from './theme/ThemeProvider';
|
||||
|
||||
// Import Roboto font
|
||||
import '@fontsource/roboto/300.css';
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
|
||||
// Create a client for React Query
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
429
frontend/src/pages/Admin/CategoriesPage.jsx
Normal file
429
frontend/src/pages/Admin/CategoriesPage.jsx
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Tooltip,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Add as AddIcon,
|
||||
Image as ImageIcon,
|
||||
ImageNotSupported as NoImageIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useAdminCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from '../../hooks/categoryAdminHooks';
|
||||
import ImageUploader from '../../components/ImageUploader';
|
||||
import { useAuth } from '../../hooks/reduxHooks';
|
||||
import imageUtils from '../../utils/imageUtils';
|
||||
|
||||
const AdminCategoriesPage = () => {
|
||||
const { apiKey } = useAuth();
|
||||
const [categoryToEdit, setCategoryToEdit] = useState(null);
|
||||
const [categoryToDelete, setCategoryToDelete] = useState(null);
|
||||
const [newCategory, setNewCategory] = useState({ name: '', description: '', imagePath: '' });
|
||||
const [categoryImages, setCategoryImages] = useState([]);
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||
const [showImageUpload, setShowImageUpload] = useState(false);
|
||||
|
||||
// React Query hooks
|
||||
const {
|
||||
data: categories,
|
||||
isLoading,
|
||||
error
|
||||
} = useAdminCategories();
|
||||
|
||||
const createCategory = useCreateCategory();
|
||||
const updateCategory = useUpdateCategory();
|
||||
const deleteCategory = useDeleteCategory();
|
||||
|
||||
// Handle new category dialog
|
||||
const handleOpenNewDialog = () => {
|
||||
setCategoryToEdit(null);
|
||||
setNewCategory({ name: '', description: '', imagePath: '' });
|
||||
setCategoryImages([]);
|
||||
setShowImageUpload(false);
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
// Handle edit category dialog
|
||||
const handleOpenEditDialog = (category) => {
|
||||
setCategoryToEdit(category);
|
||||
setNewCategory({
|
||||
name: category.name,
|
||||
description: category.description || '',
|
||||
imagePath: category.image_path || ''
|
||||
});
|
||||
|
||||
// Set up category images for the uploader
|
||||
const images = [];
|
||||
if (category.image_path) {
|
||||
images.push({
|
||||
path: category.image_path,
|
||||
isPrimary: true,
|
||||
displayOrder: 0
|
||||
});
|
||||
}
|
||||
setCategoryImages(images);
|
||||
setShowImageUpload(false);
|
||||
setOpenDialog(true);
|
||||
};
|
||||
|
||||
// Handle images change
|
||||
const handleImagesChange = (newImages) => {
|
||||
setCategoryImages(newImages);
|
||||
// Update the imagePath in the form data
|
||||
if (newImages.length > 0) {
|
||||
setNewCategory(prev => ({
|
||||
...prev,
|
||||
imagePath: newImages[0].path
|
||||
}));
|
||||
} else {
|
||||
setNewCategory(prev => ({
|
||||
...prev,
|
||||
imagePath: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle close dialog
|
||||
const handleCloseDialog = () => {
|
||||
setOpenDialog(false);
|
||||
setCategoryToEdit(null);
|
||||
setNewCategory({ name: '', description: '', imagePath: '' });
|
||||
setCategoryImages([]);
|
||||
setShowImageUpload(false);
|
||||
};
|
||||
|
||||
// Handle save category (create or update)
|
||||
const handleSaveCategory = async () => {
|
||||
try {
|
||||
if (categoryToEdit) {
|
||||
// Update existing category
|
||||
await updateCategory.mutateAsync({
|
||||
id: categoryToEdit.id,
|
||||
categoryData: newCategory
|
||||
});
|
||||
} else {
|
||||
// Create new category
|
||||
await createCategory.mutateAsync(newCategory);
|
||||
}
|
||||
handleCloseDialog();
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation
|
||||
console.error('Error saving category:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete click
|
||||
const handleDeleteClick = (category) => {
|
||||
setCategoryToDelete(category);
|
||||
setOpenDeleteDialog(true);
|
||||
};
|
||||
|
||||
// Confirm delete
|
||||
const handleConfirmDelete = async () => {
|
||||
if (categoryToDelete) {
|
||||
try {
|
||||
await deleteCategory.mutateAsync(categoryToDelete.id);
|
||||
setOpenDeleteDialog(false);
|
||||
setCategoryToDelete(null);
|
||||
} catch (error) {
|
||||
// Error is handled by the mutation
|
||||
console.error('Error deleting category:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel delete
|
||||
const handleCancelDelete = () => {
|
||||
setOpenDeleteDialog(false);
|
||||
setCategoryToDelete(null);
|
||||
};
|
||||
|
||||
// Handle form changes
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewCategory(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading categories: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Categories
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleOpenNewDialog}
|
||||
>
|
||||
Add Category
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Categories Table */}
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="categories table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Image</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{categories && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<TableRow key={category.id}>
|
||||
<TableCell sx={{ width: 80 }}>
|
||||
{category.image_path ? (
|
||||
<Avatar
|
||||
src={imageUtils.getImageUrl(category.image_path)}
|
||||
alt={category.name}
|
||||
variant="rounded"
|
||||
sx={{ width: 60, height: 60 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
sx={{ width: 60, height: 60, bgcolor: 'grey.300' }}
|
||||
>
|
||||
<NoImageIcon color="action" />
|
||||
</Avatar>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body1" fontWeight="medium">
|
||||
{category.name}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{category.description || 'No description'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => handleOpenEditDialog(category)}
|
||||
aria-label="edit"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(category)}
|
||||
aria-label="delete"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">
|
||||
<Typography variant="body1" py={3}>
|
||||
No categories found. Add a category to get started.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Add/Edit Category Dialog */}
|
||||
<Dialog
|
||||
open={openDialog}
|
||||
onClose={handleCloseDialog}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{categoryToEdit ? 'Edit Category' : 'Add New Category'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1, mb: 2 }}>
|
||||
{/* Image preview or placeholder */}
|
||||
{showImageUpload ? (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Category Image
|
||||
</Typography>
|
||||
<ImageUploader
|
||||
images={categoryImages}
|
||||
onChange={handleImagesChange}
|
||||
multiple={false}
|
||||
admin={true}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
onClick={() => setShowImageUpload(false)}
|
||||
>
|
||||
Hide Image Upload
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
{categoryImages.length > 0 ? (
|
||||
<Avatar
|
||||
src={imageUtils.getImageUrl(categoryImages[0].path)}
|
||||
alt="Category image"
|
||||
variant="rounded"
|
||||
sx={{ width: 80, height: 80, mr: 2 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
sx={{ width: 80, height: 80, mr: 2, bgcolor: 'grey.200' }}
|
||||
>
|
||||
<NoImageIcon color="action" fontSize="large" />
|
||||
</Avatar>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ImageIcon />}
|
||||
onClick={() => setShowImageUpload(true)}
|
||||
>
|
||||
{categoryImages.length > 0 ? 'Change Image' : 'Add Image'}
|
||||
</Button>
|
||||
{categoryImages.length > 0 && (
|
||||
<Button
|
||||
variant="text"
|
||||
color="error"
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => handleImagesChange([])}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
name="name"
|
||||
label="Category Name"
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newCategory.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
name="description"
|
||||
label="Description"
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={newCategory.description}
|
||||
onChange={handleChange}
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSaveCategory}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!newCategory.name ||
|
||||
createCategory.isLoading ||
|
||||
updateCategory.isLoading}
|
||||
>
|
||||
{(createCategory.isLoading || updateCategory.isLoading) ? (
|
||||
<CircularProgress size={24} />
|
||||
) : categoryToEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={openDeleteDialog}
|
||||
onClose={handleCancelDelete}
|
||||
>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete the category "{categoryToDelete?.name}"?
|
||||
{categoryToDelete && <strong> This action cannot be undone.</strong>}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelDelete} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleteCategory.isLoading}
|
||||
>
|
||||
{deleteCategory.isLoading ? <CircularProgress size={24} /> : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminCategoriesPage;
|
||||
294
frontend/src/pages/Admin/DashboardPage.jsx
Normal file
294
frontend/src/pages/Admin/DashboardPage.jsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Paper,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
People as PeopleIcon,
|
||||
ShoppingCart as ShoppingCartIcon,
|
||||
Store as StoreIcon,
|
||||
AttachMoney as MoneyIcon,
|
||||
Class as CategoryIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import apiClient from '../../services/api';
|
||||
import { useAdminCategories } from '../../hooks/categoryAdminHooks';
|
||||
|
||||
const AdminDashboardPage = () => {
|
||||
// Mock data - would be replaced with real API calls
|
||||
const [stats, setStats] = useState({
|
||||
totalProducts: 0,
|
||||
totalUsers: 0,
|
||||
totalOrders: 0,
|
||||
revenue: 0,
|
||||
totalCategories: 0
|
||||
});
|
||||
|
||||
// Fetch products for the stats
|
||||
const { data: products, isLoading: productsLoading, error: productsError } = useQuery({
|
||||
queryKey: ['admin-products'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/products');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch products');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories, isLoading: categoriesLoading } = useAdminCategories();
|
||||
|
||||
// Mock recent orders - would be replaced with real API data
|
||||
const recentOrders = [
|
||||
{ id: '4532', customer: 'John Doe', date: '2023-04-22', total: 49.99, status: 'Delivered' },
|
||||
{ id: '4531', customer: 'Jane Smith', date: '2023-04-21', total: 89.95, status: 'Processing' },
|
||||
{ id: '4530', customer: 'Bob Johnson', date: '2023-04-20', total: 24.99, status: 'Shipped' },
|
||||
{ id: '4529', customer: 'Alice Brown', date: '2023-04-19', total: 129.99, status: 'Delivered' }
|
||||
];
|
||||
|
||||
// Update stats when products and categories are loaded
|
||||
useEffect(() => {
|
||||
if (products) {
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalProducts: products.length,
|
||||
// Other stats would be updated from their respective API calls
|
||||
totalUsers: 15, // Mock data
|
||||
totalOrders: 42, // Mock data
|
||||
revenue: 2459.99 // Mock data
|
||||
}));
|
||||
}
|
||||
|
||||
if (categories) {
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalCategories: categories.length
|
||||
}));
|
||||
}
|
||||
}, [products, categories]);
|
||||
|
||||
// Placeholder for when we add actual API calls
|
||||
const isLoading = productsLoading || categoriesLoading;
|
||||
const error = productsError;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading dashboard data: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Admin Dashboard
|
||||
</Typography>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'primary.light',
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
>
|
||||
<StoreIcon sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography component="h2" variant="h5">
|
||||
{stats.totalProducts}
|
||||
</Typography>
|
||||
<Typography variant="body1">Products</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'info.light',
|
||||
color: 'info.contrastText',
|
||||
}}
|
||||
>
|
||||
<CategoryIcon sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography component="h2" variant="h5">
|
||||
{stats.totalCategories}
|
||||
</Typography>
|
||||
<Typography variant="body1">Categories</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'secondary.light',
|
||||
color: 'secondary.contrastText',
|
||||
}}
|
||||
>
|
||||
<PeopleIcon sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography component="h2" variant="h5">
|
||||
{stats.totalUsers}
|
||||
</Typography>
|
||||
<Typography variant="body1">Users</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'success.light',
|
||||
color: 'success.contrastText',
|
||||
}}
|
||||
>
|
||||
<MoneyIcon sx={{ fontSize: 40, mb: 1 }} />
|
||||
<Typography component="h2" variant="h5">
|
||||
${stats.revenue.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography variant="body1">Revenue</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Recent Activity Section */}
|
||||
<Grid container spacing={3}>
|
||||
{/* Recent Orders */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Recent Orders"
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
component={RouterLink}
|
||||
to="/admin/orders"
|
||||
>
|
||||
View All
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
<List>
|
||||
{recentOrders.map((order, index) => (
|
||||
<React.Fragment key={order.id}>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={`Order #${order.id} - ${order.customer}`}
|
||||
secondary={`Date: ${order.date} | Total: $${order.total} | Status: ${order.status}`}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < recentOrders.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Recent Products */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Recent Products"
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
component={RouterLink}
|
||||
to="/admin/products"
|
||||
>
|
||||
View All
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent sx={{ pt: 0 }}>
|
||||
<List>
|
||||
{products && products.slice(0, 4).map((product, index) => (
|
||||
<React.Fragment key={product.id}>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary={product.name}
|
||||
secondary={`Category: ${product.category_name} | Price: $${parseFloat(product.price).toFixed(2)} | Stock: ${product.stock_quantity}`}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < 3 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Paper sx={{ p: 3, mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Quick Actions
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
component={RouterLink}
|
||||
to="/admin/products/new"
|
||||
>
|
||||
Add New Product
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
component={RouterLink}
|
||||
to="/admin/categories"
|
||||
>
|
||||
Manage Categories
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={RouterLink}
|
||||
to="/admin/products"
|
||||
>
|
||||
Manage Products
|
||||
</Button>
|
||||
<Button variant="outlined">
|
||||
View Orders
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardPage;
|
||||
614
frontend/src/pages/Admin/ProductEditPage.jsx
Normal file
614
frontend/src/pages/Admin/ProductEditPage.jsx
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputAdornment,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
Divider,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Snackbar,
|
||||
Autocomplete
|
||||
} from '@mui/material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import ImageUploader from '@components/ImageUploader';
|
||||
import apiClient from '@services/api';
|
||||
import { useAuth } from '@hooks/reduxHooks';
|
||||
|
||||
const ProductEditPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { apiKey } = useAuth();
|
||||
const isNewProduct = id === 'new';
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
categoryName: '',
|
||||
price: '',
|
||||
stockQuantity: '',
|
||||
weightGrams: '',
|
||||
lengthCm: '',
|
||||
widthCm: '',
|
||||
heightCm: '',
|
||||
origin: '',
|
||||
age: '',
|
||||
materialType: '',
|
||||
color: '',
|
||||
tags: [],
|
||||
images: []
|
||||
});
|
||||
|
||||
// Validation state
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// Notification state
|
||||
const [notification, setNotification] = useState({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories, isLoading: categoriesLoading } = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/products/categories/all');
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch all available tags
|
||||
const { data: allTags, isLoading: tagsLoading } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/products/tags/all');
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch product data if editing
|
||||
const {
|
||||
data: product,
|
||||
isLoading: productLoading,
|
||||
error: productError
|
||||
} = useQuery({
|
||||
queryKey: ['product', id],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get(`/products/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !isNewProduct
|
||||
});
|
||||
|
||||
// Create product mutation
|
||||
const createProduct = useMutation({
|
||||
mutationFn: async (productData) => {
|
||||
return await apiClient.post('/admin/products', productData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Product created successfully!',
|
||||
severity: 'success'
|
||||
});
|
||||
// Redirect after a short delay
|
||||
setTimeout(() => {
|
||||
navigate('/admin/products');
|
||||
}, 1500);
|
||||
},
|
||||
onError: (error) => {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `Failed to create product: ${error.message}`,
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update product mutation
|
||||
const updateProduct = useMutation({
|
||||
mutationFn: async ({ id, productData }) => {
|
||||
return await apiClient.put(`/admin/products/${id}`, productData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['product', id] });
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Product updated successfully!',
|
||||
severity: 'success'
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `Failed to update product: ${error.message}`,
|
||||
severity: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle form changes
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Clear validation error when field is edited
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle tags change
|
||||
const handleTagsChange = (event, newTags) => {
|
||||
setFormData(prev => ({ ...prev, tags: newTags }));
|
||||
};
|
||||
|
||||
// Handle images change
|
||||
const handleImagesChange = (newImages) => {
|
||||
setFormData(prev => ({ ...prev, images: newImages }));
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
// Required fields
|
||||
if (!formData.name) newErrors.name = 'Name is required';
|
||||
if (!formData.description) newErrors.description = 'Description is required';
|
||||
if (!formData.categoryName) newErrors.categoryName = 'Category is required';
|
||||
|
||||
// Price validation
|
||||
if (!formData.price) {
|
||||
newErrors.price = 'Price is required';
|
||||
} else if (isNaN(formData.price) || parseFloat(formData.price) <= 0) {
|
||||
newErrors.price = 'Price must be a positive number';
|
||||
}
|
||||
|
||||
// Stock validation
|
||||
if (!formData.stockQuantity) {
|
||||
newErrors.stockQuantity = 'Stock quantity is required';
|
||||
} else if (isNaN(formData.stockQuantity) || parseInt(formData.stockQuantity) < 0) {
|
||||
newErrors.stockQuantity = 'Stock quantity must be a non-negative number';
|
||||
}
|
||||
|
||||
// Numeric field validations
|
||||
if (formData.weightGrams && (isNaN(formData.weightGrams) || parseFloat(formData.weightGrams) <= 0)) {
|
||||
newErrors.weightGrams = 'Weight must be a positive number';
|
||||
}
|
||||
|
||||
if (formData.lengthCm && (isNaN(formData.lengthCm) || parseFloat(formData.lengthCm) <= 0)) {
|
||||
newErrors.lengthCm = 'Length must be a positive number';
|
||||
}
|
||||
|
||||
if (formData.widthCm && (isNaN(formData.widthCm) || parseFloat(formData.widthCm) <= 0)) {
|
||||
newErrors.widthCm = 'Width must be a positive number';
|
||||
}
|
||||
|
||||
if (formData.heightCm && (isNaN(formData.heightCm) || parseFloat(formData.heightCm) <= 0)) {
|
||||
newErrors.heightCm = 'Height must be a positive number';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Please fix the form errors before submitting',
|
||||
severity: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Format data for API
|
||||
const productData = {
|
||||
...formData,
|
||||
price: parseFloat(formData.price),
|
||||
stockQuantity: parseInt(formData.stockQuantity),
|
||||
weightGrams: formData.weightGrams ? parseFloat(formData.weightGrams) : null,
|
||||
lengthCm: formData.lengthCm ? parseFloat(formData.lengthCm) : null,
|
||||
widthCm: formData.widthCm ? parseFloat(formData.widthCm) : null,
|
||||
heightCm: formData.heightCm ? parseFloat(formData.heightCm) : null,
|
||||
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
|
||||
};
|
||||
|
||||
if (isNewProduct) {
|
||||
createProduct.mutate(productData);
|
||||
} else {
|
||||
updateProduct.mutate({ id, productData });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle notification close
|
||||
const handleNotificationClose = () => {
|
||||
setNotification(prev => ({ ...prev, open: false }));
|
||||
};
|
||||
|
||||
// Load product data when available
|
||||
useEffect(() => {
|
||||
if (product && !isNewProduct) {
|
||||
setFormData({
|
||||
name: product.name || '',
|
||||
description: product.description || '',
|
||||
categoryName: product.category_name || '',
|
||||
price: product.price ? String(product.price) : '',
|
||||
stockQuantity: product.stock_quantity !== undefined ? String(product.stock_quantity) : '',
|
||||
weightGrams: product.weight_grams ? String(product.weight_grams) : '',
|
||||
lengthCm: product.length_cm ? String(product.length_cm) : '',
|
||||
widthCm: product.width_cm ? String(product.width_cm) : '',
|
||||
heightCm: product.height_cm ? String(product.height_cm) : '',
|
||||
origin: product.origin || '',
|
||||
age: product.age || '',
|
||||
materialType: product.material_type || '',
|
||||
color: product.color || '',
|
||||
tags: product.tags || [],
|
||||
images: product.images ? product.images.map(img => ({
|
||||
path: img.path || img,
|
||||
isPrimary: img.isPrimary || img.is_primary,
|
||||
displayOrder: img.displayOrder || img.display_order || 0
|
||||
})) : []
|
||||
});
|
||||
}
|
||||
}, [product, isNewProduct]);
|
||||
|
||||
// Format tag objects for Autocomplete component
|
||||
const formatTags = () => {
|
||||
if (!allTags) return [];
|
||||
return allTags.map(tag => typeof tag === 'string' ? tag : tag.name);
|
||||
};
|
||||
|
||||
const isLoading = categoriesLoading || tagsLoading || (productLoading && !isNewProduct);
|
||||
const isSaving = createProduct.isLoading || updateProduct.isLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (productError && !isNewProduct) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading product: {productError.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/admin/products')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Back to Products
|
||||
</Button>
|
||||
<Typography variant="h4" component="h1">
|
||||
{isNewProduct ? 'Add New Product' : 'Edit Product'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Paper component="form" onSubmit={handleSubmit} sx={{ p: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
{/* Basic Information */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Basic Information
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="Product Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
error={!!errors.name}
|
||||
helperText={errors.name}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth required error={!!errors.categoryName}>
|
||||
<InputLabel id="category-label">Category</InputLabel>
|
||||
<Select
|
||||
labelId="category-label"
|
||||
name="categoryName"
|
||||
value={formData.categoryName}
|
||||
onChange={handleChange}
|
||||
label="Category"
|
||||
>
|
||||
{categories?.map((category) => (
|
||||
<MenuItem key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{errors.categoryName && (
|
||||
<FormHelperText>{errors.categoryName}</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
multiline
|
||||
rows={4}
|
||||
label="Description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
error={!!errors.description}
|
||||
helperText={errors.description}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="Price"
|
||||
name="price"
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
error={!!errors.price}
|
||||
helperText={errors.price}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start">$</InputAdornment>,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="Stock Quantity"
|
||||
name="stockQuantity"
|
||||
type="number"
|
||||
value={formData.stockQuantity}
|
||||
onChange={handleChange}
|
||||
error={!!errors.stockQuantity}
|
||||
helperText={errors.stockQuantity}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
{/* Images */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Product Images
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Upload images for this product. The first image will be used as the primary image.
|
||||
</Typography>
|
||||
|
||||
<ImageUploader
|
||||
images={formData.images}
|
||||
onChange={handleImagesChange}
|
||||
multiple={true}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
{/* Additional Details */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Additional Details
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Material Type"
|
||||
name="materialType"
|
||||
value={formData.materialType}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Origin"
|
||||
name="origin"
|
||||
value={formData.origin}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Age"
|
||||
name="age"
|
||||
value={formData.age}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
{/* Dimensions */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Dimensions
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Weight (grams)"
|
||||
name="weightGrams"
|
||||
type="number"
|
||||
value={formData.weightGrams}
|
||||
onChange={handleChange}
|
||||
error={!!errors.weightGrams}
|
||||
helperText={errors.weightGrams}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Length (cm)"
|
||||
name="lengthCm"
|
||||
type="number"
|
||||
value={formData.lengthCm}
|
||||
onChange={handleChange}
|
||||
error={!!errors.lengthCm}
|
||||
helperText={errors.lengthCm}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Width (cm)"
|
||||
name="widthCm"
|
||||
type="number"
|
||||
value={formData.widthCm}
|
||||
onChange={handleChange}
|
||||
error={!!errors.widthCm}
|
||||
helperText={errors.widthCm}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Height (cm)"
|
||||
name="heightCm"
|
||||
type="number"
|
||||
value={formData.heightCm}
|
||||
onChange={handleChange}
|
||||
error={!!errors.heightCm}
|
||||
helperText={errors.heightCm}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
</Grid>
|
||||
|
||||
{/* Tags */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Tags
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Add tags to help customers find this product.
|
||||
</Typography>
|
||||
|
||||
<Autocomplete
|
||||
multiple
|
||||
freeSolo
|
||||
options={formatTags()}
|
||||
value={formData.tags}
|
||||
onChange={handleTagsChange}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => (
|
||||
<Chip
|
||||
label={typeof option === 'string' ? option : option.name}
|
||||
{...getTagProps({ index })}
|
||||
key={index}
|
||||
/>
|
||||
))
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Tags"
|
||||
placeholder="Add tags"
|
||||
helperText="Type and press enter to add new tags"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Grid item xs={12} sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
startIcon={isSaving ? <CircularProgress size={20} /> : <SaveIcon />}
|
||||
size="large"
|
||||
>
|
||||
{isSaving ? 'Saving...' : (isNewProduct ? 'Create Product' : 'Update Product')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Notification */}
|
||||
<Snackbar
|
||||
open={notification.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleNotificationClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleNotificationClose}
|
||||
severity={notification.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductEditPage;
|
||||
308
frontend/src/pages/Admin/ProductsPage.jsx
Normal file
308
frontend/src/pages/Admin/ProductsPage.jsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
IconButton,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Add as AddIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../../services/api';
|
||||
import ProductImage from '../../components/ProductImage';
|
||||
|
||||
const AdminProductsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState(null);
|
||||
|
||||
// Fetch products
|
||||
const {
|
||||
data: products,
|
||||
isLoading,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['admin-products', search],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const params = {};
|
||||
if (search) params.search = search;
|
||||
|
||||
const response = await apiClient.get('/products', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw new Error('Failed to fetch products');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Delete product mutation
|
||||
const deleteProduct = useMutation({
|
||||
mutationFn: async (productId) => {
|
||||
return await apiClient.delete(`/admin/products/${productId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-products'] });
|
||||
setDeleteDialogOpen(false);
|
||||
setProductToDelete(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle search change
|
||||
const handleSearchChange = (event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(0); // Reset page when searching
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setSearch('');
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// Handle rows per page change
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// Handle delete click
|
||||
const handleDeleteClick = (product) => {
|
||||
setProductToDelete(product);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// Confirm delete
|
||||
const handleConfirmDelete = () => {
|
||||
if (productToDelete) {
|
||||
deleteProduct.mutate(productToDelete.id);
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel delete
|
||||
const handleCancelDelete = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setProductToDelete(null);
|
||||
};
|
||||
|
||||
// Navigate to edit page
|
||||
const handleEditClick = (productId) => {
|
||||
navigate(`/admin/products/${productId}`);
|
||||
};
|
||||
|
||||
// Filter and paginate products
|
||||
const filteredProducts = products || [];
|
||||
const paginatedProducts = filteredProducts.slice(
|
||||
page * rowsPerPage,
|
||||
page * rowsPerPage + rowsPerPage
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading products: {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Products
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
component={RouterLink}
|
||||
to="/admin/products/new"
|
||||
>
|
||||
Add Product
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Search */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Search products by name or description..."
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: search && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={handleClearSearch} edge="end">
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Products Table */}
|
||||
<TableContainer component={Paper}>
|
||||
<Table sx={{ minWidth: 650 }} aria-label="products table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Image</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Price</TableCell>
|
||||
<TableCell>Stock</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paginatedProducts.length > 0 ? (
|
||||
paginatedProducts.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell sx={{ width: 80 }}>
|
||||
<ProductImage
|
||||
images={product.images}
|
||||
alt={product.name}
|
||||
sx={{ width: 60, height: 60, borderRadius: 1 }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body1" fontWeight="medium">
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" noWrap>
|
||||
{product.description && product.description.substring(0, 60)}
|
||||
{product.description && product.description.length > 60 ? '...' : ''}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{product.category_name}</TableCell>
|
||||
<TableCell>${parseFloat(product.price).toFixed(2)}</TableCell>
|
||||
<TableCell>{product.stock_quantity}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={product.stock_quantity > 0 ? 'In Stock' : 'Out of Stock'}
|
||||
color={product.stock_quantity > 0 ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={() => handleEditClick(product.id)}
|
||||
aria-label="edit"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="error"
|
||||
onClick={() => handleDeleteClick(product)}
|
||||
aria-label="delete"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center">
|
||||
<Typography variant="body1" py={3}>
|
||||
No products found. {search ? 'Try a different search term.' : 'Add a product to get started.'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePagination
|
||||
rowsPerPageOptions={[5, 10, 25]}
|
||||
component="div"
|
||||
count={filteredProducts.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
/>
|
||||
</TableContainer>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={handleCancelDelete}
|
||||
>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Are you sure you want to delete the product "{productToDelete?.name}"? This action cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelDelete} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleteProduct.isLoading}
|
||||
>
|
||||
{deleteProduct.isLoading ? <CircularProgress size={24} /> : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProductsPage;
|
||||
331
frontend/src/pages/CartPage.jsx
Normal file
331
frontend/src/pages/CartPage.jsx
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Grid,
|
||||
Divider,
|
||||
Card,
|
||||
CardMedia,
|
||||
IconButton,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Breadcrumbs,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@hooks/reduxHooks';
|
||||
import { useGetCart, useUpdateCartItem, useClearCart } from '.@hooks/apiHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const CartPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Get cart data
|
||||
const { data: cart, isLoading, error } = useGetCart(user?.id);
|
||||
|
||||
// Cart mutations
|
||||
const updateCartItem = useUpdateCartItem();
|
||||
const clearCart = useClearCart();
|
||||
|
||||
// Handle quantity change
|
||||
const handleUpdateQuantity = (productId, newQuantity) => {
|
||||
updateCartItem.mutate({
|
||||
userId: user.id,
|
||||
productId,
|
||||
quantity: newQuantity
|
||||
});
|
||||
};
|
||||
|
||||
// Handle remove item
|
||||
const handleRemoveItem = (productId) => {
|
||||
updateCartItem.mutate({
|
||||
userId: user.id,
|
||||
productId,
|
||||
quantity: 0
|
||||
});
|
||||
};
|
||||
|
||||
// Handle clear cart
|
||||
const handleClearCart = () => {
|
||||
clearCart.mutate(user.id);
|
||||
};
|
||||
|
||||
// Handle proceed to checkout
|
||||
const handleCheckout = () => {
|
||||
navigate('/checkout');
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
Error loading your cart. Please try again.
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={RouterLink}
|
||||
to="/products"
|
||||
>
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty cart state
|
||||
if (!cart || !cart.items || cart.items.length === 0) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 6 }}>
|
||||
<ShoppingCartIcon sx={{ fontSize: 60, color: 'text.secondary', mb: 2 }} />
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Your Cart is Empty
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
Looks like you haven't added any items to your cart yet.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={RouterLink}
|
||||
to="/products"
|
||||
size="large"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Start Shopping
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Breadcrumbs navigation */}
|
||||
<Breadcrumbs
|
||||
separator={<NavigateNextIcon fontSize="small" />}
|
||||
aria-label="breadcrumb"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Link component={RouterLink} to="/" color="inherit">
|
||||
Home
|
||||
</Link>
|
||||
<Typography color="text.primary">Your Cart</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Your Shopping Cart
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Cart items */}
|
||||
<Grid item xs={12} lg={8}>
|
||||
<Paper variant="outlined" sx={{ mb: { xs: 3, lg: 0 } }}>
|
||||
<Box sx={{ p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6">
|
||||
Cart Items ({cart.itemCount})
|
||||
</Typography>
|
||||
<Button
|
||||
variant="text"
|
||||
color="error"
|
||||
onClick={handleClearCart}
|
||||
disabled={clearCart.isLoading}
|
||||
startIcon={<DeleteIcon />}
|
||||
>
|
||||
Clear Cart
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{cart.items.map((item) => (
|
||||
<React.Fragment key={item.product_id}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
{/* Product image */}
|
||||
<Grid item xs={3} sm={2}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={imageUtils.getImageUrl(item.image_url || '/images/placeholder.jpg')}
|
||||
alt={item.name}
|
||||
sx={{ height: 80, objectFit: 'cover' }}
|
||||
/>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Product details */}
|
||||
<Grid item xs={9} sm={6}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component={RouterLink}
|
||||
to={`/products/${item.product_id}`}
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
display: 'block',
|
||||
mb: 0.5
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Category: {item.category_name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Price: ${parseFloat(item.price).toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{/* Quantity controls */}
|
||||
<Grid item xs={7} sm={2}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleUpdateQuantity(item.product_id, item.quantity - 1)}
|
||||
disabled={item.quantity <= 1 || updateCartItem.isLoading}
|
||||
>
|
||||
<RemoveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<TextField
|
||||
value={item.quantity}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value > 0) {
|
||||
handleUpdateQuantity(item.product_id, value);
|
||||
}
|
||||
}}
|
||||
inputProps={{ min: 1, style: { textAlign: 'center' } }}
|
||||
size="small"
|
||||
sx={{ width: 40, mx: 1 }}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleUpdateQuantity(item.product_id, item.quantity + 1)}
|
||||
disabled={updateCartItem.isLoading}
|
||||
>
|
||||
<AddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Subtotal and remove */}
|
||||
<Grid item xs={5} sm={2} sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="subtitle1">
|
||||
${(parseFloat(item.price) * item.quantity).toFixed(2)}
|
||||
</Typography>
|
||||
|
||||
<IconButton
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={() => handleRemoveItem(item.product_id)}
|
||||
disabled={updateCartItem.isLoading}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<Box sx={{ p: 2, textAlign: 'right' }}>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/products"
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Order summary */}
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Order Summary
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ my: 2 }}>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={8}>
|
||||
<Typography variant="body1">
|
||||
Subtotal ({cart.itemCount} items)
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4} sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="body1">
|
||||
${cart.total.toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={8}>
|
||||
<Typography variant="body1">
|
||||
Shipping
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4} sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="body1">
|
||||
Free
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Grid container spacing={1} sx={{ mb: 2 }}>
|
||||
<Grid item xs={8}>
|
||||
<Typography variant="h6">
|
||||
Total
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4} sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="h6">
|
||||
${cart.total.toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
size="large"
|
||||
onClick={handleCheckout}
|
||||
disabled={cart.items.length === 0}
|
||||
>
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartPage;
|
||||
441
frontend/src/pages/CheckoutPage.jsx
Normal file
441
frontend/src/pages/CheckoutPage.jsx
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Button,
|
||||
Divider,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||
import { useAuth, useCart } from '../hooks/reduxHooks';
|
||||
import { useCheckout } from '../hooks/apiHooks';
|
||||
|
||||
// Checkout steps
|
||||
const steps = ['Shipping Address', 'Review Order', 'Payment', 'Confirmation'];
|
||||
|
||||
const CheckoutPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { items, total, itemCount } = useCart();
|
||||
const checkout = useCheckout();
|
||||
|
||||
// State for checkout steps
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
// State for form data
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: user?.first_name || '',
|
||||
lastName: user?.last_name || '',
|
||||
email: user?.email || '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zipCode: '',
|
||||
country: '',
|
||||
saveAddress: false,
|
||||
});
|
||||
|
||||
// Handle form changes
|
||||
const handleChange = (e) => {
|
||||
const { name, value, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: name === 'saveAddress' ? checked : value,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle next step
|
||||
const handleNext = () => {
|
||||
if (activeStep === steps.length - 1) {
|
||||
// Complete checkout - placeholder
|
||||
return;
|
||||
}
|
||||
|
||||
// If on shipping address step, validate form
|
||||
if (activeStep === 0) {
|
||||
if (!validateShippingForm()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If on review step, process checkout
|
||||
if (activeStep === 1) {
|
||||
handlePlaceOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStep((prevStep) => prevStep + 1);
|
||||
};
|
||||
|
||||
// Handle back step
|
||||
const handleBack = () => {
|
||||
setActiveStep((prevStep) => prevStep - 1);
|
||||
};
|
||||
|
||||
// Validate shipping form
|
||||
const validateShippingForm = () => {
|
||||
const requiredFields = ['firstName', 'lastName', 'email', 'address', 'city', 'state', 'zipCode', 'country'];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!formData[field]) {
|
||||
// In a real app, you'd set specific errors for each field
|
||||
alert(`Please fill in all required fields`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(formData.email)) {
|
||||
alert('Please enter a valid email address');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Handle place order
|
||||
const handlePlaceOrder = () => {
|
||||
if (!user || !items || items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format shipping address for API
|
||||
const shippingAddress = `${formData.firstName} ${formData.lastName}
|
||||
${formData.address}
|
||||
${formData.city}, ${formData.state} ${formData.zipCode}
|
||||
${formData.country}
|
||||
${formData.email}`;
|
||||
|
||||
checkout.mutate({
|
||||
userId: user.id,
|
||||
shippingAddress
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
// Move to confirmation step
|
||||
setActiveStep(3);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// If no items in cart, redirect to cart page
|
||||
if (!items || items.length === 0) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 6 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Your cart is empty
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
You need to add items to your cart before checkout.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={RouterLink}
|
||||
to="/products"
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Browse Products
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={RouterLink}
|
||||
to="/cart"
|
||||
>
|
||||
View Cart
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render different step content based on active step
|
||||
const getStepContent = (step) => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<Box component="form" sx={{ mt: 3 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="firstName"
|
||||
label="First Name"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="lastName"
|
||||
label="Last Name"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Email Address"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="address"
|
||||
label="Address"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="city"
|
||||
label="City"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="state"
|
||||
label="State/Province"
|
||||
name="state"
|
||||
value={formData.state}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="zipCode"
|
||||
label="Zip / Postal code"
|
||||
name="zipCode"
|
||||
value={formData.zipCode}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="country"
|
||||
label="Country"
|
||||
name="country"
|
||||
value={formData.country}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="saveAddress"
|
||||
color="primary"
|
||||
checked={formData.saveAddress}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
}
|
||||
label="Save this address for future orders"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{/* Order summary */}
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Order Summary
|
||||
</Typography>
|
||||
|
||||
<List disablePadding>
|
||||
{items.map((item) => (
|
||||
<ListItem key={item.product_id} sx={{ py: 1, px: 0 }}>
|
||||
<ListItemText
|
||||
primary={item.name}
|
||||
secondary={`Quantity: ${item.quantity}`}
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
${(parseFloat(item.price) * item.quantity).toFixed(2)}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
))}
|
||||
|
||||
<ListItem sx={{ py: 1, px: 0 }}>
|
||||
<ListItemText primary="Shipping" />
|
||||
<Typography variant="body2">Free</Typography>
|
||||
</ListItem>
|
||||
|
||||
<ListItem sx={{ py: 1, px: 0 }}>
|
||||
<ListItemText primary="Total" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
${total.toFixed(2)}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Shipping address */}
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Shipping Address
|
||||
</Typography>
|
||||
|
||||
<Typography gutterBottom>
|
||||
{formData.firstName} {formData.lastName}
|
||||
</Typography>
|
||||
<Typography gutterBottom>
|
||||
{formData.address}
|
||||
</Typography>
|
||||
<Typography gutterBottom>
|
||||
{formData.city}, {formData.state} {formData.zipCode}
|
||||
</Typography>
|
||||
<Typography gutterBottom>
|
||||
{formData.country}
|
||||
</Typography>
|
||||
<Typography gutterBottom>
|
||||
{formData.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
case 2:
|
||||
// Placeholder for payment (in a real app, this would have a payment form)
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
This is a demo application. No actual payment will be processed.
|
||||
</Alert>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Payment Method
|
||||
</Typography>
|
||||
|
||||
<Typography paragraph>
|
||||
For this demo, we'll simulate a successful payment.
|
||||
</Typography>
|
||||
|
||||
<Typography paragraph>
|
||||
Total to pay: ${total.toFixed(2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||
<Alert severity="success" sx={{ mb: 3 }}>
|
||||
Your order has been placed successfully!
|
||||
</Alert>
|
||||
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Thank you for your order
|
||||
</Typography>
|
||||
|
||||
<Typography paragraph>
|
||||
Your order number is: #{Math.floor(100000 + Math.random() * 900000)}
|
||||
</Typography>
|
||||
|
||||
<Typography paragraph>
|
||||
We will send you a confirmation email with your order details.
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
component={RouterLink}
|
||||
to="/products"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return <Typography>Unknown step</Typography>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 8 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Checkout
|
||||
</Typography>
|
||||
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||
{getStepContent(activeStep)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
||||
{activeStep !== 0 && activeStep !== 3 && (
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
sx={{ mr: 1 }}
|
||||
disabled={checkout.isLoading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{activeStep !== 3 ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleNext}
|
||||
disabled={checkout.isLoading}
|
||||
>
|
||||
{activeStep === steps.length - 2 ? 'Place Order' : 'Next'}
|
||||
{checkout.isLoading && (
|
||||
<CircularProgress size={24} sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
component={RouterLink}
|
||||
to="/"
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutPage;
|
||||
168
frontend/src/pages/HomePage.jsx
Normal file
168
frontend/src/pages/HomePage.jsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Button, Grid, Card, CardMedia, CardContent, Container } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useProducts, useCategories } from '@hooks/apiHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const HomePage = () => {
|
||||
const { data: products, isLoading: productsLoading } = useProducts({ limit: 6 });
|
||||
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Hero Section */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
py: 8,
|
||||
mb: 6,
|
||||
borderRadius: 2,
|
||||
backgroundImage: 'linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url(/images/hero-background.jpg)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md">
|
||||
<Typography variant="h2" component="h1" gutterBottom>
|
||||
Discover Natural Wonders
|
||||
</Typography>
|
||||
<Typography variant="h5" paragraph>
|
||||
Unique rocks, bones, and sticks from around my backyards
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
size="large"
|
||||
component={RouterLink}
|
||||
to="/products"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Shop Now
|
||||
</Button>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Categories Section */}
|
||||
<Typography variant="h4" component="h2" gutterBottom>
|
||||
Shop by Category
|
||||
</Typography>
|
||||
|
||||
{!categoriesLoading && categories && (
|
||||
<Grid container spacing={3} mb={6}>
|
||||
{categories.map((category) => (
|
||||
<Grid item xs={12} sm={4} key={category.id}>
|
||||
<Card
|
||||
component={RouterLink}
|
||||
to={`/products?category=${category.name}`}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
textDecoration: 'none',
|
||||
transition: '0.3s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.03)',
|
||||
boxShadow: (theme) => theme.shadows[8],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={imageUtils.getImageUrl(category.image_path)}
|
||||
alt={category.name}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography gutterBottom variant="h5" component="div">
|
||||
{category.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{category.description}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Featured Products Section */}
|
||||
<Typography variant="h4" component="h2" gutterBottom>
|
||||
Featured Products
|
||||
</Typography>
|
||||
{!productsLoading && products && (
|
||||
<Grid container spacing={3}>
|
||||
{products.slice(0, 6).map((product) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={product.id}>
|
||||
<Card
|
||||
component={RouterLink}
|
||||
to={`/products/${product.id}`}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
textDecoration: 'none',
|
||||
transition: '0.3s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.03)',
|
||||
boxShadow: (theme) => theme.shadows[8],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={imageUtils.getImageUrl((product.images && product.images.length > 0)
|
||||
? product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||
: '/images/placeholder.jpg')}
|
||||
alt={product.name}
|
||||
/>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography gutterBottom variant="h6" component="div">
|
||||
{product.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={2}>
|
||||
{product.description.length > 100
|
||||
? `${product.description.substring(0, 100)}...`
|
||||
: product.description}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
${parseFloat(product.price).toFixed(2)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Call to Action */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 8,
|
||||
py: 6,
|
||||
textAlign: 'center',
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" component="h2" gutterBottom>
|
||||
Ready to explore more?
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
component={RouterLink}
|
||||
to="/products"
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
View All Products
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
172
frontend/src/pages/LoginPage.jsx
Normal file
172
frontend/src/pages/LoginPage.jsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, CircularProgress, Alert } from '@mui/material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useRequestLoginCode, useVerifyCode } from '../hooks/apiHooks';
|
||||
|
||||
const LoginPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [step, setStep] = useState(1); // 1: Enter email, 2: Enter code
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Get the return URL from location state or default to home
|
||||
const from = location.state?.from?.pathname || '/';
|
||||
|
||||
// Get the code from URL params if available (direct link from email)
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const urlCode = searchParams.get('code');
|
||||
const urlEmail = searchParams.get('email');
|
||||
|
||||
// Use the URL params if available
|
||||
React.useEffect(() => {
|
||||
if (urlCode && urlEmail) {
|
||||
setEmail(urlEmail);
|
||||
setCode(urlCode);
|
||||
setStep(2);
|
||||
}
|
||||
}, [urlCode, urlEmail]);
|
||||
|
||||
// React Query mutations
|
||||
const requestLoginCode = useRequestLoginCode();
|
||||
const verifyCode = useVerifyCode();
|
||||
|
||||
const handleRequestCode = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
await requestLoginCode.mutateAsync(email);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleVerifyCode = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email || !code) {
|
||||
return;
|
||||
}
|
||||
|
||||
await verifyCode.mutateAsync({ email, code });
|
||||
navigate(from, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" noValidate sx={{ mt: 1 }}>
|
||||
{step === 1 ? (
|
||||
// Step 1: Request login code with email
|
||||
<>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Login with Email
|
||||
</Typography>
|
||||
|
||||
{requestLoginCode.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{requestLoginCode.error?.message || 'Failed to send login code. Please try again.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Email Address"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={requestLoginCode.isLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={handleRequestCode}
|
||||
disabled={!email || requestLoginCode.isLoading}
|
||||
>
|
||||
{requestLoginCode.isLoading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Request Login Code'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// Step 2: Verify login code
|
||||
<>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Enter Login Code
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
We've sent a verification code to your email.
|
||||
</Typography>
|
||||
|
||||
{verifyCode.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{verifyCode.error?.message || 'Invalid code. Please try again.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Email Address"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={verifyCode.isLoading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="code"
|
||||
label="Verification Code"
|
||||
name="code"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
disabled={verifyCode.isLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={handleVerifyCode}
|
||||
disabled={!email || !code || verifyCode.isLoading}
|
||||
>
|
||||
{verifyCode.isLoading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Verify & Login'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="text"
|
||||
onClick={() => setStep(1)}
|
||||
disabled={verifyCode.isLoading}
|
||||
>
|
||||
Request a new code
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
59
frontend/src/pages/NotFoundPage.jsx
Normal file
59
frontend/src/pages/NotFoundPage.jsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { Box, Typography, Button, Container } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import SentimentDissatisfiedIcon from '@mui/icons-material/SentimentDissatisfied';
|
||||
|
||||
const NotFoundPage = () => {
|
||||
return (
|
||||
<Container maxWidth="md">
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '50vh',
|
||||
textAlign: 'center',
|
||||
py: 4,
|
||||
}}
|
||||
>
|
||||
<SentimentDissatisfiedIcon sx={{ fontSize: 100, color: 'text.secondary', mb: 2 }} />
|
||||
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
404 - Page Not Found
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h5" color="text.secondary" paragraph>
|
||||
Oops! The page you are looking for does not exist.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
It seems you've ventured too far into the wilderness.
|
||||
This natural specimen hasn't been discovered yet.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={RouterLink}
|
||||
to="/"
|
||||
size="large"
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
component={RouterLink}
|
||||
to="/products"
|
||||
>
|
||||
Browse Products
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
||||
360
frontend/src/pages/ProductDetailPage.jsx
Normal file
360
frontend/src/pages/ProductDetailPage.jsx
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
Button,
|
||||
Chip,
|
||||
Divider,
|
||||
TextField,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableRow,
|
||||
Breadcrumbs,
|
||||
Link,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useProduct, useAddToCart } from '../hooks/apiHooks';
|
||||
import { useAuth } from '../hooks/reduxHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const ProductDetailPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
// Fetch product data
|
||||
const { data: product, isLoading, error } = useProduct(id);
|
||||
const addToCart = useAddToCart();
|
||||
|
||||
// Handle quantity changes
|
||||
const increaseQuantity = () => {
|
||||
if (product && quantity < product.stock_quantity) {
|
||||
setQuantity(quantity + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const decreaseQuantity = () => {
|
||||
if (quantity > 1) {
|
||||
setQuantity(quantity - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuantityChange = (e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
setQuantity(1);
|
||||
} else if (product && value > product.stock_quantity) {
|
||||
setQuantity(product.stock_quantity);
|
||||
} else {
|
||||
setQuantity(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle add to cart
|
||||
const handleAddToCart = () => {
|
||||
if (!isAuthenticated) {
|
||||
navigate('/auth/login', { state: { from: `/products/${id}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
addToCart.mutate({
|
||||
userId: user.id,
|
||||
productId: id,
|
||||
quantity
|
||||
});
|
||||
};
|
||||
|
||||
// Format properties for display
|
||||
const getProductProperties = () => {
|
||||
if (!product) return [];
|
||||
|
||||
const properties = [];
|
||||
|
||||
if (product.category_name) {
|
||||
properties.push({ name: 'Category', value: product.category_name });
|
||||
}
|
||||
|
||||
if (product.material_type) {
|
||||
properties.push({ name: 'Material', value: product.material_type });
|
||||
}
|
||||
|
||||
if (product.color) {
|
||||
properties.push({ name: 'Color', value: product.color });
|
||||
}
|
||||
|
||||
if (product.origin) {
|
||||
properties.push({ name: 'Origin', value: product.origin });
|
||||
}
|
||||
|
||||
if (product.weight_grams) {
|
||||
properties.push({ name: 'Weight', value: `${product.weight_grams}g` });
|
||||
}
|
||||
|
||||
if (product.length_cm) {
|
||||
properties.push({ name: 'Length', value: `${product.length_cm}cm` });
|
||||
}
|
||||
|
||||
if (product.width_cm) {
|
||||
properties.push({ name: 'Width', value: `${product.width_cm}cm` });
|
||||
}
|
||||
|
||||
if (product.height_cm) {
|
||||
properties.push({ name: 'Height', value: `${product.height_cm}cm` });
|
||||
}
|
||||
|
||||
if (product.age) {
|
||||
properties.push({ name: 'Age', value: product.age });
|
||||
}
|
||||
|
||||
return properties;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<Box sx={{ my: 4 }}>
|
||||
<Typography variant="h5" color="error" gutterBottom>
|
||||
Error loading product details
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
component={RouterLink}
|
||||
to="/products"
|
||||
>
|
||||
Back to Products
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Breadcrumbs navigation */}
|
||||
<Breadcrumbs
|
||||
separator={<NavigateNextIcon fontSize="small" />}
|
||||
aria-label="breadcrumb"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
<Link component={RouterLink} to="/" color="inherit">
|
||||
Home
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/products" color="inherit">
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/products?category=${product.category_name}`}
|
||||
color="inherit"
|
||||
>
|
||||
{product.category_name}
|
||||
</Link>
|
||||
<Typography color="text.primary">{product.name}</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Product Images */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Card>
|
||||
{console.log(product)}
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={
|
||||
imageUtils.getImageUrl(product.images && product.images.length > 0
|
||||
? product.images[selectedImage]?.path
|
||||
: '/images/placeholder.jpg')
|
||||
}
|
||||
alt={product.name}
|
||||
sx={{
|
||||
height: 400,
|
||||
objectFit: 'contain',
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Thumbnail images */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{product.images.map((image, index) => (
|
||||
<Box
|
||||
key={image.id}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
cursor: 'pointer',
|
||||
border: index === selectedImage ? `2px solid ${theme.palette.primary.main}` : '2px solid transparent',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={image.path}
|
||||
alt={`${product.name} thumbnail ${index + 1}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Product Details */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
{product.name}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h5" color="primary" gutterBottom>
|
||||
${parseFloat(product.price).toFixed(2)}
|
||||
</Typography>
|
||||
|
||||
{/* Tags */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, my: 2 }}>
|
||||
{product.tags && product.tags.map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
component={RouterLink}
|
||||
to={`/products?tag=${tag}`}
|
||||
clickable
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography variant="body1" paragraph sx={{ mt: 2 }}>
|
||||
{product.description}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
{/* Stock information */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Availability:
|
||||
<Chip
|
||||
label={product.stock_quantity > 0 ? 'In Stock' : 'Out of Stock'}
|
||||
color={product.stock_quantity > 0 ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
{product.stock_quantity > 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{product.stock_quantity} items available
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Add to cart section */}
|
||||
{product.stock_quantity > 0 ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mr: 2 }}>
|
||||
<IconButton
|
||||
onClick={decreaseQuantity}
|
||||
disabled={quantity <= 1}
|
||||
size="small"
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
|
||||
<TextField
|
||||
value={quantity}
|
||||
onChange={handleQuantityChange}
|
||||
inputProps={{ min: 1, max: product.stock_quantity }}
|
||||
sx={{ width: 60, mx: 1 }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
onClick={increaseQuantity}
|
||||
disabled={quantity >= product.stock_quantity}
|
||||
size="small"
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
onClick={handleAddToCart}
|
||||
disabled={addToCart.isLoading}
|
||||
sx={{ flexGrow: 1 }}
|
||||
>
|
||||
{addToCart.isLoading ? 'Adding...' : 'Add to Cart'}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled
|
||||
fullWidth
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
Out of Stock
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Product properties table */}
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Product Details
|
||||
</Typography>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ mb: 3 }}>
|
||||
<Table aria-label="product specifications">
|
||||
<TableBody>
|
||||
{getProductProperties().map((prop) => (
|
||||
<TableRow key={prop.name}>
|
||||
<TableCell
|
||||
component="th"
|
||||
scope="row"
|
||||
sx={{
|
||||
width: '40%',
|
||||
bgcolor: 'background.paper',
|
||||
fontWeight: 'medium'
|
||||
}}
|
||||
>
|
||||
{prop.name}
|
||||
</TableCell>
|
||||
<TableCell>{prop.value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailPage;
|
||||
557
frontend/src/pages/ProductsPage.jsx
Normal file
557
frontend/src/pages/ProductsPage.jsx
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardMedia,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Button,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Divider,
|
||||
Chip,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
CircularProgress,
|
||||
Pagination
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||
import SortIcon from '@mui/icons-material/Sort';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useProducts, useCategories, useTags, useAddToCart } from '../hooks/apiHooks';
|
||||
import { useAuth } from '../hooks/reduxHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const ProductsPage = () => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
|
||||
// Parse query params
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
|
||||
// State for filters and search
|
||||
const [filters, setFilters] = useState({
|
||||
category: queryParams.get('category') || '',
|
||||
tag: queryParams.get('tag') || '',
|
||||
search: queryParams.get('search') || '',
|
||||
sort: queryParams.get('sort') || 'name',
|
||||
order: queryParams.get('order') || 'asc'
|
||||
});
|
||||
|
||||
// State for filter drawer
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1);
|
||||
const itemsPerPage = 12;
|
||||
|
||||
// Data fetching with React Query
|
||||
const { data: products, isLoading, error } = useProducts(filters);
|
||||
const { data: categories } = useCategories();
|
||||
const { data: tags } = useTags();
|
||||
const addToCart = useAddToCart();
|
||||
|
||||
// Update URL when filters change
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.category) params.set('category', filters.category);
|
||||
if (filters.tag) params.set('tag', filters.tag);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.sort !== 'name') params.set('sort', filters.sort);
|
||||
if (filters.order !== 'asc') params.set('order', filters.order);
|
||||
|
||||
navigate(`/products${params.toString() ? `?${params.toString()}` : ''}`, { replace: true });
|
||||
}, [filters, navigate]);
|
||||
|
||||
// Handle search input
|
||||
const handleSearchChange = (e) => {
|
||||
setFilters({ ...filters, search: e.target.value });
|
||||
};
|
||||
|
||||
// Handle search submit
|
||||
const handleSearchSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
// Reset pagination when searching
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters({ ...filters, [name]: value });
|
||||
setPage(1); // Reset pagination when changing filters
|
||||
};
|
||||
|
||||
// Handle sort change
|
||||
const handleSortChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters({ ...filters, [name]: value });
|
||||
};
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
category: '',
|
||||
tag: '',
|
||||
search: '',
|
||||
sort: 'name',
|
||||
order: 'asc'
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// Add to cart
|
||||
const handleAddToCart = (productId) => {
|
||||
if (!isAuthenticated) {
|
||||
navigate('/auth/login', { state: { from: location } });
|
||||
return;
|
||||
}
|
||||
|
||||
addToCart.mutate({
|
||||
userId: user.id,
|
||||
productId,
|
||||
quantity: 1
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate pagination
|
||||
const paginatedProducts = products ?
|
||||
products.slice((page - 1) * itemsPerPage, page * itemsPerPage) :
|
||||
[];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Products
|
||||
</Typography>
|
||||
|
||||
{/* Search and filter bar */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
mb: 3,
|
||||
gap: 2
|
||||
}}>
|
||||
{/* Search */}
|
||||
<Box component="form" onSubmit={handleSearchSubmit} sx={{ flexGrow: 1, maxWidth: 500 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
placeholder="Search products..."
|
||||
value={filters.search}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Filter button (mobile) */}
|
||||
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
|
||||
<Button
|
||||
startIcon={<FilterListIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Sort options (desktop) */}
|
||||
<Box sx={{ display: { xs: 'none', md: 'flex' }, alignItems: 'center', gap: 2 }}>
|
||||
<SortIcon color="action" />
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel id="sort-label">Sort by</InputLabel>
|
||||
<Select
|
||||
labelId="sort-label"
|
||||
id="sort"
|
||||
name="sort"
|
||||
value={filters.sort}
|
||||
label="Sort by"
|
||||
onChange={handleSortChange}
|
||||
>
|
||||
<MenuItem value="name">Name</MenuItem>
|
||||
<MenuItem value="price">Price</MenuItem>
|
||||
<MenuItem value="created_at">Newest</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||
<InputLabel id="order-label">Order</InputLabel>
|
||||
<Select
|
||||
labelId="order-label"
|
||||
id="order"
|
||||
name="order"
|
||||
value={filters.order}
|
||||
label="Order"
|
||||
onChange={handleSortChange}
|
||||
>
|
||||
<MenuItem value="asc">Ascending</MenuItem>
|
||||
<MenuItem value="desc">Descending</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Active filters display */}
|
||||
{(filters.category || filters.tag || filters.search) && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{filters.category && (
|
||||
<Chip
|
||||
label={`Category: ${filters.category}`}
|
||||
onDelete={() => setFilters({ ...filters, category: '' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filters.tag && (
|
||||
<Chip
|
||||
label={`Tag: ${filters.tag}`}
|
||||
onDelete={() => setFilters({ ...filters, tag: '' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filters.search && (
|
||||
<Chip
|
||||
label={`Search: ${filters.search}`}
|
||||
onDelete={() => setFilters({ ...filters, search: '' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
onClick={clearFilters}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
{/* Filter sidebar (desktop) */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 240,
|
||||
flexShrink: 0,
|
||||
mr: 3,
|
||||
display: { xs: 'none', md: 'block' }
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Categories
|
||||
</Typography>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem
|
||||
button
|
||||
selected={filters.category === ''}
|
||||
onClick={() => setFilters({ ...filters, category: '' })}
|
||||
>
|
||||
<ListItemText primary="All Categories" />
|
||||
</ListItem>
|
||||
|
||||
{categories?.map((category) => (
|
||||
<ListItem
|
||||
button
|
||||
key={category.id}
|
||||
selected={filters.category === category.name}
|
||||
onClick={() => setFilters({ ...filters, category: category.name })}
|
||||
>
|
||||
<ListItemText primary={category.name} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Popular Tags
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{tags?.map((tag) => (
|
||||
<Chip
|
||||
key={tag.id}
|
||||
label={tag.name}
|
||||
onClick={() => setFilters({ ...filters, tag: tag.name })}
|
||||
color={filters.tag === tag.name ? 'primary' : 'default'}
|
||||
clickable
|
||||
size="small"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Product grid */}
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<Typography color="error" sx={{ my: 4 }}>
|
||||
Error loading products. Please try again.
|
||||
</Typography>
|
||||
) : paginatedProducts?.length === 0 ? (
|
||||
<Typography sx={{ my: 4 }}>
|
||||
No products found with the selected filters.
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
<Grid container spacing={3}>
|
||||
{paginatedProducts.map((product) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={product.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: '0.3s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: (theme) => theme.shadows[8],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={imageUtils.getImageUrl((product.images && product.images.length > 0)
|
||||
? product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||
: '/images/placeholder.jpg')}
|
||||
alt={product.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
onClick={() => navigate(`/products/${product.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component={RouterLink}
|
||||
to={`/products/${product.id}`}
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
'&:hover': { color: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
{product.name}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
mt: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{product.description}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', mt: 2, mb: 1 }}>
|
||||
{product.tags && product.tags.slice(0, 3).map((tag, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={tag}
|
||||
size="small"
|
||||
sx={{ mr: 0.5, mb: 0.5 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters({ ...filters, tag });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primary"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
${parseFloat(product.price).toFixed(2)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
<CardActions sx={{ p: 2, pt: 0 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<ShoppingCartIcon />}
|
||||
onClick={() => handleAddToCart(product.id)}
|
||||
disabled={addToCart.isLoading}
|
||||
>
|
||||
Add to Cart
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
component={RouterLink}
|
||||
to={`/products/${product.id}`}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Pagination */}
|
||||
{products && products.length > itemsPerPage && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<Pagination
|
||||
count={Math.ceil(products.length / itemsPerPage)}
|
||||
page={page}
|
||||
onChange={(e, value) => setPage(value)}
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Filter drawer (mobile) */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
>
|
||||
<Box sx={{ width: 280, p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Filters</Typography>
|
||||
<IconButton onClick={() => setDrawerOpen(false)}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Sort by
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
|
||||
<InputLabel id="mobile-sort-label">Sort</InputLabel>
|
||||
<Select
|
||||
labelId="mobile-sort-label"
|
||||
id="mobile-sort"
|
||||
name="sort"
|
||||
value={filters.sort}
|
||||
label="Sort"
|
||||
onChange={handleSortChange}
|
||||
>
|
||||
<MenuItem value="name">Name</MenuItem>
|
||||
<MenuItem value="price">Price</MenuItem>
|
||||
<MenuItem value="created_at">Newest</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
|
||||
<InputLabel id="mobile-order-label">Order</InputLabel>
|
||||
<Select
|
||||
labelId="mobile-order-label"
|
||||
id="mobile-order"
|
||||
name="order"
|
||||
value={filters.order}
|
||||
label="Order"
|
||||
onChange={handleSortChange}
|
||||
>
|
||||
<MenuItem value="asc">Ascending</MenuItem>
|
||||
<MenuItem value="desc">Descending</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Categories
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
|
||||
<InputLabel id="category-label">Category</InputLabel>
|
||||
<Select
|
||||
labelId="category-label"
|
||||
id="category"
|
||||
name="category"
|
||||
value={filters.category}
|
||||
label="Category"
|
||||
onChange={handleFilterChange}
|
||||
>
|
||||
<MenuItem value="">All Categories</MenuItem>
|
||||
{categories?.map((category) => (
|
||||
<MenuItem key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Popular Tags
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{tags?.slice(0, 10).map((tag) => (
|
||||
<Chip
|
||||
key={tag.id}
|
||||
label={tag.name}
|
||||
onClick={() => {
|
||||
setFilters({ ...filters, tag: tag.name });
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
color={filters.tag === tag.name ? 'primary' : 'default'}
|
||||
clickable
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
clearFilters();
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
>
|
||||
Clear All Filters
|
||||
</Button>
|
||||
</Box>
|
||||
</Drawer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductsPage;
|
||||
133
frontend/src/pages/RegisterPage.jsx
Normal file
133
frontend/src/pages/RegisterPage.jsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, CircularProgress, Alert, Grid } from '@mui/material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useRegister } from '../hooks/apiHooks';
|
||||
|
||||
const RegisterPage = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
// React Query mutation
|
||||
const register = useRegister();
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Simple validation
|
||||
if (!formData.firstName || !formData.lastName || !formData.email) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register.mutateAsync(formData);
|
||||
|
||||
// Redirect to login page after successful registration
|
||||
setTimeout(() => {
|
||||
navigate('/auth/login', { state: { email: formData.email } });
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
// Error handling is done by the hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" noValidate onSubmit={handleSubmit} sx={{ mt: 1 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Create an Account
|
||||
</Typography>
|
||||
|
||||
{register.isSuccess && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
Registration successful! Redirecting to login...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{register.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{register.error?.message || 'Registration failed. Please try again.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="firstName"
|
||||
label="First Name"
|
||||
name="firstName"
|
||||
autoComplete="given-name"
|
||||
autoFocus
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
disabled={register.isLoading || register.isSuccess}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="lastName"
|
||||
label="Last Name"
|
||||
name="lastName"
|
||||
autoComplete="family-name"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
disabled={register.isLoading || register.isSuccess}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Email Address"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={register.isLoading || register.isSuccess}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={register.isLoading || register.isSuccess ||
|
||||
!formData.firstName || !formData.lastName || !formData.email}
|
||||
>
|
||||
{register.isLoading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Register'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Typography variant="body2" align="center">
|
||||
Already have an account?{' '}
|
||||
<RouterLink to="/auth/login">
|
||||
Sign in
|
||||
</RouterLink>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
119
frontend/src/pages/VerifyPage.jsx
Normal file
119
frontend/src/pages/VerifyPage.jsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import { useVerifyCode } from '../hooks/apiHooks';
|
||||
import { useAuth } from '../hooks/reduxHooks';
|
||||
|
||||
const VerifyPage = () => {
|
||||
const [verificationStatus, setVerificationStatus] = useState('pending'); // pending, success, error
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const verifyCode = useVerifyCode();
|
||||
|
||||
// If already authenticated, redirect to home
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
// Extract code and email from URL params
|
||||
useEffect(() => {
|
||||
if(location.search, verifyCode, navigate){
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const code = queryParams.get('code');
|
||||
const email = queryParams.get('email');
|
||||
|
||||
// attempt verification
|
||||
if (code && email) {
|
||||
setVerificationStatus('verifying');
|
||||
verifyCode.mutate(
|
||||
{ email, code },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setVerificationStatus('success');
|
||||
// Redirect to home after successful verification
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
},
|
||||
onError: (error) => {
|
||||
setVerificationStatus('error');
|
||||
setErrorMessage(error.message || 'Verification failed. Please try again.');
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setVerificationStatus('error');
|
||||
setErrorMessage('Missing verification code or email. Please check your link.');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'background.default' : 'grey.100',
|
||||
p: 3
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
p: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Account Verification
|
||||
</Typography>
|
||||
|
||||
{verificationStatus === 'pending' || verificationStatus === 'verifying' ? (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<CircularProgress size={48} sx={{ mb: 2 }} />
|
||||
<Typography>
|
||||
Verifying your account...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : verificationStatus === 'success' ? (
|
||||
<Alert severity="success" sx={{ width: '100%', mb: 2 }}>
|
||||
Your account has been verified successfully! Redirecting to home page...
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/auth/login')}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyPage;
|
||||
42
frontend/src/services/api.js
Normal file
42
frontend/src/services/api.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import axios from 'axios';
|
||||
import { store } from '../store';
|
||||
|
||||
// Create the base axios instance
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor to include API key in headers if available
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const state = store.getState();
|
||||
const apiKey = state.auth.apiKey;
|
||||
|
||||
if (apiKey) {
|
||||
config.headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add response interceptor to handle common errors
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle 401 unauthorized errors
|
||||
if (error.response && error.response.status === 401) {
|
||||
store.dispatch({ type: 'auth/logout' });
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
80
frontend/src/services/authService.js
Normal file
80
frontend/src/services/authService.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import apiClient from './api';
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Register a new user
|
||||
* @param {Object} userData - User registration data
|
||||
* @param {string} userData.email - User's email
|
||||
* @param {string} userData.firstName - User's first name
|
||||
* @param {string} userData.lastName - User's last name
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
register: async (userData) => {
|
||||
try {
|
||||
const response = await apiClient.post('/auth/register', userData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Request a login code
|
||||
* @param {string} email - User's email
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
requestLoginCode: async (email) => {
|
||||
try {
|
||||
const response = await apiClient.post('/auth/login-request', { email });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify login code
|
||||
* @param {Object} verifyData - Verification data
|
||||
* @param {string} verifyData.email - User's email
|
||||
* @param {string} verifyData.code - Login code
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
verifyCode: async (verifyData) => {
|
||||
try {
|
||||
const response = await apiClient.post('/auth/verify', verifyData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify API key
|
||||
* @param {string} apiKey - API key to verify
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
verifyApiKey: async (apiKey) => {
|
||||
try {
|
||||
const response = await apiClient.post('/auth/verify-key', { apiKey });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout
|
||||
* @param {string} userId - User ID to logout
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
logout: async (userId) => {
|
||||
try {
|
||||
const response = await apiClient.post('/auth/logout', { userId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default authService;
|
||||
83
frontend/src/services/cartService.js
Normal file
83
frontend/src/services/cartService.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import apiClient from './api';
|
||||
|
||||
export const cartService = {
|
||||
/**
|
||||
* Get user's cart
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getCart: async (userId) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/cart/${userId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add item to cart
|
||||
* @param {Object} cartItemData - Cart item data
|
||||
* @param {string} cartItemData.userId - User ID
|
||||
* @param {string} cartItemData.productId - Product ID
|
||||
* @param {number} [cartItemData.quantity=1] - Quantity to add
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
addToCart: async (cartItemData) => {
|
||||
try {
|
||||
const response = await apiClient.post('/cart/add', cartItemData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update cart item quantity
|
||||
* @param {Object} updateData - Update data
|
||||
* @param {string} updateData.userId - User ID
|
||||
* @param {string} updateData.productId - Product ID
|
||||
* @param {number} updateData.quantity - New quantity (0 to remove)
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
updateCartItem: async (updateData) => {
|
||||
try {
|
||||
const response = await apiClient.put('/cart/update', updateData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all items from cart
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
clearCart: async (userId) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/cart/clear/${userId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Checkout cart and create order
|
||||
* @param {Object} checkoutData - Checkout data
|
||||
* @param {string} checkoutData.userId - User ID
|
||||
* @param {string} checkoutData.shippingAddress - Shipping address
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
checkout: async (checkoutData) => {
|
||||
try {
|
||||
const response = await apiClient.post('/cart/checkout', checkoutData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default cartService;
|
||||
81
frontend/src/services/categoryAdminService.js
Normal file
81
frontend/src/services/categoryAdminService.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import apiClient from './api';
|
||||
|
||||
export const categoryAdminService = {
|
||||
/**
|
||||
* Get all categories
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getAllCategories: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/categories');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get category by ID
|
||||
* @param {string} id - Category ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getCategoryById: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/categories/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new category
|
||||
* @param {Object} categoryData - Category data
|
||||
* @param {string} categoryData.name - Category name
|
||||
* @param {string} [categoryData.description] - Category description
|
||||
* @param {string} [categoryData.imagePath] - Category image path
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
createCategory: async (categoryData) => {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/categories', categoryData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a category
|
||||
* @param {string} id - Category ID
|
||||
* @param {Object} categoryData - Updated category data
|
||||
* @param {string} categoryData.name - Category name
|
||||
* @param {string} [categoryData.description] - Category description
|
||||
* @param {string} [categoryData.imagePath] - Category image path
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
updateCategory: async (id, categoryData) => {
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/categories/${id}`, categoryData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a category
|
||||
* @param {string} id - Category ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
deleteCategory: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/admin/categories/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default categoryAdminService;
|
||||
85
frontend/src/services/imageService.js
Normal file
85
frontend/src/services/imageService.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import apiClient from './api';
|
||||
|
||||
export const imageService = {
|
||||
/**
|
||||
* Upload a single product image (admin only)
|
||||
* @param {File} imageFile - The image file to upload
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
uploadProductImage: async (imageFile) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/images/admin/products', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'Failed to upload image' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload multiple product images (admin only)
|
||||
* @param {Array<File>} imageFiles - The image files to upload
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
uploadMultipleProductImages: async (imageFiles) => {
|
||||
const formData = new FormData();
|
||||
|
||||
imageFiles.forEach(file => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/images/admin/products/multiple', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'Failed to upload images' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a product image (admin only)
|
||||
* @param {string} filename - The filename to delete
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
deleteProductImage: async (filename) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/images/admin/products/${filename}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'Failed to delete image' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the full path to an image
|
||||
* @param {string} imagePath - The relative image path
|
||||
* @returns {string} The full image URL
|
||||
*/
|
||||
getImageUrl: (imagePath) => {
|
||||
if (!imagePath) return '/images/placeholder.jpg';
|
||||
|
||||
// If it's already a full URL, return it
|
||||
if (imagePath.startsWith('http')) return imagePath;
|
||||
|
||||
// If it's a relative path, add the API base URL
|
||||
// The API base URL is either from the environment or '/api'
|
||||
const baseUrl = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// If the path already starts with a slash, remove it to avoid double slashes
|
||||
const cleanPath = imagePath.startsWith('/') ? imagePath.substring(1) : imagePath;
|
||||
|
||||
return `${baseUrl}/${cleanPath}`;
|
||||
}
|
||||
};
|
||||
|
||||
export default imageService;
|
||||
124
frontend/src/services/productService.js
Normal file
124
frontend/src/services/productService.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import apiClient from './api';
|
||||
|
||||
export const productService = {
|
||||
/**
|
||||
* Get all products with optional filtering
|
||||
* @param {Object} params - Query parameters
|
||||
* @param {string} [params.category] - Filter by category name
|
||||
* @param {string} [params.tag] - Filter by tag name
|
||||
* @param {string} [params.search] - Search term
|
||||
* @param {string} [params.sort] - Sort field
|
||||
* @param {string} [params.order] - Sort order ('asc' or 'desc')
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getAllProducts: async (params = {}) => {
|
||||
try {
|
||||
const response = await apiClient.get('/products', { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single product by ID
|
||||
* @param {string} id - Product ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getProductById: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/products/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all product categories
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getAllCategories: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/products/categories/all');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all product tags
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getAllTags: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/products/tags/all');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get products by category
|
||||
* @param {string} categoryName - Category name
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
getProductsByCategory: async (categoryName) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/products/category/${categoryName}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Admin-only product service functions
|
||||
export const productAdminService = {
|
||||
/**
|
||||
* Create a new product (admin only)
|
||||
* @param {Object} productData - Product data
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
createProduct: async (productData) => {
|
||||
try {
|
||||
const response = await apiClient.post('/admin/products', productData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a product (admin only)
|
||||
* @param {string} id - Product ID
|
||||
* @param {Object} productData - Updated product data
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
updateProduct: async (id, productData) => {
|
||||
try {
|
||||
const response = await apiClient.put(`/admin/products/${id}`, productData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a product (admin only)
|
||||
* @param {string} id - Product ID
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
deleteProduct: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/admin/products/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default productService;
|
||||
26
frontend/src/store/index.js
Normal file
26
frontend/src/store/index.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import authReducer from '../features/auth/authSlice';
|
||||
import cartReducer from '../features/cart/cartSlice';
|
||||
import uiReducer from '../features/ui/uiSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
cart: cartReducer,
|
||||
ui: uiReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
}),
|
||||
devTools: process.env.NODE_ENV !== 'production',
|
||||
});
|
||||
|
||||
// Export types for cleaner usage in components
|
||||
export * from '../features/auth/authSlice';
|
||||
export * from '../features/cart/cartSlice';
|
||||
export * from '../features/ui/uiSlice';
|
||||
|
||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||
// export type RootState = ReturnType<typeof store.getState>;
|
||||
// export type AppDispatch = typeof store.dispatch;
|
||||
21
frontend/src/theme/ThemeProvider.jsx
Normal file
21
frontend/src/theme/ThemeProvider.jsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { useAppTheme } from './index';
|
||||
|
||||
/**
|
||||
* Custom ThemeProvider that uses the app's theme with dark mode support
|
||||
* This component should be used instead of the direct MUI ThemeProvider
|
||||
*/
|
||||
const ThemeProvider = ({ children }) => {
|
||||
const theme = useAppTheme();
|
||||
|
||||
return (
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
||||
106
frontend/src/theme/index.js
Normal file
106
frontend/src/theme/index.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { createTheme } from '@mui/material/styles';
|
||||
import { red, amber, grey, deepPurple } from '@mui/material/colors';
|
||||
import { useMemo } from 'react';
|
||||
import { useAppSelector } from '../hooks/reduxHooks';
|
||||
import { selectDarkMode } from '../features/ui/uiSlice';
|
||||
|
||||
// Create a theme instance based on the dark mode state
|
||||
export const createAppTheme = (darkMode) => {
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: darkMode ? 'dark' : 'light',
|
||||
primary: {
|
||||
main: deepPurple[400],
|
||||
light: deepPurple[300],
|
||||
dark: deepPurple[600],
|
||||
},
|
||||
secondary: {
|
||||
main: amber[500],
|
||||
light: amber[300],
|
||||
dark: amber[700],
|
||||
},
|
||||
error: {
|
||||
main: red.A400,
|
||||
},
|
||||
background: {
|
||||
default: darkMode ? grey[900] : '#f5f5f5',
|
||||
paper: darkMode ? grey[800] : '#fff',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'Roboto',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
].join(','),
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 6,
|
||||
textTransform: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
boxShadow: darkMode
|
||||
? '0 4px 20px rgba(0, 0, 0, 0.5)'
|
||||
: '0 4px 20px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: darkMode
|
||||
? '0 4px 20px rgba(0, 0, 0, 0.5)'
|
||||
: '0 2px 10px rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Default theme instance
|
||||
const defaultTheme = createAppTheme(true);
|
||||
|
||||
// Custom hook to create theme based on current dark mode setting
|
||||
export const useAppTheme = () => {
|
||||
const darkMode = useAppSelector(selectDarkMode);
|
||||
|
||||
return useMemo(() => createAppTheme(darkMode), [darkMode]);
|
||||
};
|
||||
|
||||
export default defaultTheme;
|
||||
163
frontend/src/utils/imageUtils.js
Normal file
163
frontend/src/utils/imageUtils.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import apiClient from '@services/api';
|
||||
|
||||
/**
|
||||
* Utility functions for handling images
|
||||
*/
|
||||
const imageUtils = {
|
||||
/**
|
||||
* Get a full URL for an image path
|
||||
* @param {string} imagePath - The image path or URL
|
||||
* @param {string} [defaultImage] - Default image to use if the path is invalid
|
||||
* @returns {string} - The full image URL
|
||||
*/
|
||||
getImageUrl: (imagePath, defaultImage = '/placeholder.jpg') => {
|
||||
if (!imagePath) return defaultImage;
|
||||
|
||||
// If it's already a complete URL, return it as is
|
||||
if (imagePath.startsWith('http')) {
|
||||
return imagePath;
|
||||
}
|
||||
|
||||
// The base API URL from environment or default
|
||||
// Remove '/api' from the end if present, as we'll add the full path ourselves
|
||||
const apiBaseUrl = import.meta.env.VITE_API_URL
|
||||
? import.meta.env.VITE_API_URL.replace(/\/api$/, '')
|
||||
: '';
|
||||
|
||||
// If it starts with '/uploads', it's a relative path to our API
|
||||
if (imagePath.startsWith('/uploads')) {
|
||||
return `${apiBaseUrl}${imagePath}`;
|
||||
}
|
||||
|
||||
// If it starts with '/images', it's a direct path to our static files
|
||||
if (imagePath.startsWith('/images')) {
|
||||
return `${apiBaseUrl}${imagePath}`;
|
||||
}
|
||||
|
||||
// Otherwise, treat it as a relative path
|
||||
return imagePath.startsWith('/') ? `${apiBaseUrl}${imagePath}` : `${apiBaseUrl}/${imagePath}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload a single image file
|
||||
* @param {File} file - The image file to upload
|
||||
* @param {Object} [options] - Upload options
|
||||
* @param {string} [options.apiKey] - API key for authentication
|
||||
* @param {boolean} [options.isProductImage] - Whether this is a product image
|
||||
* @returns {Promise<Object>} - The upload response
|
||||
*/
|
||||
uploadImage: async (file, options = {}) => {
|
||||
const { apiKey, isProductImage = false } = options;
|
||||
console.log("uploadImage", options);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
const headers = {};
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
|
||||
// Use the appropriate endpoint based on image type
|
||||
const endpoint = isProductImage ? '/image/product' : '/image/upload';
|
||||
|
||||
try {
|
||||
console.log(`Uploading image to: ${endpoint}`);
|
||||
const response = await apiClient.post(endpoint, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...headers
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Upload response:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Image upload failed:', error);
|
||||
console.error('Response status:', error.response?.status);
|
||||
console.error('Response data:', error.response?.data);
|
||||
throw error.response?.data || { message: 'Image upload failed' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload multiple image files
|
||||
* @param {Array<File>} files - The image files to upload
|
||||
* @param {Object} [options] - Upload options
|
||||
* @param {string} [options.apiKey] - API key for authentication
|
||||
* @returns {Promise<Object>} - The upload response
|
||||
*/
|
||||
uploadMultipleImages: async (files, options = {}) => {
|
||||
const { apiKey } = options;
|
||||
|
||||
const formData = new FormData();
|
||||
files.forEach(file => {
|
||||
formData.append('images', file);
|
||||
});
|
||||
|
||||
const headers = {};
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Uploading multiple images');
|
||||
const response = await apiClient.post('/image/products', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...headers
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Multiple image upload response:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Multiple image upload failed:', error);
|
||||
console.error('Response status:', error.response?.status);
|
||||
console.error('Response data:', error.response?.data);
|
||||
throw error.response?.data || { message: 'Multiple image upload failed' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an image by filename
|
||||
* @param {string} filename - The filename to delete
|
||||
* @param {Object} [options] - Delete options
|
||||
* @param {string} [options.apiKey] - API key for authentication
|
||||
* @returns {Promise<Object>} - The delete response
|
||||
*/
|
||||
deleteImage: async (filename, options = {}) => {
|
||||
const { apiKey } = options;
|
||||
|
||||
const headers = {};
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.delete(`/image/product/${filename}`, {
|
||||
headers
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Image deletion failed:', error);
|
||||
throw error.response?.data || { message: 'Image deletion failed' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract filename from an image path
|
||||
* @param {string} imagePath - The image path
|
||||
* @returns {string|null} - The filename or null if not found
|
||||
*/
|
||||
getFilenameFromPath: (imagePath) => {
|
||||
if (!imagePath) return null;
|
||||
|
||||
// Try to extract the filename from the path
|
||||
const matches = imagePath.match(/\/([^\/]+)$/);
|
||||
return matches ? matches[1] : null;
|
||||
}
|
||||
};
|
||||
|
||||
export default imageUtils;
|
||||
48
frontend/vite.config.js
Normal file
48
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@components': path.resolve(__dirname, 'src/components'),
|
||||
'@features': path.resolve(__dirname, 'src/features'),
|
||||
'@hooks': path.resolve(__dirname, 'src/hooks'),
|
||||
'@layouts': path.resolve(__dirname, 'src/layouts'),
|
||||
'@pages': path.resolve(__dirname, 'src/pages'),
|
||||
'@services': path.resolve(__dirname, 'src/services'),
|
||||
'@store': path.resolve(__dirname, 'src/store'),
|
||||
'@theme': path.resolve(__dirname, 'src/theme'),
|
||||
'@utils': path.resolve(__dirname, 'src/utils'),
|
||||
'@assets': path.resolve(__dirname, 'src/assets')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
// port: 3000,
|
||||
// proxy: {
|
||||
// '/api': {
|
||||
// target: 'http://localhost:4000',
|
||||
// changeOrigin: true,
|
||||
// secure: false
|
||||
// }
|
||||
// },
|
||||
host: '0.0.0.0', // Required for Docker
|
||||
port: 3000,
|
||||
watch: {
|
||||
usePolling: true, // Required for Docker volumes
|
||||
},
|
||||
hmr: {
|
||||
clientPort: 3000, // Match with the exposed port
|
||||
host: 'localhost',
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
emptyOutDir: true,
|
||||
sourcemap: process.env.NODE_ENV !== 'production'
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue