From e21e096e548fb33906c7eb9aa14068c9f32b2e47 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 05:11:21 +0700 Subject: [PATCH] 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 --- apps/web/app/[locale]/(public)/du-an/page.tsx | 71 ++++- apps/web/app/[locale]/(public)/layout.tsx | 7 + .../components/du-an/du-an-detail-client.tsx | 178 ++++++++++--- apps/web/components/du-an/project-map.tsx | 146 +++++++++++ .../__tests__/neighborhood-poi-map.spec.tsx | 91 +++++++ .../neighborhood-radar-chart.spec.tsx | 71 +++++ apps/web/components/neighborhood/index.ts | 10 + .../neighborhood/neighborhood-poi-map.tsx | 244 ++++++++++++++++++ .../neighborhood/neighborhood-radar-chart.tsx | 103 ++++++++ .../neighborhood/neighborhood-score.tsx | 78 ++++++ apps/web/components/neighborhood/types.ts | 53 ++++ apps/web/lib/du-an-api.ts | 199 ++++++++++++++ apps/web/lib/du-an-server.ts | 58 +++++ apps/web/lib/hooks/use-du-an.ts | 39 +++ 14 files changed, 1299 insertions(+), 49 deletions(-) create mode 100644 apps/web/components/du-an/project-map.tsx create mode 100644 apps/web/components/neighborhood/__tests__/neighborhood-poi-map.spec.tsx create mode 100644 apps/web/components/neighborhood/__tests__/neighborhood-radar-chart.spec.tsx create mode 100644 apps/web/components/neighborhood/index.ts create mode 100644 apps/web/components/neighborhood/neighborhood-poi-map.tsx create mode 100644 apps/web/components/neighborhood/neighborhood-radar-chart.tsx create mode 100644 apps/web/components/neighborhood/neighborhood-score.tsx create mode 100644 apps/web/components/neighborhood/types.ts create mode 100644 apps/web/lib/du-an-api.ts create mode 100644 apps/web/lib/du-an-server.ts create mode 100644 apps/web/lib/hooks/use-du-an.ts diff --git a/apps/web/app/[locale]/(public)/du-an/page.tsx b/apps/web/app/[locale]/(public)/du-an/page.tsx index 3eceb35..7d354ce 100644 --- a/apps/web/app/[locale]/(public)/du-an/page.tsx +++ b/apps/web/app/[locale]/(public)/du-an/page.tsx @@ -1,20 +1,30 @@ 'use client'; -import { Building2 } from 'lucide-react'; +import { Building2, LayoutGrid, Map } from 'lucide-react'; +import dynamic from 'next/dynamic'; import * as React from 'react'; import { ProjectCard } from '@/components/du-an/project-card'; import { ProjectFilterBar } from '@/components/du-an/project-filter-bar'; import { Button } from '@/components/ui/button'; import type { SearchProjectsParams } from '@/lib/du-an-api'; import { useProjectsSearch } from '@/lib/hooks/use-du-an'; +import { cn } from '@/lib/utils'; + +const ProjectMap = dynamic( + () => import('@/components/du-an/project-map').then((m) => m.ProjectMap), + { ssr: false }, +); const PAGE_SIZE = 12; +type ViewMode = 'grid' | 'map'; + export default function DuAnPage() { const [filters, setFilters] = React.useState({ page: 1, limit: PAGE_SIZE, }); + const [viewMode, setViewMode] = React.useState('grid'); const { data, isLoading, isError } = useProjectsSearch(filters); @@ -31,11 +41,41 @@ export default function DuAnPage() { return (
{/* Page header */} -
-

Dự án bất động sản

-

- Khám phá các dự án mới nhất từ các chủ đầu tư uy tín -

+
+
+

Dự án bất động sản

+

+ Khám phá các dự án mới nhất từ các chủ đầu tư uy tín +

+
+
+ + +
{/* Filters */} @@ -70,14 +110,19 @@ export default function DuAnPage() {

{data.total} dự án được tìm thấy

-
- {data.data.map((project) => ( - - ))} -
- {/* Pagination */} - {data.totalPages > 1 && ( + {viewMode === 'map' ? ( + + ) : ( +
+ {data.data.map((project) => ( + + ))} +
+ )} + + {/* Pagination (grid mode only) */} + {viewMode === 'grid' && data.totalPages > 1 && (
+ {mp.caption && ( +

+ {mp.caption} +

+ )} +
+ ))} +
+ + {/* Fullscreen overlay */} + {expanded && ( +
setExpanded(false)} + > + + Mặt bằng tổng thể e.stopPropagation()} + /> +
+ )} + + + ); +} + function PriceTab({ project }: { project: ProjectDetail }) { return (
@@ -456,28 +571,18 @@ function PriceTab({ project }: { project: ProjectDetail }) {
)} - {/* Price history */} + {/* Price history chart */} {project.priceHistory.length > 0 && (

Lịch sử giá

-
- {project.priceHistory.map((ph) => ( -
- {ph.period} -
- - {(ph.avgPricePerM2 / 1_000_000).toFixed(1)} tr/m² - - - {ph.transactionCount} giao dịch - -
-
- ))} -
+ ({ + period: ph.period, + 'Gia/m2': ph.avgPricePerM2 / 1_000_000, + 'Tin đăng': ph.transactionCount, + }))} + height={300} + />
)} @@ -496,11 +601,12 @@ function ListingsTab({ project }: { project: ProjectDetail }) {

{project.linkedListingCount} tin đăng liên quan đến dự án này

- + + Xem tất cả tin đăng +
) : (

diff --git a/apps/web/components/du-an/project-map.tsx b/apps/web/components/du-an/project-map.tsx new file mode 100644 index 0000000..e53afeb --- /dev/null +++ b/apps/web/components/du-an/project-map.tsx @@ -0,0 +1,146 @@ +'use client'; + +/* eslint-disable import-x/no-named-as-default-member */ +import mapboxgl from 'mapbox-gl'; +import * as React from 'react'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { formatPrice } from '@/lib/currency'; +import { + PROJECT_STATUS_LABELS, + type ProjectSummary, +} from '@/lib/du-an-api'; + +interface ProjectMapProps { + projects: ProjectSummary[]; + className?: string; +} + +const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231]; // HCMC +const DEFAULT_ZOOM = 12; + +export function ProjectMap({ projects, className }: ProjectMapProps) { + const mapContainerRef = React.useRef(null); + const mapRef = React.useRef(null); + const markersRef = React.useRef([]); + + const geoProjects = React.useMemo( + () => projects.filter((p) => p.latitude != null && p.longitude != null), + [projects], + ); + + React.useEffect(() => { + if (!mapContainerRef.current) return; + + const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + if (!token) return; + + mapboxgl.accessToken = token; + + const map = new mapboxgl.Map({ + container: mapContainerRef.current, + style: 'mapbox://styles/mapbox/streets-v12', + center: DEFAULT_CENTER, + zoom: DEFAULT_ZOOM, + attributionControl: false, + }); + + map.addControl(new mapboxgl.NavigationControl(), 'top-right'); + map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right'); + + mapRef.current = map; + + return () => { + map.remove(); + mapRef.current = null; + }; + }, []); + + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + + markersRef.current.forEach((m) => m.remove()); + markersRef.current = []; + + if (geoProjects.length === 0) return; + + const bounds = new mapboxgl.LngLatBounds(); + + geoProjects.forEach((project) => { + const el = document.createElement('div'); + el.className = 'project-map-marker'; + el.style.cssText = ` + background: white; + border-radius: 8px; + padding: 4px 8px; + font-size: 11px; + font-weight: 600; + box-shadow: 0 2px 6px rgba(0,0,0,0.15); + white-space: nowrap; + cursor: pointer; + border-left: 3px solid hsl(142.1, 76.2%, 36.3%); + transition: transform 0.15s; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + `; + el.textContent = project.name; + el.addEventListener('mouseenter', () => { + el.style.transform = 'scale(1.05)'; + }); + el.addEventListener('mouseleave', () => { + el.style.transform = 'scale(1)'; + }); + + const statusLabel = PROJECT_STATUS_LABELS[project.status]; + const priceText = project.minPrice ? formatPrice(project.minPrice) : 'Liên hệ'; + + const popup = new mapboxgl.Popup({ offset: 15, maxWidth: '240px', closeButton: false }) + .setHTML( + `

