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 @@ -5,8 +5,10 @@ import About from './components/About';
import Footer from './components/Footer';
import Products from './components/entity/product/Products';
import Login from './components/Login';
import Cart from './components/Cart';
import { AuthProvider } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
import { CartProvider } from './context/CartContext';
import AdminProducts from './components/admin/AdminProducts';
import { useTheme } from './context/ThemeContext';

Expand All @@ -25,6 +27,7 @@ function ThemedApp() {
<Route path="/" element={<Welcome />} />
<Route path="/about" element={<About />} />
<Route path="/products" element={<Products />} />
<Route path="/cart" element={<Cart />} />
<Route path="/login" element={<Login />} />
<Route path="/admin/products" element={<AdminProducts />} />
</Routes>
Expand All @@ -39,7 +42,9 @@ function App() {
return (
<AuthProvider>
<ThemeProvider>
<ThemedApp />
<CartProvider>
<ThemedApp />
</CartProvider>
</ThemeProvider>
</AuthProvider>
);
Expand Down
164 changes: 164 additions & 0 deletions frontend/src/components/Cart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Link } from 'react-router-dom';
import { useCart, CartItem } from '../context/CartContext';
import { useTheme } from '../context/ThemeContext';

export default function Cart() {
const { items, removeItem, updateQuantity, clearCart, totalPrice } = useCart();
const { darkMode } = useTheme();

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-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1
className={`text-3xl font-bold ${darkMode ? 'text-light' : 'text-gray-800'} transition-colors duration-300`}
>
Shopping Cart
</h1>
{items.length > 0 && (
<button
onClick={clearCart}
className="text-sm text-red-500 hover:text-red-700 transition-colors"
aria-label="Clear all items from cart"
>
Clear cart
</button>
)}
</div>

{items.length === 0 ? (
<div
className={`flex flex-col items-center justify-center text-center py-20 rounded-lg ${
darkMode ? 'bg-gray-800' : 'bg-white'
} shadow-sm border ${darkMode ? 'border-gray-700' : 'border-gray-200'}`}
role="status"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-12 w-12 mb-4 ${darkMode ? 'text-gray-400' : 'text-gray-500'}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<p className={`${darkMode ? 'text-light' : 'text-gray-800'} text-lg font-medium`}>
Your cart is empty
</p>
<Link
to="/products"
className="mt-4 bg-primary hover:bg-accent text-white px-6 py-2 rounded-md text-sm font-medium transition-colors"
>
Browse Products
</Link>
</div>
) : (
<div className="space-y-4">
{items.map((item: CartItem) => {
const effectivePrice = item.price * (1 - (item.discount ?? 0));
return (
<div
key={item.productId}
className={`flex items-center gap-4 p-4 rounded-lg shadow-sm border ${
darkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'
} transition-colors duration-300`}
>
<img
src={`/${item.imgName}`}
alt={item.name}
className="h-20 w-20 object-contain flex-shrink-0"
/>
<div className="flex-grow min-w-0">
<p
className={`font-semibold truncate ${darkMode ? 'text-light' : 'text-gray-800'}`}
>
{item.name}
</p>
<p className="text-primary font-bold">
${effectivePrice.toFixed(2)}{' '}
<span className={`text-sm font-normal ${darkMode ? 'text-gray-400' : 'text-gray-500'}`}>
/ {item.unit}
</span>
</p>
</div>
<div
className={`flex items-center space-x-2 ${darkMode ? 'bg-gray-700' : 'bg-gray-100'} rounded-lg p-1`}
>
<button
onClick={() => updateQuantity(item.productId, item.quantity - 1)}
className={`w-8 h-8 flex items-center justify-center ${darkMode ? 'text-light' : 'text-gray-700'} hover:text-primary transition-colors`}
aria-label={`Decrease quantity of ${item.name}`}
>
<span aria-hidden="true">-</span>
</button>
<span
className={`min-w-[2rem] text-center ${darkMode ? 'text-light' : 'text-gray-800'}`}
aria-label={`Quantity: ${item.quantity}`}
>
{item.quantity}
</span>
<button
onClick={() => updateQuantity(item.productId, item.quantity + 1)}
className={`w-8 h-8 flex items-center justify-center ${darkMode ? 'text-light' : 'text-gray-700'} hover:text-primary transition-colors`}
aria-label={`Increase quantity of ${item.name}`}
>
<span aria-hidden="true">+</span>
</button>
</div>
<p
className={`w-20 text-right font-semibold ${darkMode ? 'text-light' : 'text-gray-800'}`}
aria-label={`Subtotal for ${item.name}`}
>
${(effectivePrice * item.quantity).toFixed(2)}
</p>
<button
onClick={() => removeItem(item.productId)}
className={`${darkMode ? 'text-gray-400 hover:text-red-400' : 'text-gray-400 hover:text-red-500'} transition-colors flex-shrink-0`}
aria-label={`Remove ${item.name} from cart`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
);
})}

<div
className={`flex justify-end p-4 rounded-lg ${
darkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'
} border shadow-sm`}
>
<div className="text-right space-y-2">
<p
className={`text-xl font-bold ${darkMode ? 'text-light' : 'text-gray-800'}`}
aria-label={`Order total: $${totalPrice.toFixed(2)}`}
>
Total: <span className="text-primary">${totalPrice.toFixed(2)}</span>
</p>
<button
className="bg-primary hover:bg-accent text-white px-8 py-2 rounded-md font-medium transition-colors"
onClick={() => alert('Order placement is not yet implemented.')}
>
Place Order
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
27 changes: 27 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 { useCart } from '../context/CartContext';
import { useState } from 'react';

export default function Navigation() {
const { isLoggedIn, isAdmin, logout } = useAuth();
const { darkMode, toggleTheme } = useTheme();
const { totalItems } = useCart();
const [adminMenuOpen, setAdminMenuOpen] = useState(false);

return (
Expand Down Expand Up @@ -85,6 +87,31 @@ export default function Navigation() {
</div>
</div>
<div className="flex items-center space-x-4">
<Link
to="/cart"
className="relative p-2 rounded-full focus:outline-none transition-colors"
aria-label={`Shopping cart, ${totalItems} item${totalItems !== 1 ? 's' : ''}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-6 w-6 ${darkMode ? 'text-light' : 'text-gray-700'} hover:text-primary transition-colors`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
{totalItems > 0 && (
<span className="absolute -top-1 -right-1 bg-primary text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
{totalItems > 99 ? '99+' : totalItems}
</span>
)}
</Link>
<button
onClick={toggleTheme}
className="p-2 rounded-full focus:outline-none transition-colors"
Expand Down
18 changes: 15 additions & 3 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 { useCart } from '../../../context/CartContext';

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 { addItem } = useCart();

const filteredProducts = products?.filter(
(product) =>
Expand All @@ -44,9 +46,19 @@ export default function Products() {

const handleAddToCart = (productId: number) => {
const quantity = quantities[productId] || 0;
if (quantity > 0) {
// TODO: Implement cart functionality
alert(`Added ${quantity} items to cart`);
const product = products?.find((p) => p.productId === productId);
if (quantity > 0 && product) {
addItem(
{
productId: product.productId,
name: product.name,
price: product.price,
imgName: product.imgName,
unit: product.unit,
discount: product.discount,
},
quantity,
);
setQuantities((prev) => ({
...prev,
[productId]: 0,
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/context/CartContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { createContext, useContext, useState, useMemo, ReactNode } from 'react';

export interface CartItem {
productId: number;
name: string;
price: number;
imgName: string;
unit: string;
discount?: number;
quantity: number;
}

interface CartContextType {
items: CartItem[];
addItem: (product: Omit<CartItem, 'quantity'>, quantity: number) => void;
removeItem: (productId: number) => void;
updateQuantity: (productId: number, quantity: number) => void;
clearCart: () => void;
totalItems: number;
totalPrice: number;
}

const CartContext = createContext<CartContextType | null>(null);

export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);

const addItem = (product: Omit<CartItem, 'quantity'>, quantity: number) => {
if (quantity <= 0) return;
setItems((prev) => {
const existing = prev.find((i) => i.productId === product.productId);
if (existing) {
return prev.map((i) =>
i.productId === product.productId ? { ...i, quantity: i.quantity + quantity } : i,
);
}
return [...prev, { ...product, quantity }];
});
};

const removeItem = (productId: number) => {
setItems((prev) => prev.filter((i) => i.productId !== productId));
};

const updateQuantity = (productId: number, quantity: number) => {
if (quantity <= 0) {
removeItem(productId);
return;
}
setItems((prev) =>
prev.map((i) => (i.productId === productId ? { ...i, quantity } : i)),
);
};

const clearCart = () => setItems([]);

const totalItems = useMemo(() => items.reduce((sum, i) => sum + i.quantity, 0), [items]);
const totalPrice = useMemo(
() =>
items.reduce(
(sum, i) => sum + i.quantity * i.price * (1 - (i.discount ?? 0)),
0,
),
[items],
);

return (
<CartContext.Provider
value={{ items, addItem, removeItem, updateQuantity, clearCart, totalItems, totalPrice }}
>
{children}
</CartContext.Provider>
);
}

// eslint-disable-next-line react-refresh/only-export-components
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}