From dd373169cdae8053146ccff793fd7699bee3269a Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Sat, 30 May 2026 11:20:30 +0100 Subject: [PATCH 1/8] feat(fe-04): add RevenueChart with Naira formatting --- frontend/cntr/charts/RevenueChart.tsx | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 frontend/cntr/charts/RevenueChart.tsx 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 From ef12345a4ffe2a729896493820ba24c866219f29 Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Sat, 30 May 2026 11:20:31 +0100 Subject: [PATCH 2/8] test(fe-04): add RevenueChart tests --- frontend/cntr/charts/RevenueChart.test.tsx | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 frontend/cntr/charts/RevenueChart.test.tsx 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 From 7885b882fd5204163cf898263dca4181d65b13e8 Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Sat, 30 May 2026 11:20:46 +0100 Subject: [PATCH 3/8] feat(fe-08): add reusable EmptyState component --- frontend/cntr/EmptyState/EmptyState.tsx | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 frontend/cntr/EmptyState/EmptyState.tsx 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 && ( + + )} +
+); \ No newline at end of file From a298fb974ff5272e388d2a9ae761ab8a0460f5aa Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Sat, 30 May 2026 11:20:47 +0100 Subject: [PATCH 4/8] test(fe-08): add EmptyState tests --- frontend/cntr/EmptyState/EmptyState.test.tsx | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 frontend/cntr/EmptyState/EmptyState.test.tsx 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 From a8831de414a2c9d8a23e73358d73a709b7e3f3fb Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Sat, 30 May 2026 11:21:08 +0100 Subject: [PATCH 5/8] feat(fe-11): add AvatarUploader with preview and validation --- .../cntr/AvatarUploader/AvatarUploader.tsx | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 frontend/cntr/AvatarUploader/AvatarUploader.tsx 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 ( +
+ + + {error &&

{error}

} +
+ ); +}; \ No newline at end of file From 3ffa27436556a145145ba0df99775a8443f521e9 Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Sat, 30 May 2026 11:21:09 +0100 Subject: [PATCH 6/8] test(fe-11): add AvatarUploader tests --- .../AvatarUploader/AvatarUploader.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 frontend/cntr/AvatarUploader/AvatarUploader.test.tsx 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 From 6469539e74fe05d41220a01a29fe3de621a30014 Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Sat, 30 May 2026 11:21:36 +0100 Subject: [PATCH 7/8] feat(fe-13): add NotificationDropdown with unread indicators and mark-all-read --- .../NotificationDropdown.tsx | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 frontend/cntr/NotificationDropdown/NotificationDropdown.tsx 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 ( +
+ + + {open && ( +
+
+ Notifications + +
+
    + {visible.length === 0 && ( +
  • No notifications
  • + )} + {visible.map((n) => ( +
  • + {n.icon && {n.icon}} +
    +

    {n.message}

    +

    {relativeTime(n.createdAt)}

    +
    + {!n.read && } +
  • + ))} +
+
+ +
+
+ )} +
+ ); +}; \ No newline at end of file From fc6b7c2585a63192fbacc83907f6caa191bd0a19 Mon Sep 17 00:00:00 2001 From: Nimatstar Date: Sat, 30 May 2026 11:21:37 +0100 Subject: [PATCH 8/8] test(fe-13): add NotificationDropdown tests --- .../NotificationDropdown.test.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 frontend/cntr/NotificationDropdown/NotificationDropdown.test.tsx 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