From 6703fe68d9270d17ef95693891109806648c800b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:46:43 +0000 Subject: [PATCH 1/3] Initial plan From d995680c2b27fde11a9a79530bcd30e820cadfa8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:50:34 +0000 Subject: [PATCH 2/3] Add full-stack wishlist feature with backend API, WishlistContext, heart buttons, and Wishlist page Agent-Logs-Url: https://github.com/thomasiverson/GitHubCopilot_Customized/sessions/29968f7b-c4e2-4b44-bf7b-a0351299f5eb Co-authored-by: thomasiverson <12767513+thomasiverson@users.noreply.github.com> --- api/src/index.ts | 2 + api/src/models/wishlist.ts | 27 ++++ api/src/routes/wishlist.test.ts | 89 +++++++++++ api/src/routes/wishlist.ts | 141 ++++++++++++++++++ frontend/src/App.tsx | 7 +- frontend/src/api/config.ts | 3 +- frontend/src/components/Navigation.tsx | 12 ++ .../components/entity/product/Products.tsx | 21 +++ frontend/src/components/wishlist/Wishlist.tsx | 112 ++++++++++++++ frontend/src/context/AuthContext.tsx | 6 +- frontend/src/context/WishlistContext.tsx | 80 ++++++++++ 11 files changed, 497 insertions(+), 3 deletions(-) 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 create mode 100644 frontend/src/components/wishlist/Wishlist.tsx create mode 100644 frontend/src/context/WishlistContext.tsx 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..6d1c61f --- /dev/null +++ b/api/src/models/wishlist.ts @@ -0,0 +1,27 @@ +/** + * @swagger + * components: + * schemas: + * WishlistItem: + * type: object + * required: + * - userId + * - productId + * - addedAt + * properties: + * userId: + * type: string + * description: The user's email address + * productId: + * type: integer + * description: The unique identifier of the product + * addedAt: + * type: string + * format: date-time + * description: ISO date string when the item was added + */ +export interface WishlistItem { + userId: string; + productId: number; + addedAt: string; +} diff --git a/api/src/routes/wishlist.test.ts b/api/src/routes/wishlist.test.ts new file mode 100644 index 0000000..71a4a62 --- /dev/null +++ b/api/src/routes/wishlist.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; +import wishlistRouter, { resetWishlist } from './wishlist'; + +let app: express.Express; + +describe('Wishlist API', () => { + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/api/wishlist', wishlistRouter); + resetWishlist(); + }); + + it('should return 400 when userId is missing on GET', async () => { + const response = await request(app).get('/api/wishlist'); + expect(response.status).toBe(400); + }); + + it('should return empty array for a user with no wishlist items', async () => { + const response = await request(app).get('/api/wishlist?userId=test@example.com'); + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + + it('should add a product to the wishlist', async () => { + const response = await request(app) + .post('/api/wishlist') + .send({ userId: 'test@example.com', productId: 1 }); + expect(response.status).toBe(201); + expect(response.body.userId).toBe('test@example.com'); + expect(response.body.productId).toBe(1); + expect(response.body.addedAt).toBeDefined(); + }); + + it('should return 409 when adding a duplicate wishlist item', async () => { + await request(app) + .post('/api/wishlist') + .send({ userId: 'test@example.com', productId: 1 }); + const response = await request(app) + .post('/api/wishlist') + .send({ userId: 'test@example.com', productId: 1 }); + expect(response.status).toBe(409); + }); + + it('should retrieve wishlist items for a user', async () => { + await request(app) + .post('/api/wishlist') + .send({ userId: 'test@example.com', productId: 1 }); + await request(app) + .post('/api/wishlist') + .send({ userId: 'test@example.com', productId: 2 }); + + const response = await request(app).get('/api/wishlist?userId=test@example.com'); + expect(response.status).toBe(200); + expect(response.body.length).toBe(2); + }); + + it('should only return items for the specified user', async () => { + await request(app) + .post('/api/wishlist') + .send({ userId: 'user1@example.com', productId: 1 }); + await request(app) + .post('/api/wishlist') + .send({ userId: 'user2@example.com', productId: 2 }); + + const response = await request(app).get('/api/wishlist?userId=user1@example.com'); + expect(response.status).toBe(200); + expect(response.body.length).toBe(1); + expect(response.body[0].userId).toBe('user1@example.com'); + }); + + it('should delete a wishlist item', async () => { + await request(app) + .post('/api/wishlist') + .send({ userId: 'test@example.com', productId: 1 }); + + const response = await request(app) + .delete('/api/wishlist/1?userId=test@example.com'); + expect(response.status).toBe(204); + }); + + it('should return 404 when deleting a non-existing wishlist item', async () => { + const response = await request(app) + .delete('/api/wishlist/999?userId=test@example.com'); + expect(response.status).toBe(404); + }); +}); diff --git a/api/src/routes/wishlist.ts b/api/src/routes/wishlist.ts new file mode 100644 index 0000000..53859f6 --- /dev/null +++ b/api/src/routes/wishlist.ts @@ -0,0 +1,141 @@ +/** + * @swagger + * tags: + * name: Wishlist + * description: API endpoints for managing user wishlists + */ + +/** + * @swagger + * /api/wishlist: + * get: + * summary: Get wishlist items for a user + * tags: [Wishlist] + * parameters: + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: The user's email address + * responses: + * 200: + * description: List of wishlist items for the user + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/WishlistItem' + * 400: + * description: Missing userId query parameter + * post: + * summary: Add a product to the wishlist + * tags: [Wishlist] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userId + * - productId + * properties: + * userId: + * type: string + * description: The user's email address + * productId: + * type: integer + * description: The product ID to add + * responses: + * 201: + * description: Wishlist item added successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/WishlistItem' + * 409: + * description: Item already exists in wishlist + * + * /api/wishlist/{productId}: + * delete: + * summary: Remove a product from the wishlist + * tags: [Wishlist] + * parameters: + * - in: path + * name: productId + * required: true + * schema: + * type: integer + * description: The product ID to remove + * - in: query + * name: userId + * required: true + * schema: + * type: string + * description: The user's email address + * responses: + * 204: + * description: Wishlist item removed successfully + * 404: + * description: Wishlist item not found + */ + +import express from 'express'; +import { WishlistItem } from '../models/wishlist'; + +const router = express.Router(); + +let wishlistItems: WishlistItem[] = []; + +export const resetWishlist = () => { + wishlistItems = []; +}; + +// Get wishlist items for a user +router.get('/', (req, res) => { + const { userId } = req.query; + if (!userId) { + res.status(400).send('Missing userId query parameter'); + } else { + const items = wishlistItems.filter(item => item.userId === userId); + res.json(items); + } +}); + +// Add a product to the wishlist +router.post('/', (req, res) => { + const { userId, productId } = req.body; + const existing = wishlistItems.find( + item => item.userId === userId && item.productId === productId + ); + if (existing) { + res.status(409).send('Item already exists in wishlist'); + } else { + const newItem: WishlistItem = { + userId, + productId, + addedAt: new Date().toISOString(), + }; + wishlistItems.push(newItem); + res.status(201).json(newItem); + } +}); + +// Remove a product from the wishlist +router.delete('/:productId', (req, res) => { + const { userId } = req.query; + const productId = parseInt(req.params.productId); + const index = wishlistItems.findIndex( + item => item.userId === userId && item.productId === 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/frontend/src/App.tsx b/frontend/src/App.tsx index d0b02da..99a2a14 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,8 @@ import { AuthProvider } from './context/AuthContext'; import { ThemeProvider } from './context/ThemeContext'; import AdminProducts from './components/admin/AdminProducts'; import { useTheme } from './context/ThemeContext'; +import { WishlistProvider } from './context/WishlistContext'; +import Wishlist from './components/wishlist/Wishlist'; // Wrapper component to apply theme classes function ThemedApp() { @@ -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..067439c 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 { wishlistCount } = useWishlist(); const [adminMenuOpen, setAdminMenuOpen] = useState(false); return ( @@ -30,6 +32,16 @@ export default function Navigation() { Home Products About us + {isLoggedIn && ( + + Wishlist + {wishlistCount > 0 && ( + + {wishlistCount} + + )} + + )} {isAdmin && (
+ )} {product.discount && (
{Math.round(product.discount * 100)}% OFF diff --git a/frontend/src/components/wishlist/Wishlist.tsx b/frontend/src/components/wishlist/Wishlist.tsx new file mode 100644 index 0000000..013092a --- /dev/null +++ b/frontend/src/components/wishlist/Wishlist.tsx @@ -0,0 +1,112 @@ +import axios from 'axios'; +import { useQuery } from 'react-query'; +import { api } from '../../api/config'; +import { useTheme } from '../../context/ThemeContext'; +import { useWishlist } from '../../context/WishlistContext'; +import { useAuth } from '../../context/AuthContext'; + +interface Product { + productId: number; + name: string; + description: string; + price: number; + imgName: string; + sku: string; + unit: string; + supplierId: number; + discount?: number; +} + +const fetchProducts = async (): Promise => { + const { data } = await axios.get(`${api.baseURL}${api.endpoints.products}`); + return data; +}; + +export default function Wishlist() { + const { darkMode } = useTheme(); + const { isLoggedIn } = useAuth(); + const { wishlistProductIds, toggleWishlist, isWishlisted } = useWishlist(); + + const { data: products, isLoading } = useQuery('products', fetchProducts); + + const wislistedProducts = (products ?? []).filter(p => + wishlistProductIds.has(p.productId) + ); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + return ( +
+
+
+

My Wishlist

+ + {!isLoggedIn || wislistedProducts.length === 0 ? ( +

+ Your wishlist is empty — browse products to save items for later +

+ ) : ( +
+ {wislistedProducts.map(product => ( +
+
+ {product.name} + {product.discount && ( +
+ {Math.round(product.discount * 100)}% OFF +
+ )} + +
+ +
+

{product.name}

+

{product.description}

+
+ {product.discount ? ( +
+ ${product.price.toFixed(2)} + ${(product.price * (1 - product.discount)).toFixed(2)} +
+ ) : ( + ${product.price.toFixed(2)} + )} +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 478cba0..2939ad8 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; + userEmail: string | null; 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 [userEmail, setUserEmail] = useState(null); 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')); + setUserEmail(email); } }; const logout = () => { setIsLoggedIn(false); setIsAdmin(false); + setUserEmail(null); }; return ( - + {children} ); diff --git a/frontend/src/context/WishlistContext.tsx b/frontend/src/context/WishlistContext.tsx new file mode 100644 index 0000000..46bcd08 --- /dev/null +++ b/frontend/src/context/WishlistContext.tsx @@ -0,0 +1,80 @@ +import { createContext, useContext, ReactNode } from 'react'; +import { useQuery, useQueryClient } from 'react-query'; +import axios from 'axios'; +import { useAuth } from './AuthContext'; +import { api } from '../api/config'; + +interface WishlistItem { + userId: string; + productId: number; + addedAt: string; +} + +interface WishlistContextType { + wishlistProductIds: Set; + toggleWishlist: (productId: number) => void; + isWishlisted: (productId: number) => boolean; + wishlistCount: number; +} + +const WishlistContext = createContext(null); + +const fetchWishlist = async (userId: string): Promise => { + const { data } = await axios.get( + `${api.baseURL}${api.endpoints.wishlist}?userId=${encodeURIComponent(userId)}` + ); + return data; +}; + +export function WishlistProvider({ children }: { children: ReactNode }) { + const { isLoggedIn, userEmail } = useAuth(); + const queryClient = useQueryClient(); + + const { data: wishlistItems } = useQuery( + ['wishlist', userEmail], + () => fetchWishlist(userEmail!), + { + enabled: isLoggedIn && !!userEmail, + staleTime: 30000, + } + ); + + const wishlistProductIds = new Set( + (wishlistItems ?? []).map(item => item.productId) + ); + + const toggleWishlist = async (productId: number) => { + if (!isLoggedIn || !userEmail) return; + + if (wishlistProductIds.has(productId)) { + await axios.delete( + `${api.baseURL}${api.endpoints.wishlist}/${productId}?userId=${encodeURIComponent(userEmail)}` + ); + } else { + await axios.post(`${api.baseURL}${api.endpoints.wishlist}`, { + userId: userEmail, + productId, + }); + } + queryClient.invalidateQueries(['wishlist', userEmail]); + }; + + const isWishlisted = (productId: number) => wishlistProductIds.has(productId); + + const wishlistCount = wishlistProductIds.size; + + 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 6d464d63cf5fab1f750873df9fb5dec61699b293 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 14:52:21 +0000 Subject: [PATCH 3/3] Address code review: JSON error responses, useMemo/useCallback, fix typo Agent-Logs-Url: https://github.com/thomasiverson/GitHubCopilot_Customized/sessions/29968f7b-c4e2-4b44-bf7b-a0351299f5eb Co-authored-by: thomasiverson <12767513+thomasiverson@users.noreply.github.com> --- api/src/routes/wishlist.test.ts | 3 +++ api/src/routes/wishlist.ts | 6 +++--- frontend/src/components/wishlist/Wishlist.tsx | 6 +++--- frontend/src/context/WishlistContext.tsx | 11 ++++++----- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/api/src/routes/wishlist.test.ts b/api/src/routes/wishlist.test.ts index 71a4a62..86b3af2 100644 --- a/api/src/routes/wishlist.test.ts +++ b/api/src/routes/wishlist.test.ts @@ -16,6 +16,7 @@ describe('Wishlist API', () => { it('should return 400 when userId is missing on GET', async () => { const response = await request(app).get('/api/wishlist'); expect(response.status).toBe(400); + expect(response.body.error).toBeDefined(); }); it('should return empty array for a user with no wishlist items', async () => { @@ -42,6 +43,7 @@ describe('Wishlist API', () => { .post('/api/wishlist') .send({ userId: 'test@example.com', productId: 1 }); expect(response.status).toBe(409); + expect(response.body.error).toBeDefined(); }); it('should retrieve wishlist items for a user', async () => { @@ -85,5 +87,6 @@ describe('Wishlist API', () => { const response = await request(app) .delete('/api/wishlist/999?userId=test@example.com'); expect(response.status).toBe(404); + expect(response.body.error).toBeDefined(); }); }); diff --git a/api/src/routes/wishlist.ts b/api/src/routes/wishlist.ts index 53859f6..3d4631f 100644 --- a/api/src/routes/wishlist.ts +++ b/api/src/routes/wishlist.ts @@ -97,7 +97,7 @@ export const resetWishlist = () => { router.get('/', (req, res) => { const { userId } = req.query; if (!userId) { - res.status(400).send('Missing userId query parameter'); + res.status(400).json({ error: 'Missing userId query parameter' }); } else { const items = wishlistItems.filter(item => item.userId === userId); res.json(items); @@ -111,7 +111,7 @@ router.post('/', (req, res) => { item => item.userId === userId && item.productId === productId ); if (existing) { - res.status(409).send('Item already exists in wishlist'); + res.status(409).json({ error: 'Item already exists in wishlist' }); } else { const newItem: WishlistItem = { userId, @@ -134,7 +134,7 @@ router.delete('/:productId', (req, res) => { wishlistItems.splice(index, 1); res.status(204).send(); } else { - res.status(404).send('Wishlist item not found'); + res.status(404).json({ error: 'Wishlist item not found' }); } }); diff --git a/frontend/src/components/wishlist/Wishlist.tsx b/frontend/src/components/wishlist/Wishlist.tsx index 013092a..360782c 100644 --- a/frontend/src/components/wishlist/Wishlist.tsx +++ b/frontend/src/components/wishlist/Wishlist.tsx @@ -29,7 +29,7 @@ export default function Wishlist() { const { data: products, isLoading } = useQuery('products', fetchProducts); - const wislistedProducts = (products ?? []).filter(p => + const wishlistedProducts = (products ?? []).filter(p => wishlistProductIds.has(p.productId) ); @@ -51,13 +51,13 @@ export default function Wishlist() {

My Wishlist

- {!isLoggedIn || wislistedProducts.length === 0 ? ( + {!isLoggedIn || wishlistedProducts.length === 0 ? (

Your wishlist is empty — browse products to save items for later

) : (
- {wislistedProducts.map(product => ( + {wishlistedProducts.map(product => (
( - (wishlistItems ?? []).map(item => item.productId) + const wishlistProductIds = useMemo( + () => new Set((wishlistItems ?? []).map(item => item.productId)), + [wishlistItems] ); - const toggleWishlist = async (productId: number) => { + const toggleWishlist = useCallback(async (productId: number) => { if (!isLoggedIn || !userEmail) return; if (wishlistProductIds.has(productId)) { @@ -57,7 +58,7 @@ export function WishlistProvider({ children }: { children: ReactNode }) { }); } queryClient.invalidateQueries(['wishlist', userEmail]); - }; + }, [isLoggedIn, userEmail, wishlistProductIds, queryClient]); const isWishlisted = (productId: number) => wishlistProductIds.has(productId);