perf(web): optimize bundle size — dynamic import Mapbox GL and code split Recharts
- Dynamic import ListingMap with next/dynamic (ssr: false) in /listings/[id] and /search pages - Extract Recharts into lazy-loaded DistrictBarChart and PriceTrendChart components - /listings/[id] first-load JS: 618KB → 149KB (-76%) - /search first-load JS: 619KB → 150KB (-76%) - Both pages now well under 300KB target Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,18 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
} from 'recharts';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
@@ -24,6 +13,16 @@ import {
|
|||||||
type PriceTrendPoint,
|
type PriceTrendPoint,
|
||||||
} from '@/lib/analytics-api';
|
} from '@/lib/analytics-api';
|
||||||
|
|
||||||
|
const DistrictBarChart = dynamic(
|
||||||
|
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
|
||||||
|
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Dang tai bieu do...</div> },
|
||||||
|
);
|
||||||
|
|
||||||
|
const PriceTrendChart = dynamic(
|
||||||
|
() => import('@/components/charts/price-trend-chart').then((mod) => mod.PriceTrendChart),
|
||||||
|
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Dang tai bieu do...</div> },
|
||||||
|
);
|
||||||
|
|
||||||
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
|
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
|
||||||
const CURRENT_PERIOD = '2026-Q1';
|
const CURRENT_PERIOD = '2026-Q1';
|
||||||
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
|
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
|
||||||
@@ -223,36 +222,7 @@ export default function AnalyticsPage() {
|
|||||||
Chua co du lieu
|
Chua co du lieu
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<DistrictBarChart data={barChartData} height={300} dataKey="price" />
|
||||||
<BarChart
|
|
||||||
data={barChartData}
|
|
||||||
margin={{ top: 5, right: 20, left: 0, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="district"
|
|
||||||
tick={{ fontSize: 11 }}
|
|
||||||
angle={-30}
|
|
||||||
textAnchor="end"
|
|
||||||
height={60}
|
|
||||||
className="fill-muted-foreground"
|
|
||||||
/>
|
|
||||||
<YAxis 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 === 'price' ? `${value} tr/m2` : value,
|
|
||||||
name === 'price' ? 'Gia' : 'Tin dang',
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="price" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -345,61 +315,7 @@ export default function AnalyticsPage() {
|
|||||||
Chua co du lieu xu huong
|
Chua co du lieu xu huong
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={350}>
|
<PriceTrendChart data={trendChartData} height={350} />
|
||||||
<LineChart
|
|
||||||
data={trendChartData}
|
|
||||||
margin={{ top: 5, right: 30, left: 0, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="period"
|
|
||||||
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 === 'Gia/m2' ? `${value} tr/m2` : value,
|
|
||||||
name,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Line
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="Gia/m2"
|
|
||||||
stroke="hsl(var(--primary))"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={{ r: 4 }}
|
|
||||||
activeDot={{ r: 6 }}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="Tin dang"
|
|
||||||
stroke="hsl(var(--muted-foreground))"
|
|
||||||
strokeWidth={1}
|
|
||||||
strokeDasharray="5 5"
|
|
||||||
dot={{ r: 3 }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
|
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -22,6 +14,11 @@ import {
|
|||||||
} from '@/lib/analytics-api';
|
} from '@/lib/analytics-api';
|
||||||
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
||||||
|
|
||||||
|
const DistrictBarChart = dynamic(
|
||||||
|
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
|
||||||
|
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Dang tai bieu do...</div> },
|
||||||
|
);
|
||||||
|
|
||||||
const CITY = 'Ho Chi Minh';
|
const CITY = 'Ho Chi Minh';
|
||||||
const PERIOD = '2026-Q1';
|
const PERIOD = '2026-Q1';
|
||||||
|
|
||||||
@@ -180,27 +177,12 @@ export default function DashboardPage() {
|
|||||||
Chua co du lieu
|
Chua co du lieu
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
<DistrictBarChart
|
||||||
<BarChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
data={chartData}
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
height={280}
|
||||||
<XAxis
|
dataKey="Gia/m2"
|
||||||
dataKey="district"
|
tooltipFormatter={(value) => [`${value} tr/m2`, 'Gia']}
|
||||||
tick={{ fontSize: 12 }}
|
/>
|
||||||
className="fill-muted-foreground"
|
|
||||||
/>
|
|
||||||
<YAxis 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) => [`${value} tr/m2`, 'Gia']}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="Gia/m2" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ImageGallery } from '@/components/listings/image-gallery';
|
import { ImageGallery } from '@/components/listings/image-gallery';
|
||||||
import { ListingMap } from '@/components/map/listing-map';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||||
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||||
|
|
||||||
|
const ListingMap = dynamic(
|
||||||
|
() => import('@/components/map/listing-map').then((mod) => mod.ListingMap),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
|
||||||
|
<p className="text-sm text-muted-foreground">Dang tai ban do...</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
function formatPrice(priceVND: string): string {
|
function formatPrice(priceVND: string): string {
|
||||||
const num = Number(priceVND);
|
const num = Number(priceVND);
|
||||||
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
|
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { ListingMap } from '@/components/map/listing-map';
|
|
||||||
import { FilterBar, type SearchFilters } from '@/components/search/filter-bar';
|
import { FilterBar, type SearchFilters } from '@/components/search/filter-bar';
|
||||||
import { SearchResults } from '@/components/search/search-results';
|
import { SearchResults } from '@/components/search/search-results';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
||||||
|
|
||||||
|
const ListingMap = dynamic(
|
||||||
|
() => import('@/components/map/listing-map').then((mod) => mod.ListingMap),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-[calc(100vh-220px)] items-center justify-center rounded-lg bg-muted">
|
||||||
|
<p className="text-sm text-muted-foreground">Dang tai ban do...</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type ViewMode = 'list' | 'map' | 'split';
|
type ViewMode = 'list' | 'map' | 'split';
|
||||||
|
|
||||||
const defaultFilters: SearchFilters = {
|
const defaultFilters: SearchFilters = {
|
||||||
|
|||||||
60
apps/web/components/charts/district-bar-chart.tsx
Normal file
60
apps/web/components/charts/district-bar-chart.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type TooltipFormatter = (value: any, name: any) => [string, string];
|
||||||
|
|
||||||
|
interface DistrictBarChartProps {
|
||||||
|
data: { district: string; price?: number; 'Gia/m2'?: number; listings: number }[];
|
||||||
|
height?: number;
|
||||||
|
dataKey?: string;
|
||||||
|
tooltipFormatter?: TooltipFormatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DistrictBarChart({
|
||||||
|
data,
|
||||||
|
height = 300,
|
||||||
|
dataKey = 'price',
|
||||||
|
tooltipFormatter,
|
||||||
|
}: DistrictBarChartProps) {
|
||||||
|
const defaultFormatter: TooltipFormatter = (value, name) => [
|
||||||
|
name === dataKey ? `${value} tr/m2` : String(value),
|
||||||
|
name === dataKey ? 'Gia' : 'Tin dang',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<BarChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="district"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
angle={-30}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
className="fill-muted-foreground"
|
||||||
|
/>
|
||||||
|
<YAxis 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={tooltipFormatter ?? defaultFormatter}
|
||||||
|
/>
|
||||||
|
<Bar dataKey={dataKey} fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/web/components/charts/price-trend-chart.tsx
Normal file
66
apps/web/components/charts/price-trend-chart.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
interface PriceTrendChartProps {
|
||||||
|
data: { period: string; 'Gia/m2': number; 'Tin dang': number }[];
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceTrendChart({ data, height = 350 }: PriceTrendChartProps) {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 30, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis dataKey="period" 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 === 'Gia/m2' ? `${value} tr/m2` : value,
|
||||||
|
name,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
yAxisId="left"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Gia/m2"
|
||||||
|
stroke="hsl(var(--primary))"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 4 }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Tin dang"
|
||||||
|
stroke="hsl(var(--muted-foreground))"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user