From 64c46e62f0c2c7d3d6d6b8bb28cf91c6514a7df8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:46:52 +0000 Subject: [PATCH 1/4] Initial plan From 01aff0e8b3af953e34a3d2e388e7cb460d9b9111 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:50:03 +0000 Subject: [PATCH 2/4] feat: Add backend wishlist API with model, routes, and tests Co-authored-by: thomasiverson <12767513+thomasiverson@users.noreply.github.com> --- api/src/index.ts | 2 + api/src/models/wishlist.ts | 32 +++++++ api/src/routes/wishlist.test.ts | 94 ++++++++++++++++++++ api/src/routes/wishlist.ts | 151 ++++++++++++++++++++++++++++++++ api/src/seedData.ts | 35 ++++++++ 5 files changed, 314 insertions(+) create mode 100644 api/src/models/wishlist.ts create mode 100644 api/src/routes/wishlist.test.ts create mode 100644 api/src/routes/wishlist.ts diff --git a/api/src/index.ts b/api/src/index.ts index b991a16..0fa96c2 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 wishlistRoutes from './routes/wishlist'; 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/wishlist', wishlistRoutes); app.get('/', (req, res) => { res.send('Hello, world!'); diff --git a/api/src/models/wishlist.ts b/api/src/models/wishlist.ts new file mode 100644 index 0000000..08406d5 --- /dev/null +++ b/api/src/models/wishlist.ts @@ -0,0 +1,32 @@ +/** + * @swagger + * components: + * schemas: + * WishlistItem: + * type: object + * required: + * - wishlistItemId + * - email + * - productId + * - addedAt + * properties: + * wishlistItemId: + * type: integer + * description: The unique identifier for the wishlist item + * email: + * type: string + * description: Email address of the user + * productId: + * type: integer + * description: The ID of the product in the wishlist + * addedAt: + * type: string + * format: date-time + * description: Timestamp when the item was added to the wishlist + */ +export interface WishlistItem { + wishlistItemId: number; + email: string; + productId: number; + addedAt: Date; +} diff --git a/api/src/routes/wishlist.test.ts b/api/src/routes/wishlist.test.ts new file mode 100644 index 0000000..3cb1cad --- /dev/null +++ b/api/src/routes/wishlist.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import wishlistRouter, { resetWishlist } from './wishlist'; +import { wishlistItems as seedWishlistItems } from '../seedData'; + +let app: express.Express; + +describe('Wishlist API', () => { + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/wishlist', wishlistRouter); + resetWishlist(); + }); + + it('should get all wishlist items for a user', async () => { + const response = await request(app).get('/wishlist/user@example.com'); + expect(response.status).toBe(200); + expect(response.body.length).toBe(3); + expect(response.body.every((item: any) => item.email === 'user@example.com')).toBe(true); + }); + + it('should get empty array for user with no wishlist items', async () => { + const response = await request(app).get('/wishlist/newuser@example.com'); + expect(response.status).toBe(200); + expect(response.body.length).toBe(0); + }); + + it('should add a new product to wishlist', async () => { + const newItem = { + email: 'newuser@example.com', + productId: 4 + }; + const response = await request(app).post('/wishlist').send(newItem); + expect(response.status).toBe(201); + expect(response.body.email).toBe(newItem.email); + expect(response.body.productId).toBe(newItem.productId); + expect(response.body.wishlistItemId).toBeDefined(); + expect(response.body.addedAt).toBeDefined(); + }); + + it('should prevent duplicate wishlist items', async () => { + const duplicateItem = { + email: 'user@example.com', + productId: 1 + }; + const response = await request(app).post('/wishlist').send(duplicateItem); + expect(response.status).toBe(400); + expect(response.text).toBe('Product already in wishlist'); + }); + + it('should return 404 when adding non-existent product', async () => { + const invalidItem = { + email: 'user@example.com', + productId: 9999 + }; + const response = await request(app).post('/wishlist').send(invalidItem); + expect(response.status).toBe(404); + expect(response.text).toBe('Product not found'); + }); + + it('should remove a product from wishlist', async () => { + const response = await request(app).delete('/wishlist/user@example.com/1'); + expect(response.status).toBe(204); + + // Verify it's removed + const getResponse = await request(app).get('/wishlist/user@example.com'); + expect(getResponse.body.some((item: any) => item.productId === 1)).toBe(false); + }); + + it('should return 404 when removing non-existent wishlist item', async () => { + const response = await request(app).delete('/wishlist/user@example.com/9999'); + expect(response.status).toBe(404); + expect(response.text).toBe('Wishlist item not found'); + }); + + it('should filter wishlist items by email correctly', async () => { + const user1Response = await request(app).get('/wishlist/user@example.com'); + const user2Response = await request(app).get('/wishlist/admin@github.com'); + + expect(user1Response.body.length).toBe(3); + expect(user2Response.body.length).toBe(2); + + expect(user1Response.body.every((item: any) => item.email === 'user@example.com')).toBe(true); + expect(user2Response.body.every((item: any) => item.email === 'admin@github.com')).toBe(true); + }); + + it('should reset wishlist to seed data', () => { + resetWishlist(); + // This is implicitly tested by the beforeEach, but we can verify manually + expect(true).toBe(true); + }); +}); diff --git a/api/src/routes/wishlist.ts b/api/src/routes/wishlist.ts new file mode 100644 index 0000000..da8b00d --- /dev/null +++ b/api/src/routes/wishlist.ts @@ -0,0 +1,151 @@ +/** + * @swagger + * tags: + * name: Wishlist + * description: API endpoints for managing user wishlists + */ + +/** + * @swagger + * /api/wishlist/{email}: + * get: + * summary: Get all wishlist items for a user + * tags: [Wishlist] + * parameters: + * - in: path + * name: email + * required: true + * schema: + * type: string + * description: User email address + * responses: + * 200: + * description: List of wishlist items for the user + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/WishlistItem' + * + * /api/wishlist: + * post: + * summary: Add a product to the wishlist + * tags: [Wishlist] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * description: User email address + * productId: + * type: integer + * description: Product ID to add to wishlist + * responses: + * 201: + * description: Product added to wishlist successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/WishlistItem' + * 400: + * description: Product already in wishlist or product not found + * 404: + * description: Product not found + * + * /api/wishlist/{email}/{productId}: + * delete: + * summary: Remove a product from the wishlist + * tags: [Wishlist] + * parameters: + * - in: path + * name: email + * required: true + * schema: + * type: string + * description: User email address + * - in: path + * name: productId + * required: true + * schema: + * type: integer + * description: Product ID + * responses: + * 204: + * description: Product removed from wishlist successfully + * 404: + * description: Wishlist item not found + */ + +import express from 'express'; +import { WishlistItem } from '../models/wishlist'; +import { wishlistItems as seedWishlistItems } from '../seedData'; +import { products as seedProducts } from '../seedData'; + +const router = express.Router(); + +let wishlistItems: WishlistItem[] = [...seedWishlistItems]; + +// Add reset function for testing +export const resetWishlist = () => { + wishlistItems = [...seedWishlistItems]; +}; + +// Get all wishlist items for a user +router.get('/:email', (req, res) => { + const userWishlist = wishlistItems.filter(item => item.email === req.params.email); + res.json(userWishlist); +}); + +// Add a product to the wishlist +router.post('/', (req, res) => { + const { email, productId } = req.body; + + // Validate that product exists + const productExists = seedProducts.some(p => p.productId === productId); + if (!productExists) { + res.status(404).send('Product not found'); + return; + } + + // Check if already in wishlist (prevent duplicates) + const existingItem = wishlistItems.find( + item => item.email === email && item.productId === productId + ); + if (existingItem) { + res.status(400).send('Product already in wishlist'); + return; + } + + // Create new wishlist item + const newWishlistItem: WishlistItem = { + wishlistItemId: Math.max(...wishlistItems.map(item => item.wishlistItemId), 0) + 1, + email, + productId, + addedAt: new Date() + }; + + wishlistItems.push(newWishlistItem); + res.status(201).json(newWishlistItem); +}); + +// Remove a product from the wishlist +router.delete('/:email/:productId', (req, res) => { + const { email, productId } = req.params; + const index = wishlistItems.findIndex( + item => item.email === email && item.productId === parseInt(productId) + ); + + if (index !== -1) { + wishlistItems.splice(index, 1); + res.status(204).send(); + } else { + res.status(404).send('Wishlist item not found'); + } +}); + +export default router; diff --git a/api/src/seedData.ts b/api/src/seedData.ts index ba0bebe..0f42315 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 { WishlistItem } from './models/wishlist'; // Suppliers export const suppliers: Supplier[] = [ @@ -291,4 +292,38 @@ export const orderDetailDeliveries: OrderDetailDelivery[] = [ quantity: 20, notes: "Delivery" } +]; + +// Wishlist Items +export const wishlistItems: WishlistItem[] = [ + { + wishlistItemId: 1, + email: "user@example.com", + productId: 1, + addedAt: new Date("2024-01-15T10:30:00Z") + }, + { + wishlistItemId: 2, + email: "user@example.com", + productId: 3, + addedAt: new Date("2024-01-16T14:20:00Z") + }, + { + wishlistItemId: 3, + email: "user@example.com", + productId: 7, + addedAt: new Date("2024-01-20T09:15:00Z") + }, + { + wishlistItemId: 4, + email: "admin@github.com", + productId: 2, + addedAt: new Date("2024-01-18T11:45:00Z") + }, + { + wishlistItemId: 5, + email: "admin@github.com", + productId: 5, + addedAt: new Date("2024-01-19T16:30:00Z") + } ]; \ No newline at end of file From 7b5f50311c344b48e3c45eb0f8d4ae63bb6912eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:52:07 +0000 Subject: [PATCH 3/4] feat: Add frontend wishlist functionality with UI components Co-authored-by: thomasiverson <12767513+thomasiverson@users.noreply.github.com> --- frontend/src/App.tsx | 7 +- frontend/src/api/config.ts | 3 +- frontend/src/components/Navigation.tsx | 12 ++ frontend/src/components/Wishlist.tsx | 149 ++++++++++++++++++ .../components/entity/product/Products.tsx | 34 ++++ frontend/src/context/AuthContext.tsx | 6 +- frontend/src/context/WishlistContext.tsx | 111 +++++++++++++ 7 files changed, 319 insertions(+), 3 deletions(-) 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..c4f0896 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,9 @@ import Products from './components/entity/product/Products'; import Login from './components/Login'; import { AuthProvider } from './context/AuthContext'; import { ThemeProvider } from './context/ThemeContext'; +import { WishlistProvider } from './context/WishlistContext'; import AdminProducts from './components/admin/AdminProducts'; +import Wishlist from './components/Wishlist'; import { useTheme } from './context/ThemeContext'; // Wrapper component to apply theme classes @@ -24,6 +26,7 @@ function ThemedApp() { } /> } /> } /> + } /> } /> @@ -37,7 +40,9 @@ function App() { return ( - + + + ); diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 6e77299..5e74d57 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', + wishlist: '/api/wishlist' } }; \ No newline at end of file diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index d7b393b..1f0c44b 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 { darkMode, toggleTheme } = useTheme(); + const { wishlistItems } = useWishlist(); const [adminMenuOpen, setAdminMenuOpen] = useState(false); return ( @@ -30,6 +32,16 @@ export default function Navigation() { Home Products About us + {isLoggedIn && ( + + Wishlist + {wishlistItems.length > 0 && ( + + {wishlistItems.length} + + )} + + )} {isAdmin && (
+
+ + ); + })} + + )} + + + ); +} diff --git a/frontend/src/components/entity/product/Products.tsx b/frontend/src/components/entity/product/Products.tsx index af2319e..a5eeaa5 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; @@ -28,6 +30,8 @@ export default function Products() { const [showModal, setShowModal] = useState(false); 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()) || @@ -58,6 +62,19 @@ export default function Products() { setShowModal(true); }; + const handleWishlistToggle = async (productId: number, e: React.MouseEvent) => { + e.stopPropagation(); + try { + if (isInWishlist(productId)) { + await removeFromWishlist(productId); + } else { + await addToWishlist(productId); + } + } catch (error) { + console.error('Failed to update wishlist:', error); + } + }; + if (isLoading) { return (
@@ -125,6 +142,23 @@ 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..6fb6f63 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -3,6 +3,7 @@ import { createContext, useContext, useState, ReactNode } from 'react'; interface AuthContextType { isLoggedIn: boolean; isAdmin: boolean; + email: string; login: (email: string, password: string) => Promise; logout: () => void; } @@ -12,6 +13,7 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [isLoggedIn, setIsLoggedIn] = useState(false); const [isAdmin, setIsAdmin] = useState(false); + const [email, setEmail] = useState(''); const login = async (email: string, password: string) => { // In a real app, you would validate credentials with an API @@ -19,16 +21,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (email && password) { setIsLoggedIn(true); setIsAdmin(email.endsWith('@github.com')); + setEmail(email); } }; const logout = () => { setIsLoggedIn(false); setIsAdmin(false); + setEmail(''); }; return ( - + {children} ); diff --git a/frontend/src/context/WishlistContext.tsx b/frontend/src/context/WishlistContext.tsx new file mode 100644 index 0000000..4cd9010 --- /dev/null +++ b/frontend/src/context/WishlistContext.tsx @@ -0,0 +1,111 @@ +import { createContext, useContext, ReactNode } from 'react'; +import axios from 'axios'; +import { useQuery, useMutation, useQueryClient } from 'react-query'; +import { api } from '../api/config'; +import { useAuth } from './AuthContext'; + +interface WishlistItem { + wishlistItemId: number; + email: string; + productId: number; + addedAt: Date; +} + +interface WishlistContextType { + wishlistItems: WishlistItem[]; + isLoading: boolean; + error: unknown; + addToWishlist: (productId: number) => Promise; + removeFromWishlist: (productId: number) => Promise; + isInWishlist: (productId: number) => boolean; +} + +const WishlistContext = createContext(null); + +const fetchWishlist = async (email: string): Promise => { + if (!email) return []; + const { data } = await axios.get(`${api.baseURL}${api.endpoints.wishlist}/${email}`); + return data; +}; + +export function WishlistProvider({ children }: { children: ReactNode }) { + const { isLoggedIn, email } = useAuth(); + const queryClient = useQueryClient(); + + const { data: wishlistItems = [], isLoading, error } = useQuery( + ['wishlist', email], + () => fetchWishlist(email), + { + enabled: isLoggedIn && !!email, + staleTime: 30000, // 30 seconds + } + ); + + const addMutation = useMutation( + async (productId: number) => { + const { data } = await axios.post(`${api.baseURL}${api.endpoints.wishlist}`, { + email, + productId + }); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['wishlist', email]); + } + } + ); + + const removeMutation = useMutation( + async (productId: number) => { + await axios.delete(`${api.baseURL}${api.endpoints.wishlist}/${email}/${productId}`); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['wishlist', email]); + } + } + ); + + const addToWishlist = async (productId: number) => { + if (!isLoggedIn) { + throw new Error('Must be logged in to add to wishlist'); + } + await addMutation.mutateAsync(productId); + }; + + const removeFromWishlist = async (productId: number) => { + if (!isLoggedIn) { + throw new Error('Must be logged in to remove from wishlist'); + } + await removeMutation.mutateAsync(productId); + }; + + const isInWishlist = (productId: number): boolean => { + return wishlistItems.some(item => item.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 c58eca6815acbaebb12d203f973b0d3bba19c3b2 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:45 +0000 Subject: [PATCH 4/4] fix: Improve wishlist reset test to verify actual reset behavior Co-authored-by: thomasiverson <12767513+thomasiverson@users.noreply.github.com> --- api/src/routes/wishlist.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/api/src/routes/wishlist.test.ts b/api/src/routes/wishlist.test.ts index 3cb1cad..abcf4ee 100644 --- a/api/src/routes/wishlist.test.ts +++ b/api/src/routes/wishlist.test.ts @@ -86,9 +86,19 @@ describe('Wishlist API', () => { expect(user2Response.body.every((item: any) => item.email === 'admin@github.com')).toBe(true); }); - it('should reset wishlist to seed data', () => { + it('should reset wishlist to seed data', async () => { + // Add a new item + await request(app).post('/wishlist').send({ + email: 'test@example.com', + productId: 5 + }); + + // Reset the wishlist resetWishlist(); - // This is implicitly tested by the beforeEach, but we can verify manually - expect(true).toBe(true); + + // Verify it was reset to seed data + const response = await request(app).get('/wishlist/user@example.com'); + expect(response.status).toBe(200); + expect(response.body.length).toBe(seedWishlistItems.filter(item => item.email === 'user@example.com').length); }); });