Skip to content
Open
43 changes: 43 additions & 0 deletions frontend/cntr/AvatarUploader/AvatarUploader.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AvatarUploader onFileSelect={vi.fn()} />);
expect(screen.getByRole('button', { name: /upload profile picture/i })).toBeInTheDocument();
});

it('shows error for non-image file type', () => {
render(<AvatarUploader onFileSelect={vi.fn()} />);
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(<AvatarUploader onFileSelect={vi.fn()} />);
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(<AvatarUploader onFileSelect={onFileSelect} />);
const input = screen.getByTestId('avatar-input');
const file = makeFile('avatar.png', 'image/png', 500);
fireEvent.change(input, { target: { files: [file] } });
expect(onFileSelect).toHaveBeenCalledWith(file);
});
});
68 changes: 68 additions & 0 deletions frontend/cntr/AvatarUploader/AvatarUploader.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ currentAvatarUrl, onFileSelect }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
return () => { if (preview) URL.revokeObjectURL(preview); };
}, [preview]);

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="flex flex-col items-center gap-3">
<button
type="button"
aria-label="Upload profile picture"
onClick={() => 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 ? (
<img src={src} alt="Avatar preview" className="w-full h-full object-cover" />
) : (
<span className="flex items-center justify-center w-full h-full bg-gray-200 text-gray-500 text-2xl font-bold">
?
</span>
)}
</button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png"
className="hidden"
onChange={handleChange}
data-testid="avatar-input"
/>
{error && <p className="text-sm text-red-600" role="alert">{error}</p>}
</div>
);
};
33 changes: 33 additions & 0 deletions frontend/cntr/EmptyState/EmptyState.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<EmptyState title="No results found" />);
expect(screen.getByText('No results found')).toBeInTheDocument();
});

it('renders description when provided', () => {
render(<EmptyState title="Empty" description="Try adjusting your filters." />);
expect(screen.getByText('Try adjusting your filters.')).toBeInTheDocument();
});

it('does not render action button when action prop is absent', () => {
render(<EmptyState title="Empty" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('renders and calls action button when provided', () => {
const onClick = vi.fn();
render(<EmptyState title="Empty" action={{ label: 'Add item', onClick }} />);
const btn = screen.getByRole('button', { name: 'Add item' });
fireEvent.click(btn);
expect(onClick).toHaveBeenCalledTimes(1);
});

it('renders custom icon when provided', () => {
render(<EmptyState title="Empty" icon={<span data-testid="custom-icon" />} />);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
});
32 changes: 32 additions & 0 deletions frontend/cntr/EmptyState/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ title, description, icon, action }) => (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<div className="mb-4 text-gray-400">
{icon ?? <Inbox size={48} aria-hidden="true" />}
</div>
<h3 className="text-lg font-semibold text-gray-700 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{action && (
<button
onClick={action.onClick}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
{action.label}
</button>
)}
</div>
);
41 changes: 41 additions & 0 deletions frontend/cntr/NotificationDropdown/NotificationDropdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<NotificationDropdown notifications={notifications} onMarkAllRead={vi.fn()} onViewAll={vi.fn()} />);
expect(screen.getByRole('button', { name: /notifications/i })).toBeInTheDocument();
});

it('shows unread count badge', () => {
render(<NotificationDropdown notifications={notifications} onMarkAllRead={vi.fn()} onViewAll={vi.fn()} />);
expect(screen.getByText('1')).toBeInTheDocument();
});

it('toggles dropdown on bell click', () => {
render(<NotificationDropdown notifications={notifications} onMarkAllRead={vi.fn()} onViewAll={vi.fn()} />);
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(<NotificationDropdown notifications={notifications} onMarkAllRead={onMarkAllRead} onViewAll={vi.fn()} />);
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(<NotificationDropdown notifications={notifications} onMarkAllRead={vi.fn()} onViewAll={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /notifications/i }));
const unreadItem = screen.getByText('Booking confirmed').closest('li');
expect(unreadItem?.className).toContain('bg-blue-50');
});
});
94 changes: 94 additions & 0 deletions frontend/cntr/NotificationDropdown/NotificationDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ notifications, onMarkAllRead, onViewAll }) => {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative inline-block">
<button
aria-label="Notifications"
onClick={() => setOpen((o) => !o)}
className="relative p-2 rounded-full hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<Bell size={20} />
{unreadCount > 0 && (
<span className="absolute top-0 right-0 w-4 h-4 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
{unreadCount}
</span>
)}
</button>

{open && (
<div className="absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-lg shadow-lg z-50">
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="font-semibold text-sm">Notifications</span>
<button onClick={onMarkAllRead} className="text-xs text-blue-600 hover:underline">
Mark all read
</button>
</div>
<ul className="max-h-72 overflow-y-auto divide-y">
{visible.length === 0 && (
<li className="px-4 py-6 text-center text-sm text-gray-400">No notifications</li>
)}
{visible.map((n) => (
<li
key={n.id}
className={`flex gap-3 px-4 py-3 text-sm ${n.read ? 'bg-white' : 'bg-blue-50'}`}
>
{n.icon && <span className="mt-0.5 shrink-0">{n.icon}</span>}
<div className="flex-1 min-w-0">
<p className="truncate">{n.message}</p>
<p className="text-xs text-gray-400 mt-0.5">{relativeTime(n.createdAt)}</p>
</div>
{!n.read && <span className="w-2 h-2 mt-1.5 rounded-full bg-blue-500 shrink-0" aria-label="Unread" />}
</li>
))}
</ul>
<div className="px-4 py-2 border-t text-center">
<button onClick={onViewAll} className="text-xs text-blue-600 hover:underline">
View all
</button>
</div>
</div>
)}
</div>
);
};
26 changes: 26 additions & 0 deletions frontend/cntr/charts/RevenueChart.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('recharts')>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
};
});

const mockData = [
{ month: 'Jan', revenueKobo: 5000000 },
{ month: 'Feb', revenueKobo: 7500000 },
];

describe('RevenueChart', () => {
it('renders without crashing', () => {
render(<RevenueChart data={mockData} />);
});

it('renders with empty data', () => {
render(<RevenueChart data={[]} />);
});
});
29 changes: 29 additions & 0 deletions frontend/cntr/charts/RevenueChart.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ data }) => (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data} margin={{ top: 8, right: 16, left: 16, bottom: 0 }}>
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={(v) => formatNaira(v)} tick={{ fontSize: 11 }} width={90} />
<Tooltip formatter={(value: number) => [formatNaira(value), 'Revenue']} />
<Bar dataKey="revenueKobo" name="Revenue" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);