Skip to content
Open
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
112 changes: 112 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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
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 { CartProvider } from './context/CartContext';
import AdminProducts from './components/admin/AdminProducts';
import Cart from './components/Cart';
import { useTheme } from './context/ThemeContext';

// Wrapper component to apply theme classes
Expand All @@ -23,6 +25,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 @@ -37,7 +40,9 @@ function App() {
return (
<AuthProvider>
<ThemeProvider>
<ThemedApp />
<CartProvider>
<ThemedApp />
</CartProvider>
</ThemeProvider>
</AuthProvider>
);
Expand Down
211 changes: 211 additions & 0 deletions frontend/src/components/Cart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { useState } from 'react';
import { useTheme } from '../context/ThemeContext';
import { useCart } from '../context/CartContext';
import { Link } from 'react-router-dom';

const SHIPPING_COST = 10;
const COUPON_CODES: Record<string, number> = {
OCTOCAT10: 0.1,
MEOW20: 0.2,
COPILOT5: 0.05,
};

export default function Cart() {
const { darkMode } = useTheme();
const { items, removeFromCart, updateQuantity, subtotal, clearCart } = useCart();
const [couponCode, setCouponCode] = useState('');
const [appliedCoupon, setAppliedCoupon] = useState<{ code: string; rate: number } | null>(null);
const [couponError, setCouponError] = useState('');

const handleApplyCoupon = () => {
const rate = COUPON_CODES[couponCode.toUpperCase()];
if (rate) {
setAppliedCoupon({ code: couponCode.toUpperCase(), rate });
setCouponError('');
} else {
setAppliedCoupon(null);
setCouponError('Invalid coupon code');
}
};

const discountAmount = appliedCoupon ? subtotal * appliedCoupon.rate : 0;
const grandTotal = subtotal - discountAmount + (items.length > 0 ? SHIPPING_COST : 0);

if (items.length === 0) {
return (
<div className={`min-h-screen ${darkMode ? 'bg-dark' : 'bg-gray-100'} pt-20 px-4 transition-colors duration-300`}>
<div className="max-w-4xl mx-auto text-center py-20">
<svg className={`mx-auto h-24 w-24 ${darkMode ? 'text-gray-600' : 'text-gray-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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 100 4 2 2 0 000-4z" />
</svg>
<h2 className={`mt-6 text-2xl font-bold ${darkMode ? 'text-light' : 'text-gray-800'}`}>Your cart is empty</h2>
<p className={`mt-2 ${darkMode ? 'text-gray-400' : 'text-gray-600'}`}>Looks like you haven't added any items yet.</p>
<Link
to="/products"
className="mt-6 inline-block bg-primary hover:bg-accent text-white px-6 py-3 rounded-lg font-medium transition-colors"
>
Browse Products
</Link>
</div>
</div>
);
}

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">
<h1 className={`text-3xl font-bold ${darkMode ? 'text-light' : 'text-gray-800'} mb-6`}>Shopping Cart</h1>

<div className="flex flex-col lg:flex-row gap-8">
{/* Cart Items Table */}
<div className="lg:w-2/3">
<div className={`${darkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'} rounded-lg border overflow-hidden`}>
{/* Table Header */}
<div className={`hidden md:grid grid-cols-12 gap-4 px-6 py-3 ${darkMode ? 'bg-gray-900 text-gray-400' : 'bg-gray-50 text-gray-500'} text-sm font-medium`}>
<div className="col-span-1">S. No.</div>
<div className="col-span-2">Product Image</div>
<div className="col-span-3">Product Name</div>
<div className="col-span-2">Unit Price</div>
<div className="col-span-1">Quantity</div>
<div className="col-span-2">Total</div>
<div className="col-span-1">Remove</div>
</div>

{/* Cart Items */}
{items.map((item, index) => {
const effectivePrice = item.discount
? item.price * (1 - item.discount)
: item.price;
const lineTotal = effectivePrice * item.quantity;

return (
<div
key={item.productId}
className={`grid grid-cols-12 gap-4 px-6 py-4 items-center ${darkMode ? 'border-gray-700' : 'border-gray-200'} border-t`}
>
<div className={`col-span-1 ${darkMode ? 'text-light' : 'text-gray-800'} font-medium`}>
{index + 1}
</div>
<div className="col-span-2">
<div className={`w-20 h-20 ${darkMode ? 'bg-gray-700' : 'bg-gray-100'} rounded-lg overflow-hidden`}>
<img
src={`/${item.imgName}`}
alt={item.name}
className="w-full h-full object-contain p-1"
/>
</div>
</div>
<div className={`col-span-3 ${darkMode ? 'text-light' : 'text-gray-800'} font-medium`}>
{item.name}
</div>
<div className={`col-span-2 ${darkMode ? 'text-light' : 'text-gray-800'}`}>
{item.discount ? (
<div>
<span className="text-gray-500 line-through text-sm mr-1">${item.price.toFixed(2)}</span>
<span className="text-primary">${effectivePrice.toFixed(2)}</span>
</div>
) : (
<span>${effectivePrice.toFixed(2)}</span>
)}
</div>
<div className="col-span-1">
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) => updateQuantity(item.productId, parseInt(e.target.value) || 1)}
className={`w-16 px-2 py-1 text-center rounded border ${darkMode ? 'bg-gray-700 border-gray-600 text-light' : 'bg-white border-gray-300 text-gray-800'} focus:border-primary focus:outline-none`}
aria-label={`Quantity of ${item.name}`}
/>
</div>
<div className={`col-span-2 ${darkMode ? 'text-light' : 'text-gray-800'} font-semibold`}>
${lineTotal.toFixed(2)}
</div>
<div className="col-span-1">
<button
onClick={() => removeFromCart(item.productId)}
className="text-red-500 hover:text-red-700 transition-colors p-1"
aria-label={`Remove ${item.name} from cart`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
);
})}

{/* Bottom Actions */}
<div className={`flex flex-col sm:flex-row justify-between items-center gap-4 px-6 py-4 ${darkMode ? 'border-gray-700' : 'border-gray-200'} border-t`}>
<div className="flex gap-2">
<input
type="text"
placeholder="Coupon Code"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
className={`px-4 py-2 rounded-lg border ${darkMode ? 'bg-gray-700 border-gray-600 text-light placeholder-gray-500' : 'bg-white border-gray-300 text-gray-800 placeholder-gray-400'} focus:border-primary focus:outline-none`}
aria-label="Coupon code"
/>
<button
onClick={handleApplyCoupon}
className="bg-primary hover:bg-accent text-white px-4 py-2 rounded-lg font-medium transition-colors"
>
Apply Coupon
</button>
</div>
{couponError && <span className="text-red-500 text-sm">{couponError}</span>}
{appliedCoupon && (
<span className="text-primary text-sm font-medium">
Coupon {appliedCoupon.code} applied ({Math.round(appliedCoupon.rate * 100)}% off)
</span>
)}
<button
onClick={clearCart}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${darkMode ? 'bg-gray-700 hover:bg-gray-600 text-light' : 'bg-gray-200 hover:bg-gray-300 text-gray-800'}`}
>
Clear Cart
</button>
</div>
</div>
</div>

{/* Order Summary */}
<div className="lg:w-1/3">
<div className={`${darkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'} rounded-lg border p-6 sticky top-24`}>
<h2 className={`text-xl font-bold ${darkMode ? 'text-light' : 'text-gray-800'} mb-4`}>Order Summary</h2>

<div className="space-y-3">
<div className={`flex justify-between ${darkMode ? 'text-gray-300' : 'text-gray-600'}`}>
<span>Subtotal</span>
<span className="font-medium">${subtotal.toFixed(2)}</span>
</div>

{appliedCoupon && (
<div className="flex justify-between text-primary">
<span>Discount ({Math.round(appliedCoupon.rate * 100)}%)</span>
<span className="font-medium">-${discountAmount.toFixed(2)}</span>
</div>
)}

<div className={`flex justify-between ${darkMode ? 'text-gray-300' : 'text-gray-600'}`}>
<span>Shipping</span>
<span className="font-medium">${SHIPPING_COST.toFixed(2)}</span>
</div>

<div className={`flex justify-between pt-3 border-t ${darkMode ? 'border-gray-700 text-light' : 'border-gray-200 text-gray-800'} text-lg font-bold`}>
<span>Grand Total</span>
<span>${grandTotal.toFixed(2)}</span>
</div>
</div>

<button className="w-full mt-6 bg-primary hover:bg-accent text-white py-3 rounded-lg font-semibold transition-colors text-lg">
Proceed To Checkout
</button>
</div>
</div>
</div>
</div>
</div>
);
}
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 { 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 @@ -68,6 +70,16 @@ 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">
<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 100 4 2 2 0 000-4z" />
</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}
</span>
)}
</Link>
<button
onClick={toggleTheme}
className="p-2 rounded-full focus:outline-none transition-colors"
Expand Down
Loading