feat(web): complete du-an project pages, neighborhood components, and public notification bell

- Add grid/map view toggle on /du-an listing page with Mapbox project markers
- Enhance du-an detail with master plan viewer, neighborhood radar chart, POI map, and price history chart
- Create neighborhood component suite: radar chart (Recharts), POI map (Mapbox), score badges
- Add du-an API client, server-side fetching, and React Query hooks
- Wire NotificationBell into public layout header for authenticated users
- Fix missing PROJECT_STATUS_COLORS import in du-an detail client

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 05:11:21 +07:00
parent 8da488711b
commit e21e096e54
14 changed files with 1299 additions and 49 deletions

View File

@@ -0,0 +1,91 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { NeighborhoodPOIMap } from '../neighborhood-poi-map';
import type { POIItem } from '../types';
// Mock mapbox-gl
vi.mock('mapbox-gl', () => {
const MockMap = vi.fn().mockImplementation(() => ({
addControl: vi.fn(),
remove: vi.fn(),
flyTo: vi.fn(),
on: vi.fn(),
}));
const MockMarker = vi.fn().mockImplementation(() => ({
setLngLat: vi.fn().mockReturnThis(),
setPopup: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}));
const MockPopup = vi.fn().mockImplementation(() => ({
setHTML: vi.fn().mockReturnThis(),
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}));
return {
default: {
Map: MockMap,
Marker: MockMarker,
Popup: MockPopup,
NavigationControl: vi.fn(),
AttributionControl: vi.fn(),
accessToken: '',
},
};
});
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
const samplePois: POIItem[] = [
{ id: '1', name: 'Trường THPT Nguyễn Du', category: 'school', lat: 10.82, lng: 106.63, distance: 200 },
{ id: '2', name: 'Bệnh viện Nhân dân 115', category: 'hospital', lat: 10.83, lng: 106.64, distance: 500 },
{ id: '3', name: 'Trạm Metro Bến Thành', category: 'transit', lat: 10.77, lng: 106.70, distance: 800 },
];
const center = { lat: 10.82, lng: 106.63 };
describe('NeighborhoodPOIMap', () => {
it('renders map container', () => {
const { container } = render(
<NeighborhoodPOIMap center={center} pois={samplePois} />,
);
expect(container.querySelector('.rounded-lg')).toBeInTheDocument();
});
it('renders all category toggle buttons', () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
expect(screen.getByText('Trường học')).toBeInTheDocument();
expect(screen.getByText('Bệnh viện')).toBeInTheDocument();
expect(screen.getByText('Giao thông')).toBeInTheDocument();
expect(screen.getByText('Mua sắm')).toBeInTheDocument();
expect(screen.getByText('Nhà hàng')).toBeInTheDocument();
expect(screen.getByText('Công viên')).toBeInTheDocument();
});
it('shows POI counts in toggle buttons', () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
// school: 1, hospital: 1, transit: 1
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(6);
});
it('toggles category on click', () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
const schoolBtn = screen.getByText('Trường học').closest('button')!;
fireEvent.click(schoolBtn);
// After clicking, it should be toggled off (line-through style applied)
expect(schoolBtn.className).toContain('line-through');
});
it('shows fallback when no mapbox token', () => {
const originalEnv = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
delete process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
if (originalEnv) process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = originalEnv;
});
});

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { NeighborhoodRadarChart } from '../neighborhood-radar-chart';
import type { NeighborhoodCategory } from '../types';
// Mock recharts
vi.mock('recharts', () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
RadarChart: ({ children, data }: { children: React.ReactNode; data: unknown[] }) => (
<div data-testid="radar-chart" data-count={data.length}>
{children}
</div>
),
Radar: ({ dataKey }: { dataKey: string }) => <div data-testid={`radar-${dataKey}`} />,
PolarGrid: () => <div data-testid="polar-grid" />,
PolarAngleAxis: ({ dataKey }: { dataKey: string }) => (
<div data-testid={`polar-angle-${dataKey}`} />
),
PolarRadiusAxis: () => <div data-testid="polar-radius" />,
Tooltip: () => <div data-testid="tooltip" />,
}));
const sampleCategories: NeighborhoodCategory[] = [
{ category: 'education', label: 'Giáo dục', score: 8.5 },
{ category: 'healthcare', label: 'Y tế', score: 7.2 },
{ category: 'transport', label: 'Giao thông', score: 6.0 },
{ category: 'shopping', label: 'Mua sắm', score: 4.5 },
{ category: 'dining', label: 'Ẩm thực', score: 9.0 },
{ category: 'environment', label: 'Môi trường', score: 3.2 },
];
describe('NeighborhoodRadarChart', () => {
it('renders responsive container', () => {
render(<NeighborhoodRadarChart categories={sampleCategories} />);
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
});
it('renders radar chart with correct data count', () => {
render(<NeighborhoodRadarChart categories={sampleCategories} />);
expect(screen.getByTestId('radar-chart')).toHaveAttribute('data-count', '6');
});
it('renders radar data layer', () => {
render(<NeighborhoodRadarChart categories={sampleCategories} />);
expect(screen.getByTestId('radar-score')).toBeInTheDocument();
});
it('renders polar grid and axes', () => {
render(<NeighborhoodRadarChart categories={sampleCategories} />);
expect(screen.getByTestId('polar-grid')).toBeInTheDocument();
expect(screen.getByTestId('polar-angle-subject')).toBeInTheDocument();
expect(screen.getByTestId('polar-radius')).toBeInTheDocument();
});
it('renders score badges by default', () => {
render(<NeighborhoodRadarChart categories={sampleCategories} />);
// Green badge for scores > 7
expect(screen.getByText(/Giáo dục: 8.5/)).toBeInTheDocument();
// Yellow badge for scores 5-7
expect(screen.getByText(/Giao thông: 6.0/)).toBeInTheDocument();
// Red badge for scores < 5
expect(screen.getByText(/Môi trường: 3.2/)).toBeInTheDocument();
});
it('hides badges when showBadges is false', () => {
render(<NeighborhoodRadarChart categories={sampleCategories} showBadges={false} />);
expect(screen.queryByText(/Giáo dục: 8.5/)).not.toBeInTheDocument();
});
});