Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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!');
Expand Down
27 changes: 27 additions & 0 deletions api/src/models/wishlist.ts
Original file line number Diff line number Diff line change
@@ -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;
}
92 changes: 92 additions & 0 deletions api/src/routes/wishlist.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
141 changes: 141 additions & 0 deletions api/src/routes/wishlist.ts
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 6 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -24,6 +26,7 @@ function ThemedApp() {
<Route path="/about" element={<About />} />
<Route path="/products" element={<Products />} />
<Route path="/login" element={<Login />} />
<Route path="/wishlist" element={<Wishlist />} />
<Route path="/admin/products" element={<AdminProducts />} />
</Routes>
</main>
Expand All @@ -37,7 +40,9 @@ function App() {
return (
<AuthProvider>
<ThemeProvider>
<ThemedApp />
<WishlistProvider>
<ThemedApp />
</WishlistProvider>
</ThemeProvider>
</AuthProvider>
);
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
};
12 changes: 12 additions & 0 deletions frontend/src/components/Navigation.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -30,6 +32,16 @@ export default function Navigation() {
<Link to="/" className={`${darkMode ? 'text-light hover:text-primary' : 'text-gray-700 hover:text-primary'} px-3 py-2 rounded-md text-sm font-medium transition-colors`}>Home</Link>
<Link to="/products" className={`${darkMode ? 'text-light hover:text-primary' : 'text-gray-700 hover:text-primary'} px-3 py-2 rounded-md text-sm font-medium transition-colors`}>Products</Link>
<Link to="/about" className={`${darkMode ? 'text-light hover:text-primary' : 'text-gray-700 hover:text-primary'} px-3 py-2 rounded-md text-sm font-medium transition-colors`}>About us</Link>
{isLoggedIn && (
<Link to="/wishlist" className={`${darkMode ? 'text-light hover:text-primary' : 'text-gray-700 hover:text-primary'} px-3 py-2 rounded-md text-sm font-medium transition-colors relative`}>
Wishlist
{wishlistCount > 0 && (
<span className="absolute -top-1 -right-1 bg-primary text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{wishlistCount}
</span>
)}
</Link>
)}
{isAdmin && (
<div className="relative">
<button
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/components/entity/product/Products.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ 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;
Expand All @@ -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 { toggleWishlist, isWishlisted } = useWishlist();

const filteredProducts = products?.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
Expand Down Expand Up @@ -120,6 +124,23 @@ export default function Products() {
alt={product.name}
className="w-full h-full object-contain p-2"
/>
{isLoggedIn && (
<button
className="absolute top-2 right-2 p-1 rounded-full bg-white/70 hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); toggleWishlist(product.productId); }}
aria-label={isWishlisted(product.productId) ? `Remove ${product.name} from wishlist` : `Add ${product.name} to wishlist`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-5 w-5 ${isWishlisted(product.productId) ? 'text-primary fill-primary' : 'text-gray-400 fill-none'}`}
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
)}
{product.discount && (
<div className="absolute top-8 left-0 bg-primary text-white px-3 py-1 -rotate-90 transform -translate-x-5 shadow-md">
{Math.round(product.discount * 100)}% OFF
Expand Down
Loading