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..86b3af2 --- /dev/null +++ b/api/src/routes/wishlist.test.ts @@ -0,0 +1,92 @@ +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); + expect(response.body.error).toBeDefined(); + }); + + 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); + expect(response.body.error).toBeDefined(); + }); + + 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); + expect(response.body.error).toBeDefined(); + }); +}); diff --git a/api/src/routes/wishlist.ts b/api/src/routes/wishlist.ts new file mode 100644 index 0000000..3d4631f --- /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).json({ error: '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).json({ error: '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).json({ error: '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..360782c --- /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 wishlistedProducts = (products ?? []).filter(p => + wishlistProductIds.has(p.productId) + ); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ ); + } + + return ( +
+
+
+

My Wishlist

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

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

+ ) : ( +
+ {wishlistedProducts.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..5f4aa50 --- /dev/null +++ b/frontend/src/context/WishlistContext.tsx @@ -0,0 +1,81 @@ +import { createContext, useContext, ReactNode, useMemo, useCallback } 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 = useMemo( + () => new Set((wishlistItems ?? []).map(item => item.productId)), + [wishlistItems] + ); + + const toggleWishlist = useCallback(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]); + }, [isLoggedIn, userEmail, wishlistProductIds, queryClient]); + + 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; +}