diff --git a/frontend/cntr/AvatarUploader/AvatarUploader.test.tsx b/frontend/cntr/AvatarUploader/AvatarUploader.test.tsx new file mode 100644 index 0000000..1a0dd18 --- /dev/null +++ b/frontend/cntr/AvatarUploader/AvatarUploader.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AvatarUploader } from './AvatarUploader'; + +beforeEach(() => { + (global as any).URL.createObjectURL = vi.fn(() => 'blob:mock'); + (global as any).URL.revokeObjectURL = vi.fn(); +}); + +function makeFile(name: string, type: string, size: number): File { + const file = new File(['x'.repeat(size)], name, { type }); + return file; +} + +describe('AvatarUploader', () => { + it('renders upload button', () => { + render(); + expect(screen.getByRole('button', { name: /upload profile picture/i })).toBeInTheDocument(); + }); + + it('shows error for non-image file type', () => { + render(); + const input = screen.getByTestId('avatar-input'); + fireEvent.change(input, { target: { files: [makeFile('doc.pdf', 'application/pdf', 100)] } }); + expect(screen.getByRole('alert')).toHaveTextContent(/jpeg and png/i); + }); + + it('shows error when file exceeds 2MB', () => { + render(); + const input = screen.getByTestId('avatar-input'); + fireEvent.change(input, { target: { files: [makeFile('big.jpg', 'image/jpeg', 3 * 1024 * 1024)] } }); + expect(screen.getByRole('alert')).toHaveTextContent(/2mb/i); + }); + + it('calls onFileSelect with valid file', () => { + const onFileSelect = vi.fn(); + render(); + const input = screen.getByTestId('avatar-input'); + const file = makeFile('avatar.png', 'image/png', 500); + fireEvent.change(input, { target: { files: [file] } }); + expect(onFileSelect).toHaveBeenCalledWith(file); + }); +}); \ No newline at end of file diff --git a/frontend/cntr/AvatarUploader/AvatarUploader.tsx b/frontend/cntr/AvatarUploader/AvatarUploader.tsx new file mode 100644 index 0000000..827f99d --- /dev/null +++ b/frontend/cntr/AvatarUploader/AvatarUploader.tsx @@ -0,0 +1,68 @@ +import React, { useRef, useState, useEffect } from 'react'; + +interface Props { + currentAvatarUrl?: string; + onFileSelect: (file: File) => void; +} + +const MAX_BYTES = 2 * 1024 * 1024; +const ALLOWED = ['image/jpeg', 'image/png']; + +export const AvatarUploader: React.FC = ({ currentAvatarUrl, onFileSelect }) => { + const inputRef = useRef(null); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + return () => { if (preview) URL.revokeObjectURL(preview); }; + }, [preview]); + + function handleChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + setError(null); + + if (!ALLOWED.includes(file.type)) { + setError('Only JPEG and PNG files are allowed.'); + return; + } + if (file.size > MAX_BYTES) { + setError('File must be 2MB or smaller.'); + return; + } + + if (preview) URL.revokeObjectURL(preview); + setPreview(URL.createObjectURL(file)); + onFileSelect(file); + } + + const src = preview ?? currentAvatarUrl; + + return ( + + inputRef.current?.click()} + className="w-24 h-24 rounded-full overflow-hidden border-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500" + > + {src ? ( + + ) : ( + + ? + + )} + + + {error && {error}} + + ); +}; \ No newline at end of file diff --git a/frontend/cntr/EmptyState/EmptyState.test.tsx b/frontend/cntr/EmptyState/EmptyState.test.tsx new file mode 100644 index 0000000..4295132 --- /dev/null +++ b/frontend/cntr/EmptyState/EmptyState.test.tsx @@ -0,0 +1,33 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EmptyState } from './EmptyState'; + +describe('EmptyState', () => { + it('renders with only title', () => { + render(); + expect(screen.getByText('No results found')).toBeInTheDocument(); + }); + + it('renders description when provided', () => { + render(); + expect(screen.getByText('Try adjusting your filters.')).toBeInTheDocument(); + }); + + it('does not render action button when action prop is absent', () => { + render(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders and calls action button when provided', () => { + const onClick = vi.fn(); + render(); + const btn = screen.getByRole('button', { name: 'Add item' }); + fireEvent.click(btn); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders custom icon when provided', () => { + render(} />); + expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/cntr/EmptyState/EmptyState.tsx b/frontend/cntr/EmptyState/EmptyState.tsx new file mode 100644 index 0000000..34f274e --- /dev/null +++ b/frontend/cntr/EmptyState/EmptyState.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Inbox } from 'lucide-react'; + +interface Action { + label: string; + onClick: () => void; +} + +interface Props { + title: string; + description?: string; + icon?: React.ReactNode; + action?: Action; +} + +export const EmptyState: React.FC = ({ title, description, icon, action }) => ( + + + {icon ?? } + + {title} + {description && {description}} + {action && ( + + {action.label} + + )} + +); \ No newline at end of file diff --git a/frontend/cntr/NotificationDropdown/NotificationDropdown.test.tsx b/frontend/cntr/NotificationDropdown/NotificationDropdown.test.tsx new file mode 100644 index 0000000..259e654 --- /dev/null +++ b/frontend/cntr/NotificationDropdown/NotificationDropdown.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { NotificationDropdown, Notification } from './NotificationDropdown'; + +const notifications: Notification[] = [ + { id: '1', message: 'Booking confirmed', createdAt: new Date(Date.now() - 7200000), read: false }, + { id: '2', message: 'Payment received', createdAt: new Date(Date.now() - 86400000), read: true }, +]; + +describe('NotificationDropdown', () => { + it('renders bell button', () => { + render(); + expect(screen.getByRole('button', { name: /notifications/i })).toBeInTheDocument(); + }); + + it('shows unread count badge', () => { + render(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('toggles dropdown on bell click', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /notifications/i })); + expect(screen.getByText('Booking confirmed')).toBeInTheDocument(); + }); + + it('calls onMarkAllRead when button clicked', () => { + const onMarkAllRead = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /notifications/i })); + fireEvent.click(screen.getByText('Mark all read')); + expect(onMarkAllRead).toHaveBeenCalledTimes(1); + }); + + it('unread notification has distinct background', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /notifications/i })); + const unreadItem = screen.getByText('Booking confirmed').closest('li'); + expect(unreadItem?.className).toContain('bg-blue-50'); + }); +}); \ No newline at end of file diff --git a/frontend/cntr/NotificationDropdown/NotificationDropdown.tsx b/frontend/cntr/NotificationDropdown/NotificationDropdown.tsx new file mode 100644 index 0000000..085e167 --- /dev/null +++ b/frontend/cntr/NotificationDropdown/NotificationDropdown.tsx @@ -0,0 +1,94 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { Bell } from 'lucide-react'; + +export interface Notification { + id: string; + message: string; + createdAt: Date; + read: boolean; + icon?: React.ReactNode; +} + +interface Props { + notifications: Notification[]; + onMarkAllRead: () => void; + onViewAll: () => void; +} + +function relativeTime(date: Date): string { + const diff = (date.getTime() - Date.now()) / 1000; + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); + const abs = Math.abs(diff); + if (abs < 60) return rtf.format(Math.round(diff), 'second'); + if (abs < 3600) return rtf.format(Math.round(diff / 60), 'minute'); + if (abs < 86400) return rtf.format(Math.round(diff / 3600), 'hour'); + return rtf.format(Math.round(diff / 86400), 'day'); +} + +export const NotificationDropdown: React.FC = ({ notifications, onMarkAllRead, onViewAll }) => { + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const unreadCount = notifications.filter((n) => !n.read).length; + const visible = notifications.slice(0, 10); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + if (open) document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [open]); + + return ( + + setOpen((o) => !o)} + className="relative p-2 rounded-full hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + > + + {unreadCount > 0 && ( + + {unreadCount} + + )} + + + {open && ( + + + Notifications + + Mark all read + + + + {visible.length === 0 && ( + No notifications + )} + {visible.map((n) => ( + + {n.icon && {n.icon}} + + {n.message} + {relativeTime(n.createdAt)} + + {!n.read && } + + ))} + + + + View all + + + + )} + + ); +}; \ No newline at end of file diff --git a/frontend/cntr/charts/RevenueChart.test.tsx b/frontend/cntr/charts/RevenueChart.test.tsx new file mode 100644 index 0000000..5da82ca --- /dev/null +++ b/frontend/cntr/charts/RevenueChart.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { RevenueChart } from './RevenueChart'; + +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => {children}, + }; +}); + +const mockData = [ + { month: 'Jan', revenueKobo: 5000000 }, + { month: 'Feb', revenueKobo: 7500000 }, +]; + +describe('RevenueChart', () => { + it('renders without crashing', () => { + render(); + }); + + it('renders with empty data', () => { + render(); + }); +}); \ No newline at end of file diff --git a/frontend/cntr/charts/RevenueChart.tsx b/frontend/cntr/charts/RevenueChart.tsx new file mode 100644 index 0000000..f311cd0 --- /dev/null +++ b/frontend/cntr/charts/RevenueChart.tsx @@ -0,0 +1,29 @@ +'use client'; +import React from 'react'; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, +} from 'recharts'; + +export interface RevenueData { + month: string; + revenueKobo: number; +} + +interface Props { + data: RevenueData[]; +} + +function formatNaira(kobo: number): string { + return `₦${(kobo / 100).toLocaleString('en-NG')}`; +} + +export const RevenueChart: React.FC = ({ data }) => ( + + + + formatNaira(v)} tick={{ fontSize: 11 }} width={90} /> + [formatNaira(value), 'Revenue']} /> + + + +); \ No newline at end of file
{error}
{description}
{n.message}
{relativeTime(n.createdAt)}