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:
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user