Git Migration

This commit is contained in:
2ManyProjects 2025-04-25 00:41:30 -05:00
commit af0608ed43
76 changed files with 13328 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules
.env
npm-debug.log
yarn-error.log
.DS_Store
uploads/*

5
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
.env
npm-debug.log
yarn-error.log
.DS_Store

14
backend/Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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;

View 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'
});
}
};
};

View 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'
});
}
};
};

View 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
View 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
View 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;
};

View 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;
};

View 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;
};

View 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;
};

View 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
};

View 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
View 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
View 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
View 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);

View 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
View 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';

View file

@ -0,0 +1 @@
ALTER TABLE product_categories ADD COLUMN image_path VARCHAR(255);

67
db/test/test-api.sh Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

38
frontend/package.json Normal file
View 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"
}
}

View 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

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

View 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">
&copy; {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
</Typography>
</Box>
</Container>
</Box>
);
};
export default Footer;

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

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

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

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

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

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

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

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

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

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

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

View 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">
&copy; {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
</Typography>
</Container>
</Box>
</Box>
);
};
export default AuthLayout;

View 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
View 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>
);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
View 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'
}
});