diff --git a/frontend/package.json b/frontend/package.json
index bc9fa52..8d357b1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
+ "test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d0b02da..26e9af1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,8 +4,10 @@ import Welcome from './components/Welcome';
import About from './components/About';
import Footer from './components/Footer';
import Products from './components/entity/product/Products';
+import CartPage from './components/cart/CartPage';
import Login from './components/Login';
import { AuthProvider } from './context/AuthContext';
+import { CartProvider } from './context/CartContext';
import { ThemeProvider } from './context/ThemeContext';
import AdminProducts from './components/admin/AdminProducts';
import { useTheme } from './context/ThemeContext';
@@ -23,6 +25,7 @@ function ThemedApp() {
} />
} />
} />
+ } />
} />
} />
@@ -36,9 +39,11 @@ function ThemedApp() {
function App() {
return (
-
-
-
+
+
+
+
+
);
}
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx
index 7ce970f..4d4abc9 100644
--- a/frontend/src/components/Footer.tsx
+++ b/frontend/src/components/Footer.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { Link } from 'react-router-dom';
import { useTheme } from '../context/ThemeContext';
const Footer: React.FC = () => {
@@ -20,8 +21,8 @@ const Footer: React.FC = () => {
Account
- - My Cart
- - Checkout
+ - My Cart
+ - Checkout
- Shopping Details
- Order
- Help Center
diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx
index d7b393b..fb66426 100644
--- a/frontend/src/components/Navigation.tsx
+++ b/frontend/src/components/Navigation.tsx
@@ -1,10 +1,12 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
+import { useCart } from '../context/CartContext';
import { useTheme } from '../context/ThemeContext';
import { useState } from 'react';
export default function Navigation() {
const { isLoggedIn, isAdmin, logout } = useAuth();
+ const { itemCount } = useCart();
const { darkMode, toggleTheme } = useTheme();
const [adminMenuOpen, setAdminMenuOpen] = useState(false);
@@ -29,6 +31,7 @@ export default function Navigation() {
Home
Products
+
Cart
About us
{isAdmin && (
@@ -68,6 +71,18 @@ export default function Navigation() {
+
+ Cart
+ {itemCount > 0 && (
+
+ {itemCount}
+
+ )}
+
handleAddToCart(product.productId)}
+ onClick={() => handleAddToCart(product)}
className={`px-4 py-2 rounded-lg transition-colors ${
quantities[product.productId]
? 'bg-primary hover:bg-accent text-white'
diff --git a/frontend/src/context/CartContext.tsx b/frontend/src/context/CartContext.tsx
new file mode 100644
index 0000000..5375302
--- /dev/null
+++ b/frontend/src/context/CartContext.tsx
@@ -0,0 +1,193 @@
+import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
+import type { AppliedCoupon, CartItem } from '../types/cart';
+import type { Product } from '../types/product';
+import { DEFAULT_DISCOUNT_RATE } from '../types/cart';
+
+interface CouponDefinition {
+ code: string;
+ discountRate: number;
+ freeShipping?: boolean;
+}
+
+interface CouponResult {
+ ok: boolean;
+ message: string;
+}
+
+interface CartContextType {
+ items: CartItem[];
+ itemCount: number;
+ coupon: AppliedCoupon | null;
+ addItem: (product: Product, quantity: number) => void;
+ updateQuantity: (productId: number, quantity: number) => void;
+ removeItem: (productId: number) => void;
+ applyCoupon: (couponCode: string) => CouponResult;
+ clearCoupon: () => void;
+ persistCart: () => void;
+ lastUpdatedAt: string | null;
+}
+
+interface PersistedCartState {
+ items: CartItem[];
+ coupon: AppliedCoupon | null;
+}
+
+const STORAGE_KEY = 'octocat-cart';
+
+const SUPPORTED_COUPONS: Record = {
+ SAVE5: { code: 'SAVE5', discountRate: 0.05 },
+ SAVE10: { code: 'SAVE10', discountRate: 0.1 },
+ FREESHIP: { code: 'FREESHIP', discountRate: DEFAULT_DISCOUNT_RATE, freeShipping: true },
+};
+
+const CartContext = createContext(null);
+
+const isValidCartItem = (item: unknown): item is CartItem => {
+ if (typeof item !== 'object' || item === null) {
+ return false;
+ }
+
+ const maybeCartItem = item as CartItem;
+ return Number.isFinite(maybeCartItem.quantity) && maybeCartItem.quantity > 0 && typeof maybeCartItem.product?.productId === 'number';
+};
+
+const readPersistedState = (): PersistedCartState => {
+ if (typeof window === 'undefined') {
+ return { items: [], coupon: null };
+ }
+
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return { items: [], coupon: null };
+ }
+
+ const parsed = JSON.parse(raw) as PersistedCartState;
+ return {
+ items: Array.isArray(parsed.items) ? parsed.items.filter(isValidCartItem) : [],
+ coupon: parsed.coupon ?? null,
+ };
+ } catch {
+ return { items: [], coupon: null };
+ }
+};
+
+export function CartProvider({ children }: { children: ReactNode }) {
+ const initialState = useMemo(() => readPersistedState(), []);
+ const [items, setItems] = useState(initialState.items);
+ const [coupon, setCoupon] = useState(initialState.coupon);
+ const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, coupon }));
+ }, [items, coupon]);
+
+ const addItem = (product: Product, quantity: number) => {
+ if (quantity <= 0) {
+ return;
+ }
+
+ setItems((prev) => {
+ const existing = prev.find((entry) => entry.product.productId === product.productId);
+ if (!existing) {
+ return [...prev, { product, quantity }];
+ }
+
+ return prev.map((entry) => {
+ if (entry.product.productId !== product.productId) {
+ return entry;
+ }
+
+ return { ...entry, quantity: entry.quantity + quantity };
+ });
+ });
+ };
+
+ const updateQuantity = (productId: number, quantity: number) => {
+ if (quantity <= 0) {
+ setItems((prev) => prev.filter((entry) => entry.product.productId !== productId));
+ return;
+ }
+
+ setItems((prev) =>
+ prev.map((entry) => {
+ if (entry.product.productId !== productId) {
+ return entry;
+ }
+
+ return { ...entry, quantity };
+ }),
+ );
+ };
+
+ const removeItem = (productId: number) => {
+ setItems((prev) => prev.filter((entry) => entry.product.productId !== productId));
+ };
+
+ const applyCoupon = (couponCode: string): CouponResult => {
+ const normalized = couponCode.trim().toUpperCase();
+
+ if (!normalized) {
+ return { ok: false, message: 'Enter a coupon code before applying.' };
+ }
+
+ const definition = SUPPORTED_COUPONS[normalized];
+ if (!definition) {
+ return { ok: false, message: 'This coupon is not recognized.' };
+ }
+
+ setCoupon({
+ code: definition.code,
+ discountRate: definition.discountRate,
+ freeShipping: definition.freeShipping,
+ });
+
+ return { ok: true, message: `${definition.code} applied successfully.` };
+ };
+
+ const clearCoupon = () => {
+ setCoupon(null);
+ };
+
+ const persistCart = () => {
+ if (typeof window !== 'undefined') {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, coupon }));
+ }
+
+ setLastUpdatedAt(new Date().toLocaleTimeString());
+ };
+
+ const itemCount = items.reduce((sum, entry) => sum + entry.quantity, 0);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCart() {
+ const context = useContext(CartContext);
+ if (!context) {
+ throw new Error('useCart must be used within a CartProvider');
+ }
+
+ return context;
+}
diff --git a/frontend/src/test/CartPage.test.tsx b/frontend/src/test/CartPage.test.tsx
new file mode 100644
index 0000000..f3ebb5a
--- /dev/null
+++ b/frontend/src/test/CartPage.test.tsx
@@ -0,0 +1,99 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, beforeEach } from 'vitest';
+import CartPage from '../components/cart/CartPage';
+import { CartProvider } from '../context/CartContext';
+import { ThemeProvider } from '../context/ThemeContext';
+
+const CART_STORAGE_KEY = 'octocat-cart';
+
+const seedCart = () => {
+ localStorage.setItem(
+ CART_STORAGE_KEY,
+ JSON.stringify({
+ items: [
+ {
+ product: {
+ productId: 1,
+ name: 'Solar Panel',
+ description: 'Portable panel',
+ price: 100,
+ imgName: 'smart-fountain.png',
+ sku: 'SOL-001',
+ unit: 'each',
+ supplierId: 2,
+ },
+ quantity: 2,
+ },
+ ],
+ coupon: null,
+ }),
+ );
+};
+
+const renderCartPage = () => {
+ return render(
+
+
+
+
+ ,
+ );
+};
+
+const getSummaryValue = (label: string | RegExp): string | null => {
+ const summary = screen.getByLabelText('Order summary');
+ const row = within(summary).getByText(label).parentElement;
+ return row?.lastElementChild?.textContent?.replace(/\s+/g, '') ?? null;
+};
+
+describe('CartPage', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ seedCart();
+ });
+
+ it('shows default order summary values from cart state', () => {
+ renderCartPage();
+
+ expect(getSummaryValue('Subtotal')).toBe('$200.00');
+ expect(getSummaryValue('Discount (5%)')).toBe('-$10.00');
+ expect(getSummaryValue('Shipping')).toBe('$10.00');
+ expect(getSummaryValue('Grand Total')).toBe('$200.00');
+ });
+
+ it('recomputes totals when quantity changes', async () => {
+ renderCartPage();
+
+ const user = userEvent.setup();
+ const quantityInput = screen.getByLabelText('Quantity for Solar Panel');
+ await user.click(quantityInput);
+ await user.keyboard('{Control>}a{/Control}3');
+
+ expect(getSummaryValue('Subtotal')).toBe('$300.00');
+ expect(getSummaryValue('Discount (5%)')).toBe('-$15.00');
+ expect(getSummaryValue('Grand Total')).toBe('$295.00');
+ });
+
+ it('applies coupon and updates discount percentage', async () => {
+ renderCartPage();
+
+ const user = userEvent.setup();
+ await user.type(screen.getByLabelText('Coupon code'), 'SAVE10');
+ await user.click(screen.getByRole('button', { name: 'Apply Coupon' }));
+
+ expect(getSummaryValue('Discount (10%)')).toBe('-$20.00');
+ expect(getSummaryValue('Grand Total')).toBe('$190.00');
+ });
+
+ it('removes items and updates totals immediately', async () => {
+ renderCartPage();
+
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Remove Solar Panel from cart' }));
+
+ expect(screen.getByText('Your cart is empty. Add products to continue.')).toBeTruthy();
+ expect(getSummaryValue('Subtotal')).toBe('$0.00');
+ expect(getSummaryValue('Grand Total')).toBe('$0.00');
+ });
+});
diff --git a/frontend/src/test/cartCalculations.test.ts b/frontend/src/test/cartCalculations.test.ts
new file mode 100644
index 0000000..4aa1dec
--- /dev/null
+++ b/frontend/src/test/cartCalculations.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import type { CartItem } from '../types/cart';
+import {
+ getCartSubtotal,
+ getDiscountAmount,
+ getGrandTotal,
+ getLineTotal,
+ getShippingAmount,
+ getUnitPrice,
+} from '../utils/cartCalculations';
+
+const cartItem: CartItem = {
+ product: {
+ productId: 10,
+ name: 'Smart Feeder',
+ description: 'Automated feeding system',
+ price: 100,
+ imgName: 'feeder.png',
+ sku: 'FEED-001',
+ unit: 'each',
+ supplierId: 1,
+ discount: 0.1,
+ },
+ quantity: 2,
+};
+
+describe('cart calculations', () => {
+ it('calculates discounted unit and line totals', () => {
+ expect(getUnitPrice(cartItem.product.price, cartItem.product.discount)).toBe(90);
+ expect(getLineTotal(cartItem)).toBe(180);
+ });
+
+ it('calculates subtotal, discount, shipping and grand total', () => {
+ const subtotal = getCartSubtotal([cartItem]);
+ const discount = getDiscountAmount(subtotal, 0.05);
+ const shipping = getShippingAmount(subtotal, false);
+
+ expect(subtotal).toBe(180);
+ expect(discount).toBe(9);
+ expect(shipping).toBe(10);
+ expect(getGrandTotal(subtotal, discount, shipping)).toBe(181);
+ });
+
+ it('returns free shipping when coupon enables it', () => {
+ expect(getShippingAmount(250, true)).toBe(0);
+ expect(getShippingAmount(0, false)).toBe(0);
+ });
+});
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 0000000..bb02c60
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest';
diff --git a/frontend/src/types/cart.ts b/frontend/src/types/cart.ts
new file mode 100644
index 0000000..070323c
--- /dev/null
+++ b/frontend/src/types/cart.ts
@@ -0,0 +1,15 @@
+import type { Product } from './product';
+
+export interface CartItem {
+ product: Product;
+ quantity: number;
+}
+
+export interface AppliedCoupon {
+ code: string;
+ discountRate: number;
+ freeShipping?: boolean;
+}
+
+export const DEFAULT_DISCOUNT_RATE = 0.05;
+export const SHIPPING_FEE = 10;
diff --git a/frontend/src/types/product.ts b/frontend/src/types/product.ts
new file mode 100644
index 0000000..35cb544
--- /dev/null
+++ b/frontend/src/types/product.ts
@@ -0,0 +1,11 @@
+export interface Product {
+ productId: number;
+ name: string;
+ description: string;
+ price: number;
+ imgName: string;
+ sku: string;
+ unit: string;
+ supplierId: number;
+ discount?: number;
+}
diff --git a/frontend/src/utils/cartCalculations.ts b/frontend/src/utils/cartCalculations.ts
new file mode 100644
index 0000000..b83dba9
--- /dev/null
+++ b/frontend/src/utils/cartCalculations.ts
@@ -0,0 +1,34 @@
+import type { CartItem } from '../types/cart';
+import { SHIPPING_FEE } from '../types/cart';
+
+const roundToCents = (value: number): number => Math.round(value * 100) / 100;
+
+export const getUnitPrice = (price: number, discount?: number): number => {
+ const safeDiscount = discount ?? 0;
+ return roundToCents(price * (1 - safeDiscount));
+};
+
+export const getLineTotal = (item: CartItem): number => {
+ const unitPrice = getUnitPrice(item.product.price, item.product.discount);
+ return roundToCents(unitPrice * item.quantity);
+};
+
+export const getCartSubtotal = (items: CartItem[]): number => {
+ return roundToCents(items.reduce((sum, item) => sum + getLineTotal(item), 0));
+};
+
+export const getDiscountAmount = (subtotal: number, discountRate: number): number => {
+ return roundToCents(subtotal * discountRate);
+};
+
+export const getShippingAmount = (subtotal: number, freeShipping = false): number => {
+ if (subtotal <= 0) {
+ return 0;
+ }
+
+ return freeShipping ? 0 : SHIPPING_FEE;
+};
+
+export const getGrandTotal = (subtotal: number, discountAmount: number, shipping: number): number => {
+ return roundToCents(Math.max(0, subtotal - discountAmount + shipping));
+};
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index cce92b7..7c9a6b8 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,3 +1,4 @@
+///
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
@@ -11,5 +12,10 @@ export default defineConfig({
},
define: {
'process.env.CODESPACE_NAME': JSON.stringify(process.env.CODESPACE_NAME),
- }
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test/setup.ts'],
+ },
})