From 9cbb7676f2de3be041c007bf28e081464bcff9e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:47:34 +0000 Subject: [PATCH 1/4] Initial plan From 2f25cdaebd216ba94bd2e8208c9add20d6280095 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:51:50 +0000 Subject: [PATCH 2/4] Complete backend implementation: user model, routes, tests, and seed data Co-authored-by: thomasiverson <12767513+thomasiverson@users.noreply.github.com> --- api/src/index.ts | 2 + api/src/models/user.ts | 44 +++++ api/src/routes/user.test.ts | 277 +++++++++++++++++++++++++++ api/src/routes/user.ts | 361 ++++++++++++++++++++++++++++++++++++ api/src/seedData.ts | 29 +++ 5 files changed, 713 insertions(+) create mode 100644 api/src/models/user.ts create mode 100644 api/src/routes/user.test.ts create mode 100644 api/src/routes/user.ts diff --git a/api/src/index.ts b/api/src/index.ts index b991a16..a372f97 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -10,6 +10,7 @@ import orderRoutes from './routes/order'; import branchRoutes from './routes/branch'; import headquartersRoutes from './routes/headquarters'; import supplierRoutes from './routes/supplier'; +import userRoutes from './routes/user'; const app = express(); const port = process.env.PORT || 3000; @@ -74,6 +75,7 @@ app.use('/api/orders', orderRoutes); app.use('/api/branches', branchRoutes); app.use('/api/headquarters', headquartersRoutes); app.use('/api/suppliers', supplierRoutes); +app.use('/api/users', userRoutes); app.get('/', (req, res) => { res.send('Hello, world!'); diff --git a/api/src/models/user.ts b/api/src/models/user.ts new file mode 100644 index 0000000..d745fa1 --- /dev/null +++ b/api/src/models/user.ts @@ -0,0 +1,44 @@ +/** + * @swagger + * components: + * schemas: + * User: + * type: object + * required: + * - userId + * - email + * - name + * properties: + * userId: + * type: integer + * description: The unique identifier for the user + * email: + * type: string + * format: email + * description: The user's email address (must be unique) + * name: + * type: string + * description: The user's full name + * isAdmin: + * type: boolean + * description: Whether the user has admin privileges + * default: false + * createdAt: + * type: string + * format: date-time + * description: The date and time the user was created + * wishlistProductIds: + * type: array + * items: + * type: integer + * description: Array of product IDs in the user's wishlist + * default: [] + */ +export interface User { + userId: number; + email: string; + name: string; + isAdmin: boolean; + createdAt: Date; + wishlistProductIds: number[]; +} diff --git a/api/src/routes/user.test.ts b/api/src/routes/user.test.ts new file mode 100644 index 0000000..e820a48 --- /dev/null +++ b/api/src/routes/user.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import userRouter, { resetUsers } from './user'; +import { users as seedUsers } from '../seedData'; + +let app: express.Express; + +describe('User API', () => { + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/users', userRouter); + resetUsers(); + }); + + describe('POST /users - Register new user', () => { + it('should create a new user', async () => { + const newUser = { + email: 'test@example.com', + name: 'Test User', + password: 'password123' + }; + const response = await request(app).post('/users').send(newUser); + expect(response.status).toBe(201); + expect(response.body.email).toBe(newUser.email); + expect(response.body.name).toBe(newUser.name); + expect(response.body.isAdmin).toBe(false); + expect(response.body.wishlistProductIds).toEqual([]); + expect(response.body).toHaveProperty('userId'); + expect(response.body).toHaveProperty('createdAt'); + }); + + it('should create an admin user with @github.com email', async () => { + const newUser = { + email: 'admin@github.com', + name: 'Admin User', + password: 'password123' + }; + const response = await request(app).post('/users').send(newUser); + expect(response.status).toBe(201); + expect(response.body.isAdmin).toBe(true); + }); + + it('should reject invalid email format', async () => { + const newUser = { + email: 'invalid-email', + name: 'Test User', + password: 'password123' + }; + const response = await request(app).post('/users').send(newUser); + expect(response.status).toBe(400); + expect(response.body.error).toContain('Invalid email format'); + }); + + it('should reject password shorter than 8 characters', async () => { + const newUser = { + email: 'test@example.com', + name: 'Test User', + password: 'short' + }; + const response = await request(app).post('/users').send(newUser); + expect(response.status).toBe(400); + expect(response.body.error).toContain('Password must be at least 8 characters'); + }); + + it('should reject duplicate email', async () => { + const existingUser = seedUsers[0]; + const newUser = { + email: existingUser.email, + name: 'Test User', + password: 'password123' + }; + const response = await request(app).post('/users').send(newUser); + expect(response.status).toBe(400); + expect(response.body.error).toContain('Email already exists'); + }); + + it('should reject missing required fields', async () => { + const response = await request(app).post('/users').send({ + email: 'test@example.com' + }); + expect(response.status).toBe(400); + expect(response.body.error).toContain('required'); + }); + }); + + describe('GET /users/:email - Get user by email', () => { + it('should get user by email', async () => { + const user = seedUsers[0]; + const response = await request(app).get(`/users/${user.email}`); + expect(response.status).toBe(200); + expect(response.body.email).toBe(user.email); + expect(response.body.userId).toBe(user.userId); + }); + + it('should be case-insensitive', async () => { + const user = seedUsers[0]; + const response = await request(app).get(`/users/${user.email.toUpperCase()}`); + expect(response.status).toBe(200); + expect(response.body.email).toBe(user.email); + }); + + it('should return 404 for non-existing user', async () => { + const response = await request(app).get('/users/nonexistent@example.com'); + expect(response.status).toBe(404); + expect(response.body.error).toContain('User not found'); + }); + }); + + describe('PUT /users/id/:userId - Update user details', () => { + it('should update user name', async () => { + const user = seedUsers[0]; + const updatedData = { name: 'Updated Name' }; + const response = await request(app) + .put(`/users/id/${user.userId}`) + .send(updatedData); + expect(response.status).toBe(200); + expect(response.body.name).toBe(updatedData.name); + expect(response.body.email).toBe(user.email); + }); + + it('should update user email', async () => { + const user = seedUsers[0]; + const updatedData = { email: 'newemail@example.com' }; + const response = await request(app) + .put(`/users/id/${user.userId}`) + .send(updatedData); + expect(response.status).toBe(200); + expect(response.body.email).toBe(updatedData.email); + }); + + it('should update isAdmin when email changes to @github.com', async () => { + const user = seedUsers[0]; + const updatedData = { email: 'admin@github.com' }; + const response = await request(app) + .put(`/users/id/${user.userId}`) + .send(updatedData); + expect(response.status).toBe(200); + expect(response.body.isAdmin).toBe(true); + }); + + it('should reject duplicate email on update', async () => { + const user1 = seedUsers[0]; + const user2 = seedUsers[1]; + const updatedData = { email: user2.email }; + const response = await request(app) + .put(`/users/id/${user1.userId}`) + .send(updatedData); + expect(response.status).toBe(400); + expect(response.body.error).toContain('Email already exists'); + }); + + it('should return 404 for non-existing user', async () => { + const response = await request(app) + .put('/users/id/999') + .send({ name: 'Updated Name' }); + expect(response.status).toBe(404); + expect(response.body.error).toContain('User not found'); + }); + }); + + describe('Wishlist Operations', () => { + describe('GET /users/:userId/wishlist - Get wishlist', () => { + it('should get empty wishlist for new user', async () => { + const user = seedUsers[0]; + const response = await request(app).get(`/users/${user.userId}/wishlist`); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + }); + + it('should get wishlist with product details', async () => { + const user = seedUsers[1]; // User with pre-populated wishlist + const response = await request(app).get(`/users/${user.userId}/wishlist`); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + if (response.body.length > 0) { + expect(response.body[0]).toHaveProperty('productId'); + expect(response.body[0]).toHaveProperty('name'); + expect(response.body[0]).toHaveProperty('price'); + } + }); + + it('should return 404 for non-existing user', async () => { + const response = await request(app).get('/users/999/wishlist'); + expect(response.status).toBe(404); + expect(response.body.error).toContain('User not found'); + }); + }); + + describe('POST /users/:userId/wishlist - Add to wishlist', () => { + it('should add product to wishlist', async () => { + const user = seedUsers[0]; + const productId = 1; + const response = await request(app) + .post(`/users/${user.userId}/wishlist`) + .send({ productId }); + expect(response.status).toBe(200); + expect(response.body.wishlistProductIds).toContain(productId); + }); + + it('should reject duplicate product in wishlist', async () => { + const user = seedUsers[0]; + const productId = 1; + // Add once + await request(app) + .post(`/users/${user.userId}/wishlist`) + .send({ productId }); + // Try to add again + const response = await request(app) + .post(`/users/${user.userId}/wishlist`) + .send({ productId }); + expect(response.status).toBe(400); + expect(response.body.error).toContain('already in wishlist'); + }); + + it('should reject invalid product ID', async () => { + const user = seedUsers[0]; + const productId = 99999; + const response = await request(app) + .post(`/users/${user.userId}/wishlist`) + .send({ productId }); + expect(response.status).toBe(400); + expect(response.body.error).toContain('Invalid product ID'); + }); + + it('should reject missing product ID', async () => { + const user = seedUsers[0]; + const response = await request(app) + .post(`/users/${user.userId}/wishlist`) + .send({}); + expect(response.status).toBe(400); + expect(response.body.error).toContain('Product ID is required'); + }); + + it('should return 404 for non-existing user', async () => { + const response = await request(app) + .post('/users/999/wishlist') + .send({ productId: 1 }); + expect(response.status).toBe(404); + expect(response.body.error).toContain('User not found'); + }); + }); + + describe('DELETE /users/:userId/wishlist/:productId - Remove from wishlist', () => { + it('should remove product from wishlist', async () => { + const user = seedUsers[0]; + const productId = 1; + // Add product first + await request(app) + .post(`/users/${user.userId}/wishlist`) + .send({ productId }); + // Remove product + const response = await request(app) + .delete(`/users/${user.userId}/wishlist/${productId}`); + expect(response.status).toBe(200); + expect(response.body.wishlistProductIds).not.toContain(productId); + }); + + it('should return 404 for product not in wishlist', async () => { + const user = seedUsers[0]; + const productId = 1; + const response = await request(app) + .delete(`/users/${user.userId}/wishlist/${productId}`); + expect(response.status).toBe(404); + expect(response.body.error).toContain('not found in wishlist'); + }); + + it('should return 404 for non-existing user', async () => { + const response = await request(app) + .delete('/users/999/wishlist/1'); + expect(response.status).toBe(404); + expect(response.body.error).toContain('User not found'); + }); + }); + }); +}); diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts new file mode 100644 index 0000000..b14bb47 --- /dev/null +++ b/api/src/routes/user.ts @@ -0,0 +1,361 @@ +/** + * @swagger + * tags: + * name: Users + * description: API endpoints for managing users and wishlists + */ + +/** + * @swagger + * /api/users: + * post: + * summary: Register a new user + * tags: [Users] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - email + * - name + * - password + * properties: + * email: + * type: string + * format: email + * name: + * type: string + * password: + * type: string + * minLength: 8 + * responses: + * 201: + * description: User created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 400: + * description: Invalid input or email already exists + * + * /api/users/{email}: + * get: + * summary: Get user by email (for login) + * tags: [Users] + * parameters: + * - in: path + * name: email + * required: true + * schema: + * type: string + * format: email + * description: User email address + * responses: + * 200: + * description: User found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 404: + * description: User not found + * + * /api/users/id/{userId}: + * put: + * summary: Update user details + * tags: [Users] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: User ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * email: + * type: string + * format: email + * responses: + * 200: + * description: User updated successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 404: + * description: User not found + * + * /api/users/{userId}/wishlist: + * get: + * summary: Get user's wishlist with full product details + * tags: [Users] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: User ID + * responses: + * 200: + * description: List of products in user's wishlist + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/Product' + * 404: + * description: User not found + * post: + * summary: Add product to user's wishlist + * tags: [Users] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: User ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - productId + * properties: + * productId: + * type: integer + * responses: + * 200: + * description: Product added to wishlist + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 400: + * description: Product already in wishlist or invalid product ID + * 404: + * description: User not found + * + * /api/users/{userId}/wishlist/{productId}: + * delete: + * summary: Remove product from user's wishlist + * tags: [Users] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: User ID + * - in: path + * name: productId + * required: true + * schema: + * type: integer + * description: Product ID + * responses: + * 200: + * description: Product removed from wishlist + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/User' + * 404: + * description: User or product not found in wishlist + */ + +import { Router, Request, Response } from 'express'; +import { User } from '../models/user'; +import { users as seedUsers } from '../seedData'; +import { products } from '../seedData'; + +const router = Router(); + +let users: User[] = [...seedUsers]; + +// Validation helpers +const isValidEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; + +const isValidPassword = (password: string): boolean => { + // Password must be at least 8 characters + return password.length >= 8; +}; + +// POST /api/users - Register new user +router.post('/', (req: Request, res: Response) => { + const { email, name, password } = req.body; + + // Validate required fields + if (!email || !name || !password) { + return res.status(400).json({ error: 'Email, name, and password are required' }); + } + + // Validate email format + if (!isValidEmail(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + // Validate password requirements + if (!isValidPassword(password)) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + + // Check for duplicate email + const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase()); + if (existingUser) { + return res.status(400).json({ error: 'Email already exists' }); + } + + // Create new user + const newUser: User = { + userId: Math.max(0, ...users.map(u => u.userId)) + 1, + email, + name, + isAdmin: email.endsWith('@github.com'), // Admin if GitHub email + createdAt: new Date(), + wishlistProductIds: [] + }; + + users.push(newUser); + res.status(201).json(newUser); +}); + +// GET /api/users/:email - Get user by email (for login) +router.get('/:email', (req: Request, res: Response) => { + const { email } = req.params; + const user = users.find(u => u.email.toLowerCase() === email.toLowerCase()); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json(user); +}); + +// PUT /api/users/id/:userId - Update user details +router.put('/id/:userId', (req: Request, res: Response) => { + const userId = parseInt(req.params.userId); + const { name, email } = req.body; + + const userIndex = users.findIndex(u => u.userId === userId); + + if (userIndex === -1) { + return res.status(404).json({ error: 'User not found' }); + } + + // If email is being updated, check for duplicates + if (email && email !== users[userIndex].email) { + if (!isValidEmail(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase()); + if (existingUser) { + return res.status(400).json({ error: 'Email already exists' }); + } + } + + // Update user + if (name) users[userIndex].name = name; + if (email) { + users[userIndex].email = email; + users[userIndex].isAdmin = email.endsWith('@github.com'); + } + + res.json(users[userIndex]); +}); + +// GET /api/users/:userId/wishlist - Get user's wishlist with full product details +router.get('/:userId/wishlist', (req: Request, res: Response) => { + const userId = parseInt(req.params.userId); + const user = users.find(u => u.userId === userId); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Get full product details for each product in wishlist + const wishlistProducts = products.filter(p => + user.wishlistProductIds.includes(p.productId) + ); + + res.json(wishlistProducts); +}); + +// POST /api/users/:userId/wishlist - Add product to wishlist +router.post('/:userId/wishlist', (req: Request, res: Response) => { + const userId = parseInt(req.params.userId); + const { productId } = req.body; + + if (!productId) { + return res.status(400).json({ error: 'Product ID is required' }); + } + + const userIndex = users.findIndex(u => u.userId === userId); + + if (userIndex === -1) { + return res.status(404).json({ error: 'User not found' }); + } + + // Verify product exists + const product = products.find(p => p.productId === productId); + if (!product) { + return res.status(400).json({ error: 'Invalid product ID' }); + } + + // Check if product is already in wishlist + if (users[userIndex].wishlistProductIds.includes(productId)) { + return res.status(400).json({ error: 'Product already in wishlist' }); + } + + // Add to wishlist + users[userIndex].wishlistProductIds.push(productId); + + res.json(users[userIndex]); +}); + +// DELETE /api/users/:userId/wishlist/:productId - Remove from wishlist +router.delete('/:userId/wishlist/:productId', (req: Request, res: Response) => { + const userId = parseInt(req.params.userId); + const productId = parseInt(req.params.productId); + + const userIndex = users.findIndex(u => u.userId === userId); + + if (userIndex === -1) { + return res.status(404).json({ error: 'User not found' }); + } + + const productIndex = users[userIndex].wishlistProductIds.indexOf(productId); + + if (productIndex === -1) { + return res.status(404).json({ error: 'Product not found in wishlist' }); + } + + // Remove from wishlist + users[userIndex].wishlistProductIds.splice(productIndex, 1); + + res.json(users[userIndex]); +}); + +// Helper function to reset users for testing +export function resetUsers() { + users = [...seedUsers]; +} + +export default router; diff --git a/api/src/seedData.ts b/api/src/seedData.ts index ba0bebe..07df6e4 100644 --- a/api/src/seedData.ts +++ b/api/src/seedData.ts @@ -6,6 +6,7 @@ import { Order } from './models/order'; import { OrderDetail } from './models/orderDetail'; import { Delivery } from './models/delivery'; import { OrderDetailDelivery } from './models/orderDetailDelivery'; +import { User } from './models/user'; // Suppliers export const suppliers: Supplier[] = [ @@ -291,4 +292,32 @@ export const orderDetailDeliveries: OrderDetailDelivery[] = [ quantity: 20, notes: "Delivery" } +]; + +// Users +export const users: User[] = [ + { + userId: 1, + email: "alice@example.com", + name: "Alice Johnson", + isAdmin: false, + createdAt: new Date("2024-01-15T10:00:00Z"), + wishlistProductIds: [] + }, + { + userId: 2, + email: "bob@github.com", + name: "Bob Smith", + isAdmin: true, + createdAt: new Date("2024-02-01T14:30:00Z"), + wishlistProductIds: [1, 3, 5] + }, + { + userId: 3, + email: "charlie@example.com", + name: "Charlie Brown", + isAdmin: false, + createdAt: new Date("2024-03-10T09:15:00Z"), + wishlistProductIds: [2, 4, 7, 10] + } ]; \ No newline at end of file From 935b55daaf7c66caf7e8e6cf21161bc4964d2110 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:55:06 +0000 Subject: [PATCH 3/4] Complete frontend implementation: auth, wishlist, register, and UI components Co-authored-by: thomasiverson <12767513+thomasiverson@users.noreply.github.com> --- frontend/src/App.tsx | 23 ++- frontend/src/api/config.ts | 3 +- frontend/src/components/Login.tsx | 25 ++- frontend/src/components/Navigation.tsx | 36 ++++- frontend/src/components/Register.tsx | 146 +++++++++++++++++ frontend/src/components/Wishlist.tsx | 148 ++++++++++++++++++ .../components/entity/product/Products.tsx | 57 +++++++ frontend/src/context/AuthContext.tsx | 83 ++++++++-- frontend/src/context/WishlistContext.tsx | 121 ++++++++++++++ 9 files changed, 611 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/Register.tsx create mode 100644 frontend/src/components/Wishlist.tsx create mode 100644 frontend/src/context/WishlistContext.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0b02da..96f1c70 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,10 +5,17 @@ import About from './components/About'; import Footer from './components/Footer'; import Products from './components/entity/product/Products'; import Login from './components/Login'; +import Register from './components/Register'; +import Wishlist from './components/Wishlist'; import { AuthProvider } from './context/AuthContext'; import { ThemeProvider } from './context/ThemeContext'; +import { WishlistProvider } from './context/WishlistContext'; import AdminProducts from './components/admin/AdminProducts'; import { useTheme } from './context/ThemeContext'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +// Create a client +const queryClient = new QueryClient(); // Wrapper component to apply theme classes function ThemedApp() { @@ -24,6 +31,8 @@ function ThemedApp() { } /> } /> } /> + } /> + } /> } /> @@ -35,11 +44,15 @@ function ThemedApp() { function App() { return ( - - - - - + + + + + + + + + ); } diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 6e77299..d691437 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -43,6 +43,7 @@ export const api = { headquarters: '/api/headquarters', deliveries: '/api/deliveries', orderDetails: '/api/order-details', - orderDetailDeliveries: '/api/order-detail-deliveries' + orderDetailDeliveries: '/api/order-detail-deliveries', + users: '/api/users' } }; \ No newline at end of file diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index dff23a4..980caa1 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { useTheme } from '../context/ThemeContext'; @@ -21,11 +21,16 @@ export default function Login() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setError(''); try { await login(email, password); navigate('/'); - } catch { - setError('Login failed. Please try again.'); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('Login failed. Please try again.'); + } } }; @@ -35,10 +40,9 @@ export default function Login() {

Login

{error && ( -
+
+ {error} +
)}
@@ -74,6 +78,13 @@ export default function Login() { Login
+ +
+ Don't have an account?{' '} + + Register here + +
); diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index d7b393b..8c16e88 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -1,11 +1,13 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { useTheme } from '../context/ThemeContext'; +import { useWishlist } from '../context/WishlistContext'; import { useState } from 'react'; export default function Navigation() { - const { isLoggedIn, isAdmin, logout } = useAuth(); + const { user, isLoggedIn, isAdmin, logout } = useAuth(); const { darkMode, toggleTheme } = useTheme(); + const { wishlist } = useWishlist(); const [adminMenuOpen, setAdminMenuOpen] = useState(false); return ( @@ -30,6 +32,16 @@ export default function Navigation() { Home Products About us + {isLoggedIn && ( + + Wishlist + {wishlist.length > 0 && ( + + {wishlist.length} + + )} + + )} {isAdmin && (
diff --git a/frontend/src/components/Register.tsx b/frontend/src/components/Register.tsx new file mode 100644 index 0000000..c757640 --- /dev/null +++ b/frontend/src/components/Register.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { useTheme } from '../context/ThemeContext'; + +export default function Register() { + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + const navigate = useNavigate(); + const { register } = useAuth(); + const { darkMode } = useTheme(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + // Validate passwords match + if (password !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + // Validate password length + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } + + try { + await register(email, name, password); + setSuccess(true); + setTimeout(() => { + navigate('/login'); + }, 2000); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('Registration failed. Please try again.'); + } + } + }; + + return ( +
+
+

Register

+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ Registration successful! Redirecting to login... +
+ )} + +
+
+ + setName(e.target.value)} + className={`w-full ${darkMode ? 'bg-gray-700 text-light' : 'bg-gray-100 text-gray-800'} rounded px-3 py-2 transition-colors duration-300`} + required + autoFocus + /> +
+ +
+ + setEmail(e.target.value)} + className={`w-full ${darkMode ? 'bg-gray-700 text-light' : 'bg-gray-100 text-gray-800'} rounded px-3 py-2 transition-colors duration-300`} + required + /> +
+ +
+ + setPassword(e.target.value)} + className={`w-full ${darkMode ? 'bg-gray-700 text-light' : 'bg-gray-100 text-gray-800'} rounded px-3 py-2 transition-colors duration-300`} + required + minLength={8} + /> +

+ Must be at least 8 characters +

+
+ +
+ + setConfirmPassword(e.target.value)} + className={`w-full ${darkMode ? 'bg-gray-700 text-light' : 'bg-gray-100 text-gray-800'} rounded px-3 py-2 transition-colors duration-300`} + required + minLength={8} + /> +
+ + +
+ +
+ Already have an account?{' '} + + Login here + +
+
+
+ ); +} diff --git a/frontend/src/components/Wishlist.tsx b/frontend/src/components/Wishlist.tsx new file mode 100644 index 0000000..2f7dce8 --- /dev/null +++ b/frontend/src/components/Wishlist.tsx @@ -0,0 +1,148 @@ +import { useWishlist } from '../context/WishlistContext'; +import { useAuth } from '../context/AuthContext'; +import { useTheme } from '../context/ThemeContext'; +import { useNavigate } from 'react-router-dom'; + +export default function Wishlist() { + const { wishlist, isLoading, removeFromWishlist } = useWishlist(); + const { isLoggedIn } = useAuth(); + const { darkMode } = useTheme(); + const navigate = useNavigate(); + + if (!isLoggedIn) { + return ( +
+
+
+

+ Please Log In +

+

+ You need to be logged in to view your wishlist. +

+ +
+
+
+ ); + } + + if (isLoading) { + return ( +
+
+

+ My Wishlist +

+
+ Loading your wishlist... +
+
+
+ ); + } + + const handleRemove = async (productId: number) => { + try { + await removeFromWishlist(productId); + } catch (error) { + console.error('Failed to remove from wishlist:', error); + } + }; + + return ( +
+
+

+ My Wishlist +

+ + {wishlist.length === 0 ? ( +
+ + + +

+ Your wishlist is empty +

+

+ Start adding products you love to your wishlist! +

+ +
+ ) : ( +
+ {wishlist.map((product) => ( +
+ {product.name} { + (e.target as HTMLImageElement).src = '/images/placeholder.png'; + }} + /> +
+

+ {product.name} +

+

+ {product.description} +

+
+ {product.discount ? ( +
+ + ${(product.price * (1 - product.discount)).toFixed(2)} + + + ${product.price.toFixed(2)} + + + {(product.discount * 100).toFixed(0)}% OFF + +
+ ) : ( + + ${product.price.toFixed(2)} + + )} +
+ +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/entity/product/Products.tsx b/frontend/src/components/entity/product/Products.tsx index af2319e..692485e 100644 --- a/frontend/src/components/entity/product/Products.tsx +++ b/frontend/src/components/entity/product/Products.tsx @@ -3,6 +3,8 @@ import axios from 'axios'; import { useQuery } from 'react-query'; import { api } from '../../../api/config'; import { useTheme } from '../../../context/ThemeContext'; +import { useAuth } from '../../../context/AuthContext'; +import { useWishlist } from '../../../context/WishlistContext'; interface Product { productId: number; @@ -26,8 +28,11 @@ export default function Products() { const [searchTerm, setSearchTerm] = useState(''); const [selectedProduct, setSelectedProduct] = useState(null); const [showModal, setShowModal] = useState(false); + const [wishlistLoading, setWishlistLoading] = useState>({}); const { data: products, isLoading, error } = useQuery('products', fetchProducts); const { darkMode } = useTheme(); + const { isLoggedIn } = useAuth(); + const { addToWishlist, removeFromWishlist, isInWishlist } = useWishlist(); const filteredProducts = products?.filter(product => product.name.toLowerCase().includes(searchTerm.toLowerCase()) || @@ -53,6 +58,27 @@ export default function Products() { } }; + const handleWishlistToggle = async (productId: number) => { + if (!isLoggedIn) { + alert('Please log in to add items to your wishlist'); + return; + } + + setWishlistLoading(prev => ({ ...prev, [productId]: true })); + try { + if (isInWishlist(productId)) { + await removeFromWishlist(productId); + } else { + await addToWishlist(productId); + } + } catch (error) { + console.error('Failed to update wishlist:', error); + alert('Failed to update wishlist. Please try again.'); + } finally { + setWishlistLoading(prev => ({ ...prev, [productId]: false })); + } + }; + const handleProductClick = (product: Product) => { setSelectedProduct(product); setShowModal(true); @@ -125,6 +151,37 @@ export default function Products() { {Math.round(product.discount * 100)}% OFF )} + {isLoggedIn && ( + + )}
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 478cba0..c9c97e4 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,34 +1,97 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, useContext, useState, ReactNode, useEffect } from 'react'; +import axios from 'axios'; +import { api } from '../api/config'; + +interface User { + userId: number; + email: string; + name: string; + isAdmin: boolean; + createdAt: Date; + wishlistProductIds: number[]; +} interface AuthContextType { + user: User | null; isLoggedIn: boolean; isAdmin: boolean; login: (email: string, password: string) => Promise; logout: () => void; + register: (email: string, name: string, password: string) => Promise; } const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [isAdmin, setIsAdmin] = useState(false); + const [user, setUser] = useState(null); + + // Load user from sessionStorage on mount + useEffect(() => { + const storedUser = sessionStorage.getItem('user'); + if (storedUser) { + try { + const parsedUser = JSON.parse(storedUser); + setUser(parsedUser); + } catch (error) { + console.error('Failed to parse stored user:', error); + sessionStorage.removeItem('user'); + } + } + }, []); + + const register = async (email: string, name: string, password: string) => { + try { + const response = await axios.post(`${api.baseURL}${api.endpoints.users}`, { + email, + name, + password + }); + // Don't auto-login after registration + return response.data; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + throw new Error(error.response.data.error || 'Registration failed'); + } + throw new Error('Registration failed. Please try again.'); + } + }; const login = async (email: string, password: string) => { // In a real app, you would validate credentials with an API - // For now, we'll just check the email domain - if (email && password) { - setIsLoggedIn(true); - setIsAdmin(email.endsWith('@github.com')); + // For now, we'll fetch the user by email if credentials are provided + if (!email || !password) { + throw new Error('Email and password are required'); + } + + try { + const response = await axios.get(`${api.baseURL}${api.endpoints.users}/${email}`); + const userData = response.data; + + // Store user in state and sessionStorage + setUser(userData); + sessionStorage.setItem('user', JSON.stringify(userData)); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + throw new Error('User not found. Please check your email or register.'); + } + throw new Error('Login failed. Please try again.'); } }; const logout = () => { - setIsLoggedIn(false); - setIsAdmin(false); + setUser(null); + sessionStorage.removeItem('user'); }; return ( - + {children} ); diff --git a/frontend/src/context/WishlistContext.tsx b/frontend/src/context/WishlistContext.tsx new file mode 100644 index 0000000..15b0d88 --- /dev/null +++ b/frontend/src/context/WishlistContext.tsx @@ -0,0 +1,121 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useQuery, useMutation, useQueryClient } from 'react-query'; +import axios from 'axios'; +import { api } from '../api/config'; +import { useAuth } from './AuthContext'; + +interface Product { + productId: number; + supplierId: number; + name: string; + description: string; + price: number; + sku: string; + unit: string; + imgName: string; + discount?: number; +} + +interface WishlistContextType { + wishlist: Product[]; + isLoading: boolean; + error: Error | null; + addToWishlist: (productId: number) => Promise; + removeFromWishlist: (productId: number) => Promise; + isInWishlist: (productId: number) => boolean; +} + +const WishlistContext = createContext(null); + +export function WishlistProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const queryClient = useQueryClient(); + + // Fetch wishlist + const { data: wishlist = [], isLoading, error } = useQuery( + ['wishlist', user?.userId], + async () => { + if (!user) return []; + const response = await axios.get( + `${api.baseURL}${api.endpoints.users}/${user.userId}/wishlist` + ); + return response.data; + }, + { + enabled: !!user, + staleTime: 1000 * 60 * 5, // 5 minutes + } + ); + + // Add to wishlist mutation + const addMutation = useMutation( + async (productId: number) => { + if (!user) throw new Error('User not logged in'); + await axios.post( + `${api.baseURL}${api.endpoints.users}/${user.userId}/wishlist`, + { productId } + ); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['wishlist', user?.userId]); + }, + onError: (error) => { + if (axios.isAxiosError(error) && error.response) { + throw new Error(error.response.data.error || 'Failed to add to wishlist'); + } + }, + } + ); + + // Remove from wishlist mutation + const removeMutation = useMutation( + async (productId: number) => { + if (!user) throw new Error('User not logged in'); + await axios.delete( + `${api.baseURL}${api.endpoints.users}/${user.userId}/wishlist/${productId}` + ); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['wishlist', user?.userId]); + }, + } + ); + + const addToWishlist = async (productId: number) => { + await addMutation.mutateAsync(productId); + }; + + const removeFromWishlist = async (productId: number) => { + await removeMutation.mutateAsync(productId); + }; + + const isInWishlist = (productId: number) => { + return wishlist.some(product => product.productId === productId); + }; + + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useWishlist() { + const context = useContext(WishlistContext); + if (!context) { + throw new Error('useWishlist must be used within a WishlistProvider'); + } + return context; +} From 30ad505d54cacf8598ec3e878ce35c015ef27c0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:58:00 +0000 Subject: [PATCH 4/4] Fix TypeScript errors in user routes to use if/else pattern Co-authored-by: thomasiverson <12767513+thomasiverson@users.noreply.github.com> --- api/src/routes/user.ts | 199 +++++++++++++++++++++-------------------- 1 file changed, 100 insertions(+), 99 deletions(-) diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts index b14bb47..1aadb95 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/user.ts @@ -178,12 +178,12 @@ * description: User or product not found in wishlist */ -import { Router, Request, Response } from 'express'; +import express from 'express'; import { User } from '../models/user'; import { users as seedUsers } from '../seedData'; import { products } from '../seedData'; -const router = Router(); +const router = express.Router(); let users: User[] = [...seedUsers]; @@ -199,158 +199,159 @@ const isValidPassword = (password: string): boolean => { }; // POST /api/users - Register new user -router.post('/', (req: Request, res: Response) => { +router.post('/', (req, res) => { const { email, name, password } = req.body; // Validate required fields if (!email || !name || !password) { - return res.status(400).json({ error: 'Email, name, and password are required' }); - } - - // Validate email format - if (!isValidEmail(email)) { - return res.status(400).json({ error: 'Invalid email format' }); - } - - // Validate password requirements - if (!isValidPassword(password)) { - return res.status(400).json({ error: 'Password must be at least 8 characters' }); - } + res.status(400).json({ error: 'Email, name, and password are required' }); + } else if (!isValidEmail(email)) { + // Validate email format + res.status(400).json({ error: 'Invalid email format' }); + } else if (!isValidPassword(password)) { + // Validate password requirements + res.status(400).json({ error: 'Password must be at least 8 characters' }); + } else { + // Check for duplicate email + const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase()); + if (existingUser) { + res.status(400).json({ error: 'Email already exists' }); + } else { + // Create new user + const newUser: User = { + userId: Math.max(0, ...users.map(u => u.userId)) + 1, + email, + name, + isAdmin: email.endsWith('@github.com'), // Admin if GitHub email + createdAt: new Date(), + wishlistProductIds: [] + }; - // Check for duplicate email - const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase()); - if (existingUser) { - return res.status(400).json({ error: 'Email already exists' }); + users.push(newUser); + res.status(201).json(newUser); + } } - - // Create new user - const newUser: User = { - userId: Math.max(0, ...users.map(u => u.userId)) + 1, - email, - name, - isAdmin: email.endsWith('@github.com'), // Admin if GitHub email - createdAt: new Date(), - wishlistProductIds: [] - }; - - users.push(newUser); - res.status(201).json(newUser); }); // GET /api/users/:email - Get user by email (for login) -router.get('/:email', (req: Request, res: Response) => { +router.get('/:email', (req, res) => { const { email } = req.params; const user = users.find(u => u.email.toLowerCase() === email.toLowerCase()); - if (!user) { - return res.status(404).json({ error: 'User not found' }); + if (user) { + res.json(user); + } else { + res.status(404).json({ error: 'User not found' }); } - - res.json(user); }); // PUT /api/users/id/:userId - Update user details -router.put('/id/:userId', (req: Request, res: Response) => { +router.put('/id/:userId', (req, res) => { const userId = parseInt(req.params.userId); const { name, email } = req.body; const userIndex = users.findIndex(u => u.userId === userId); if (userIndex === -1) { - return res.status(404).json({ error: 'User not found' }); - } - - // If email is being updated, check for duplicates - if (email && email !== users[userIndex].email) { - if (!isValidEmail(email)) { - return res.status(400).json({ error: 'Invalid email format' }); + res.status(404).json({ error: 'User not found' }); + } else { + // If email is being updated, check for duplicates + if (email && email !== users[userIndex].email) { + if (!isValidEmail(email)) { + res.status(400).json({ error: 'Invalid email format' }); + return; + } + const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase()); + if (existingUser) { + res.status(400).json({ error: 'Email already exists' }); + return; + } } - const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase()); - if (existingUser) { - return res.status(400).json({ error: 'Email already exists' }); - } - } - // Update user - if (name) users[userIndex].name = name; - if (email) { - users[userIndex].email = email; - users[userIndex].isAdmin = email.endsWith('@github.com'); + // Update user + if (name) users[userIndex].name = name; + if (email) { + users[userIndex].email = email; + users[userIndex].isAdmin = email.endsWith('@github.com'); + } + + res.json(users[userIndex]); } - - res.json(users[userIndex]); }); // GET /api/users/:userId/wishlist - Get user's wishlist with full product details -router.get('/:userId/wishlist', (req: Request, res: Response) => { +router.get('/:userId/wishlist', (req, res) => { const userId = parseInt(req.params.userId); const user = users.find(u => u.userId === userId); - if (!user) { - return res.status(404).json({ error: 'User not found' }); + if (user) { + // Get full product details for each product in wishlist + const wishlistProducts = products.filter(p => + user.wishlistProductIds.includes(p.productId) + ); + + res.json(wishlistProducts); + } else { + res.status(404).json({ error: 'User not found' }); } - - // Get full product details for each product in wishlist - const wishlistProducts = products.filter(p => - user.wishlistProductIds.includes(p.productId) - ); - - res.json(wishlistProducts); }); // POST /api/users/:userId/wishlist - Add product to wishlist -router.post('/:userId/wishlist', (req: Request, res: Response) => { +router.post('/:userId/wishlist', (req, res) => { const userId = parseInt(req.params.userId); const { productId } = req.body; if (!productId) { - return res.status(400).json({ error: 'Product ID is required' }); + res.status(400).json({ error: 'Product ID is required' }); + return; } const userIndex = users.findIndex(u => u.userId === userId); if (userIndex === -1) { - return res.status(404).json({ error: 'User not found' }); - } - - // Verify product exists - const product = products.find(p => p.productId === productId); - if (!product) { - return res.status(400).json({ error: 'Invalid product ID' }); - } - - // Check if product is already in wishlist - if (users[userIndex].wishlistProductIds.includes(productId)) { - return res.status(400).json({ error: 'Product already in wishlist' }); + res.status(404).json({ error: 'User not found' }); + } else { + // Verify product exists + const product = products.find(p => p.productId === productId); + if (!product) { + res.status(400).json({ error: 'Invalid product ID' }); + return; + } + + // Check if product is already in wishlist + if (users[userIndex].wishlistProductIds.includes(productId)) { + res.status(400).json({ error: 'Product already in wishlist' }); + return; + } + + // Add to wishlist + users[userIndex].wishlistProductIds.push(productId); + + res.json(users[userIndex]); } - - // Add to wishlist - users[userIndex].wishlistProductIds.push(productId); - - res.json(users[userIndex]); }); // DELETE /api/users/:userId/wishlist/:productId - Remove from wishlist -router.delete('/:userId/wishlist/:productId', (req: Request, res: Response) => { +router.delete('/:userId/wishlist/:productId', (req, res) => { const userId = parseInt(req.params.userId); const productId = parseInt(req.params.productId); const userIndex = users.findIndex(u => u.userId === userId); if (userIndex === -1) { - return res.status(404).json({ error: 'User not found' }); - } - - const productIndex = users[userIndex].wishlistProductIds.indexOf(productId); - - if (productIndex === -1) { - return res.status(404).json({ error: 'Product not found in wishlist' }); + res.status(404).json({ error: 'User not found' }); + } else { + const productIndex = users[userIndex].wishlistProductIds.indexOf(productId); + + if (productIndex === -1) { + res.status(404).json({ error: 'Product not found in wishlist' }); + } else { + // Remove from wishlist + users[userIndex].wishlistProductIds.splice(productIndex, 1); + + res.json(users[userIndex]); + } } - - // Remove from wishlist - users[userIndex].wishlistProductIds.splice(productIndex, 1); - - res.json(users[userIndex]); }); // Helper function to reset users for testing