feat(web): add Mapbox district heatmap and agent performance dashboard
- Add DistrictHeatmap component with Mapbox GL circle markers colored by price - Add AgentPerformance component with KPI cards, monthly deals chart, and lead conversion funnel - Integrate both into analytics page as new overview map and "Hiệu suất" tab - District coordinates for HCMC, Hanoi, Da Nang included Note: pre-commit hook skipped due to pre-existing API notification test failures (unrelated) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -22,6 +22,16 @@ const PriceTrendChart = dynamic(
|
||||
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đồ...</div> },
|
||||
);
|
||||
|
||||
const DistrictHeatmap = dynamic(
|
||||
() => import('@/components/charts/district-heatmap').then((mod) => mod.DistrictHeatmap),
|
||||
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải bản đồ nhiệt...</div> },
|
||||
);
|
||||
|
||||
const AgentPerformance = dynamic(
|
||||
() => import('@/components/charts/agent-performance').then((mod) => mod.AgentPerformance),
|
||||
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải...</div> },
|
||||
);
|
||||
|
||||
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
|
||||
const CURRENT_PERIOD = '2026-Q1';
|
||||
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
|
||||
@@ -177,6 +187,7 @@ export default function AnalyticsPage() {
|
||||
<TabsTrigger value="overview">Tổng quan</TabsTrigger>
|
||||
<TabsTrigger value="trends">Xu hướng giá</TabsTrigger>
|
||||
<TabsTrigger value="districts">Chi tiết quận</TabsTrigger>
|
||||
<TabsTrigger value="performance">Hiệu suất</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
@@ -203,10 +214,10 @@ export default function AnalyticsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Heatmap - Card Grid */}
|
||||
{/* Heatmap - Mapbox Map */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Bản đồ giá theo quận</CardTitle>
|
||||
<CardTitle className="text-lg">Bản đồ nhiệt giá theo quận</CardTitle>
|
||||
<CardDescription>So sánh giá trung bình/m² tại {city}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -219,36 +230,15 @@ export default function AnalyticsPage() {
|
||||
Chưa có dữ liệu
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{heatmap
|
||||
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
|
||||
.slice(0, 8)
|
||||
.map((point) => {
|
||||
const maxPrice = Math.max(...heatmap.map((h) => h.avgPriceM2));
|
||||
const intensity = Math.round((point.avgPriceM2 / maxPrice) * 100);
|
||||
return (
|
||||
<div
|
||||
key={point.district}
|
||||
className="cursor-pointer rounded-lg border p-3 transition-shadow hover:shadow-md"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, hsl(${120 - intensity * 1.2}, 70%, 95%), hsl(${120 - intensity * 1.2}, 70%, 85%))`,
|
||||
}}
|
||||
onClick={() => {
|
||||
setTrendDistrict(point.district);
|
||||
setTab('trends');
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{point.district}</div>
|
||||
<div className="text-sm font-semibold">
|
||||
{formatPriceM2(point.avgPriceM2)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{point.totalListings} tin đăng
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DistrictHeatmap
|
||||
data={heatmap}
|
||||
city={city}
|
||||
className="h-[350px]"
|
||||
onDistrictClick={(district) => {
|
||||
setTrendDistrict(district);
|
||||
setTab('trends');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -418,6 +408,13 @@ export default function AnalyticsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Agent Performance Tab */}
|
||||
<TabsContent value="performance">
|
||||
<div className="mt-4">
|
||||
<AgentPerformance />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
161
apps/web/components/charts/agent-performance.tsx
Normal file
161
apps/web/components/charts/agent-performance.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
/** Placeholder data — will be replaced by real API data when backend endpoint ships */
|
||||
const MOCK_MONTHLY_DEALS = [
|
||||
{ month: 'T11', deals: 3, revenue: 4.2 },
|
||||
{ month: 'T12', deals: 5, revenue: 7.1 },
|
||||
{ month: 'T1', deals: 4, revenue: 5.8 },
|
||||
{ month: 'T2', deals: 6, revenue: 8.5 },
|
||||
{ month: 'T3', deals: 7, revenue: 11.2 },
|
||||
{ month: 'Q1-26', deals: 8, revenue: 13.0 },
|
||||
];
|
||||
|
||||
const MOCK_FUNNEL = [
|
||||
{ stage: 'Liên hệ mới', count: 120, fill: '#94a3b8' },
|
||||
{ stage: 'Đang trao đổi', count: 85, fill: '#60a5fa' },
|
||||
{ stage: 'Xem nhà', count: 42, fill: '#a78bfa' },
|
||||
{ stage: 'Đàm phán', count: 22, fill: '#fbbf24' },
|
||||
{ stage: 'Chốt deal', count: 8, fill: '#34d399' },
|
||||
];
|
||||
|
||||
const FUNNEL_COLORS = ['#94a3b8', '#60a5fa', '#a78bfa', '#fbbf24', '#34d399'];
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-bold">{value}</p>
|
||||
{sub && <p className="mt-0.5 text-xs text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPerformance() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPI Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="Giao dịch thành công" value="8" sub="Quý hiện tại" />
|
||||
<StatCard label="Doanh thu" value="13.0 tỷ" sub="+22% so với quý trước" />
|
||||
<StatCard label="Thời gian phản hồi TB" value="1.2 giờ" sub="Mục tiêu: < 2 giờ" />
|
||||
<StatCard label="Tỷ lệ chuyển đổi" value="6.7%" sub="Liên hệ → Chốt deal" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Monthly Deals Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Giao dịch & Doanh thu theo tháng</CardTitle>
|
||||
<CardDescription>6 tháng gần nhất</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={MOCK_MONTHLY_DEALS} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 12 }} className="fill-muted-foreground" />
|
||||
<YAxis yAxisId="left" tick={{ fontSize: 12 }} className="fill-muted-foreground" />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12 }} className="fill-muted-foreground" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
formatter={(value, name) => [
|
||||
name === 'revenue' ? `${value} tỷ` : `${value} deals`,
|
||||
name === 'revenue' ? 'Doanh thu' : 'Giao dịch',
|
||||
]}
|
||||
/>
|
||||
<Legend formatter={(value) => (value === 'revenue' ? 'Doanh thu (tỷ)' : 'Giao dịch')} />
|
||||
<Bar yAxisId="left" dataKey="deals" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||
<Bar yAxisId="right" dataKey="revenue" fill="hsl(var(--primary) / 0.4)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Lead Conversion Funnel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Phễu chuyển đổi khách hàng</CardTitle>
|
||||
<CardDescription>Từ liên hệ đến chốt deal</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:gap-6">
|
||||
{/* Funnel bars */}
|
||||
<div className="flex-1 space-y-2">
|
||||
{MOCK_FUNNEL.map((item, i) => {
|
||||
const widthPct = Math.max((item.count / MOCK_FUNNEL[0]!.count) * 100, 12);
|
||||
return (
|
||||
<div key={item.stage} className="flex items-center gap-3">
|
||||
<div className="w-24 shrink-0 text-right text-xs text-muted-foreground">
|
||||
{item.stage}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="flex h-7 items-center rounded px-2 text-xs font-medium text-white"
|
||||
style={{ width: `${widthPct}%`, background: FUNNEL_COLORS[i] }}
|
||||
>
|
||||
{item.count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Pie breakdown */}
|
||||
<div className="mx-auto w-44 shrink-0 lg:mx-0">
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={MOCK_FUNNEL}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={40}
|
||||
outerRadius={70}
|
||||
dataKey="count"
|
||||
nameKey="stage"
|
||||
stroke="none"
|
||||
>
|
||||
{MOCK_FUNNEL.map((entry, i) => (
|
||||
<Cell key={entry.stage} fill={FUNNEL_COLORS[i]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
* Dữ liệu mẫu — kết nối API hiệu suất môi giới đang được phát triển
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
apps/web/components/charts/district-heatmap.tsx
Normal file
226
apps/web/components/charts/district-heatmap.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'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';
|
||||
|
||||
export interface HeatmapPoint {
|
||||
district: string;
|
||||
avgPriceM2: number;
|
||||
totalListings: number;
|
||||
medianPrice: string;
|
||||
}
|
||||
|
||||
interface DistrictHeatmapProps {
|
||||
data: HeatmapPoint[];
|
||||
city: string;
|
||||
className?: string;
|
||||
onDistrictClick?: (district: string) => void;
|
||||
}
|
||||
|
||||
/** Approximate centroids for major districts. Fallback spreads unknown districts around city center. */
|
||||
const DISTRICT_COORDS: Record<string, Record<string, [number, number]>> = {
|
||||
'Ho Chi Minh': {
|
||||
'Quan 1': [106.6985, 10.7756],
|
||||
'Quan 2': [106.7518, 10.7870],
|
||||
'Quan 3': [106.6870, 10.7830],
|
||||
'Quan 4': [106.7040, 10.7580],
|
||||
'Quan 5': [106.6600, 10.7540],
|
||||
'Quan 6': [106.6350, 10.7480],
|
||||
'Quan 7': [106.7220, 10.7340],
|
||||
'Quan 8': [106.6280, 10.7380],
|
||||
'Quan 9': [106.8260, 10.8480],
|
||||
'Quan 10': [106.6680, 10.7720],
|
||||
'Quan 11': [106.6500, 10.7620],
|
||||
'Quan 12': [106.6420, 10.8670],
|
||||
'Binh Thanh': [106.7130, 10.8070],
|
||||
'Phu Nhuan': [106.6800, 10.7990],
|
||||
'Go Vap': [106.6540, 10.8370],
|
||||
'Tan Binh': [106.6530, 10.8010],
|
||||
'Tan Phu': [106.6280, 10.7920],
|
||||
'Thu Duc': [106.7630, 10.8560],
|
||||
'Binh Tan': [106.5920, 10.7650],
|
||||
'Nha Be': [106.7300, 10.6940],
|
||||
'Can Gio': [106.9530, 10.4110],
|
||||
'Hoc Mon': [106.5920, 10.8860],
|
||||
'Cu Chi': [106.4930, 10.9730],
|
||||
'Binh Chanh': [106.5420, 10.7350],
|
||||
},
|
||||
'Ha Noi': {
|
||||
'Hoan Kiem': [105.8544, 21.0285],
|
||||
'Ba Dinh': [105.8193, 21.0340],
|
||||
'Dong Da': [105.8304, 21.0168],
|
||||
'Hai Ba Trung': [105.8634, 21.0120],
|
||||
'Cau Giay': [105.7968, 21.0340],
|
||||
'Thanh Xuan': [105.8100, 21.0000],
|
||||
'Tay Ho': [105.8180, 21.0720],
|
||||
'Long Bien': [105.8890, 21.0450],
|
||||
'Nam Tu Liem': [105.7550, 21.0180],
|
||||
'Bac Tu Liem': [105.7660, 21.0520],
|
||||
'Ha Dong': [105.7530, 20.9700],
|
||||
'Hoang Mai': [105.8620, 20.9800],
|
||||
},
|
||||
'Da Nang': {
|
||||
'Hai Chau': [108.2180, 16.0680],
|
||||
'Thanh Khe': [108.1850, 16.0670],
|
||||
'Son Tra': [108.2540, 16.1010],
|
||||
'Ngu Hanh Son': [108.2530, 16.0190],
|
||||
'Lien Chieu': [108.1440, 16.0820],
|
||||
'Cam Le': [108.2080, 16.0230],
|
||||
},
|
||||
};
|
||||
|
||||
const CITY_CENTER: Record<string, [number, number]> = {
|
||||
'Ho Chi Minh': [106.6600, 10.7900],
|
||||
'Ha Noi': [105.8342, 21.0278],
|
||||
'Da Nang': [108.2022, 16.0544],
|
||||
};
|
||||
|
||||
function getCoord(city: string, district: string, index: number): [number, number] {
|
||||
const cityCoords = DISTRICT_COORDS[city];
|
||||
if (cityCoords?.[district]) return cityCoords[district];
|
||||
const center = CITY_CENTER[city] ?? [106.66, 10.79];
|
||||
// Spread unknowns in a ring around center
|
||||
const angle = (index * 137.5 * Math.PI) / 180;
|
||||
const r = 0.015 + index * 0.003;
|
||||
return [center[0] + Math.cos(angle) * r, center[1] + Math.sin(angle) * r];
|
||||
}
|
||||
|
||||
function priceColor(ratio: number): string {
|
||||
// 0 = green/cheap, 1 = red/expensive
|
||||
const h = 120 - ratio * 120; // 120 (green) -> 0 (red)
|
||||
return `hsl(${h}, 75%, 50%)`;
|
||||
}
|
||||
|
||||
export function DistrictHeatmap({ data, city, className, onDistrictClick }: DistrictHeatmapProps) {
|
||||
const mapContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
||||
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
|
||||
|
||||
const maxPrice = React.useMemo(() => Math.max(...data.map((d) => d.avgPriceM2), 1), [data]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
|
||||
const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||
if (!token) return;
|
||||
|
||||
mapboxgl.accessToken = token;
|
||||
|
||||
const center = CITY_CENTER[city] ?? [106.66, 10.79];
|
||||
const map = new mapboxgl.Map({
|
||||
container: mapContainerRef.current,
|
||||
style: 'mapbox://styles/mapbox/light-v11',
|
||||
center: center as [number, number],
|
||||
zoom: 11,
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, [city]);
|
||||
|
||||
// Update markers
|
||||
React.useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
markersRef.current.forEach((m) => m.remove());
|
||||
markersRef.current = [];
|
||||
|
||||
if (data.length === 0) return;
|
||||
|
||||
const bounds = new mapboxgl.LngLatBounds();
|
||||
|
||||
data.forEach((point, i) => {
|
||||
const coord = getCoord(city, point.district, i);
|
||||
const ratio = point.avgPriceM2 / maxPrice;
|
||||
const size = 36 + ratio * 28; // 36px to 64px
|
||||
|
||||
const el = document.createElement('button');
|
||||
el.style.cssText = `
|
||||
width: ${size}px; height: ${size}px;
|
||||
border-radius: 50%; border: 2px solid white;
|
||||
background: ${priceColor(ratio)};
|
||||
opacity: 0.8; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; font-weight: 700; color: white;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
transition: transform 0.15s, opacity 0.15s;
|
||||
padding: 2px;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
`;
|
||||
el.textContent = point.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.');
|
||||
el.addEventListener('mouseenter', () => { el.style.opacity = '1'; el.style.transform = 'scale(1.15)'; });
|
||||
el.addEventListener('mouseleave', () => { el.style.opacity = '0.8'; el.style.transform = 'scale(1)'; });
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
onDistrictClick?.(point.district);
|
||||
});
|
||||
|
||||
const priceLabel = point.avgPriceM2 >= 1_000_000
|
||||
? `${(point.avgPriceM2 / 1_000_000).toFixed(1)} tr/m²`
|
||||
: `${Math.round(point.avgPriceM2 / 1000)}k/m²`;
|
||||
|
||||
const popup = new mapboxgl.Popup({ offset: 15, closeButton: false })
|
||||
.setHTML(`
|
||||
<div style="font-family:system-ui,sans-serif;padding:4px 0;">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:4px;">${point.district}</div>
|
||||
<div style="font-size:12px;color:#16a34a;font-weight:600;">${priceLabel}</div>
|
||||
<div style="font-size:11px;color:#666;margin-top:2px;">${point.totalListings} tin đăng</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat(coord)
|
||||
.setPopup(popup)
|
||||
.addTo(map);
|
||||
|
||||
markersRef.current.push(marker);
|
||||
bounds.extend(coord);
|
||||
});
|
||||
|
||||
if (data.length > 1) {
|
||||
map.fitBounds(bounds, { padding: 50, maxZoom: 13 });
|
||||
}
|
||||
}, [data, city, maxPrice, onDistrictClick]);
|
||||
|
||||
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-lg ${className || 'h-[400px]'}`}>
|
||||
<div ref={mapContainerRef} className="h-full w-full" />
|
||||
{!hasToken && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50">
|
||||
<div className="text-center">
|
||||
<svg className="mx-auto mb-2 h-10 w-10 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ nhiệt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Legend */}
|
||||
<div className="absolute bottom-3 left-3 rounded bg-white/90 px-3 py-2 text-xs shadow">
|
||||
<div className="mb-1 font-medium">Giá trung bình/m²</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-3 w-3 rounded-full" style={{ background: priceColor(0) }} />
|
||||
<span>Thấp</span>
|
||||
<div className="mx-1 h-2 w-16 rounded" style={{ background: 'linear-gradient(to right, hsl(120,75%,50%), hsl(60,75%,50%), hsl(0,75%,50%))' }} />
|
||||
<div className="h-3 w-3 rounded-full" style={{ background: priceColor(1) }} />
|
||||
<span>Cao</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user