feat(web): add industrial compare page, listing search, and Mapbox park map

- Add interactive Mapbox map to /khu-cong-nghiep landing page with park markers and popups
- Build compare page at /khu-cong-nghiep/so-sanh with recharts RadarChart and detailed comparison table
- Build listing search page at /khu-cong-nghiep/cho-thue with filters for property type, lease type, area, and price
- Add IndustrialListing types, API client functions, and React Query hooks

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 12:40:35 +07:00
parent 28cdd92846
commit 5810f0be56
9 changed files with 964 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import {
industrialApi,
type SearchIndustrialListingsParams,
type SearchIndustrialParksParams,
} from '@/lib/khu-cong-nghiep-api';
@@ -11,6 +12,7 @@ export const industrialKeys = {
stats: () => ['industrial', 'stats'] as const,
market: () => ['industrial', 'market'] as const,
compare: (ids: string[]) => ['industrial', 'compare', ids] as const,
listings: (params: SearchIndustrialListingsParams) => ['industrial', 'listings', params] as const,
};
export function useIndustrialParksSearch(params: SearchIndustrialParksParams = {}) {
@@ -51,3 +53,10 @@ export function useIndustrialCompare(ids: string[]) {
enabled: ids.length >= 2,
});
}
export function useIndustrialListingsSearch(params: SearchIndustrialListingsParams = {}) {
return useQuery({
queryKey: industrialKeys.listings(params),
queryFn: () => industrialApi.searchListings(params),
});
}

View File

@@ -90,6 +90,65 @@ export interface IndustrialMarketData {
rentByProvince: { province: string; avgLandRent: number | null; avgRbfRent: number | null; parkCount: number }[];
}
// ─── Industrial Listing Types ───────────────────────────
export type IndustrialPropertyType =
| 'INDUSTRIAL_LAND'
| 'READY_BUILT_FACTORY'
| 'READY_BUILT_WAREHOUSE'
| 'LOGISTICS_CENTER'
| 'OFFICE_IN_PARK'
| 'DATA_CENTER';
export type IndustrialLeaseType =
| 'LAND_LEASE'
| 'FACTORY_LEASE'
| 'WAREHOUSE_LEASE'
| 'SUBLEASE';
export type IndustrialListingStatus =
| 'DRAFT'
| 'ACTIVE'
| 'RESERVED'
| 'LEASED'
| 'EXPIRED';
export interface IndustrialListingItem {
id: string;
parkId: string;
parkName: string;
parkSlug: string;
propertyType: IndustrialPropertyType;
leaseType: IndustrialLeaseType;
status: IndustrialListingStatus;
title: string;
description: string | null;
areaM2: number;
ceilingHeightM: number | null;
priceUsdM2: number | null;
pricingUnit: string | null;
totalLeasePrice: number | null;
minLeaseYears: number | null;
maxLeaseYears: number | null;
availableFrom: string | null;
media: { url: string; type: string; caption?: string }[] | null;
viewCount: number;
publishedAt: string | null;
}
export interface SearchIndustrialListingsParams {
parkId?: string;
propertyType?: IndustrialPropertyType;
leaseType?: IndustrialLeaseType;
minAreaM2?: number;
maxAreaM2?: number;
minPriceUsdM2?: number;
maxPriceUsdM2?: number;
q?: string;
page?: number;
limit?: number;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
@@ -132,6 +191,22 @@ export const REGION_LABELS: Record<VietnamRegion, string> = {
SOUTH: 'Miền Nam',
};
export const PROPERTY_TYPE_LABELS: Record<IndustrialPropertyType, string> = {
INDUSTRIAL_LAND: 'Đất công nghiệp',
READY_BUILT_FACTORY: 'Nhà xưởng xây sẵn',
READY_BUILT_WAREHOUSE: 'Kho bãi xây sẵn',
LOGISTICS_CENTER: 'Trung tâm logistics',
OFFICE_IN_PARK: 'Văn phòng trong KCN',
DATA_CENTER: 'Trung tâm dữ liệu',
};
export const LEASE_TYPE_LABELS: Record<IndustrialLeaseType, string> = {
LAND_LEASE: 'Thuê đất',
FACTORY_LEASE: 'Thuê nhà xưởng',
WAREHOUSE_LEASE: 'Thuê kho bãi',
SUBLEASE: 'Cho thuê lại',
};
// ─── API Functions ──────────────────────────────────────
export const industrialApi = {
@@ -157,4 +232,15 @@ export const industrialApi = {
getMarket: () =>
apiClient.get<IndustrialMarketData>('/industrial/market'),
searchListings: (params: SearchIndustrialListingsParams = {}) => {
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<PaginatedResult<IndustrialListingItem>>(
`/industrial/listings${qs ? `?${qs}` : ''}`,
);
},
};