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
7 changes: 6 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +27,7 @@ function ThemedApp() {
<Route path="/products" element={<Products />} />
<Route path="/login" element={<Login />} />
<Route path="/admin/products" element={<AdminProducts />} />
<Route path="/wishlist" element={<Wishlist />} />
</Routes>
</main>
<Footer />
Expand All @@ -37,7 +40,9 @@ function App() {
return (
<AuthProvider>
<ThemeProvider>
<ThemedApp />
<WishlistProvider>
<ThemedApp />
</WishlistProvider>
</ThemeProvider>
</AuthProvider>
);
Expand Down
13 changes: 13 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 { wishlistItems } = useWishlist();
const [adminMenuOpen, setAdminMenuOpen] = useState(false);

return (
Expand All @@ -30,6 +32,17 @@ 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>
<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 flex items-center gap-1`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<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>
Wishlist
{wishlistItems.length > 0 && (
<span className="ml-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">
{wishlistItems.length > 99 ? '99+' : wishlistItems.length}
</span>
)}
</Link>
{isAdmin && (
<div className="relative">
<button
Expand Down
163 changes: 163 additions & 0 deletions frontend/src/components/Wishlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { useState } from 'react';
import { useWishlist } from '../context/WishlistContext';
import { useTheme } from '../context/ThemeContext';

export default function Wishlist() {
const { wishlistItems, removeFromWishlist } = useWishlist();
const { darkMode } = useTheme();
const [searchTerm, setSearchTerm] = useState('');
const [quantities, setQuantities] = useState<Record<number, number>>({});

const filteredItems = wishlistItems.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.description.toLowerCase().includes(searchTerm.toLowerCase())
);

const handleQuantityChange = (productId: number, change: number) => {
setQuantities(prev => ({
...prev,
[productId]: Math.max(0, (prev[productId] || 0) + change)
}));
};

const handleAddToCart = (productId: number, name: string) => {
const quantity = quantities[productId] || 0;
if (quantity > 0) {
// TODO: Implement cart functionality
alert(`Added ${quantity} ${name} to cart`);
setQuantities(prev => ({ ...prev, [productId]: 0 }));
}
};

return (
<div className={`min-h-screen ${darkMode ? 'bg-dark' : 'bg-gray-100'} pt-20 pb-16 px-4 transition-colors duration-300`}>
<div className="max-w-7xl mx-auto">
<div className="flex flex-col space-y-6">
<h1 className={`text-3xl font-bold ${darkMode ? 'text-light' : 'text-gray-800'} transition-colors duration-300`}>My Wishlist</h1>

{wishlistItems.length === 0 ? (
<div className={`flex flex-col items-center justify-center py-24 space-y-4 ${darkMode ? 'text-gray-400' : 'text-gray-500'}`}>
<svg className="w-20 h-20 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" 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>
<p className="text-xl font-medium">Your wishlist is empty</p>
<p className="text-sm">Browse products and click the heart icon to save items here.</p>
</div>
) : (
<>
<div className="relative">
<input
type="text"
placeholder="Search wishlist..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className={`w-full px-4 py-2 ${darkMode ? 'bg-gray-800 text-light border-gray-700' : 'bg-white text-gray-800 border-gray-300'} rounded-lg border focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none transition-colors duration-300`}
aria-label="Search wishlist"
/>
<svg
className={`absolute right-3 top-1/2 transform -translate-y-1/2 h-5 w-5 ${darkMode ? 'text-gray-400' : 'text-gray-500'} transition-colors duration-300`}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>

{filteredItems.length === 0 ? (
<p className={`text-center py-8 ${darkMode ? 'text-gray-400' : 'text-gray-500'}`}>
No items match your search.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{filteredItems.map(item => (
<div key={item.productId} className={`${darkMode ? 'bg-gray-800' : 'bg-white'} rounded-lg overflow-hidden shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-[0_0_25px_rgba(118,184,82,0.3)] flex flex-col`}>
<div className={`relative h-56 ${darkMode ? 'bg-gradient-to-t from-gray-700 to-gray-800' : 'bg-gradient-to-t from-gray-100 to-white'} transition-colors duration-300`}>
<img
src={`/${item.imgName}`}
alt={item.name}
className="w-full h-full object-contain p-2"
/>
{item.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(item.discount * 100)}% OFF
</div>
)}
<button
onClick={() => removeFromWishlist(item.productId)}
className="absolute top-2 right-2 p-1.5 rounded-full bg-white/80 hover:bg-white text-red-500 hover:text-red-600 shadow transition-colors duration-200"
aria-label={`Remove ${item.name} from wishlist`}
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path 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>
</div>

<div className="p-4 flex flex-col flex-grow">
<h3 className={`text-xl font-semibold ${darkMode ? 'text-light' : 'text-gray-800'} mb-2 transition-colors duration-300`}>{item.name}</h3>
<p className={`${darkMode ? 'text-gray-400' : 'text-gray-600'} mb-4 flex-grow transition-colors duration-300`}>{item.description}</p>
<div className="space-y-4 mt-auto">
<div className="flex justify-between items-center">
{item.discount ? (
<div>
<span className="text-gray-500 line-through text-sm mr-2">${item.price.toFixed(2)}</span>
<span className="text-primary text-xl font-bold">${(item.price * (1 - item.discount)).toFixed(2)}</span>
</div>
) : (
<span className="text-primary text-xl font-bold">${item.price.toFixed(2)}</span>
)}
</div>

<div className="flex items-center justify-between">
<div className={`flex items-center space-x-3 ${darkMode ? 'bg-gray-700' : 'bg-gray-200'} rounded-lg p-1 transition-colors duration-300`}>
<button
onClick={() => handleQuantityChange(item.productId, -1)}
className={`w-8 h-8 flex items-center justify-center ${darkMode ? 'text-light' : 'text-gray-700'} hover:text-primary transition-colors duration-300`}
aria-label={`Decrease quantity of ${item.name}`}
>
<span aria-hidden="true">-</span>
</button>
<span
className={`${darkMode ? 'text-light' : 'text-gray-800'} min-w-[2rem] text-center transition-colors duration-300`}
aria-label={`Quantity of ${item.name}`}
>
{quantities[item.productId] || 0}
</span>
<button
onClick={() => handleQuantityChange(item.productId, 1)}
className={`w-8 h-8 flex items-center justify-center ${darkMode ? 'text-light' : 'text-gray-700'} hover:text-primary transition-colors duration-300`}
aria-label={`Increase quantity of ${item.name}`}
>
<span aria-hidden="true">+</span>
</button>
</div>
<button
onClick={() => handleAddToCart(item.productId, item.name)}
className={`px-4 py-2 rounded-lg transition-colors ${
quantities[item.productId]
? 'bg-primary hover:bg-accent text-white'
: `${darkMode ? 'bg-gray-700 text-gray-400' : 'bg-gray-200 text-gray-500'} cursor-not-allowed`
}`}
disabled={!quantities[item.productId]}
aria-label={`Add ${quantities[item.productId] || 0} ${item.name} to cart`}
>
Add to Cart
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
31 changes: 31 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,7 @@ import axios from 'axios';
import { useQuery } from 'react-query';
import { api } from '../../../api/config';
import { useTheme } from '../../../context/ThemeContext';
import { useWishlist } from '../../../context/WishlistContext';

interface Product {
productId: number;
Expand All @@ -28,6 +29,7 @@ export default function Products() {
const [showModal, setShowModal] = useState(false);
const { data: products, isLoading, error } = useQuery('products', fetchProducts);
const { darkMode } = useTheme();
const { addToWishlist, removeFromWishlist, isInWishlist } = useWishlist();

const filteredProducts = products?.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
Expand Down Expand Up @@ -125,6 +127,35 @@ export default function Products() {
{Math.round(product.discount * 100)}% OFF
</div>
)}
<button
onClick={e => {
e.stopPropagation();
if (isInWishlist(product.productId)) {
removeFromWishlist(product.productId);
} else {
addToWishlist({
productId: product.productId,
name: product.name,
description: product.description,
price: product.price,
imgName: product.imgName,
discount: product.discount,
});
}
}}
className="absolute top-2 right-2 p-1.5 rounded-full bg-white/80 hover:bg-white shadow transition-colors duration-200"
aria-label={isInWishlist(product.productId) ? `Remove ${product.name} from wishlist` : `Add ${product.name} to wishlist`}
>
<svg
className={`w-5 h-5 transition-colors duration-200 ${isInWishlist(product.productId) ? 'text-red-500' : 'text-gray-400 hover:text-red-400'}`}
fill={isInWishlist(product.productId) ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<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>
</div>

<div className="p-4 flex flex-col flex-grow">
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/context/WishlistContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* eslint-disable react-refresh/only-export-components */
import React, { useContext, useState, useEffect } from 'react';
import { WishlistContext, WishlistItem } from './wishlistContextUtils';

const WISHLIST_STORAGE_KEY = 'octocat-wishlist';

function loadFromStorage(): WishlistItem[] {
try {
const stored = localStorage.getItem(WISHLIST_STORAGE_KEY);
return stored ? (JSON.parse(stored) as WishlistItem[]) : [];
} catch {
return [];
}
}

function saveToStorage(items: WishlistItem[]): void {
try {
localStorage.setItem(WISHLIST_STORAGE_KEY, JSON.stringify(items));
} catch {
// Gracefully handle private browsing / quota exceeded
}
}

export const useWishlist = () => {
const context = useContext(WishlistContext);
if (context === undefined) {
throw new Error('useWishlist must be used within a WishlistProvider');
}
return context;
};

export function WishlistProvider({ children }: { children: React.ReactNode }) {
const [wishlistItems, setWishlistItems] = useState<WishlistItem[]>(loadFromStorage);

useEffect(() => {
saveToStorage(wishlistItems);
}, [wishlistItems]);

const addToWishlist = (item: Omit<WishlistItem, 'dateAdded'>) => {
setWishlistItems(prev => {
if (prev.some(w => w.productId === item.productId)) return prev;
return [...prev, { ...item, dateAdded: new Date().toISOString() }];
});
};

const removeFromWishlist = (productId: number) => {
setWishlistItems(prev => prev.filter(w => w.productId !== productId));
};

const isInWishlist = (productId: number): boolean => {
return wishlistItems.some(w => w.productId === productId);
};

const clearWishlist = () => {
setWishlistItems([]);
};

return (
<WishlistContext.Provider value={{ wishlistItems, addToWishlist, removeFromWishlist, isInWishlist, clearWishlist }}>
{children}
</WishlistContext.Provider>
);
}
21 changes: 21 additions & 0 deletions frontend/src/context/wishlistContextUtils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createContext } from 'react';

export interface WishlistItem {
productId: number;
name: string;
description: string;
price: number;
imgName: string;
discount?: number;
dateAdded: string;
}

export type WishlistContextType = {
wishlistItems: WishlistItem[];
addToWishlist: (item: Omit<WishlistItem, 'dateAdded'>) => void;
removeFromWishlist: (productId: number) => void;
isInWishlist: (productId: number) => boolean;
clearWishlist: () => void;
};

export const WishlistContext = createContext<WishlistContextType | undefined>(undefined);