diff --git a/__tests__/components/settings/NotificationPreferences.test.tsx b/__tests__/components/settings/NotificationPreferences.test.tsx new file mode 100644 index 00000000..5a878083 --- /dev/null +++ b/__tests__/components/settings/NotificationPreferences.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NotificationPreferences } from '@/components/settings/NotificationPreferences'; +import { useNotificationPreferences } from '@/hooks/useNotificationPreferences'; +import { toast } from 'sonner'; + +jest.mock('@/hooks/useNotificationPreferences'); +jest.mock('sonner', () => ({ + toast: { error: jest.fn(), success: jest.fn() }, +})); + +const mockedHook = useNotificationPreferences as jest.MockedFunction< + typeof useNotificationPreferences +>; +const togglePreference = jest.fn(); +const savePreferences = jest.fn().mockResolvedValue(true); +const resetPreferences = jest.fn(); +const retry = jest.fn().mockResolvedValue(undefined); + +const groupedPreferences = { + deliveries: [ + { + key: 'delivery_created_in_app', + category: 'deliveries' as const, + label: 'Delivery Created', + channel: 'in_app' as const, + enabled: true, + }, + ], + escrow: [ + { + key: 'escrow_locked_in_app', + category: 'escrow' as const, + label: 'Escrow Locked', + channel: 'in_app' as const, + enabled: false, + }, + ], + marketing: [], + system: [], +}; + +function mockHook( + overrides: Partial> = {}, +) { + mockedHook.mockReturnValue({ + preferences: groupedPreferences, + isLoading: false, + isSaving: false, + error: null, + togglePreference, + savePreferences, + clearError: jest.fn(), + resetPreferences, + retry, + ...overrides, + }); +} + +describe('NotificationPreferences page contract', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockHook(); + }); + + it('renders grouped categories and their toggles', () => { + render(); + expect(screen.getByTestId('category-deliveries')).toBeInTheDocument(); + expect(screen.getByTestId('category-escrow')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Delivery Created notification' })).toBeChecked(); + }); + + it('persists toggle changes when save is selected', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('switch', { name: 'Escrow Locked notification' })); + expect(togglePreference).toHaveBeenCalledWith('escrow_locked_in_app', true); + await user.click(screen.getByRole('button', { name: 'Save Preferences' })); + expect(savePreferences).toHaveBeenCalledTimes(1); + }); + + it('shows a save confirmation toast', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Save Preferences' })); + + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith('Notification preferences saved'), + ); + }); + + it('resets preferences to the backend defaults', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Reset to defaults' })); + expect(resetPreferences).toHaveBeenCalledTimes(1); + }); + + it('shows one empty state when no preferences are loaded', () => { + mockHook({ + preferences: { deliveries: [], escrow: [], marketing: [], system: [] }, + }); + render(); + expect(screen.getByText('No notification preferences available.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save Preferences' })).toBeDisabled(); + }); + + it('shows an error state and retries loading', async () => { + mockHook({ error: 'Unable to load preferences' }); + const user = userEvent.setup(); + render(); + expect(screen.getByRole('alert')).toHaveTextContent('Unable to load preferences'); + await user.click(screen.getByRole('button', { name: 'Retry' })); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/hooks/useDriverReviews.test.ts b/__tests__/hooks/useDriverReviews.test.ts index 66fa8672..8e5e1dd9 100644 --- a/__tests__/hooks/useDriverReviews.test.ts +++ b/__tests__/hooks/useDriverReviews.test.ts @@ -3,14 +3,25 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createElement } from 'react'; import { useDriverReviews, REVIEWS_PAGE_SIZE } from '@/hooks/useDriverReviews'; import { driverReviewService } from '@/services/driverReviewService'; -import { DEFAULT_REVIEW_FILTERS } from '@/components/fleet/ReputationReviewGrid'; +import type { DriverReviewFilters } from '@/types/driverReview'; import { buildReviewsPage, driverReviewFixtures } from '../fixtures/driverReviewFixtures'; +const DEFAULT_REVIEW_FILTERS: DriverReviewFilters = { + minRating: null, + dateRange: 'all', + verifiedOnly: false, + sort: 'date_desc', +}; + jest.mock('@/services/driverReviewService', () => ({ - driverReviewService: { getDriverReviews: jest.fn() }, + driverReviewService: { + getDriverReviews: jest.fn(), + getDriverReputation: jest.fn(), + }, })); const getReviews = driverReviewService.getDriverReviews as jest.Mock; +const getReputation = driverReviewService.getDriverReputation as jest.Mock; const wrapper = ({ children }: { children: React.ReactNode }) => createElement( @@ -19,7 +30,17 @@ const wrapper = ({ children }: { children: React.ReactNode }) => children, ); -beforeEach(() => jest.clearAllMocks()); +beforeEach(() => { + jest.clearAllMocks(); + getReputation.mockResolvedValue({ + driverId: 'driver-1', + score: 92, + totalReviews: 24, + verifiedReviews: 20, + updatedAt: '2026-10-18T00:00:00.000Z', + source: 'on_chain', + }); +}); describe('useDriverReviews', () => { it('fetches the first page through the service with filters and page size', async () => { @@ -33,6 +54,14 @@ describe('useDriverReviews', () => { ); expect(result.current.reviews).toHaveLength(3); expect(result.current.hasNextPage).toBe(false); + expect(result.current.reputation?.score).toBe(92); + expect(result.current.pagination).toEqual({ + loaded: 3, + total: 3, + currentPage: 1, + pageSize: REVIEWS_PAGE_SIZE, + hasMore: false, + }); }); it('appends the next page when fetchNextPage is called', async () => { @@ -61,5 +90,21 @@ describe('useDriverReviews', () => { it('does not fetch without a driverId', () => { renderHook(() => useDriverReviews('', DEFAULT_REVIEW_FILTERS), { wrapper }); expect(getReviews).not.toHaveBeenCalled(); + expect(getReputation).not.toHaveBeenCalled(); + }); + + it('fetches the on-chain reputation from its dedicated endpoint service', async () => { + getReviews.mockResolvedValueOnce(buildReviewsPage([], 1, false)); + const { result } = renderHook( + () => useDriverReviews('driver-1', DEFAULT_REVIEW_FILTERS), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(getReputation).toHaveBeenCalledWith('driver-1', expect.anything()); + expect(result.current.reputation).toEqual( + expect.objectContaining({ score: 92, source: 'on_chain' }), + ); }); }); diff --git a/components/deliveries/SignatureCanvas.tsx b/components/deliveries/SignatureCanvas.tsx new file mode 100644 index 00000000..0450540d --- /dev/null +++ b/components/deliveries/SignatureCanvas.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useRef } from 'react'; +import { Eraser, Save } from 'lucide-react'; +import { useSignatureCanvas } from '@/hooks/useSignatureCanvas'; + +export interface SignatureCanvasProps { + onSave: (_dataUrl: string) => void; + width?: number; + height?: number; + disabled?: boolean; + className?: string; +} + +/** Canvas signature capture with a keyboard-accessible typed fallback. */ +export function SignatureCanvas({ + onSave, + width = 720, + height = 260, + disabled = false, + className = '', +}: SignatureCanvasProps) { + const canvasRef = useRef(null); + const { + beginStroke, + clear, + continueStroke, + endStroke, + hasSignature, + isDrawing, + isTooLight, + save, + setTypedSignature, + typedSignature, + } = useSignatureCanvas({ canvasRef, onSave, disabled }); + + const cannotSave = disabled || !hasSignature || isTooLight; + + return ( +
+
+

+ Recipient signature +

+

+ Sign inside the box using a mouse, stylus, or touch screen. +

+
+ + + + {hasSignature && isTooLight && ( +

+ Signature is too light. Add a longer stroke before saving. +

+ )} + +
+ +

+ Type the recipient's name when drawing is not accessible. +

+