+

${project.name}

+

${project.district}, ${project.city}

+

+ ${statusLabel} + ${priceText} +

+ Xem chi tiết → +
`, + ); + + const marker = new mapboxgl.Marker({ element: el, anchor: 'left' }) + .setLngLat([project.longitude!, project.latitude!]) + .setPopup(popup) + .addTo(map); + + markersRef.current.push(marker); + bounds.extend([project.longitude!, project.latitude!]); + }); + + if (geoProjects.length > 1) { + map.fitBounds(bounds, { padding: 60, maxZoom: 15 }); + } else { + map.flyTo({ center: [geoProjects[0]!.longitude!, geoProjects[0]!.latitude!], zoom: 14 }); + } + }, [geoProjects]); + + const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + + return ( +
+
+ + {!hasToken && ( +
+

+ Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ +

+
+ )} + +
+ {geoProjects.length} dự án trên bản đồ +
+
+ ); +} diff --git a/apps/web/components/neighborhood/__tests__/neighborhood-poi-map.spec.tsx b/apps/web/components/neighborhood/__tests__/neighborhood-poi-map.spec.tsx new file mode 100644 index 0000000..5dedfff --- /dev/null +++ b/apps/web/components/neighborhood/__tests__/neighborhood-poi-map.spec.tsx @@ -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( + , + ); + expect(container.querySelector('.rounded-lg')).toBeInTheDocument(); + }); + + it('renders all category toggle buttons', () => { + render(); + 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(); + // school: 1, hospital: 1, transit: 1 + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThanOrEqual(6); + }); + + it('toggles category on click', () => { + render(); + 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(); + expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument(); + + if (originalEnv) process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = originalEnv; + }); +}); diff --git a/apps/web/components/neighborhood/__tests__/neighborhood-radar-chart.spec.tsx b/apps/web/components/neighborhood/__tests__/neighborhood-radar-chart.spec.tsx new file mode 100644 index 0000000..b88d23e --- /dev/null +++ b/apps/web/components/neighborhood/__tests__/neighborhood-radar-chart.spec.tsx @@ -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 }) => ( +
{children}
+ ), + RadarChart: ({ children, data }: { children: React.ReactNode; data: unknown[] }) => ( +
+ {children} +
+ ), + Radar: ({ dataKey }: { dataKey: string }) =>
, + PolarGrid: () =>
, + PolarAngleAxis: ({ dataKey }: { dataKey: string }) => ( +
+ ), + PolarRadiusAxis: () =>
, + 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(); + expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); + }); + + it('renders radar chart with correct data count', () => { + render(); + expect(screen.getByTestId('radar-chart')).toHaveAttribute('data-count', '6'); + }); + + it('renders radar data layer', () => { + render(); + expect(screen.getByTestId('radar-score')).toBeInTheDocument(); + }); + + it('renders polar grid and axes', () => { + render(); + 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(); + // 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(); + expect(screen.queryByText(/Giáo dục: 8.5/)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/neighborhood/index.ts b/apps/web/components/neighborhood/index.ts new file mode 100644 index 0000000..288c2fd --- /dev/null +++ b/apps/web/components/neighborhood/index.ts @@ -0,0 +1,10 @@ +export { NeighborhoodRadarChart } from './neighborhood-radar-chart'; +export { NeighborhoodPOIMap } from './neighborhood-poi-map'; +export { NeighborhoodScore } from './neighborhood-score'; +export type { + NeighborhoodCategory, + NeighborhoodScoreData, + POIItem, + POICategory, +} from './types'; +export { POI_CATEGORY_CONFIG, DEFAULT_CATEGORIES } from './types'; diff --git a/apps/web/components/neighborhood/neighborhood-poi-map.tsx b/apps/web/components/neighborhood/neighborhood-poi-map.tsx new file mode 100644 index 0000000..8eeb25e --- /dev/null +++ b/apps/web/components/neighborhood/neighborhood-poi-map.tsx @@ -0,0 +1,244 @@ +'use client'; + +/* eslint-disable import-x/no-named-as-default-member */ +import mapboxgl from 'mapbox-gl'; +import * as React from 'react'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { cn } from '@/lib/utils'; +import { type POIItem, type POICategory, POI_CATEGORY_CONFIG } from './types'; + +interface NeighborhoodPOIMapProps { + center: { lat: number; lng: number }; + pois: POIItem[]; + zoom?: number; + height?: string; + className?: string; +} + +export function NeighborhoodPOIMap({ + center, + pois, + zoom = 14, + height = '400px', + className, +}: NeighborhoodPOIMapProps) { + const mapContainerRef = React.useRef(null); + const mapRef = React.useRef(null); + const markersRef = React.useRef([]); + + const [activeCategories, setActiveCategories] = React.useState>( + () => new Set(Object.keys(POI_CATEGORY_CONFIG) as POICategory[]), + ); + + const toggleCategory = React.useCallback((category: POICategory) => { + setActiveCategories((prev) => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }, []); + + // Initialize map + React.useEffect(() => { + if (!mapContainerRef.current) return; + + const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + if (!token) return; + + mapboxgl.accessToken = token; + + const map = new mapboxgl.Map({ + container: mapContainerRef.current, + style: 'mapbox://styles/mapbox/streets-v12', + center: [center.lng, center.lat], + zoom, + attributionControl: false, + }); + + map.addControl(new mapboxgl.NavigationControl(), 'top-right'); + map.addControl( + new mapboxgl.AttributionControl({ compact: true }), + 'bottom-right', + ); + + mapRef.current = map; + + return () => { + map.remove(); + mapRef.current = null; + }; + }, []); + + // Update center when prop changes + React.useEffect(() => { + mapRef.current?.flyTo({ center: [center.lng, center.lat], zoom }); + }, [center, zoom]); + + // Render POI markers based on active categories + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + + // Clear existing markers + markersRef.current.forEach((m) => m.remove()); + markersRef.current = []; + + const visiblePois = pois.filter((poi) => activeCategories.has(poi.category)); + + visiblePois.forEach((poi) => { + const config = POI_CATEGORY_CONFIG[poi.category]; + + const el = document.createElement('div'); + el.className = 'poi-marker'; + el.style.cssText = ` + width: 28px; + height: 28px; + border-radius: 50%; + background: ${config.color}; + border: 2px solid white; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + cursor: pointer; + transition: transform 0.15s; + `; + el.textContent = config.icon; + el.title = `${poi.name} (${config.label})`; + + el.addEventListener('mouseenter', () => { + el.style.transform = 'scale(1.3)'; + }); + el.addEventListener('mouseleave', () => { + el.style.transform = 'scale(1)'; + }); + + const popup = new mapboxgl.Popup({ offset: 20, closeButton: false }) + .setHTML( + `
+

