From a5eb557085572ad164283588e8c2bc85d1cc947c Mon Sep 17 00:00:00 2001 From: folorunsho olamide Date: Mon, 1 Jun 2026 17:04:55 +0100 Subject: [PATCH] feat(deliveries): add delivery filters hook and component - Implemented `useDeliveryFilters` hook for managing delivery filter state via URL query parameters. - Created `DeliveryFilters` component to utilize the new hook. - Added tests for `useDeliveryFilters` to ensure correct functionality and state management. feat(escrow): create EscrowLock component for locking payments - Developed `EscrowLock` component to handle payment locking with user confirmation. - Integrated escrow service for locking functionality and error handling. - Added tests for `EscrowLock` component covering various states and interactions. test(deliveries): add unit tests for deliveries service - Created tests for `deliveriesService` to validate API interactions and error handling. - Ensured correct behavior for fetching deliveries with and without filters. feat(types): define filter types for delivery management - Added type definitions for delivery filters including `DeliveryStatus` and `DeliveryFilterParams`. --- APPLAYOUT_IMPLEMENTATION.md | 339 ------------- IMPLEMENTATION_SUMMARY.md | 386 --------------- MODAL_IMPLEMENTATION.md | 444 ------------------ TOAST_IMPLEMENTATION.md | 375 --------------- TOPLOADER_IMPLEMENTATION.md | 166 ------- components/DeliveryList.tsx | 95 +++- .../deliveries/components/DeliveryFilters.tsx | 167 +++++++ .../__tests__/DeliveryFilters.test.tsx | 240 ++++++++++ features/deliveries/components/index.ts | 1 + .../__tests__/useDeliveryFilters.test.ts | 143 ++++++ features/deliveries/hooks/index.ts | 1 + .../deliveries/hooks/useDeliveryFilters.ts | 79 ++++ features/escrow/components/EscrowLock.tsx | 237 ++++++++++ .../components/__tests__/EscrowLock.test.tsx | 365 ++++++++++++++ hooks/__tests__/useEscrowLock.test.ts | 261 ++++++++++ hooks/useDeliveries.ts | 7 +- hooks/useEscrowLock.ts | 58 +++ services/__tests__/deliveries.service.test.ts | 104 ++++ services/deliveries.service.ts | 19 +- services/escrowService.ts | 23 + tailwind.config.ts | 2 + tsconfig.json | 3 +- types/filters.ts | 15 + 23 files changed, 1801 insertions(+), 1729 deletions(-) delete mode 100644 APPLAYOUT_IMPLEMENTATION.md delete mode 100644 IMPLEMENTATION_SUMMARY.md delete mode 100644 MODAL_IMPLEMENTATION.md delete mode 100644 TOAST_IMPLEMENTATION.md delete mode 100644 TOPLOADER_IMPLEMENTATION.md create mode 100644 features/deliveries/components/DeliveryFilters.tsx create mode 100644 features/deliveries/components/__tests__/DeliveryFilters.test.tsx create mode 100644 features/deliveries/components/index.ts create mode 100644 features/deliveries/hooks/__tests__/useDeliveryFilters.test.ts create mode 100644 features/deliveries/hooks/index.ts create mode 100644 features/deliveries/hooks/useDeliveryFilters.ts create mode 100644 features/escrow/components/EscrowLock.tsx create mode 100644 features/escrow/components/__tests__/EscrowLock.test.tsx create mode 100644 hooks/__tests__/useEscrowLock.test.ts create mode 100644 hooks/useEscrowLock.ts create mode 100644 services/__tests__/deliveries.service.test.ts create mode 100644 types/filters.ts diff --git a/APPLAYOUT_IMPLEMENTATION.md b/APPLAYOUT_IMPLEMENTATION.md deleted file mode 100644 index 562ed56..0000000 --- a/APPLAYOUT_IMPLEMENTATION.md +++ /dev/null @@ -1,339 +0,0 @@ -# Global Layout & Responsive Sidebar - Implementation Summary - -## Overview - -Implemented a global application shell (`AppLayout`) with a responsive sidebar system that adapts to both desktop and mobile viewports. The implementation follows the strict Component → Hook → Service layered architecture pattern. - -## Implementation Details - -### Architecture: Component → Hook → Service Pattern ✓ - -#### 1. **Service Layer** (`services/layoutService.ts`) - -- **Responsibility**: Manages all layout-related API communication and configuration -- **Key Features**: - - Fetches layout configuration from backend API - - Retrieves navigation items based on user context - - Manages user layout preferences (sidebar collapsed state, theme) - - Provides fallback default configuration when API fails - - Type-safe interfaces for all data structures -- **Key Methods**: - - `getLayoutConfig()`: Fetches branding and theme config - - `getNavigationItems()`: Retrieves personalized nav items - - `getUserLayoutPreferences()`: Gets user-specific preferences - - `saveUserLayoutPreferences()`: Persists user preferences - -#### 2. **Custom Hook** (`hooks/useAppLayout.ts`) - -- **Responsibility**: Manages React component state and lifecycle -- **Key Features**: - - Detects mobile vs desktop (breakpoint: lg = 1024px) - - Manages sidebar visibility state - - Manages sidebar collapse state (desktop) - - Handles window resize events - - Provides method to filter nav items by role - - Automatically initializes on mount -- **Exposed API**: - - `state`: Layout state (isLoading, error, isMobile, sidebarOpen, etc.) - - `toggleSidebar()`: Toggle sidebar visibility on mobile - - `closeSidebar()`: Close sidebar - - `openSidebar()`: Open sidebar - - `toggleSidebarCollapse()`: Toggle collapse state on desktop - - `getVisibleNavItems()`: Get filtered navigation items - -#### 3. **UI Component** (`components/layout/AppLayout.tsx`) - -- **Responsibility**: Renders the application shell with responsive behavior -- **Structure**: - - `DesktopSidebar`: Fixed left sidebar (hidden on mobile) - - `MobileBottomSheet`: Bottom-sheet menu (mobile only) - - `NavLink`: Reusable navigation link component - - Main layout wrapper with responsive margins -- **Key Features**: - - Responsive breakpoint: lg (1024px) - - Mobile first approach with progressive enhancement - - Dark mode support throughout - - Loading state with spinner - - Smooth transitions and animations - - Accessibility features (ARIA labels, semantic HTML) - -### Files Created - -| File | Purpose | Lines | -| --------------------------------- | ------------------------ | ----- | -| `services/layoutService.ts` | API integration & config | 186 | -| `hooks/useAppLayout.ts` | State management & logic | 142 | -| `components/layout/AppLayout.tsx` | UI rendering | 268 | - -## Responsive Design - -### Desktop (1024px+) - -- **Sidebar**: Fixed left sidebar, 256px wide (or 80px when collapsed) -- **Layout**: Main content shifts right based on sidebar state -- **Interaction**: Click toggle button to collapse/expand sidebar -- **Features**: - - Collapsible sidebar with smooth transition - - Full navigation items visible - - Descriptions shown under nav labels - -### Mobile (< 1024px, tested at 375px) - -- **Header**: Sticky top header with hamburger menu icon -- **Navigation**: Bottom-sheet drawer (slides up from bottom) -- **Layout**: Full width main content -- **Interactions**: - - Tap hamburger to open bottom-sheet - - Tap X or backdrop to close - - Auto-closes when navigating to a link -- **Features**: - - Non-blocking bottom-sheet (draggable area at top) - - Smooth slide-in animation - - Dark overlay backdrop - - Full navigation items visible in sheet - -## Component Usage - -```tsx -import AppLayout from '@/components/layout/AppLayout'; - -export default function RootLayout({ children }) { - return ( - - - {children} - - - ); -} -``` - -## Data Flow - -``` -Backend API - ↓ -layoutService (fetch & format) - ↓ -useAppLayout Hook (state management) - ↓ -AppLayout Component (render) - ↓ -NavLink Components (interactive) -``` - -## API Integration - -### Expected Backend Endpoints - -1. **GET `/api/layout/config`** - - ```json - { - "success": true, - "data": { - "branding": { - "appName": "SwiftChain", - "logo": "/logo.svg", - "logoDark": "/logo-dark.svg" - }, - "navigation": [...], - "theme": { - "primaryColor": "#3b82f6", - "sidebarCollapsible": true - } - } - } - ``` - -2. **GET `/api/layout/navigation`** - - ```json - { - "success": true, - "data": [ - { - "id": "dashboard", - "label": "Dashboard", - "href": "/dashboard", - "icon": "📊", - "description": "Overview and stats", - "roles": ["customer", "driver", "admin"] - }, - ... - ] - } - ``` - -3. **GET `/api/layout/preferences`** - - ```json - { - "success": true, - "data": { - "sidebarCollapsed": false, - "theme": "light", - "sidebarWidth": 256 - } - } - ``` - -4. **PATCH `/api/layout/preferences`** - - Request: `{ "sidebarCollapsed": boolean, "theme": "light" | "dark" }` - - Response: Same as GET - -### Fallback Strategy - -- If API calls fail, service returns default configuration -- Navigation items still functional with default items -- User preferences default to: sidebar expanded, light theme, 256px width -- No breaking UI changes due to API failures - -## Responsive Breakpoints - -| Breakpoint | Width | Sidebar | -| -------------- | -------------- | ------------- | -| Mobile (xs-sm) | < 1024px | Bottom-sheet | -| Tablet (md) | 768px - 1023px | Bottom-sheet | -| Desktop (lg) | ≥ 1024px | Fixed sidebar | - -## Styling & Theme - -### Color Scheme - -- **Primary**: Uses Tailwind `primary` color (#3b82f6) -- **Light Mode**: Slate-50 background, white sidebar -- **Dark Mode**: Slate-950 background, slate-900 sidebar -- **Hover States**: Slate-100/800 backgrounds -- **Borders**: Slate-200/800 colors - -### Animations - -- **Sidebar Toggle**: 300ms ease transitions -- **Bottom Sheet**: 300ms ease translate animations -- **Nav Items**: 200ms ease color/background transitions -- **Loading Spinner**: Continuous rotation animation - -### Accessibility - -- Semantic HTML (header, nav, main, aside) -- ARIA labels on all interactive elements -- Proper heading hierarchy -- Keyboard navigation support -- Color contrast compliance (WCAG AA) -- Focus management - -## Key Features - -✅ **Responsive Design** - -- Tested at 375px (mobile) and 1024px (desktop) -- Smooth transitions between breakpoints -- Touch-friendly mobile interactions - -✅ **Dark Mode Support** - -- Full dark mode styling throughout -- Automatic theme switching via Tailwind - -✅ **Accessibility** - -- ARIA labels and semantic HTML -- Keyboard navigation -- Proper focus states - -✅ **Performance** - -- Lazy loads navigation data -- Efficient state management -- Minimal re-renders - -✅ **Error Handling** - -- Graceful fallback to default config -- Error state display -- Network error recovery - -✅ **Backend Integration** - -- Real API data source (no mocks) -- User preference persistence -- Role-based navigation (ready for filtering) - -## Testing Recommendations - -### Mobile Testing (375px) - -1. ✓ Hamburger menu appears at top -2. ✓ Tap hamburger opens bottom-sheet -3. ✓ Bottom-sheet animates from bottom -4. ✓ Navigation items visible and tappable -5. ✓ Backdrop click closes bottom-sheet -6. ✓ X button closes bottom-sheet -7. ✓ Automatic close on navigation -8. ✓ Responsive text sizing - -### Desktop Testing (1024px+) - -1. ✓ Fixed sidebar appears on left (256px) -2. ✓ Main content shifts right (margin-left: 256px) -3. ✓ Collapse button works -4. ✓ Sidebar collapses to 80px -5. ✓ Navigation items remain visible when collapsed -6. ✓ Hover states work on nav items -7. ✓ Active route highlighted correctly -8. ✓ Smooth transitions on all state changes - -### API Integration Testing - -1. ✓ Verify API endpoints return proper format -2. ✓ Test fallback when API is down -3. ✓ Test preferences persistence -4. ✓ Test user role-based filtering -5. ✓ Test network error recovery - -### Cross-browser Testing - -- Chrome/Edge (Chromium) -- Firefox -- Safari -- Mobile browsers (Chrome, Safari) - -## Future Enhancements - -- Add nested navigation support -- Implement breadcrumb integration -- Add keyboard shortcuts -- Persistent sidebar state per device -- Animation preferences (respects prefers-reduced-motion) -- Multi-language support -- Custom icon support (SVG components) -- Search/filter navigation items - -## Code Quality - -✅ TypeScript strict mode compliant -✅ ESLint formatted -✅ Prettier compliant -✅ No console warnings -✅ Comprehensive JSDoc comments -✅ Proper error handling and guards -✅ Follows project conventions -✅ Mobile-first responsive approach - -## Related Issues - -Closes #[issue_id] - ---- - -## PR Submission Checklist - -- [ ] All tests pass (mobile + desktop) -- [ ] API endpoints verified -- [ ] Screenshots included (mobile & desktop) -- [ ] Documentation updated -- [ ] No console errors or warnings -- [ ] Dark mode tested -- [ ] Accessibility verified -- [ ] PR description includes this summary diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 6815ccc..0000000 --- a/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,386 +0,0 @@ -# Login Interface Implementation - Summary - -## Overview - -Successfully implemented a complete login interface for SwiftChain Frontend following the Component → Hook → Service architecture pattern as specified in the requirements. - -## Acceptance Criteria - ALL MET ✅ - -### ✅ Form Validation - -- Email field validation (required, valid format) -- Password field validation (required) -- Real-time error clearing on input change -- Client-side validation before submission - -### ✅ Required Features - -- Email/Password input fields with validation -- "Remember Me" checkbox -- "Forgot Password" link -- Password visibility toggle -- Submit button with loading state -- Link to registration page - -### ✅ Strict Layered Architecture - -Implementation follows **Component → Hook → Service** pattern: - -``` -LoginForm (Component) - ↓ -useLogin (Custom Hook) - ↓ -authService (Service Layer) - ↓ -Axios API Calls -``` - -### ✅ Backend API Integration - -- No inline mock objects -- All data retrieved from backend API via `authService` -- Response-based error handling -- Token management and storage - -### ✅ Unit Tests - -- ✅ 54/54 tests passing -- ✅ All test suites pass (9/9) -- ✅ Comprehensive coverage of components, hooks, and services - ---- - -## Implementation Details - -### Files Created - -#### 1. **LoginForm Component** - -[components/forms/LoginForm.tsx](../components/forms/LoginForm.tsx) - -- Renders login UI with all required fields -- Integrates useLogin hook -- Displays validation errors -- Shows/hides password with toggle button -- Remember me checkbox -- Responsive design with TailwindCSS - -#### 2. **useLogin Hook** - -[hooks/useLogin.ts](../hooks/useLogin.ts) - -- Manages form state (values, errors) -- Email format validation with regex -- Real-time error clearing -- Form submission with async API calls -- Token storage in localStorage -- Automatic redirect to dashboard on success -- Error handling and display - -#### 3. **Authentication Service** - -[services/authService.ts](../services/authService.ts) - -- Centralized API communication -- `login()` - Main authentication endpoint -- `logout()` - Clears session -- `requestPasswordReset()` - Forgot password flow -- `resetPassword()` - Password reset with token -- `getCurrentUser()` - Fetch authenticated user -- Proper error handling - -#### 4. **Login Page** - -[app/(auth)/login/page.tsx](<../app/(auth)/login/page.tsx>) - -- Route: `/login` -- Server component wrapper -- Centered layout with responsive styling - -#### 5. **Comprehensive Tests** - -- [hooks/**tests**/useLogin.test.ts](../hooks/__tests__/useLogin.test.ts) - 19 tests -- [components/forms/**tests**/LoginForm.test.tsx](../components/forms/__tests__/LoginForm.test.tsx) - 12 tests -- [services/**tests**/authService.test.ts](../services/__tests__/authService.test.ts) - 10 tests - ---- - -## API Endpoints Used - -```typescript -POST /api/auth/login -{ - email: string, - password: string -} - -// Response -{ - success: boolean, - message: string, - data: { - token: string, - user: { - id: string, - email: string, - name: string, - role: 'customer' | 'driver' | 'admin' - } - } -} -``` - ---- - -## Key Features - -### 1. **Email Validation** - -- Required field validation -- Format validation: `user@domain.com` -- Real-time validation on blur -- Clear error messages - -### 2. **Password Management** - -- Required field validation -- Show/hide password toggle -- Secure input type - -### 3. **User Experience** - -- Loading state on submit button -- Error messages display -- Remember me checkbox -- Forgot password link -- Link to registration -- Responsive mobile design - -### 4. **Security** - -- Token stored in localStorage -- Client-side validation before API call -- Proper error handling (no credential exposure) -- Secure password input - ---- - -## Testing Summary - -### Test Results - -``` -Test Suites: 9 passed, 9 total -Tests: 54 passed, 54 total -Snapshots: 0 total -Time: 6.65 s -``` - -### Test Coverage - -#### useLogin Hook Tests (19 tests) - -- ✅ Initial state validation -- ✅ Email/password value updates -- ✅ Email validation (empty, invalid format, valid) -- ✅ Password validation -- ✅ Error clearing on input -- ✅ Form submission validation -- ✅ API integration -- ✅ Error handling - -#### LoginForm Component Tests (12 tests) - -- ✅ Render all form fields -- ✅ Sign in button rendering -- ✅ Registration link -- ✅ Password visibility toggle -- ✅ Remember me checkbox -- ✅ Input field interactions -- ✅ Form blur handling -- ✅ Form submit handling - -#### authService Tests (10 tests) - -- ✅ Successful login -- ✅ Error handling -- ✅ API endpoint verification -- ✅ Logout functionality -- ✅ Password reset request -- ✅ Password reset confirmation -- ✅ Get current user - ---- - -## Type Safety - -- ✅ Full TypeScript support -- ✅ Interfaces defined for API responses -- ✅ Type-safe hook returns -- ✅ No any types in implementation - ---- - -## Code Quality - -- ✅ ESLint compliant -- ✅ Follows project conventions -- ✅ Proper error handling -- ✅ Clean, readable code -- ✅ Comprehensive comments - ---- - -## Browser Support - -- ✅ Chrome/Edge (latest) -- ✅ Firefox (latest) -- ✅ Safari (latest) -- ✅ Mobile browsers -- ✅ Responsive: 375px - 2560px - ---- - -## Performance - -- ✅ No unnecessary re-renders -- ✅ useCallback for handlers -- ✅ Efficient state management -- ✅ Minimal bundle impact - ---- - -## Compliance with Contributing.md - -✅ Screenshot capability (visual components ready for PR screenshots) -✅ Component reliability (graceful error handling) -✅ Responsive design (mobile and desktop tested) -✅ Accessibility (ARIA labels, keyboard navigation) -✅ Git branch ready: `feat/login-ui` -✅ All unit tests passing -✅ Clear PR description template ready - ---- - -## Next Steps for PR Submission - -1. Create branch: `git checkout -b feat/login-ui` -2. Take screenshots of: - - Login form rendering - - Email validation error - - Password validation error - - Successful login flow - - Test coverage output -3. Commit changes: `git add .` -4. Create pull request with: - - Title: "feat: Implement login interface for customers and drivers" - - Description: - - ``` - Closes #[issue_id] - - ## Summary of Work - Implemented complete login interface with: - - Email/password fields with real-time validation - - Remember me checkbox - - Forgot password link - - Password visibility toggle - - Strict Component → Hook → Service architecture - - Backend API integration (no mock data) - - 54 passing unit tests - - Full TypeScript support - - ## Screenshots - [Include screenshots here] - ``` - - - Include test coverage screenshot - - Link issue with "Closes #[issue_id]" - ---- - -## Files Modified/Created - -### Created - -- ✅ `components/forms/LoginForm.tsx` -- ✅ `hooks/useLogin.ts` -- ✅ `services/authService.ts` -- ✅ `hooks/__tests__/useLogin.test.ts` -- ✅ `components/forms/__tests__/LoginForm.test.tsx` -- ✅ `services/__tests__/authService.test.ts` - -### Modified - -- ✅ `app/(auth)/login/page.tsx` - Updated with LoginForm integration - ---- - -## Verification Commands - -```bash -# Run all tests -pnpm test - -# Type checking (login implementation has no errors) -pnpm type-check - -# Development server -pnpm dev -# Navigate to http://localhost:3000/login -``` - ---- - -## Environment Variables Required - -```env -NEXT_PUBLIC_API_URL= -``` - -Example: `NEXT_PUBLIC_API_URL=http://localhost:3001` - ---- - -## Architecture Diagram - -``` -┌─────────────────────────────────────────────────┐ -│ LoginForm Component │ -│ - Renders UI │ -│ - Calls useLogin hook │ -│ - Displays validation errors │ -└──────────────────┬──────────────────────────────┘ - │ uses - ▼ -┌─────────────────────────────────────────────────┐ -│ useLogin Hook │ -│ - State management (values, errors) │ -│ - Form validation logic │ -│ - Calls authService.login() │ -│ - Handles token storage │ -└──────────────────┬──────────────────────────────┘ - │ calls - ▼ -┌─────────────────────────────────────────────────┐ -│ authService (Service Layer) │ -│ - API communication with Axios │ -│ - POST /api/auth/login │ -│ - Returns token and user data │ -└──────────────────┬──────────────────────────────┘ - │ calls - ▼ -┌─────────────────────────────────────────────────┐ -│ Backend API Endpoint │ -│ - Validates credentials │ -│ - Generates JWT token │ -│ - Returns user profile │ -└─────────────────────────────────────────────────┘ -``` - ---- - -**Implementation Status:** ✅ COMPLETE - Ready for PR submission - -All acceptance criteria have been met. The implementation follows best practices, includes comprehensive testing, and maintains strict architecture patterns as specified in the requirements. diff --git a/MODAL_IMPLEMENTATION.md b/MODAL_IMPLEMENTATION.md deleted file mode 100644 index c93bf2c..0000000 --- a/MODAL_IMPLEMENTATION.md +++ /dev/null @@ -1,444 +0,0 @@ -# Global Modal Framework - Implementation Summary - -## Overview - -Implemented a reusable, accessible modal component framework with focus trapping, backdrop blur, and smooth animations. The system follows strict layered architecture (Component → Hook → Service) with backend API integration for modal templates. - -## Implementation Details - -### Architecture: Component → Hook → Service Pattern ✓ - -#### 1. **Service Layer** (`services/modalService.ts`) - -- **Responsibility**: Manages modal state, lifecycle, and API communication -- **Key Features**: - - Singleton service for centralized modal management - - Event subscription pattern for listeners - - Modal stack support for nested modals - - API integration for fetching modal templates - - Submit modal actions to backend - - Type-safe interfaces for all configurations -- **Key Methods**: - - `open(config)`: Open a modal with configuration - - `close(callback?)`: Close current modal - - `closeAll()`: Close all modals - - `isOpen()`: Check if modal is open - - `getStackDepth()`: Get nesting depth - - `fetchModalTemplates()`: Get templates from API - - `fetchModalTemplate(id)`: Get specific template - - `submitModalAction()`: Submit action to backend -- **Features**: - - Modal stacking for nested modals - - SSR-safe singleton - - Error handling with fallbacks - -#### 2. **Custom Hook** (`hooks/useModal.ts`) - -- **Responsibility**: Provides React components easy access to modal functionality -- **Exposed API**: - - `isOpen`: Boolean indicating modal open state - - `currentModal`: Current modal configuration - - `stackDepth`: Number of modals in stack - - `open(config)`: Open a modal - - `close(callback?)`: Close modal - - `closeAll()`: Close all modals - - `fetchTemplates()`: Load templates from API - - `openFromTemplate(id)`: Open modal from template - - `isLoading`: Loading state during API calls -- **Features**: - - Real-time state synchronization - - Simple component integration - - Built-in API data fetching - - Type-safe TypeScript interfaces - -#### 3. **UI Component** (`components/ui/Modal.tsx`) - -- **Responsibility**: Renders the modal UI with accessibility features -- **Features**: - - **Focus Trap**: Automatically traps focus within modal, cycles through focusable elements - - **Backdrop Blur**: CSS blur effect on backdrop overlay - - **ESC to Close**: Press ESC key to close modal (if closeable) - - **Outside Click Close**: Click backdrop to close modal (if closeable) - - **Body Scroll Lock**: Prevents body scrolling when modal open - - **React Portals**: Renders outside DOM tree to avoid z-index issues - - **Framer Motion**: Smooth animations and transitions - - **Dark Mode**: Full dark mode support - - **ARIA Labels**: Proper accessibility attributes - -#### 4. **Provider Component** (`components/providers/ModalProvider.tsx`) - -- **Responsibility**: Global provider wrapping app with modal context -- **Features**: - - Manages global modal state - - Renders active modal instances - - Provides portal target for modals - - Subscribes to modal service events - - Handles modal lifecycle - -#### 5. **Integration** (`app/layout.tsx`) - -- Wrapped app with ModalProvider -- ModalProvider wraps ToastProvider to maintain provider hierarchy - -### Files Created/Modified - -| File | Purpose | Lines | -| ---------------------------------------- | ------------------------------- | ----- | -| `services/modalService.ts` | Service layer & API integration | 198 | -| `hooks/useModal.ts` | Custom React hook | 118 | -| `components/ui/Modal.tsx` | Modal UI with focus trap | 236 | -| `components/providers/ModalProvider.tsx` | Global provider | 74 | -| `app/layout.tsx` | Integration (modified) | 27 | - -## Key Features - -### 1. Focus Trap ✓ - -``` -- Automatically focuses first focusable element on open -- Tabs cycle through focusable elements within modal -- Shift+Tab goes backwards through elements -- Focus restored to previously focused element on close -- Complies with WCAG 2.1 Level AA accessibility standards -``` - -### 2. Backdrop Blur Overlay ✓ - -``` -- CSS backdrop-blur-sm effect on background -- Semi-transparent black (black/40) overlay -- Configurable backdrop visibility -- Smooth fade animation on open/close -- Prevents interaction with background content -``` - -### 3. Close Behaviors ✓ - -``` -- ESC key closes modal (if closeable=true) -- Click outside modal closes it (if closeable=true) -- Close button in header (if closeable=true) -- Cancel button in footer -- Callback support for custom cleanup -``` - -### 4. Body Scroll Lock ✓ - -``` -- Locks document.body overflow when modal opens -- Prevents content shift from scrollbar -- Automatically released on modal close -- Works with nested modals -``` - -### 5. Responsive Sizing - -``` -- sm: max-width: 24rem (small modal) -- md: max-width: 28rem (default, medium modal) -- lg: max-width: 32rem (large modal) -- xl: max-width: 36rem (extra large modal) -- full: width calc(100% - 2rem) (full screen) -``` - -### 6. Positioning Options - -``` -- center: Centered on screen (default) -- top: Positioned near top with padding -- bottom: Positioned near bottom with padding -``` - -## Usage Examples - -### Basic Modal Usage - -```tsx -'use client'; - -import { useModal } from '@/hooks/useModal'; - -export function MyComponent() { - const { open, close } = useModal(); - - const handleOpenModal = () => { - open({ - id: 'my-modal', - title: 'Confirm Action', - content: 'Are you sure you want to proceed?', - size: 'md', - position: 'center', - closeable: true, - focusTrap: true, - onConfirm: async () => { - // Handle confirmation - console.log('Confirmed!'); - close(); // Manually close after action - }, - confirmLabel: 'Confirm', - cancelLabel: 'Cancel', - }); - }; - - return ; -} -``` - -### Loading Modal - -```tsx -const { open } = useModal(); - -open({ - id: 'loading-modal', - title: 'Processing', - content: 'Please wait while we process your request...', - closeable: false, - isLoading: true, -}); - -// Later... -close(); -``` - -### Modal from Template - -```tsx -const { openFromTemplate } = useModal(); - -await openFromTemplate('confirm-delete-modal'); -``` - -### Nested Modals - -```tsx -const { open, close } = useModal(); - -// Open first modal -open({ - id: 'modal-1', - title: 'First Modal', - content: 'Click button to open nested modal', - onConfirm: () => { - // Open nested modal - open({ - id: 'modal-2', - title: 'Second Modal', - content: 'This is nested!', - }); - }, -}); -``` - -## API Integration - -### Expected Backend Endpoints - -1. **GET `/api/modals/templates`** - - ```json - { - "success": true, - "data": [ - { - "id": "confirm-delete", - "name": "Confirm Delete", - "title": "Delete Item", - "content": "Are you sure you want to delete this item?", - "size": "md", - "position": "center", - "closeable": true, - "backdropBlur": true, - "focusTrap": true, - "confirmLabel": "Delete", - "cancelLabel": "Cancel" - } - ] - } - ``` - -2. **GET `/api/modals/templates/{id}`** - - Returns single modal template - -3. **GET `/api/modals/config`** - - ```json - { - "success": true, - "data": { - "defaultSize": "md", - "enableAnimations": true - } - } - ``` - -4. **POST `/api/modals/{id}/action`** - - Request: `{ "action": "string", "data": any }` - - Response: Confirmation of action - -### Fallback Strategy - -- If API fails, modals still work with provided configuration -- Default size: md -- Default position: center -- No template system but direct configuration still works - -## Accessibility Features - -✅ **WCAG 2.1 Level AA Compliance** - -- Focus trap prevents keyboard users from being trapped -- ARIA labels and descriptions on modal elements -- Semantic HTML structure (role="dialog", aria-modal="true") -- Proper heading hierarchy -- Color contrast meets standards -- Focus visible with ring styling - -✅ **Keyboard Navigation** - -- Tab/Shift+Tab cycles through focusable elements -- ESC closes modal (if closeable) -- Focus management on open/close - -✅ **Screen Reader Support** - -- Modal properly announced as dialog -- Heading associated with aria-labelledby -- Descriptive button labels - -## Animation Details - -### Opening Animation - -``` -- Duration: 200ms -- Easing: easeOut -- Backdrop: Fade in (0 → 1 opacity) -- Modal: Scale + fade (0.95 → 1 scale, 0 → 1 opacity, 20px offset) -``` - -### Closing Animation - -``` -- Duration: 200ms -- Backdrop: Fade out (1 → 0 opacity) -- Modal: Scale + fade (1 → 0.95 scale, 1 → 0 opacity, 0 → 20px offset) -``` - -## Styling - -### Colors - -- **Background**: White (light) / Slate-900 (dark) -- **Text**: Slate-900 (light) / White (dark) -- **Borders**: Slate-200 (light) / Slate-800 (dark) -- **Buttons**: Primary color / Slate backgrounds -- **Backdrop**: Black with 40% opacity - -### Typography - -- **Title**: Large (18px), Semi-bold -- **Content**: Small (14px), Regular weight -- **Buttons**: Small (14px), Medium weight - -### Spacing - -- **Padding**: 1.5rem (24px) -- **Border-radius**: 0.75rem (12px) -- **Focus ring**: 2px offset - -## Testing Recommendations - -### Functionality Testing - -1. ✓ Modal opens with correct configuration -2. ✓ Modal closes on ESC key -3. ✓ Modal closes on outside click -4. ✓ Modal closes with close button -5. ✓ Focus trap works (Tab cycles, Shift+Tab backwards) -6. ✓ Body scroll locked when open -7. ✓ Multiple nested modals work -8. ✓ Confirm/Cancel actions trigger correctly - -### API Integration Testing - -1. ✓ Fetch modal templates from backend -2. ✓ Load specific template by ID -3. ✓ Submit modal actions to backend -4. ✓ Handle API errors gracefully - -### Accessibility Testing - -1. ✓ Focus management correct -2. ✓ ARIA attributes present -3. ✓ Keyboard navigation works -4. ✓ Screen reader announces modal -5. ✓ Color contrast compliant -6. ✓ Focus visible on interactive elements - -### Visual Testing - -1. ✓ Backdrop blur displays correctly -2. ✓ Animations smooth -3. ✓ Different sizes render correctly -4. ✓ Different positions render correctly -5. ✓ Dark mode works -6. ✓ Responsive at 375px and 1024px - -### Edge Cases - -1. ✓ Very long content scrolls properly -2. ✓ No focusable elements handled -3. ✓ Rapid open/close works -4. ✓ Nested modals display stacking correctly -5. ✓ Modal with no actions displays correctly - -## Code Quality - -✅ TypeScript strict mode compliant -✅ ESLint and Prettier formatted -✅ No console errors or warnings -✅ Comprehensive JSDoc comments -✅ Proper error handling -✅ Type-safe interfaces -✅ Follows project conventions -✅ React Portal best practices -✅ Framer Motion optimizations - -## Future Enhancements - -- Drawer/side modal variant -- Sheet modal variant (bottom sheet on mobile) -- Modal animations (spring animations option) -- Modal presets (confirm, alert, prompt) -- Dismissable notifications in modal -- Modal history/stack visualization -- Keyboard shortcuts for common actions -- Custom transition timing -- Modal analytics tracking -- Async loading states - -## Related Issues - -Closes #[issue_id] - ---- - -## PR Submission Checklist - -- [ ] All modal sizes tested (sm, md, lg, xl, full) -- [ ] All positions tested (center, top, bottom) -- [ ] ESC key closes modal -- [ ] Outside click closes modal -- [ ] Focus trap working correctly -- [ ] Body scroll locked when open -- [ ] Nested modals work -- [ ] API template loading works -- [ ] Dark mode tested -- [ ] Accessibility verified -- [ ] Animations smooth -- [ ] Mobile responsive (375px) -- [ ] Desktop view (1024px) -- [ ] Screenshots included (various states) -- [ ] No console errors -- [ ] PR description includes this summary diff --git a/TOAST_IMPLEMENTATION.md b/TOAST_IMPLEMENTATION.md deleted file mode 100644 index 57a5859..0000000 --- a/TOAST_IMPLEMENTATION.md +++ /dev/null @@ -1,375 +0,0 @@ -# Toast Notification System - Implementation Summary - -## Overview - -Implemented a global toast notification system with custom styled variants (Success, Error, Info, Loading) using Sonner. The system integrates backend API for notification configuration and auto-dismisses toasts after 4 seconds. - -## Implementation Details - -### Architecture: Component → Hook → Service Pattern ✓ - -#### 1. **Service Layer** (`lib/toast.ts`) - -- **Responsibility**: Manages toast notifications and backend API integration -- **Key Features**: - - Singleton service for centralized toast management - - Event subscription pattern for listeners - - API integration for fetching toast configurations - - Support for marking notifications as read - - Type-safe interfaces -- **Methods**: - - `success()`: Show success toast - - `error()`: Show error toast - - `info()`: Show info toast - - `loading()`: Show loading toast - - `subscribe()`: Subscribe to toast events - - `fetchToastMessages()`: Fetch from backend API - - `getToastConfig()`: Get toast configuration - - `markAsRead()`: Mark notification as read -- **Features**: - - Default 4-second auto-dismiss duration - - Loading toasts never auto-dismiss (duration: 0) - - Optional action buttons - - Optional descriptions - -#### 2. **Custom Hook** (`hooks/useToast.ts`) - -- **Responsibility**: Provides React component access to toast functionality -- **Exposed API**: - - `success()`: Trigger success toast - - `error()`: Trigger error toast - - `info()`: Trigger info toast - - `loading()`: Trigger loading toast - - `fetchNotifications()`: Fetch notifications from API - - `notifications`: Array of fetched notifications - - `isLoading`: Loading state during API fetch -- **Features**: - - Easy-to-use methods for components - - Automatic API data fetching capability - - Built-in loading state - - Auto-shows urgent notifications (error, loading) from API - -#### 3. **Provider Component** (`components/providers/ToastProvider.tsx`) - -- **Responsibility**: Renders the global toaster and manages UI -- **Features**: - - Wraps app with Sonner's Toaster - - Custom styled toast variants: - - **Success**: Green icon, success styling - - **Error**: Red icon, error styling - - **Info**: Blue icon, information styling - - **Loading**: Spinning icon, loading state - - Responsive positioning (bottom-right) - - Dark mode ready (can be enhanced) - - Smooth animations and transitions - - Close button for all toasts - - Rich color support - -#### 4. **Integration** (`app/layout.tsx`) - -- Wrapped root layout with ToastProvider -- ToastProvider surrounds all app content -- Ensures toast system is globally available - -### Files Created/Modified - -| File | Purpose | Lines | -| ---------------------------------------- | ------------------------------- | ----- | -| `lib/toast.ts` | Service layer & API integration | 186 | -| `hooks/useToast.ts` | Custom React hook | 96 | -| `components/providers/ToastProvider.tsx` | Provider & UI rendering | 142 | -| `app/layout.tsx` | Integration (modified) | 22 | - -## Toast Variants - -### Success Toast - -``` -✓ [Green checkmark icon] -Message -Optional description -``` - -- **Color**: Green (#059669) -- **Icon**: CheckCircle from lucide-react -- **Duration**: 4 seconds (configurable) -- **Use Case**: Successful operations, completed tasks - -### Error Toast - -``` -✗ [Red alert icon] -Message -Optional description -``` - -- **Color**: Red (#dc2626) -- **Icon**: AlertCircle from lucide-react -- **Duration**: 4 seconds (configurable) -- **Use Case**: Failed operations, errors, validation issues - -### Info Toast - -``` -ℹ [Blue info icon] -Message -Optional description -``` - -- **Color**: Blue (#2563eb) -- **Icon**: Info from lucide-react -- **Duration**: 4 seconds (configurable) -- **Use Case**: Information, notifications, status updates - -### Loading Toast - -``` -⟳ [Spinning primary color icon] -Message -Optional description -``` - -- **Color**: Primary (#3b82f6) -- **Icon**: Loader (animated spin) -- **Duration**: 0 (never auto-dismisses) -- **Use Case**: Long-running operations, async tasks - -## Usage Examples - -### In Components - -```tsx -'use client'; - -import { useToast } from '@/hooks/useToast'; - -export function MyComponent() { - const { success, error, info, loading } = useToast(); - - const handleAction = async () => { - loading('Processing...', 'Please wait'); - - try { - await performAction(); - success('Success!', 'Action completed successfully'); - } catch (err) { - error('Error', 'Something went wrong'); - } - }; - - return ; -} -``` - -### Direct Service Usage - -```tsx -import { toastService } from '@/lib/toast'; - -// Show success -toastService.success('Profile saved', 'Your changes have been saved'); - -// Show error -toastService.error('Upload failed', 'File size exceeds limit', 5000); - -// Show info -toastService.info('Update available', 'A new version is ready'); - -// Show loading -toastService.loading('Syncing data...', 'Please do not close the app'); -``` - -## API Integration - -### Expected Backend Endpoints - -1. **GET `/api/notifications/toasts`** - - ```json - { - "success": true, - "message": "Notifications fetched", - "data": [ - { - "id": "notif-1", - "variant": "info", - "title": "System Update", - "message": "A new version is available", - "dismissible": true, - "duration": 4000 - } - ] - } - ``` - -2. **GET `/api/notifications/config`** - - ```json - { - "success": true, - "data": { - "duration": 4000, - "position": "bottom-right" - } - } - ``` - -3. **PATCH `/api/notifications/{id}/read`** - - Request: Empty body (ID in URL) - - Response: Confirmation of successful read marking - -### Fallback Strategy - -- If API fails, service continues with default configuration -- Default duration: 4 seconds for all toasts except loading -- Default position: bottom-right -- UI remains fully functional with no API data - -## Features - -✅ **Auto-Dismiss** - -- Success, Error, Info: 4 seconds -- Loading: Never auto-dismisses -- Configurable duration per toast - -✅ **Custom Styled Variants** - -- Unique icons and colors per type -- Responsive text sizing -- Support for descriptions -- Optional action buttons - -✅ **Backend Integration** - -- Fetch notifications from API -- Store notification preferences -- Mark notifications as read -- Configurable toast settings - -✅ **User Experience** - -- Smooth animations -- Close button on all toasts -- Non-blocking positioning -- Accessible color contrast -- Responsive on all devices - -✅ **Developer Experience** - -- Simple hook-based API -- Type-safe TypeScript -- Easy integration -- Multiple usage patterns - -## Responsive Design - -### Mobile (< 768px) - -- Position: Bottom-right corner -- Size: Adapts to screen width -- Touch-friendly close button -- Stack vertically on screen - -### Desktop (≥ 768px) - -- Position: Bottom-right (fixed) -- Consistent sizing -- Hover states on buttons -- Keyboard accessible - -## Styling Details - -### Colors - -- **Success**: #059669 (green) -- **Error**: #dc2626 (red) -- **Info**: #2563eb (blue) -- **Loading**: #3b82f6 (primary) - -### Typography - -- **Title**: Medium weight, slate-900 (14px) -- **Description**: Regular weight, slate-600 (13px) - -### Spacing - -- **Toast padding**: 1rem (16px) -- **Icon gap**: 12px from content -- **Icon size**: 20px - -## Testing Recommendations - -### Functionality Testing - -1. ✓ Success toast shows and auto-dismisses after 4s -2. ✓ Error toast shows and auto-dismisses after 4s -3. ✓ Info toast shows and auto-dismisses after 4s -4. ✓ Loading toast shows and never auto-dismisses -5. ✓ Close button dismisses toast immediately -6. ✓ Multiple toasts stack vertically -7. ✓ API data displays in toasts - -### API Integration Testing - -1. ✓ Fetch notifications from backend -2. ✓ Handle API errors gracefully -3. ✓ Mark notifications as read -4. ✓ Load configuration from API - -### Visual Testing - -1. ✓ Icons display correctly -2. ✓ Colors match brand -3. ✓ Typography is readable -4. ✓ Animations are smooth -5. ✓ Responsive at 375px and 1024px - -### Accessibility Testing - -1. ✓ Color contrast meets WCAG AA -2. ✓ Keyboard navigation works -3. ✓ Screen readers announce toasts -4. ✓ Focus visible on buttons - -## Code Quality - -✅ TypeScript strict mode compliant -✅ ESLint and Prettier formatted -✅ No console errors or warnings -✅ Comprehensive JSDoc comments -✅ Proper error handling -✅ Type-safe interfaces -✅ Follows project conventions -✅ No external dependencies beyond Sonner - -## Future Enhancements - -- Notification history panel -- Toast sound effects -- Persistent notification storage -- Email notifications integration -- Toast templates from backend -- Animation preferences (prefers-reduced-motion) -- Toast grouping by type -- Undo actions in toasts -- WebSocket real-time notifications - -## Related Issues - -Closes #[issue_id] - ---- - -## PR Submission Checklist - -- [ ] All toast types tested (Success, Error, Info, Loading) -- [ ] Auto-dismiss timing verified (4 seconds) -- [ ] API endpoints working correctly -- [ ] Screenshots included (each toast variant) -- [ ] Mobile responsive verified (375px) -- [ ] Desktop verified (1024px) -- [ ] Dark mode tested (if applicable) -- [ ] Accessibility verified -- [ ] No console errors -- [ ] PR description includes this summary diff --git a/TOPLOADER_IMPLEMENTATION.md b/TOPLOADER_IMPLEMENTATION.md deleted file mode 100644 index 72e6580..0000000 --- a/TOPLOADER_IMPLEMENTATION.md +++ /dev/null @@ -1,166 +0,0 @@ -# Global Top Loader System - Implementation Summary - -## Overview - -Implemented a global top loader (progress bar) system that provides visual feedback during App Router navigations in the SwiftChain Frontend application. - -## Implementation Details - -### Architecture: Component → Hook → Service Pattern - -The implementation strictly follows the layered architecture pattern as required: - -#### 1. **Service Layer** (`services/topLoaderService.ts`) - -- **Responsibility**: Manages router event subscriptions and state management -- **Key Features**: - - Singleton pattern for single instance across the app - - Subscribes to route navigation events (popstate, link clicks) - - Emits loading state changes to all listeners - - Auto-completion delay (500ms) for smooth UX - - SSR-safe (guards against server-side execution) -- **Methods**: - - `initialize()`: Sets up router event listeners (called once) - - `subscribe(callback)`: Returns unsubscribe function for cleanup - - `cleanup()`: Graceful teardown on unmount - -#### 2. **Custom Hook** (`hooks/useTopLoader.ts`) - -- **Responsibility**: Provides React component access to loader state -- **Key Features**: - - Returns `boolean` loading state - - Initializes service on first use - - Manages subscription lifecycle - - Proper cleanup on component unmount -- **Usage**: `const isLoading = useTopLoader();` - -#### 3. **UI Component** (`components/ui/TopLoader.tsx`) - -- **Responsibility**: Renders the visual progress bar -- **Key Features**: - - Fixed position at top of viewport (z-index: 50) - - Primary brand color (#3b82f6) - - Smooth transitions (300ms duration) - - Accessible with ARIA labels - - Optional shadow effect for visual depth - - Appears on loading, fades on completion - -#### 4. **Integration** (`app/layout.tsx`) - -- Added `` as the first element in the root layout -- Ensures it renders above all other content -- Global availability across all pages and routes - -## Files Created/Modified - -### New Files - -- ✅ `services/topLoaderService.ts` - Service layer (86 lines) -- ✅ `hooks/useTopLoader.ts` - Custom hook (26 lines) -- ✅ `components/ui/TopLoader.tsx` - UI component (40 lines) - -### Modified Files - -- ✅ `app/layout.tsx` - Added TopLoader import and component - -## Visual Features - -### Progress Bar Styling - -- **Position**: Fixed at top of viewport -- **Height**: 4px (h-1 in Tailwind) -- **Color**: Primary brand color (#3b82f6) -- **Animation**: Smooth fade in/out with 300ms transition -- **Shadow**: Subtle gradient shadow below the bar for depth -- **Z-Index**: 50 (above most content, below modals if needed) - -### Behavior - -1. **Initialization**: Automatically starts when navigation begins -2. **Loading**: Progress bar expands to full width with full opacity -3. **Completion**: Automatically shrinks and fades after 500ms -4. **Error Handling**: Gracefully handles failed navigations - -## Acceptance Criteria Met - -✅ **Progress bar appears strictly on route changes** - -- Triggers on internal link navigation and popstate events -- SSR-safe implementation - -✅ **Strict Layered Architecture (Component → Hook → Service)** - -- Clear separation of concerns -- Service manages logic -- Hook provides state -- Component handles rendering -- Easy to test and maintain - -✅ **Uses primary brand color** - -- Utilizes tailwind primary color (#3b82f6) -- Responsive to theme changes - -✅ **Global availability** - -- Integrated at root layout level -- Works across all routes and pages - -## Technical Specifications - -### Dependencies - -- No new external dependencies required -- Uses existing Next.js, React, and Tailwind CSS -- Compatible with Next.js 16.1.6+ - -### Performance - -- Minimal runtime overhead -- Event delegation for efficient listening -- Memory cleanup on component unmount -- Single service instance (singleton pattern) - -### Browser Compatibility - -- Works with all modern browsers -- Graceful degradation for older browsers -- SSR-safe with proper guards - -## Testing Recommendations - -1. **Navigation Testing**: - - Click internal links and verify progress bar appears - - Verify bar disappears after completion - - Test with slow network to observe behavior - -2. **Edge Cases**: - - Rapid consecutive navigations - - Navigation to same route - - Failed navigation scenarios - - Mobile responsiveness - -3. **Accessibility**: - - ARIA labels implemented - - Keyboard navigation unaffected - - Color contrast compliant - -## Future Enhancements - -- Animated progress width progression (0% → 100% during load) -- Configurable colors per theme -- Optional skip animation on instant navigation -- Integration with actual route timing data - -## Code Quality - -✅ TypeScript strict mode compliant -✅ ESLint and Prettier formatted -✅ No console warnings or errors -✅ Follows project conventions -✅ Well-documented with JSDoc comments -✅ Proper error handling and guards - -## Related Issues - -Closes #[issue_id] diff --git a/components/DeliveryList.tsx b/components/DeliveryList.tsx index 7264c11..99d86ff 100644 --- a/components/DeliveryList.tsx +++ b/components/DeliveryList.tsx @@ -1,37 +1,106 @@ 'use client'; import { useDeliveries } from '@/hooks/useDeliveries'; +import { useDeliveryFilters } from '@/features/deliveries/hooks'; +import { DeliveryFilters } from '@/features/deliveries/components'; export function DeliveryList() { - const { data, isLoading, error } = useDeliveries(); + const { search, status, sortBy, hasActiveFilters, updateFilters, clearFilters } = useDeliveryFilters(); + const { data, isLoading, error } = useDeliveries({ search, status, sortBy }); if (isLoading) return
Loading deliveries...
; if (error) return
Error fetching deliveries: {error.message}
; + const getStatusColor = (status: string) => { + switch (status) { + case 'DELIVERED': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + case 'IN_TRANSIT': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; + case 'PENDING': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'; + case 'ACCEPTED': + return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200'; + case 'CANCELLED': + return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'; + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + return (

Active Deliveries

+ + {/* Filter Controls */} + updateFilters({ search: newSearch })} + onStatusChange={(newStatus) => updateFilters({ status: newStatus })} + onSortChange={(newSort) => updateFilters({ sortBy: newSort })} + onClearAll={clearFilters} + /> + + {/* Deliveries List */} {data && data.length > 0 ? (
    {data.map((del) => ( -
  • -
    -

    {del.trackingNumber}

    -

    {del.origin} ➔ {del.destination}

    +
  • +
    +

    {del.trackingNumber}

    +

    {del.origin} ➔ {del.destination}

    +

    + Created: {formatDate(del.createdAt)} +

    -
    - - {del.status} - -

    Escrow: {del.escrowStatus}

    +
    +
    + + {del.status} + + {del.amount && ( + + {del.amount} + + )} +
    +

    + Escrow: {del.escrowStatus} +

  • ))}
) : ( -

No deliveries found.

+
+

+ {hasActiveFilters ? 'No deliveries match your filters.' : 'No deliveries found.'} +

+ {hasActiveFilters && ( + + )} +
)}
); diff --git a/features/deliveries/components/DeliveryFilters.tsx b/features/deliveries/components/DeliveryFilters.tsx new file mode 100644 index 0000000..7e0193d --- /dev/null +++ b/features/deliveries/components/DeliveryFilters.tsx @@ -0,0 +1,167 @@ +'use client'; + +import React, { useCallback, useMemo } from 'react'; +import { Search, X, ChevronDown } from 'lucide-react'; +import { DeliveryStatus } from '@/types/filters'; + +interface DeliveryFiltersProps { + search?: string; + status?: DeliveryStatus; + sortBy?: 'date-asc' | 'date-desc'; + hasActiveFilters: boolean; + onSearchChange: (search: string) => void; + onStatusChange: (status: DeliveryStatus | undefined) => void; + onSortChange: (sort: 'date-asc' | 'date-desc' | undefined) => void; + onClearAll: () => void; +} + +const STATUS_OPTIONS: DeliveryStatus[] = ['PENDING', 'ACCEPTED', 'IN_TRANSIT', 'DELIVERED', 'CANCELLED']; + +const STATUS_COLORS: Record = { + PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + ACCEPTED: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + IN_TRANSIT: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200', + DELIVERED: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', +}; + +export function DeliveryFilters({ + search = '', + status, + sortBy, + hasActiveFilters, + onSearchChange, + onStatusChange, + onSortChange, + onClearAll, +}: DeliveryFiltersProps) { + const searchInput = React.useRef(null); + + const handleSearchBlur = useCallback(() => { + onSearchChange(searchInput.current?.value || ''); + }, [onSearchChange]); + + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onSearchChange((e.target as HTMLInputElement).value); + } + }, [onSearchChange]); + + const handleClearSearch = useCallback(() => { + if (searchInput.current) { + searchInput.current.value = ''; + onSearchChange(''); + } + }, [onSearchChange]); + + const activeFiltersDisplay = useMemo(() => { + const filters = []; + if (search) filters.push(`Search: ${search}`); + if (status) filters.push(`Status: ${status}`); + if (sortBy) filters.push(`Sort: ${sortBy === 'date-desc' ? 'Newest' : 'Oldest'}`); + return filters; + }, [search, status, sortBy]); + + return ( +
+ {/* Search Bar */} +
+
+ +
+
+
+ + {/* Status Filter */} +
+ +
+ +
+
+ + {/* Sort Filter */} +
+ +
+ +
+
+
+ + {/* Active Filters Display */} + {hasActiveFilters && ( +
+ Active filters: + {activeFiltersDisplay.map((filter) => ( + + {filter} + + ))} + +
+ )} +
+ ); +} diff --git a/features/deliveries/components/__tests__/DeliveryFilters.test.tsx b/features/deliveries/components/__tests__/DeliveryFilters.test.tsx new file mode 100644 index 0000000..35265ef --- /dev/null +++ b/features/deliveries/components/__tests__/DeliveryFilters.test.tsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DeliveryFilters } from '@/features/deliveries/components'; + +describe('DeliveryFilters', () => { + const mockHandlers = { + onSearchChange: jest.fn(), + onStatusChange: jest.fn(), + onSortChange: jest.fn(), + onClearAll: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all filter controls', () => { + render( + + ); + + expect(screen.getByLabelText('Search by Tracking ID')).toBeInTheDocument(); + expect(screen.getByLabelText('Filter by status')).toBeInTheDocument(); + expect(screen.getByLabelText('Sort by date')).toBeInTheDocument(); + }); + + it('displays search input with correct placeholder', () => { + render( + + ); + + const searchInput = screen.getByPlaceholderText('e.g., TRK123456'); + expect(searchInput).toBeInTheDocument(); + }); + + it('calls onSearchChange on blur with input value', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + + const searchInput = screen.getByPlaceholderText('e.g., TRK123456') as HTMLInputElement; + await user.click(searchInput); + await user.keyboard('TRK123'); + fireEvent.blur(searchInput); + + await waitFor(() => { + expect(mockHandlers.onSearchChange).toHaveBeenCalledWith('TRK123'); + }); + }); + + it('calls onSearchChange on Enter key', async () => { + const user = userEvent.setup(); + render( + + ); + + const searchInput = screen.getByPlaceholderText('e.g., TRK123456'); + await user.click(searchInput); + await user.keyboard('TRK456{Enter}'); + + await waitFor(() => { + expect(mockHandlers.onSearchChange).toHaveBeenCalledWith('TRK456'); + }); + }); + + it('clears search input when X button clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const clearButton = screen.getByLabelText('Clear search'); + await user.click(clearButton); + + await waitFor(() => { + expect(mockHandlers.onSearchChange).toHaveBeenCalledWith(''); + }); + }); + + it('calls onStatusChange when status dropdown changes', async () => { + const user = userEvent.setup(); + render( + + ); + + const statusSelect = screen.getByDisplayValue('All Statuses'); + await user.selectOptions(statusSelect, 'DELIVERED'); + + await waitFor(() => { + expect(mockHandlers.onStatusChange).toHaveBeenCalledWith('DELIVERED'); + }); + }); + + it('calls onSortChange when sort dropdown changes', async () => { + const user = userEvent.setup(); + render( + + ); + + const sortSelect = screen.getByDisplayValue('No Sort'); + await user.selectOptions(sortSelect, 'date-desc'); + + await waitFor(() => { + expect(mockHandlers.onSortChange).toHaveBeenCalledWith('date-desc'); + }); + }); + + it('displays active filters when hasActiveFilters is true', () => { + render( + + ); + + expect(screen.getByText(/Search: TRK123/)).toBeInTheDocument(); + expect(screen.getByText(/Status: DELIVERED/)).toBeInTheDocument(); + expect(screen.getByText(/Sort: Newest/)).toBeInTheDocument(); + }); + + it('calls onClearAll when Clear All button clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const clearAllButton = screen.getByLabelText('Clear all filters'); + await user.click(clearAllButton); + + expect(mockHandlers.onClearAll).toHaveBeenCalled(); + }); + + it('does not display active filters section when hasActiveFilters is false', () => { + render( + + ); + + expect(screen.queryByText('Active filters:')).not.toBeInTheDocument(); + }); + + it('renders all status options in dropdown', () => { + const { container } = render( + + ); + + const statusSelect = screen.getByLabelText('Filter by status') as HTMLSelectElement; + const options = Array.from(statusSelect.options).map((opt) => opt.value); + + expect(options).toContain(''); + expect(options).toContain('PENDING'); + expect(options).toContain('ACCEPTED'); + expect(options).toContain('IN_TRANSIT'); + expect(options).toContain('DELIVERED'); + expect(options).toContain('CANCELLED'); + }); + + it('renders all sort options in dropdown', () => { + render( + + ); + + const sortSelect = screen.getByLabelText('Sort by date') as HTMLSelectElement; + const options = Array.from(sortSelect.options).map((opt) => opt.value); + + expect(options).toContain(''); + expect(options).toContain('date-desc'); + expect(options).toContain('date-asc'); + }); +}); diff --git a/features/deliveries/components/index.ts b/features/deliveries/components/index.ts new file mode 100644 index 0000000..0d537fe --- /dev/null +++ b/features/deliveries/components/index.ts @@ -0,0 +1 @@ +export { DeliveryFilters } from './DeliveryFilters'; diff --git a/features/deliveries/hooks/__tests__/useDeliveryFilters.test.ts b/features/deliveries/hooks/__tests__/useDeliveryFilters.test.ts new file mode 100644 index 0000000..2d18c8c --- /dev/null +++ b/features/deliveries/hooks/__tests__/useDeliveryFilters.test.ts @@ -0,0 +1,143 @@ +import { renderHook, act } from '@testing-library/react'; +import { useDeliveryFilters } from '@/features/deliveries/hooks'; +import { useRouter, useSearchParams } from 'next/navigation'; + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), + useSearchParams: jest.fn(), +})); + +describe('useDeliveryFilters', () => { + const mockRouter = { push: jest.fn() }; + const mockSearchParams = new URLSearchParams(); + + beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue(mockRouter); + (useSearchParams as jest.Mock).mockReturnValue(mockSearchParams); + }); + + it('initializes with empty filters', () => { + const { result } = renderHook(() => useDeliveryFilters()); + + expect(result.current.search).toBeUndefined(); + expect(result.current.status).toBeUndefined(); + expect(result.current.sortBy).toBeUndefined(); + expect(result.current.hasActiveFilters).toBe(false); + }); + + it('reads search from URL params', () => { + mockSearchParams.set('search', 'TRK123'); + (useSearchParams as jest.Mock).mockReturnValue(mockSearchParams); + + const { result } = renderHook(() => useDeliveryFilters()); + + expect(result.current.search).toBe('TRK123'); + expect(result.current.hasActiveFilters).toBe(true); + }); + + it('reads status from URL params', () => { + mockSearchParams.set('status', 'DELIVERED'); + (useSearchParams as jest.Mock).mockReturnValue(mockSearchParams); + + const { result } = renderHook(() => useDeliveryFilters()); + + expect(result.current.status).toBe('DELIVERED'); + expect(result.current.hasActiveFilters).toBe(true); + }); + + it('reads sortBy from URL params', () => { + mockSearchParams.set('sortBy', 'date-desc'); + (useSearchParams as jest.Mock).mockReturnValue(mockSearchParams); + + const { result } = renderHook(() => useDeliveryFilters()); + + expect(result.current.sortBy).toBe('date-desc'); + expect(result.current.hasActiveFilters).toBe(true); + }); + + it('updates search filter and pushes to URL', () => { + const { result } = renderHook(() => useDeliveryFilters()); + + act(() => { + result.current.updateFilters({ search: 'TRK456' }); + }); + + expect(mockRouter.push).toHaveBeenCalledWith(expect.stringContaining('search=TRK456'), { + scroll: false, + }); + }); + + it('updates status filter and pushes to URL', () => { + const { result } = renderHook(() => useDeliveryFilters()); + + act(() => { + result.current.updateFilters({ status: 'IN_TRANSIT' }); + }); + + expect(mockRouter.push).toHaveBeenCalledWith(expect.stringContaining('status=IN_TRANSIT'), { + scroll: false, + }); + }); + + it('clears individual filters', () => { + mockSearchParams.set('search', 'TRK123'); + mockSearchParams.set('status', 'DELIVERED'); + (useSearchParams as jest.Mock).mockReturnValue(mockSearchParams); + + const { result } = renderHook(() => useDeliveryFilters()); + + act(() => { + result.current.updateFilters({ search: undefined }); + }); + + const pushCall = mockRouter.push.mock.calls[0][0]; + expect(pushCall).not.toContain('search='); + expect(pushCall).toContain('status=DELIVERED'); + }); + + it('clears all filters', () => { + mockSearchParams.set('search', 'TRK123'); + mockSearchParams.set('status', 'DELIVERED'); + (useSearchParams as jest.Mock).mockReturnValue(mockSearchParams); + + const { result } = renderHook(() => useDeliveryFilters()); + + act(() => { + result.current.clearFilters(); + }); + + expect(mockRouter.push).toHaveBeenCalledWith('?', { scroll: false }); + }); + + it('detects hasActiveFilters correctly', () => { + const emptyParams = new URLSearchParams(); + (useSearchParams as jest.Mock).mockReturnValue(emptyParams); + + const { result: emptyResult } = renderHook(() => useDeliveryFilters()); + expect(emptyResult.current.hasActiveFilters).toBe(false); + + const activeParams = new URLSearchParams('search=TRK'); + (useSearchParams as jest.Mock).mockReturnValue(activeParams); + + const { result: activeResult } = renderHook(() => useDeliveryFilters()); + expect(activeResult.current.hasActiveFilters).toBe(true); + }); + + it('handles multiple filter updates', () => { + const { result } = renderHook(() => useDeliveryFilters()); + + act(() => { + result.current.updateFilters({ + search: 'TRK789', + status: 'PENDING', + sortBy: 'date-asc', + }); + }); + + const pushCall = mockRouter.push.mock.calls[0][0]; + expect(pushCall).toContain('search=TRK789'); + expect(pushCall).toContain('status=PENDING'); + expect(pushCall).toContain('sortBy=date-asc'); + }); +}); diff --git a/features/deliveries/hooks/index.ts b/features/deliveries/hooks/index.ts new file mode 100644 index 0000000..55e81fb --- /dev/null +++ b/features/deliveries/hooks/index.ts @@ -0,0 +1 @@ +export { useDeliveryFilters } from './useDeliveryFilters'; diff --git a/features/deliveries/hooks/useDeliveryFilters.ts b/features/deliveries/hooks/useDeliveryFilters.ts new file mode 100644 index 0000000..dbcb2bf --- /dev/null +++ b/features/deliveries/hooks/useDeliveryFilters.ts @@ -0,0 +1,79 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { DeliveryFilterParams, DeliveryStatus } from '@/types/filters'; + +/** + * Hook for managing delivery filter state via URL query parameters + * Provides type-safe filter management with persistence across page reloads + */ +export function useDeliveryFilters() { + const router = useRouter(); + const searchParams = useSearchParams(); + + // Extract filter state from URL + const filters = useMemo( + () => ({ + search: searchParams.get('search') || undefined, + status: (searchParams.get('status') as DeliveryStatus) || undefined, + sortBy: (searchParams.get('sortBy') as 'date-asc' | 'date-desc') || undefined, + }), + [searchParams] + ); + + const hasActiveFilters = useMemo( + () => !!(filters.search || filters.status || filters.sortBy), + [filters] + ); + + /** + * Update one or more filters and persist to URL + */ + const updateFilters = useCallback( + (newFilters: Partial) => { + const params = new URLSearchParams(searchParams); + + if (newFilters.search !== undefined) { + if (newFilters.search) { + params.set('search', newFilters.search); + } else { + params.delete('search'); + } + } + + if (newFilters.status !== undefined) { + if (newFilters.status) { + params.set('status', newFilters.status); + } else { + params.delete('status'); + } + } + + if (newFilters.sortBy !== undefined) { + if (newFilters.sortBy) { + params.set('sortBy', newFilters.sortBy); + } else { + params.delete('sortBy'); + } + } + + router.push(`?${params.toString()}`, { scroll: false }); + }, + [router, searchParams] + ); + + /** + * Clear all filters + */ + const clearFilters = useCallback(() => { + router.push('?', { scroll: false }); + }, [router]); + + return { + ...filters, + hasActiveFilters, + updateFilters, + clearFilters, + }; +} diff --git a/features/escrow/components/EscrowLock.tsx b/features/escrow/components/EscrowLock.tsx new file mode 100644 index 0000000..6aa8c03 --- /dev/null +++ b/features/escrow/components/EscrowLock.tsx @@ -0,0 +1,237 @@ +'use client'; + +import React, { useState } from 'react'; +import { Lock, Check, AlertCircle, Loader } from 'lucide-react'; +import { useEscrowLock } from '@/hooks/useEscrowLock'; +import { useToast } from '@/hooks/useToast'; + +interface EscrowLockProps { + deliveryId: string; + amount: number; + currency: string; + walletAddress?: string; + onSuccess?: (escrowId: string, transactionHash: string) => void; + onError?: (error: string) => void; +} + +type LockState = 'idle' | 'pending' | 'success' | 'error'; + +export function EscrowLock({ + deliveryId, + amount, + currency, + walletAddress, + onSuccess, + onError, +}: EscrowLockProps) { + const [showConfirmation, setShowConfirmation] = useState(false); + const [state, setState] = useState('idle'); + const { isLoading, error, escrowId, transactionHash, lockEscrow, reset } = useEscrowLock(); + const { toast } = useToast(); + + const isWalletConnected = !!walletAddress; + const formattedAmount = amount.toFixed(2); + + const handleLockClick = () => { + if (!isWalletConnected) { + setState('error'); + toast({ + title: 'Wallet Not Connected', + description: 'Please connect your wallet to lock this payment.', + variant: 'destructive', + }); + return; + } + setShowConfirmation(true); + }; + + const handleConfirm = async () => { + setShowConfirmation(false); + setState('pending'); + + try { + await lockEscrow({ + deliveryId, + amount, + currency, + walletAddress: walletAddress!, + }); + + setState('success'); + toast({ + title: 'Success!', + description: `Escrow locked! Transaction: ${transactionHash?.slice(0, 10)}...`, + }); + onSuccess?.(escrowId!, transactionHash!); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : 'Failed to lock escrow'; + setState('error'); + toast({ + title: 'Error', + description: errorMsg, + variant: 'destructive', + }); + onError?.(errorMsg); + } + }; + + const handleCancel = () => { + setShowConfirmation(false); + }; + + const handleReset = () => { + setState('idle'); + reset(); + }; + + // Render based on state + if (state === 'success') { + return ( +
+
+
+ +
+

+ Payment Locked Successfully! +

+

+ Your escrow payment has been securely locked. +

+
+

Transaction Hash

+

+ {transactionHash} +

+
+ +
+
+ ); + } + + if (state === 'error') { + return ( +
+
+
+ +
+

+ Error Locking Payment +

+

+ {error || 'An unexpected error occurred. Please try again.'} +

+ +
+
+ ); + } + + return ( + <> +
+
+

Total Cost

+

+ {formattedAmount} {currency} +

+

+ This amount will be locked in escrow until delivery is confirmed +

+
+ +
+

+ ✓ Your payment is protected in escrow +

+

+ ✓ Released only upon delivery confirmation +

+
+ + {!isWalletConnected && ( +
+

+ ⚠️ Connect your wallet to lock this payment +

+
+ )} + + + +

+ {isWalletConnected ? 'Wallet connected' : 'Wallet status: disconnected'} +

+
+ + {/* Confirmation Modal */} + {showConfirmation && ( +
+
+

+ Confirm Escrow Lock +

+

+ You are about to lock + + {formattedAmount} {currency} + + in escrow. This action cannot be reversed immediately. +

+
+ + +
+
+
+ )} + + ); +} diff --git a/features/escrow/components/__tests__/EscrowLock.test.tsx b/features/escrow/components/__tests__/EscrowLock.test.tsx new file mode 100644 index 0000000..ab312e2 --- /dev/null +++ b/features/escrow/components/__tests__/EscrowLock.test.tsx @@ -0,0 +1,365 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EscrowLock } from '@/features/escrow/components'; +import { useEscrowLock } from '@/hooks/useEscrowLock'; +import { useToast } from '@/hooks/useToast'; + +jest.mock('@/hooks/useEscrowLock'); +jest.mock('@/hooks/useToast'); + +describe('EscrowLock', () => { + const mockEscrowLock = useEscrowLock as jest.MockedFunction; + const mockUseToast = useToast as jest.MockedFunction; + const mockToast = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockUseToast.mockReturnValue({ toast: mockToast } as any); + mockEscrowLock.mockReturnValue({ + isLoading: false, + error: null, + escrowId: null, + transactionHash: null, + lockEscrow: jest.fn().mockResolvedValue({ + escrowId: 'escrow-123', + transactionHash: '0xabc123', + }), + reset: jest.fn(), + } as any); + }); + + it('displays the total cost in large format', () => { + render( + + ); + + expect(screen.getByText('100.50 USDC')).toBeInTheDocument(); + }); + + it('shows wallet connection warning when no wallet', () => { + render( + + ); + + expect( + screen.getByText('⚠️ Connect your wallet to lock this payment') + ).toBeInTheDocument(); + }); + + it('disables button when wallet not connected', () => { + render( + + ); + + const button = screen.getByRole('button', { name: /Lock Payment in Escrow/i }); + expect(button).toBeDisabled(); + }); + + it('shows spinner during loading state', () => { + mockEscrowLock.mockReturnValue({ + isLoading: true, + error: null, + escrowId: null, + transactionHash: null, + lockEscrow: jest.fn(), + reset: jest.fn(), + } as any); + + render( + + ); + + expect(screen.getByText('Locking Payment…')).toBeInTheDocument(); + }); + + it('opens confirmation modal when lock button clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByRole('button', { name: /Lock Payment in Escrow/i }); + await user.click(button); + + await waitFor(() => { + expect(screen.getByText('Confirm Escrow Lock')).toBeInTheDocument(); + }); + }); + + it('shows amount in confirmation modal', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByRole('button', { name: /Lock Payment in Escrow/i }); + await user.click(button); + + await waitFor(() => { + expect(screen.getByText('100.50 USDC')).toBeInTheDocument(); + }); + }); + + it('cancels confirmation and closes modal', async () => { + const user = userEvent.setup(); + render( + + ); + + const lockButton = screen.getByRole('button', { name: /Lock Payment in Escrow/i }); + await user.click(lockButton); + + const cancelButton = screen.getByRole('button', { name: /Cancel/i }); + await user.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByText('Confirm Escrow Lock')).not.toBeInTheDocument(); + }); + }); + + it('calls lockEscrow service when confirmed', async () => { + const user = userEvent.setup(); + const mockLockEscrow = jest.fn().mockResolvedValue({ + escrowId: 'escrow-123', + transactionHash: '0xabc123', + }); + + mockEscrowLock.mockReturnValue({ + isLoading: false, + error: null, + escrowId: 'escrow-123', + transactionHash: '0xabc123', + lockEscrow: mockLockEscrow, + reset: jest.fn(), + } as any); + + render( + + ); + + const lockButton = screen.getByRole('button', { name: /Lock Payment in Escrow/i }); + await user.click(lockButton); + + const confirmButton = screen.getByRole('button', { name: /Confirm Lock/i }); + await user.click(confirmButton); + + await waitFor(() => { + expect(mockLockEscrow).toHaveBeenCalledWith({ + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }); + }); + }); + + it('displays success state with transaction hash', () => { + mockEscrowLock.mockReturnValue({ + isLoading: false, + error: null, + escrowId: 'escrow-123', + transactionHash: '0xabc123def456789', + lockEscrow: jest.fn(), + reset: jest.fn(), + } as any); + + render( + + ); + + expect(screen.getByText('Payment Locked Successfully!')).toBeInTheDocument(); + expect(screen.getByText('0xabc123def456789')).toBeInTheDocument(); + }); + + it('displays error state with message', () => { + mockEscrowLock.mockReturnValue({ + isLoading: false, + error: 'Insufficient funds in wallet', + escrowId: null, + transactionHash: null, + lockEscrow: jest.fn(), + reset: jest.fn(), + } as any); + + render( + + ); + + expect(screen.getByText('Error Locking Payment')).toBeInTheDocument(); + expect(screen.getByText('Insufficient funds in wallet')).toBeInTheDocument(); + }); + + it('calls onSuccess callback with escrowId and transactionHash', async () => { + const mockOnSuccess = jest.fn(); + const mockLockEscrow = jest.fn().mockResolvedValue({ + escrowId: 'escrow-123', + transactionHash: '0xabc123', + }); + + mockEscrowLock.mockReturnValue({ + isLoading: false, + error: null, + escrowId: 'escrow-123', + transactionHash: '0xabc123', + lockEscrow: mockLockEscrow, + reset: jest.fn(), + } as any); + + render( + + ); + + expect(mockOnSuccess).toHaveBeenCalledWith('escrow-123', '0xabc123'); + }); + + it('calls onError callback with error message', () => { + const mockOnError = jest.fn(); + mockEscrowLock.mockReturnValue({ + isLoading: false, + error: 'Failed to lock escrow', + escrowId: null, + transactionHash: null, + lockEscrow: jest.fn(), + reset: jest.fn(), + } as any); + + render( + + ); + + expect(mockOnError).toHaveBeenCalledWith('Failed to lock escrow'); + }); + + it('shows reset button in success state', () => { + mockEscrowLock.mockReturnValue({ + isLoading: false, + error: null, + escrowId: 'escrow-123', + transactionHash: '0xabc123', + lockEscrow: jest.fn(), + reset: jest.fn(), + } as any); + + render( + + ); + + expect(screen.getByRole('button', { name: /Lock Another Delivery/i })).toBeInTheDocument(); + }); + + it('shows try again button in error state', () => { + mockEscrowLock.mockReturnValue({ + isLoading: false, + error: 'Failed', + escrowId: null, + transactionHash: null, + lockEscrow: jest.fn(), + reset: jest.fn(), + } as any); + + render( + + ); + + expect(screen.getByRole('button', { name: /Try Again/i })).toBeInTheDocument(); + }); + + it('shows wallet status when disconnected', () => { + render( + + ); + + expect(screen.getByText('Wallet status: disconnected')).toBeInTheDocument(); + }); + + it('shows wallet status when connected', () => { + render( + + ); + + expect(screen.getByText('Wallet status: connected')).toBeInTheDocument(); + }); +}); diff --git a/hooks/__tests__/useEscrowLock.test.ts b/hooks/__tests__/useEscrowLock.test.ts new file mode 100644 index 0000000..8ea15b3 --- /dev/null +++ b/hooks/__tests__/useEscrowLock.test.ts @@ -0,0 +1,261 @@ +import { renderHook, act } from '@testing-library/react'; +import { useEscrowLock } from '@/hooks/useEscrowLock'; +import { escrowService, LockEscrowParams } from '@/services/escrowService'; + +jest.mock('@/services/escrowService'); + +describe('useEscrowLock', () => { + const mockEscrowService = escrowService as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('initializes with idle state', () => { + const { result } = renderHook(() => useEscrowLock()); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.escrowId).toBeNull(); + expect(result.current.transactionHash).toBeNull(); + }); + + it('sets isLoading to true before lock request', async () => { + let loadingState: boolean | null = null; + + mockEscrowService.lockEscrow.mockImplementation(async () => { + loadingState = true; + return { + success: true, + message: 'Locked', + escrowId: 'escrow-123', + transactionHash: '0xabc', + lockedAmount: '100', + }; + }); + + const { result } = renderHook(() => useEscrowLock()); + + await act(async () => { + try { + await result.current.lockEscrow({ + deliveryId: 'delivery-1', + amount: 100, + currency: 'USDC', + walletAddress: '0x123', + }); + } catch {} + }); + + expect(loadingState).toBe(true); + }); + + it('sets state on successful lock', async () => { + mockEscrowService.lockEscrow.mockResolvedValue({ + success: true, + message: 'Locked', + escrowId: 'escrow-123', + transactionHash: '0xabc123', + lockedAmount: '100.50', + }); + + const { result } = renderHook(() => useEscrowLock()); + + await act(async () => { + await result.current.lockEscrow({ + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.escrowId).toBe('escrow-123'); + expect(result.current.transactionHash).toBe('0xabc123'); + }); + + it('sets error state on lock failure', async () => { + const error = new Error('Insufficient funds'); + mockEscrowService.lockEscrow.mockRejectedValue(error); + + const { result } = renderHook(() => useEscrowLock()); + + await act(async () => { + try { + await result.current.lockEscrow({ + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }); + } catch {} + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe('Insufficient funds'); + expect(result.current.escrowId).toBeNull(); + expect(result.current.transactionHash).toBeNull(); + }); + + it('passes correct parameters to service', async () => { + mockEscrowService.lockEscrow.mockResolvedValue({ + success: true, + message: 'Locked', + escrowId: 'escrow-123', + transactionHash: '0xabc123', + lockedAmount: '100.50', + }); + + const { result } = renderHook(() => useEscrowLock()); + const params: LockEscrowParams = { + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }; + + await act(async () => { + await result.current.lockEscrow(params); + }); + + expect(mockEscrowService.lockEscrow).toHaveBeenCalledWith(params); + }); + + it('resets state to initial values', async () => { + mockEscrowService.lockEscrow.mockResolvedValue({ + success: true, + message: 'Locked', + escrowId: 'escrow-123', + transactionHash: '0xabc123', + lockedAmount: '100.50', + }); + + const { result } = renderHook(() => useEscrowLock()); + + // Lock escrow + await act(async () => { + await result.current.lockEscrow({ + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }); + }); + + expect(result.current.escrowId).toBe('escrow-123'); + + // Reset + act(() => { + result.current.reset(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.escrowId).toBeNull(); + expect(result.current.transactionHash).toBeNull(); + }); + + it('handles generic error messages', async () => { + mockEscrowService.lockEscrow.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useEscrowLock()); + + await act(async () => { + try { + await result.current.lockEscrow({ + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }); + } catch {} + }); + + expect(result.current.error).toBe('Network error'); + }); + + it('handles non-Error exceptions', async () => { + mockEscrowService.lockEscrow.mockRejectedValue('Unknown error'); + + const { result } = renderHook(() => useEscrowLock()); + + await act(async () => { + try { + await result.current.lockEscrow({ + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }); + } catch {} + }); + + expect(result.current.error).toBe('Failed to lock escrow'); + }); + + it('throws error after setting state', async () => { + const error = new Error('Lock failed'); + mockEscrowService.lockEscrow.mockRejectedValue(error); + + const { result } = renderHook(() => useEscrowLock()); + + let thrownError: Error | null = null; + + await act(async () => { + try { + await result.current.lockEscrow({ + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }); + } catch (err) { + thrownError = err as Error; + } + }); + + expect(thrownError).toBe(error); + }); + + it('clears previous error on retry', async () => { + mockEscrowService.lockEscrow.mockRejectedValueOnce(new Error('First error')); + mockEscrowService.lockEscrow.mockResolvedValueOnce({ + success: true, + message: 'Locked', + escrowId: 'escrow-123', + transactionHash: '0xabc123', + lockedAmount: '100.50', + }); + + const { result } = renderHook(() => useEscrowLock()); + + // First attempt - fails + await act(async () => { + try { + await result.current.lockEscrow({ + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }); + } catch {} + }); + + expect(result.current.error).toBe('First error'); + + // Second attempt - succeeds + await act(async () => { + await result.current.lockEscrow({ + deliveryId: 'delivery-1', + amount: 100.5, + currency: 'USDC', + walletAddress: '0x123...789', + }); + }); + + expect(result.current.error).toBeNull(); + expect(result.current.escrowId).toBe('escrow-123'); + }); +}); diff --git a/hooks/useDeliveries.ts b/hooks/useDeliveries.ts index 07facd1..7a19913 100644 --- a/hooks/useDeliveries.ts +++ b/hooks/useDeliveries.ts @@ -1,11 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import { deliveriesService } from '../services/deliveries.service'; import { Delivery } from '../types/delivery'; +import { DeliveryFilterParams } from '../types/filters'; -export function useDeliveries() { +export function useDeliveries(filters?: DeliveryFilterParams) { return useQuery({ - queryKey: ['deliveries'], - queryFn: deliveriesService.getDeliveries, + queryKey: ['deliveries', filters], + queryFn: () => deliveriesService.getDeliveries(filters), }); } diff --git a/hooks/useEscrowLock.ts b/hooks/useEscrowLock.ts new file mode 100644 index 0000000..fb58113 --- /dev/null +++ b/hooks/useEscrowLock.ts @@ -0,0 +1,58 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { escrowService, LockEscrowParams } from '@/services/escrowService'; + +interface UseEscrowLockState { + isLoading: boolean; + error: string | null; + escrowId: string | null; + transactionHash: string | null; +} + +/** + * Hook for managing escrow lock state and API communication + * Follows Component → Hook → Service pattern + */ +export function useEscrowLock() { + const [state, setState] = useState({ + isLoading: false, + error: null, + escrowId: null, + transactionHash: null, + }); + + const lockEscrow = useCallback(async (params: LockEscrowParams) => { + setState({ isLoading: true, error: null, escrowId: null, transactionHash: null }); + + try { + const response = await escrowService.lockEscrow(params); + setState({ + isLoading: false, + error: null, + escrowId: response.escrowId, + transactionHash: response.transactionHash, + }); + return response; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to lock escrow'; + setState({ + isLoading: false, + error: errorMessage, + escrowId: null, + transactionHash: null, + }); + throw err; + } + }, []); + + const reset = useCallback(() => { + setState({ isLoading: false, error: null, escrowId: null, transactionHash: null }); + }, []); + + return { + ...state, + lockEscrow, + reset, + }; +} diff --git a/services/__tests__/deliveries.service.test.ts b/services/__tests__/deliveries.service.test.ts new file mode 100644 index 0000000..910d14b --- /dev/null +++ b/services/__tests__/deliveries.service.test.ts @@ -0,0 +1,104 @@ +import { deliveriesService } from '@/services/deliveries.service'; +import { apiClient } from '@/services/api'; + +jest.mock('@/services/api'); + +describe('deliveriesService', () => { + const mockApiClient = apiClient as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fetches deliveries without filters', async () => { + const mockData = [ + { + id: '1', + trackingNumber: 'TRK001', + status: 'PENDING', + origin: 'NYC', + destination: 'LA', + amount: 100, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + }, + ]; + + mockApiClient.get.mockResolvedValue({ data: mockData }); + + const result = await deliveriesService.getDeliveries(); + + expect(mockApiClient.get).toHaveBeenCalledWith('/deliveries'); + expect(result).toEqual(mockData); + }); + + it('fetches deliveries with search filter', async () => { + const mockData = []; + mockApiClient.get.mockResolvedValue({ data: mockData }); + + await deliveriesService.getDeliveries({ search: 'TRK123' }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/deliveries?search=TRK123'); + }); + + it('fetches deliveries with status filter', async () => { + const mockData = []; + mockApiClient.get.mockResolvedValue({ data: mockData }); + + await deliveriesService.getDeliveries({ status: 'DELIVERED' }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/deliveries?status=DELIVERED'); + }); + + it('fetches deliveries with sortBy filter', async () => { + const mockData = []; + mockApiClient.get.mockResolvedValue({ data: mockData }); + + await deliveriesService.getDeliveries({ sortBy: 'date-desc' }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/deliveries?sortBy=date-desc'); + }); + + it('fetches deliveries with multiple filters', async () => { + const mockData = []; + mockApiClient.get.mockResolvedValue({ data: mockData }); + + await deliveriesService.getDeliveries({ + search: 'TRK456', + status: 'IN_TRANSIT', + sortBy: 'date-asc', + }); + + const call = mockApiClient.get.mock.calls[0][0]; + expect(call).toContain('search=TRK456'); + expect(call).toContain('status=IN_TRANSIT'); + expect(call).toContain('sortBy=date-asc'); + }); + + it('fetches delivery by ID', async () => { + const mockData = { + id: '1', + trackingNumber: 'TRK001', + status: 'PENDING', + origin: 'NYC', + destination: 'LA', + amount: 100, + createdAt: '2024-01-01', + updatedAt: '2024-01-01', + }; + + mockApiClient.get.mockResolvedValue({ data: mockData }); + + const result = await deliveriesService.getDeliveryById('1'); + + expect(mockApiClient.get).toHaveBeenCalledWith('/deliveries/1'); + expect(result).toEqual(mockData); + }); + + it('handles API errors gracefully', async () => { + const error = new Error('API Error'); + mockApiClient.get.mockRejectedValue(error); + + await expect(deliveriesService.getDeliveries()).rejects.toThrow('API Error'); + }); +}); diff --git a/services/deliveries.service.ts b/services/deliveries.service.ts index 5dd4e50..14a9bf1 100644 --- a/services/deliveries.service.ts +++ b/services/deliveries.service.ts @@ -1,9 +1,24 @@ import { apiClient } from './api'; import { Delivery } from '../types/delivery'; +import { DeliveryFilterParams } from '../types/filters'; export const deliveriesService = { - getDeliveries: async (): Promise => { - const { data } = await apiClient.get('/deliveries'); + getDeliveries: async (filters?: DeliveryFilterParams): Promise => { + let url = '/deliveries'; + + if (filters) { + const params = new URLSearchParams(); + if (filters.search) params.append('search', filters.search); + if (filters.status) params.append('status', filters.status); + if (filters.sortBy) params.append('sortBy', filters.sortBy); + + const queryString = params.toString(); + if (queryString) { + url += `?${queryString}`; + } + } + + const { data } = await apiClient.get(url); return data; }, diff --git a/services/escrowService.ts b/services/escrowService.ts index f4f1ceb..790a23a 100644 --- a/services/escrowService.ts +++ b/services/escrowService.ts @@ -34,6 +34,21 @@ export interface ApiResponse { data?: T; } +export interface LockEscrowParams { + deliveryId: string; + amount: number; + currency: string; + walletAddress: string; +} + +export interface LockEscrowResponse { + success: boolean; + message: string; + escrowId: string; + transactionHash: string; + lockedAmount: string; +} + /** * escrowService — responsible for all escrow-related API communication. * The hook calls this; components never call this directly. @@ -61,4 +76,12 @@ export const escrowService = { ); return data; }, + + async lockEscrow(params: LockEscrowParams): Promise { + const { data } = await axios.post( + `${API_BASE_URL}/api/escrow/lock`, + params + ); + return data; + }, }; \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts index 1f12457..7e3e8ab 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,5 +1,7 @@ +// @ts-ignore - Tailwind CSS v4 type resolution import type { Config } from 'tailwindcss'; +// Tailwind CSS configuration const config: Config = { darkMode: 'class', content: [ diff --git a/tsconfig.json b/tsconfig.json index 6c024a3..f172211 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2020", "lib": [ "dom", "dom.iterable", @@ -8,6 +8,7 @@ ], "allowJs": true, "skipLibCheck": true, + "ignoreDeprecations": "6.0", "strict": true, "noEmit": true, "esModuleInterop": true, diff --git a/types/filters.ts b/types/filters.ts new file mode 100644 index 0000000..10ec575 --- /dev/null +++ b/types/filters.ts @@ -0,0 +1,15 @@ +/** + * Filter type definitions for delivery list + */ + +export type DeliveryStatus = 'PENDING' | 'ACCEPTED' | 'IN_TRANSIT' | 'DELIVERED' | 'CANCELLED'; + +export interface DeliveryFilterParams { + search?: string; + status?: DeliveryStatus; + sortBy?: 'date-asc' | 'date-desc'; +} + +export interface FilterState extends DeliveryFilterParams { + hasActiveFilters: boolean; +}