diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..cc61999 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,112 @@ +# OctoCAT Supply: Copilot Instructions + +This is a **GitHub Copilot demo project** showcasing AI-assisted development across full-stack TypeScript. It's a supply chain management system with Express API and React frontend, designed to demonstrate Copilot's capabilities in Agent Mode, MCP servers, custom instructions, and IaC. + +## Architecture Overview + +**Monorepo structure** (npm workspaces): +- `api/` - Express.js REST API (port 3000) with Swagger/OpenAPI docs +- `frontend/` - React 18+ with Vite (port 5137) + +**Data model**: Headquarters → Branch → Order → OrderDetail → OrderDetailDelivery ← Delivery ← Supplier; OrderDetail references Product + +**Key principle**: All APIs are documented via JSDoc in route files for auto-generated Swagger (`/api-docs`). The frontend calls APIs through `axios` configured in `frontend/src/api/config.ts`. + +## Quick Start Commands + +```bash +npm install # Install all dependencies +npm run build # Build API & Frontend (workspaces) +npm run dev # Run both API (3000) & Frontend (5137) concurrently +npm run test # Run tests in both workspaces (vitest) +npm run test:coverage # API coverage with vitest +``` + +Alternatively, use VS Code tasks: `Ctrl+Shift+P` → "Run Task" → "Build API/Frontend" + +## Critical Developer Patterns + +### API Routes (Express + Swagger) +- **Structure**: `api/src/routes/{entity}.ts` files export Router with JSDoc `@swagger` blocks +- **Swagger location**: Routes auto-documented via JSDoc; schema definitions in `api/src/models/{entity}.ts` +- **Example**: `/api/products` → GET (list), POST (create), /{id} for GET/PUT/DELETE +- **CORS**: Configured in `index.ts`; origins include localhost and Codespace domains via `API_CORS_ORIGINS` env var +- **Seed data**: `api/src/seedData.ts` contains initial dataset; routes implement in-memory state (not persistent DB) + +### Frontend Components & State +- **Routing**: React Router v7 in `App.tsx`; pages in `src/components/` (Products, About, Welcome, Login) +- **State management**: + - `AuthContext` - login/logout, admin detection (emails ending in `@github.com`) + - `ThemeContext` - dark/light mode toggle, persisted to localStorage +- **Data fetching**: `react-query` + `axios` for API calls; see `Products.tsx` for pattern +- **Styling**: Tailwind CSS with theme-aware classNames (check `darkMode` boolean from `useTheme()`) +- **UI patterns**: Modal windows, dropdown menus, product carousels (via react-slick) + +### Testing +- **API**: Vitest + supertest in `api/src/routes/*.test.ts` +- **Pattern**: Create Express app, mount router, test endpoints (GET/POST/PUT/DELETE) +- **Setup**: `beforeEach` resets seed data; tests verify status codes and response bodies +- **Frontend**: Vitest configured but minimal existing tests; use `@testing-library/react` + +## Important Conventions + +1. **TypeScript**: Strict mode everywhere; interfaces defined in model files, used across routes and components +2. **API contracts**: All entity types in `api/src/models/{entity}.ts` as interfaces with optional fields; reused in frontend +3. **Environment configuration**: + - API: `PORT` (default 3000), `API_CORS_ORIGINS` (comma-separated) + - Frontend: `CODESPACE_NAME` automatically detected; frontend auto-configures API URL (localhost, Codespace, or runtime config) +4. **Admin features**: Check `isAdmin` from `useAuth()` in components; hides/shows admin menu in Navigation +5. **Discount logic**: Product model includes optional `discount` field (decimal, e.g., 0.25 = 25%); use in price calculations +6. **Error handling**: Routes return HTTP 404 when entity not found; frontend catches with axios error handling (see Products.tsx) + +## File Organization Reference + +``` +api/src/ + index.ts # Express app, CORS, Swagger setup, route imports + seedData.ts # Initial data for all entities + models/ # TypeScript interfaces + Swagger schemas (JSDoc) + routes/ # Express routers with CRUD endpoints + JSDoc @swagger + +frontend/src/ + api/config.ts # Axios baseURL configuration (auto-detects environment) + components/ # React components (Navigation, Products, etc.) + context/ # AuthContext, ThemeContext with providers + App.tsx # React Router setup +``` + +## Integration Points & Common Tasks + +**Adding a new API endpoint**: +1. Define interface in `api/src/models/{entity}.ts` with `@swagger` schema block +2. Add route in `api/src/routes/{entity}.ts` with JSDoc `@swagger` operation blocks (GET/POST/PUT/DELETE) +3. Import route in `api/src/index.ts` and mount at `app.use('/api/{entities}', ...)` +4. Swagger docs auto-update at localhost:3000/api-docs + +**Connecting frontend to new API**: +1. Import axios and API config in component +2. Use `useQuery('key', () => axios.get(...))` pattern (react-query) +3. Respect `api.baseURL` from config; it auto-switches between localhost/Codespace/production + +**Theme-aware styling**: +- Use `const { darkMode } = useTheme()` in component +- Apply conditional classNames: `${darkMode ? 'bg-dark text-light' : 'bg-white text-gray-800'}` +- See Navigation.tsx for comprehensive example + +## Demo Scenarios Hints + +This repo is built for Copilot demos: +- **Custom Instructions**: Refer to fictional TAO observability framework (docs/tao.md) in your instructions +- **Agent Mode**: Implement Cart page from mockup (docs/design/cart.png) or new Product from image + natural language +- **Test Generation**: Analyze coverage gaps; prompt to add tests for uncovered routes +- **Vision**: Generate components from design files (docs/design/) +- **MCP Servers**: Use Playwright MCP to write BDD tests; GitHub API MCP to create PRs +- **IaC/Deployment**: Check docs/deployment.md for Bicep/Azure Container Apps patterns + +## Notes for AI Agents + +- **In-memory data**: Seed data resets on each test; routes use module-level arrays (not database) +- **No authentication**: AuthContext is mock (demo only); endpoints not protected +- **No cart persistence**: Cart functionality stub in Products.tsx; implement in new Cart component +- **Discounts**: Implemented in Product model but not applied in UI (add discount calculation when building Cart) +- **Images**: Product images in `frontend/public/`; referenced by `imgName` field; add new images and update seedData diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0b02da..b90081b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,9 +4,12 @@ import Welcome from './components/Welcome'; import About from './components/About'; import Footer from './components/Footer'; import Products from './components/entity/product/Products'; +import Cart from './components/entity/cart/Cart'; +import CheckoutPlaceholder from './components/entity/cart/CheckoutPlaceholder'; import Login from './components/Login'; 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'; @@ -23,6 +26,8 @@ function ThemedApp() { } /> } /> } /> + } /> + } /> } /> } /> @@ -37,7 +42,9 @@ function App() { return ( - + + + ); diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx index d7b393b..f9eb2a8 100644 --- a/frontend/src/components/Navigation.tsx +++ b/frontend/src/components/Navigation.tsx @@ -2,10 +2,12 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { useTheme } from '../context/ThemeContext'; import { useState } from 'react'; +import { useCart } from '../context/CartContext'; export default function Navigation() { const { isLoggedIn, isAdmin, logout } = useAuth(); const { darkMode, toggleTheme } = useTheme(); + const { itemCount } = useCart(); const [adminMenuOpen, setAdminMenuOpen] = useState(false); return ( @@ -29,6 +31,7 @@ export default function Navigation() {
Home Products + Cart About us {isAdmin && (
@@ -68,6 +71,20 @@ export default function Navigation() {
+ + + + + + + + {itemCount} + + +
+ + ); + })} + + +
+
+ setCouponInput(event.target.value)} + placeholder="Coupon Code" + className={`flex-1 rounded-l-full border px-4 py-3 ${darkMode ? 'border-gray-600 bg-gray-800 text-light placeholder:text-gray-500' : 'border-gray-300 bg-white text-gray-800'} transition-colors duration-300`} + /> + +
+ +
+ + + + + + + ); +} diff --git a/frontend/src/components/entity/cart/CheckoutPlaceholder.tsx b/frontend/src/components/entity/cart/CheckoutPlaceholder.tsx new file mode 100644 index 0000000..63d3d0d --- /dev/null +++ b/frontend/src/components/entity/cart/CheckoutPlaceholder.tsx @@ -0,0 +1,23 @@ +import { Link } from 'react-router-dom'; +import { useTheme } from '../../../context/ThemeContext'; + +export default function CheckoutPlaceholder() { + const { darkMode } = useTheme(); + + return ( +
+
+

Checkout

+

+ Checkout submission is intentionally out of scope for this phase. This page is a placeholder route. +

+ + Back to Cart + +
+
+ ); +} diff --git a/frontend/src/components/entity/product/Products.tsx b/frontend/src/components/entity/product/Products.tsx index af2319e..a31e4f4 100644 --- a/frontend/src/components/entity/product/Products.tsx +++ b/frontend/src/components/entity/product/Products.tsx @@ -3,18 +3,8 @@ import axios from 'axios'; import { useQuery } from 'react-query'; import { api } from '../../../api/config'; import { useTheme } from '../../../context/ThemeContext'; - -interface Product { - productId: number; - name: string; - description: string; - price: number; - imgName: string; - sku: string; - unit: string; - supplierId: number; - discount?: number; -} +import { useCart } from '../../../context/CartContext'; +import type { Product } from '../../../types/product'; const fetchProducts = async (): Promise => { const { data } = await axios.get(`${api.baseURL}${api.endpoints.products}`); @@ -26,8 +16,10 @@ export default function Products() { const [searchTerm, setSearchTerm] = useState(''); const [selectedProduct, setSelectedProduct] = useState(null); const [showModal, setShowModal] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); const { data: products, isLoading, error } = useQuery('products', fetchProducts); const { darkMode } = useTheme(); + const { addToCart } = useCart(); const filteredProducts = products?.filter(product => product.name.toLowerCase().includes(searchTerm.toLowerCase()) || @@ -43,9 +35,15 @@ export default function Products() { const handleAddToCart = (productId: number) => { const quantity = quantities[productId] || 0; + const product = products?.find((item) => item.productId === productId); + if (quantity > 0) { - // TODO: Implement cart functionality - alert(`Added ${quantity} items to cart`); + if (!product) { + return; + } + + addToCart(product, quantity); + setStatusMessage(`Added ${quantity} ${product.name} item(s) to cart.`); setQuantities(prev => ({ ...prev, [productId]: 0 @@ -85,6 +83,21 @@ export default function Products() {

Products

+ + {statusMessage && ( +
+
+ {statusMessage} + +
+
+ )}
void; + updateQuantity: (productId: number, quantity: number) => void; + removeFromCart: (productId: number) => void; + clearCart: () => void; + applyCoupon: (couponCode: string) => void; +} + +const CART_STORAGE_KEY = 'octocat.cart.v1'; + +const CartContext = createContext(undefined); + +const readStoredCart = (): CartState => { + const fallbackState: CartState = { items: [], couponCode: '' }; + + if (typeof window === 'undefined') { + return fallbackState; + } + + try { + const storedState = window.localStorage.getItem(CART_STORAGE_KEY); + if (!storedState) { + return fallbackState; + } + + const parsedState = JSON.parse(storedState) as Partial; + return { + items: Array.isArray(parsedState.items) ? parsedState.items : [], + couponCode: typeof parsedState.couponCode === 'string' ? parsedState.couponCode : '', + }; + } catch { + return fallbackState; + } +}; + +export function CartProvider({ children }: { children: React.ReactNode }) { + const [cartState, setCartState] = useState(readStoredCart); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cartState)); + }, [cartState]); + + const addToCart = (product: Product, quantity: number) => { + if (quantity <= 0) { + return; + } + + setCartState((prevState) => { + const existingItem = prevState.items.find((item) => item.product.productId === product.productId); + + if (existingItem) { + return { + ...prevState, + items: prevState.items.map((item) => + item.product.productId === product.productId + ? { ...item, quantity: item.quantity + quantity } + : item, + ), + }; + } + + return { + ...prevState, + items: [...prevState.items, { product, quantity }], + }; + }); + }; + + const updateQuantity = (productId: number, quantity: number) => { + setCartState((prevState) => { + if (quantity <= 0) { + return { + ...prevState, + items: prevState.items.filter((item) => item.product.productId !== productId), + }; + } + + return { + ...prevState, + items: prevState.items.map((item) => + item.product.productId === productId ? { ...item, quantity } : item, + ), + }; + }); + }; + + const removeFromCart = (productId: number) => { + setCartState((prevState) => ({ + ...prevState, + items: prevState.items.filter((item) => item.product.productId !== productId), + })); + }; + + const clearCart = () => { + setCartState((prevState) => ({ + ...prevState, + items: [], + couponCode: '', + })); + }; + + const applyCoupon = (couponCode: string) => { + setCartState((prevState) => ({ + ...prevState, + couponCode: couponCode.trim(), + })); + }; + + const itemCount = useMemo( + () => cartState.items.reduce((sum, item) => sum + item.quantity, 0), + [cartState.items], + ); + + const contextValue: CartContextValue = { + items: cartState.items, + couponCode: cartState.couponCode, + itemCount, + addToCart, + updateQuantity, + removeFromCart, + clearCart, + applyCoupon, + }; + + return {children}; +} + +export const useCart = () => { + const context = useContext(CartContext); + if (context === undefined) { + throw new Error('useCart must be used within a CartProvider'); + } + return context; +}; diff --git a/frontend/src/types/cart.ts b/frontend/src/types/cart.ts new file mode 100644 index 0000000..e46c4d1 --- /dev/null +++ b/frontend/src/types/cart.ts @@ -0,0 +1,11 @@ +import type { Product } from './product'; + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface CartState { + items: CartItem[]; + couponCode: string; +} diff --git a/frontend/src/types/product.ts b/frontend/src/types/product.ts new file mode 100644 index 0000000..c8530cc --- /dev/null +++ b/frontend/src/types/product.ts @@ -0,0 +1,11 @@ +export interface Product { + productId: number; + supplierId: number; + name: string; + description: string; + price: number; + sku: string; + unit: string; + imgName: string; + discount?: number; +} diff --git a/frontend/src/utils/cartPricing.ts b/frontend/src/utils/cartPricing.ts new file mode 100644 index 0000000..84c1085 --- /dev/null +++ b/frontend/src/utils/cartPricing.ts @@ -0,0 +1,30 @@ +import type { CartItem } from '../types/cart'; +import type { Product } from '../types/product'; + +export const CART_DISCOUNT_RATE = 0.05; +export const CART_SHIPPING_FEE = 10; + +export const getEffectiveUnitPrice = (product: Product): number => { + const discount = product.discount ?? 0; + return product.price * (1 - discount); +}; + +export const calculateSubtotal = (items: CartItem[]): number => { + return items.reduce((sum, item) => sum + getEffectiveUnitPrice(item.product) * item.quantity, 0); +}; + +export const calculateCartSummary = (items: CartItem[]) => { + const subtotal = calculateSubtotal(items); + const discount = subtotal * CART_DISCOUNT_RATE; + const shipping = CART_SHIPPING_FEE; + const total = subtotal - discount + shipping; + + return { + subtotal, + discount, + shipping, + total, + }; +}; + +export const formatCurrency = (value: number): string => `$${value.toFixed(2)}`;