${config.icon} ${poi.name}

+

${config.label}${poi.distance ? ` · ${poi.distance}m` : ''}

+
`, + ); + + const marker = new mapboxgl.Marker({ element: el, anchor: 'center' }) + .setLngLat([poi.lng, poi.lat]) + .setPopup(popup) + .addTo(map); + + markersRef.current.push(marker); + }); + }, [pois, activeCategories]); + + // Add property center marker + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const el = document.createElement('div'); + el.style.cssText = ` + width: 16px; + height: 16px; + border-radius: 50%; + background: hsl(142.1, 76.2%, 36.3%); + border: 3px solid white; + box-shadow: 0 0 0 2px hsl(142.1, 76.2%, 36.3%), 0 2px 8px rgba(0,0,0,0.3); + `; + + const marker = new mapboxgl.Marker({ element: el, anchor: 'center' }) + .setLngLat([center.lng, center.lat]) + .addTo(map); + + return () => { + marker.remove(); + }; + }, [center]); + + const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + + const allCategories = Object.entries(POI_CATEGORY_CONFIG) as [ + POICategory, + (typeof POI_CATEGORY_CONFIG)[POICategory], + ][]; + + return ( +
+
+ + {/* Layer toggle controls */} +
+ {allCategories.map(([key, config]) => { + const isActive = activeCategories.has(key); + const poiCount = pois.filter((p) => p.category === key).length; + return ( + + ); + })} +
+ + {/* Fallback when no Mapbox token */} + {!hasToken && ( +
+
+ + + + +

+ Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ POI +

+
+
+ )} +
+ ); +} diff --git a/apps/web/components/neighborhood/neighborhood-radar-chart.tsx b/apps/web/components/neighborhood/neighborhood-radar-chart.tsx new file mode 100644 index 0000000..8603264 --- /dev/null +++ b/apps/web/components/neighborhood/neighborhood-radar-chart.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { + Radar, + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + ResponsiveContainer, + Tooltip, +} from 'recharts'; +import { Badge } from '@/components/ui/badge'; +import type { NeighborhoodCategory } from './types'; + +interface NeighborhoodRadarChartProps { + categories: NeighborhoodCategory[]; + height?: number; + showBadges?: boolean; + className?: string; +} + +function getScoreVariant(score: number): 'success' | 'warning' | 'destructive' { + if (score > 7) return 'success'; + if (score >= 5) return 'warning'; + return 'destructive'; +} + +function getScoreLabel(score: number): string { + if (score > 7) return 'Tốt'; + if (score >= 5) return 'TB'; + return 'Yếu'; +} + +export function NeighborhoodRadarChart({ + categories, + height = 300, + showBadges = true, + className, +}: NeighborhoodRadarChartProps) { + const chartData = categories.map((cat) => ({ + subject: cat.label, + score: cat.score, + fullMark: 10, + })); + + return ( +
+ + + + + + [`${Number(value).toFixed(1)}/10`, 'Điểm']} + /> + + + + + {showBadges && ( +
+ {categories.map((cat) => ( + + {cat.label}: {cat.score.toFixed(1)} + ({getScoreLabel(cat.score)}) + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/neighborhood/neighborhood-score.tsx b/apps/web/components/neighborhood/neighborhood-score.tsx new file mode 100644 index 0000000..2e10a49 --- /dev/null +++ b/apps/web/components/neighborhood/neighborhood-score.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { NeighborhoodPOIMap } from './neighborhood-poi-map'; +import { NeighborhoodRadarChart } from './neighborhood-radar-chart'; +import type { NeighborhoodScoreData } from './types'; + +interface NeighborhoodScoreProps { + data: NeighborhoodScoreData; + showMap?: boolean; + mapHeight?: string; + className?: string; +} + +function getOverallVariant(score: number): 'success' | 'warning' | 'destructive' { + if (score > 7) return 'success'; + if (score >= 5) return 'warning'; + return 'destructive'; +} + +function getOverallLabel(score: number): string { + if (score > 7) return 'Khu vực tốt'; + if (score >= 5) return 'Khu vực trung bình'; + return 'Khu vực cần cải thiện'; +} + +export function NeighborhoodScore({ + data, + showMap = true, + mapHeight = '350px', + className, +}: NeighborhoodScoreProps) { + return ( +
+ {/* Overall Score Header */} + + +
+ Đánh giá khu vực +
+ + {data.overallScore.toFixed(1)}/10 + + + {getOverallLabel(data.overallScore)} + +
+
+
+ + + +
+ + {/* POI Map */} + {showMap && data.pois.length > 0 && ( + + + Tiện ích xung quanh + + + + + + )} +
+ ); +} diff --git a/apps/web/components/neighborhood/types.ts b/apps/web/components/neighborhood/types.ts new file mode 100644 index 0000000..38fc517 --- /dev/null +++ b/apps/web/components/neighborhood/types.ts @@ -0,0 +1,53 @@ +/** Neighborhood scoring types shared across components */ + +export interface NeighborhoodCategory { + category: string; + label: string; + score: number; // 0–10 + icon?: string; +} + +export interface POIItem { + id: string; + name: string; + category: POICategory; + lat: number; + lng: number; + distance?: number; // meters from property +} + +export type POICategory = + | 'school' + | 'hospital' + | 'transit' + | 'shopping' + | 'restaurant' + | 'park'; + +export interface NeighborhoodScoreData { + overallScore: number; + categories: NeighborhoodCategory[]; + pois: POIItem[]; + center: { lat: number; lng: number }; +} + +export const POI_CATEGORY_CONFIG: Record< + POICategory, + { label: string; color: string; icon: string } +> = { + school: { label: 'Trường học', color: '#3B82F6', icon: '🏫' }, + hospital: { label: 'Bệnh viện', color: '#EF4444', icon: '🏥' }, + transit: { label: 'Giao thông', color: '#8B5CF6', icon: '🚇' }, + shopping: { label: 'Mua sắm', color: '#F59E0B', icon: '🛒' }, + restaurant: { label: 'Nhà hàng', color: '#F97316', icon: '🍽️' }, + park: { label: 'Công viên', color: '#22C55E', icon: '🌳' }, +}; + +export const DEFAULT_CATEGORIES: NeighborhoodCategory[] = [ + { category: 'education', label: 'Giáo dục', score: 0 }, + { category: 'healthcare', label: 'Y tế', score: 0 }, + { category: 'transport', label: 'Giao thông', score: 0 }, + { category: 'shopping', label: 'Mua sắm', score: 0 }, + { category: 'dining', label: 'Ẩm thực', score: 0 }, + { category: 'environment', label: 'Môi trường', score: 0 }, +]; diff --git a/apps/web/lib/du-an-api.ts b/apps/web/lib/du-an-api.ts new file mode 100644 index 0000000..5472b4d --- /dev/null +++ b/apps/web/lib/du-an-api.ts @@ -0,0 +1,199 @@ +import { apiClient } from './api-client'; +import type { ListingDetail } from './listings-api'; + +// ─── Enums ─────────────────────────────────────────────── + +export type ProjectStatus = + | 'UPCOMING' + | 'SELLING' + | 'HANDOVER' + | 'COMPLETED'; + +export type ProjectPropertyType = + | 'APARTMENT' + | 'VILLA' + | 'TOWNHOUSE' + | 'SHOPHOUSE' + | 'LAND' + | 'MIXED'; + +// ─── Interfaces ────────────────────────────────────────── + +export interface ProjectMedia { + id: string; + url: string; + type: 'image' | 'video' | 'master_plan' | 'document'; + order: number; + caption: string | null; +} + +export interface ProjectBlock { + id: string; + name: string; + totalUnits: number; + availableUnits: number; + floors: number; +} + +export interface ProjectAmenity { + id: string; + name: string; + icon: string; + category: string; +} + +export interface ProjectPriceRange { + propertyType: ProjectPropertyType; + minPrice: string; + maxPrice: string; + pricePerM2Min: number | null; + pricePerM2Max: number | null; +} + +export interface ProjectPriceHistory { + period: string; + avgPricePerM2: number; + transactionCount: number; +} + +export interface NeighborhoodScore { + category: string; + score: number; + label: string; +} + +export interface ProjectPOI { + id: string; + name: string; + type: string; + distance: number; + latitude: number; + longitude: number; +} + +export interface ProjectDeveloper { + id: string; + name: string; + logoUrl: string | null; + totalProjects: number; +} + +export interface ProjectDocument { + id: string; + name: string; + url: string; + type: string; + sizeBytes: number; +} + +export interface ProjectSummary { + id: string; + slug: string; + name: string; + status: ProjectStatus; + developer: ProjectDeveloper; + city: string; + district: string; + address: string; + latitude: number | null; + longitude: number | null; + thumbnailUrl: string | null; + totalArea: number; + totalUnits: number; + propertyTypes: ProjectPropertyType[]; + minPrice: string | null; + maxPrice: string | null; + completionDate: string | null; + createdAt: string; +} + +export interface ProjectDetail extends ProjectSummary { + description: string; + media: ProjectMedia[]; + blocks: ProjectBlock[]; + amenities: ProjectAmenity[]; + priceRanges: ProjectPriceRange[]; + priceHistory: ProjectPriceHistory[]; + neighborhoodScores: NeighborhoodScore[]; + pois: ProjectPOI[]; + documents: ProjectDocument[]; + linkedListingCount: number; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface SearchProjectsParams { + city?: string; + district?: string; + developerId?: string; + status?: ProjectStatus; + propertyType?: ProjectPropertyType; + minPrice?: string; + maxPrice?: string; + sort?: string; + page?: number; + limit?: number; + q?: string; +} + +// ─── Status Labels ─────────────────────────────────────── + +export const PROJECT_STATUS_LABELS: Record = { + UPCOMING: 'Sắp mở bán', + SELLING: 'Đang bán', + HANDOVER: 'Đang bàn giao', + COMPLETED: 'Đã hoàn thành', +}; + +export const PROJECT_STATUS_COLORS: Record = { + UPCOMING: 'bg-blue-100 text-blue-800', + SELLING: 'bg-green-100 text-green-800', + HANDOVER: 'bg-amber-100 text-amber-800', + COMPLETED: 'bg-gray-100 text-gray-800', +}; + +export const PROJECT_PROPERTY_TYPE_LABELS: Record = { + APARTMENT: 'Căn hộ', + VILLA: 'Biệt thự', + TOWNHOUSE: 'Nhà phố', + SHOPHOUSE: 'Shophouse', + LAND: 'Đất nền', + MIXED: 'Tổng hợp', +}; + +// ─── API Functions ─────────────────────────────────────── + +export const duAnApi = { + search: (params: SearchProjectsParams = {}) => { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') query.append(key, String(value)); + }); + const qs = query.toString(); + return apiClient.get>( + `/projects${qs ? `?${qs}` : ''}`, + ); + }, + + getBySlug: (slug: string) => + apiClient.get(`/projects/${slug}`), + + getLinkedListings: (projectId: string, params: { page?: number; limit?: number } = {}) => { + const query = new URLSearchParams(); + if (params.page) query.append('page', String(params.page)); + if (params.limit) query.append('limit', String(params.limit)); + const qs = query.toString(); + return apiClient.get>( + `/projects/${projectId}/listings${qs ? `?${qs}` : ''}`, + ); + }, + + submitInquiry: (projectId: string, data: { name: string; phone: string; message: string }) => + apiClient.post<{ inquiryId: string }>(`/projects/${projectId}/inquiries`, data), +}; diff --git a/apps/web/lib/du-an-server.ts b/apps/web/lib/du-an-server.ts new file mode 100644 index 0000000..e717652 --- /dev/null +++ b/apps/web/lib/du-an-server.ts @@ -0,0 +1,58 @@ +/** + * Server-side project data fetching for Next.js Server Components. + * + * Uses `fetch` directly (no browser-only helpers) so it can run + * inside `generateMetadata`, `generateStaticParams`, `sitemap()`, etc. + */ + +import type { ProjectDetail, ProjectSummary, PaginatedResult } from './du-an-api'; + +const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; + +/** + * Fetch a single project by slug — server-only. + * Returns `null` when the project is not found (404) so callers can `notFound()`. + */ +export async function fetchProjectBySlug(slug: string): Promise { + try { + const res = await fetch(`${API_BASE_URL}/projects/${slug}`, { + next: { revalidate: 300 }, // ISR: re-validate every 5 min + }); + + if (!res.ok) return null; + return (await res.json()) as ProjectDetail; + } catch { + return null; + } +} + +/** + * Fetch active projects — server-only, used by the dynamic sitemap. + */ +export async function fetchProjects(params: { + page?: number; + limit?: number; + city?: string; + status?: string; +}): Promise> { + const query = new URLSearchParams({ + page: String(params.page ?? 1), + limit: String(params.limit ?? 100), + }); + if (params.city) query.append('city', params.city); + if (params.status) query.append('status', params.status); + + try { + const res = await fetch(`${API_BASE_URL}/projects?${query}`, { + next: { revalidate: 3600 }, // re-validate every hour for sitemap + }); + + if (!res.ok) { + return { data: [], total: 0, page: 1, limit: 100, totalPages: 0 }; + } + + return (await res.json()) as PaginatedResult; + } catch { + return { data: [], total: 0, page: 1, limit: 100, totalPages: 0 }; + } +} diff --git a/apps/web/lib/hooks/use-du-an.ts b/apps/web/lib/hooks/use-du-an.ts new file mode 100644 index 0000000..e04d286 --- /dev/null +++ b/apps/web/lib/hooks/use-du-an.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; +import { + duAnApi, + type SearchProjectsParams, +} from '@/lib/du-an-api'; + +export const projectKeys = { + all: ['projects'] as const, + search: (params: SearchProjectsParams) => ['projects', 'search', params] as const, + detail: (slug: string) => ['projects', 'detail', slug] as const, + linkedListings: (projectId: string, page: number) => + ['projects', 'listings', projectId, page] as const, +}; + +export function useProjectsSearch(params: SearchProjectsParams = {}) { + return useQuery({ + queryKey: projectKeys.search(params), + queryFn: () => duAnApi.search(params), + }); +} + +export function useProjectDetail(slug: string) { + return useQuery({ + queryKey: projectKeys.detail(slug), + queryFn: () => duAnApi.getBySlug(slug), + enabled: !!slug, + }); +} + +export function useProjectLinkedListings( + projectId: string, + page: number = 1, +) { + return useQuery({ + queryKey: projectKeys.linkedListings(projectId, page), + queryFn: () => duAnApi.getLinkedListings(projectId, { page, limit: 12 }), + enabled: !!projectId, + }); +}