diff --git a/apps/web/app/(dashboard)/analytics/page.tsx b/apps/web/app/(dashboard)/analytics/page.tsx index 6a4dc2d..f669427 100644 --- a/apps/web/app/(dashboard)/analytics/page.tsx +++ b/apps/web/app/(dashboard)/analytics/page.tsx @@ -3,15 +3,30 @@ import { useEffect, useState } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { analyticsApi, type MarketReportDistrict, type HeatmapDataPoint, type DistrictStats, + type PriceTrendPoint, } from '@/lib/analytics-api'; +import { + BarChart, + Bar, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; 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']; function formatPrice(priceStr: string): string { const num = Number(priceStr); @@ -29,19 +44,26 @@ function YoYBadge({ value }: { value: number | null }) { if (value === null) return N/A; const isPositive = value >= 0; return ( - - {isPositive ? '+' : ''}{value.toFixed(1)}% + + {isPositive ? '+' : ''} + {value.toFixed(1)}% ); } export default function AnalyticsPage() { - const [city, setCity] = useState(CITIES[0]); + const [city, setCity] = useState(CITIES[0] ?? 'Ho Chi Minh'); const [period] = useState(CURRENT_PERIOD); + const [tab, setTab] = useState('overview'); const [marketReport, setMarketReport] = useState([]); const [heatmap, setHeatmap] = useState([]); const [districtStats, setDistrictStats] = useState([]); + const [priceTrend, setPriceTrend] = useState([]); + const [trendDistrict, setTrendDistrict] = useState(''); const [loading, setLoading] = useState(true); + const [trendLoading, setTrendLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { @@ -49,30 +71,73 @@ export default function AnalyticsPage() { setError(null); Promise.all([ - analyticsApi.getMarketReport(city, period).catch(() => ({ districts: [] as MarketReportDistrict[] })), - analyticsApi.getHeatmap(city, period).catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })), - analyticsApi.getDistrictStats(city, period).catch(() => ({ districts: [] as DistrictStats[] })), + analyticsApi + .getMarketReport(city, period) + .catch(() => ({ districts: [] as MarketReportDistrict[] })), + analyticsApi + .getHeatmap(city, period) + .catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })), + analyticsApi + .getDistrictStats(city, period) + .catch(() => ({ districts: [] as DistrictStats[] })), ]) .then(([report, heatmapData, stats]) => { setMarketReport(report.districts); setHeatmap(heatmapData.dataPoints); setDistrictStats(stats.districts); + + // Auto-select first district for trend + const firstDistrict = report.districts[0]?.district ?? ''; + if (firstDistrict && !trendDistrict) { + setTrendDistrict(firstDistrict); + } }) .catch(() => setError('Khong the tai du lieu phan tich')) .finally(() => setLoading(false)); }, [city, period]); + // Load price trend when district changes + useEffect(() => { + if (!trendDistrict || !city) return; + setTrendLoading(true); + analyticsApi + .getPriceTrend(trendDistrict, city, 'APARTMENT', TREND_PERIODS) + .then((res) => setPriceTrend(res.trend)) + .catch(() => setPriceTrend([])) + .finally(() => setTrendLoading(false)); + }, [trendDistrict, city]); + const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0); - const avgDaysOnMarket = marketReport.length > 0 - ? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length - : 0; - const avgPriceM2 = marketReport.length > 0 - ? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length - : 0; + const avgDaysOnMarket = + marketReport.length > 0 + ? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length + : 0; + const avgPriceM2 = + marketReport.length > 0 + ? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length + : 0; + + const uniqueDistricts = [...new Set(marketReport.map((d) => d.district))]; + + // Chart data for bar chart + const barChartData = heatmap + .sort((a, b) => b.avgPriceM2 - a.avgPriceM2) + .map((p) => ({ + district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'), + price: Math.round(p.avgPriceM2 / 1_000_000), + listings: p.totalListings, + })); + + // Chart data for line chart + const trendChartData = priceTrend.map((p) => ({ + period: p.period, + 'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000), + 'Tin dang': p.totalListings, + })); return (
-
+

Phan tich thi truong

@@ -93,163 +158,375 @@ export default function AnalyticsPage() {

- {error && ( -
{error}
- )} + {error &&
{error}
} {/* Summary Cards */}
Tong tin dang - {loading ? '...' : totalListings.toLocaleString('vi-VN')} + + {loading ? '...' : totalListings.toLocaleString('vi-VN')} + Gia TB/m2 - {loading ? '...' : formatPriceM2(avgPriceM2)} + + {loading ? '...' : formatPriceM2(avgPriceM2)} + Ngay trung binh de ban - {loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`} + + {loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`} + So quan/huyen - {loading ? '...' : new Set(marketReport.map(d => d.district)).size} + + {loading ? '...' : new Set(marketReport.map((d) => d.district)).size} +
- {/* Heatmap - Price by District */} - - - Ban do gia theo quan - So sanh gia trung binh/m2 giua cac quan tai {city} - - - {loading ? ( -
Dang tai...
- ) : heatmap.length === 0 ? ( -
Chua co du lieu
- ) : ( -
- {heatmap - .sort((a, b) => b.avgPriceM2 - a.avgPriceM2) - .map((point) => { - const maxPrice = heatmap[0] ? Math.max(...heatmap.map(h => h.avgPriceM2)) : 1; - const intensity = Math.round((point.avgPriceM2 / maxPrice) * 100); - return ( -
-
{point.district}
-
{formatPriceM2(point.avgPriceM2)}
-
{point.totalListings} tin dang
-
- ); - })} -
- )} -
-
+ {/* Tabs */} + + + Tong quan + Xu huong gia + Chi tiet quan + - {/* District Stats Table */} - - - Thong ke chi tiet theo quan - Du lieu thi truong bat dong san tai {city} - {period} - - - {loading ? ( -
Dang tai...
- ) : districtStats.length === 0 ? ( -
Chua co du lieu
- ) : ( -
- - - - - - - - - - - - - - {districtStats.map((stat, i) => ( - - - - - - - - - - ))} - -
QuanLoai BDSGia trung viGia/m2Tin dangNgay banYoY
{stat.district}{stat.propertyType}{formatPrice(stat.medianPrice)}{formatPriceM2(stat.avgPriceM2)}{stat.totalListings}{stat.daysOnMarket.toFixed(0)}
-
- )} -
-
- - {/* Market Report Summary */} - - - Bao cao thi truong - Tong hop chi so thi truong theo tung quan - - - {loading ? ( -
Dang tai...
- ) : marketReport.length === 0 ? ( -
Chua co du lieu
- ) : ( -
- {[...new Map(marketReport.map(d => [d.district, d])).values()].map((district) => ( -
-

{district.district}

-
-
- Gia trung vi - {formatPrice(district.medianPrice)} -
-
- Gia/m2 - {formatPriceM2(district.avgPriceM2)} -
-
- Tin dang - {district.totalListings} -
-
- Ton kho - {district.inventoryLevel} -
-
- Thay doi YoY - -
+ {/* Overview Tab */} + +
+ {/* Bar Chart - Price by District */} + + + Gia trung binh theo quan + Trieu VND/m2 tai {city} + + + {loading ? ( +
+ Dang tai...
-
+ ) : barChartData.length === 0 ? ( +
+ Chua co du lieu +
+ ) : ( + + + + + + [ + name === 'price' ? `${value} tr/m2` : value, + name === 'price' ? 'Gia' : 'Tin dang', + ]} + /> + + + + )} + + + + {/* Heatmap - Card Grid */} + + + Ban do gia theo quan + So sanh gia trung binh/m2 tai {city} + + + {loading ? ( +
+ Dang tai... +
+ ) : heatmap.length === 0 ? ( +
+ Chua co du lieu +
+ ) : ( +
+ {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 ( +
{ + setTrendDistrict(point.district); + setTab('trends'); + }} + > +
{point.district}
+
+ {formatPriceM2(point.avgPriceM2)} +
+
+ {point.totalListings} tin dang +
+
+ ); + })} +
+ )} +
+
+
+ + + {/* Trends Tab */} + +
+ {/* District selector */} +
+ {uniqueDistricts.map((d) => ( + ))}
- )} - - + + + + + Xu huong gia - {trendDistrict || 'Chon quan'} + + + Bien dong gia trung binh/m2 qua cac quy (Can ho) + + + + {trendLoading ? ( +
+ Dang tai... +
+ ) : trendChartData.length === 0 ? ( +
+ Chua co du lieu xu huong +
+ ) : ( + + + + + + + [ + name === 'Gia/m2' ? `${value} tr/m2` : value, + name, + ]} + /> + + + + + + )} +
+
+
+
+ + {/* District Stats Tab */} + +
+ {/* Stats Table */} + + + Thong ke chi tiet theo quan + + Du lieu thi truong bat dong san tai {city} - {period} + + + + {loading ? ( +
+ Dang tai... +
+ ) : districtStats.length === 0 ? ( +
+ Chua co du lieu +
+ ) : ( +
+ + + + + + + + + + + + + + {districtStats.map((stat, i) => ( + + + + + + + + + + ))} + +
QuanLoai BDSGia trung viGia/m2Tin dangNgay banYoY
{stat.district} + {stat.propertyType} + + {formatPrice(stat.medianPrice)} + + {formatPriceM2(stat.avgPriceM2)} + {stat.totalListings} + {stat.daysOnMarket.toFixed(0)} + + +
+
+ )} +
+
+ + {/* Market Report Cards */} + + + Bao cao thi truong + Tong hop chi so thi truong theo tung quan + + + {loading ? ( +
+ Dang tai... +
+ ) : marketReport.length === 0 ? ( +
+ Chua co du lieu +
+ ) : ( +
+ {[...new Map(marketReport.map((d) => [d.district, d])).values()].map( + (district) => ( +
+

{district.district}

+
+
+ Gia trung vi + + {formatPrice(district.medianPrice)} + +
+
+ Gia/m2 + {formatPriceM2(district.avgPriceM2)} +
+
+ Tin dang + {district.totalListings} +
+
+ Ton kho + {district.inventoryLevel} +
+
+ Thay doi YoY + +
+
+
+ ), + )} +
+ )} +
+
+
+
+
); } diff --git a/apps/web/app/(dashboard)/dashboard/page.tsx b/apps/web/app/(dashboard)/dashboard/page.tsx index f5c02e9..c9f665e 100644 --- a/apps/web/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/page.tsx @@ -1,54 +1,322 @@ +'use client'; + +import { useEffect, useState } from 'react'; import Link from 'next/link'; -import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ListingStatusBadge } from '@/components/listings/listing-status-badge'; +import { + analyticsApi, + type MarketReportDistrict, + type HeatmapDataPoint, +} from '@/lib/analytics-api'; +import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +const CITY = 'Ho Chi Minh'; +const PERIOD = '2026-Q1'; + +function formatPrice(priceStr: string): string { + const num = Number(priceStr); + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} ty`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`; + return num.toLocaleString('vi-VN'); +} + +function formatPriceM2(price: number): string { + if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m2`; + return `${price.toLocaleString('vi-VN')} d/m2`; +} + +interface StatCardProps { + title: string; + value: string; + description?: string; + trend?: number | null; +} + +function StatCard({ title, value, description, trend }: StatCardProps) { + return ( + + + {title} + {value} + + {(description || trend != null) && ( + +
+ {trend != null && ( + = 0 ? 'text-green-600' : 'text-red-600'}`} + > + {trend >= 0 ? '+' : ''} + {trend.toFixed(1)}% + + )} + {description && ( + {description} + )} +
+
+ )} +
+ ); +} export default function DashboardPage() { + const [marketReport, setMarketReport] = useState([]); + const [heatmap, setHeatmap] = useState([]); + const [listings, setListings] = useState | null>(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + Promise.all([ + analyticsApi.getMarketReport(CITY, PERIOD).catch(() => ({ districts: [] as MarketReportDistrict[] })), + analyticsApi.getHeatmap(CITY, PERIOD).catch(() => ({ dataPoints: [] as HeatmapDataPoint[] })), + listingsApi.search({ page: 1, limit: 6 }).catch(() => null), + ]) + .then(([report, heatmapData, listingsResult]) => { + setMarketReport(report.districts); + setHeatmap(heatmapData.dataPoints); + setListings(listingsResult); + }) + .finally(() => setLoading(false)); + }, []); + + const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0); + const avgPriceM2 = + marketReport.length > 0 + ? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length + : 0; + const avgDaysOnMarket = + marketReport.length > 0 + ? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length + : 0; + const avgYoy = + marketReport.filter((d) => d.yoyChange != null).length > 0 + ? marketReport + .filter((d) => d.yoyChange != null) + .reduce((sum, d) => sum + (d.yoyChange ?? 0), 0) / + marketReport.filter((d) => d.yoyChange != null).length + : null; + + const myListingsCount = listings?.total ?? 0; + const totalViews = listings?.data.reduce((s, l) => s + l.viewCount, 0) ?? 0; + const totalInquiries = listings?.data.reduce((s, l) => s + l.inquiryCount, 0) ?? 0; + + const chartData = heatmap + .sort((a, b) => b.avgPriceM2 - a.avgPriceM2) + .slice(0, 8) + .map((p) => ({ + district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'), + 'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000), + listings: p.totalListings, + })); + return (
-
-

Bảng điều khiển

-

- Quản lý tin đăng bất động sản của bạn -

+
+
+

Bang dieu khien

+

+ Tong quan thi truong va tin dang cua ban +

+
+ + +
-
- + {/* Stats overview */} +
+ + + + +
+ + {/* Market overview + quick stats */} +
+ {/* Price chart */} + - Đăng tin mới - Tạo tin đăng bán hoặc cho thuê bất động sản + Gia trung binh theo quan + {CITY} - {PERIOD} (trieu VND/m2) - - - + {loading ? ( +
+ Dang tai... +
+ ) : chartData.length === 0 ? ( +
+ Chua co du lieu +
+ ) : ( + + + + + + [`${value} tr/m2`, 'Gia']} + /> + + + + )}
+ {/* Market summary */} - Tin đăng của tôi - Quản lý các tin đăng đã tạo + Thi truong {CITY} + Chi so chinh - {PERIOD} - - - - - - - - - - Tìm kiếm - Tìm bất động sản phù hợp nhu cầu - - - - - + +
+ Tong tin dang + + {loading ? '...' : totalListings.toLocaleString('vi-VN')} + +
+
+ Gia TB/m2 + + {loading ? '...' : formatPriceM2(avgPriceM2)} + +
+
+ Ngay TB de ban + + {loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngay`} + +
+
+ So quan + + {loading ? '...' : new Set(marketReport.map((d) => d.district)).size} + +
+
+ + + +
+ + {/* Recent listings */} + + +
+ Tin dang gan day + Danh sach tin dang moi nhat cua ban +
+ + + +
+ + {loading ? ( +
+ Dang tai... +
+ ) : !listings || listings.data.length === 0 ? ( +
+

Chua co tin dang nao

+ + + +
+ ) : ( +
+ {listings.data.slice(0, 5).map((listing) => ( + +
+ {listing.property.media.length > 0 ? ( + + ) : ( +
+ N/A +
+ )} +
+
+

{listing.property.title}

+

+ {listing.property.district}, {listing.property.city} +

+
+
+

+ {formatPrice(listing.priceVND)} +

+ +
+
+ {listing.viewCount} luot xem + {listing.inquiryCount} lien he +
+ + ))} +
+ )} +
+
); } diff --git a/apps/web/app/(dashboard)/listings/page.tsx b/apps/web/app/(dashboard)/listings/page.tsx index 6b64d98..b129885 100644 --- a/apps/web/app/(dashboard)/listings/page.tsx +++ b/apps/web/app/(dashboard)/listings/page.tsx @@ -2,35 +2,57 @@ import * as React from 'react'; import Link from 'next/link'; -import { Card, CardContent } from '@/components/ui/card'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Select } from '@/components/ui/select'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { ListingStatusBadge } from '@/components/listings/listing-status-badge'; -import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api'; -import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings'; +import { + listingsApi, + type ListingDetail, + type ListingStatus, + type PaginatedResult, +} from '@/lib/listings-api'; +import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings'; +import { useAuthStore } from '@/lib/auth-store'; function formatPrice(priceVND: string): string { const num = Number(priceVND); - if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`; - if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`; + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} ty`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`; return num.toLocaleString('vi-VN'); } +function formatDate(dateStr: string | null): string { + if (!dateStr) return 'N/A'; + return new Date(dateStr).toLocaleDateString('vi-VN', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +type ViewMode = 'grid' | 'table'; + export default function ListingsPage() { + const { tokens } = useAuthStore(); const [result, setResult] = React.useState | null>(null); const [loading, setLoading] = React.useState(true); + const [viewMode, setViewMode] = React.useState('grid'); const [filters, setFilters] = React.useState({ transactionType: '', propertyType: '', + status: '' as string, page: 1, }); - React.useEffect(() => { + const fetchListings = React.useCallback(() => { setLoading(true); const params: Record = { page: filters.page, limit: 12 }; if (filters.transactionType) params['transactionType'] = filters.transactionType; if (filters.propertyType) params['propertyType'] = filters.propertyType; + if (filters.status) params['status'] = filters.status; listingsApi .search(params) @@ -39,23 +61,78 @@ export default function ListingsPage() { .finally(() => setLoading(false)); }, [filters]); + React.useEffect(() => { + fetchListings(); + }, [fetchListings]); + + // Stats from current page data + const stats = React.useMemo(() => { + if (!result) return { total: 0, active: 0, pending: 0, views: 0 }; + return { + total: result.total, + active: result.data.filter((l) => l.status === 'ACTIVE').length, + pending: result.data.filter((l) => l.status === 'PENDING_REVIEW').length, + views: result.data.reduce((s, l) => s + l.viewCount, 0), + }; + }, [result]); + return (
+ {/* Header */}
-

Tin đăng

+
+

Quan ly tin dang

+

+ Quan ly, theo doi va cap nhat cac tin dang cua ban +

+
- +
- {/* Filters */} -
+ {/* Stats */} +
+ + + Tong tin dang + {loading ? '...' : stats.total} + + + + + Dang hoat dong + + {loading ? '...' : stats.active} + + + + + + Cho duyet + + {loading ? '...' : stats.pending} + + + + + + Tong luot xem + {loading ? '...' : stats.views} + + +
+ + {/* Filters + View Toggle */} +
+ + +
+ + +
- {/* Listing grid */} + {/* Content */} {loading ? (
) : !result || result.data.length === 0 ? (
-

Chưa có tin đăng nào

+

Chua co tin dang nao

- ) : ( - <> -
- {result.data.map((listing) => ( - - -
- {listing.property.media.length > 0 ? ( - {listing.property.title} - ) : ( -
- Chưa có ảnh -
- )} -
- + ) : viewMode === 'grid' ? ( + /* Grid View */ +
+ {result.data.map((listing) => ( + + +
+ {listing.property.media.length > 0 ? ( + {listing.property.title} + ) : ( +
+ Chua co anh
+ )} +
+
- -

- {formatPrice(listing.priceVND)} VNĐ -

-

{listing.property.title}

-

- {listing.property.district}, {listing.property.city} -

-
+
+ +

+ {formatPrice(listing.priceVND)} VND +

+

{listing.property.title}

+

+ {listing.property.district}, {listing.property.city} +

+
+ + {listing.property.areaM2} m2 + + {listing.property.bedrooms != null && ( - {listing.property.areaM2} m² + {listing.property.bedrooms} PN - {listing.property.bedrooms != null && ( - - {listing.property.bedrooms} PN - - )} - {listing.property.bathrooms != null && listing.property.bathrooms > 0 && ( - - {listing.property.bathrooms} PT - - )} -
-
- - - ))} -
- - {/* Pagination */} - {result.totalPages > 1 && ( -
- - - Trang {result.page} / {result.totalPages} - - + )} + {listing.property.bathrooms != null && listing.property.bathrooms > 0 && ( + + {listing.property.bathrooms} PT + + )} +
+
+ {listing.viewCount} luot xem + {listing.inquiryCount} lien he + {listing.saveCount} da luu +
+ +
+ + ))} +
+ ) : ( + /* Table View */ + + +
+ + + + + + + + + + + + + + + {result.data.map((listing) => ( + + + + + + + + + + + ))} + +
Tin dangLoaiGiaDien tichTrang thaiLuot xemLien heNgay dang
+ +
+ {listing.property.media.length > 0 ? ( + + ) : ( +
+ N/A +
+ )} +
+
+

+ {listing.property.title} +

+

+ {listing.property.district}, {listing.property.city} +

+
+ +
+ {listing.property.propertyType} + + {formatPrice(listing.priceVND)} + {listing.property.areaM2} m2 + + {listing.viewCount}{listing.inquiryCount} + {formatDate(listing.publishedAt ?? listing.createdAt)} +
- )} - +
+
+ )} + + {/* Pagination */} + {result && result.totalPages > 1 && ( +
+ + + Trang {result.page} / {result.totalPages} + + +
)}
); diff --git a/apps/web/package.json b/apps/web/package.json index 66e880d..d7b1391 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "react": "^18.3.0", "react-dom": "^18.3.0", "react-hook-form": "^7.72.1", + "recharts": "^3.8.1", "tailwind-merge": "^3.5.0", "zod": "^4.3.6", "zustand": "^5.0.12" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d56a6c1..c4ac88f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,6 +241,9 @@ importers: react-hook-form: specifier: ^7.72.1 version: 7.72.1(react@18.3.1) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.4)(react@18.3.1)(redux@5.0.1) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -249,7 +252,7 @@ importers: version: 4.3.6 zustand: specifier: ^5.0.12 - version: 5.0.12(@types/react@18.3.28)(react@18.3.1) + version: 5.0.12(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: '@types/mapbox-gl': specifier: ^3.5.0 @@ -1306,6 +1309,17 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rollup/rollup-android-arm-eabi@4.60.1': resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] @@ -1725,6 +1739,33 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1833,6 +1874,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} @@ -2524,6 +2568,50 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -2540,6 +2628,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2690,6 +2781,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -3196,6 +3290,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3211,6 +3311,10 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + interpret@3.1.1: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} @@ -4036,6 +4140,21 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@19.2.4: + resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -4059,6 +4178,14 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} @@ -4071,6 +4198,14 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -4086,6 +4221,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4452,6 +4590,9 @@ packages: thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4594,6 +4735,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4621,6 +4767,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -6089,6 +6238,18 @@ snapshots: '@protobufjs/utf8@1.1.0': optional: true + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 18.3.1 + react-redux: 9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1) + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true @@ -6572,6 +6733,30 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -6710,6 +6895,8 @@ snapshots: '@types/tough-cookie@4.0.5': optional: true + '@types/use-sync-external-store@0.0.6': {} + '@types/validator@13.15.10': {} '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': @@ -7411,6 +7598,44 @@ snapshots: csstype@3.2.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-uri-to-buffer@4.0.1: {} dateformat@4.6.3: {} @@ -7419,6 +7644,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -7568,6 +7795,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.45.1: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -8230,6 +8459,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.4: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -8241,6 +8474,8 @@ snapshots: ini@4.1.1: {} + internmap@2.0.3: {} + interpret@3.1.1: {} ioredis@5.10.1: @@ -9033,6 +9268,17 @@ snapshots: dependencies: react: 18.3.1 + react-is@19.2.4: {} + + react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + redux: 5.0.1 + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -9055,6 +9301,26 @@ snapshots: real-require@0.2.0: {} + recharts@3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.4)(react@18.3.1)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1))(react@18.3.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 19.2.4 + react-redux: 9.2.0(@types/react@18.3.28)(react@18.3.1)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@18.3.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + rechoir@0.8.0: dependencies: resolve: 1.22.11 @@ -9065,6 +9331,12 @@ snapshots: dependencies: redis-errors: 1.2.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect-metadata@0.2.2: {} regexp-tree@0.1.27: {} @@ -9074,6 +9346,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -9523,6 +9797,8 @@ snapshots: dependencies: real-require: 0.2.0 + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -9682,6 +9958,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -9698,6 +9978,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14 @@ -9908,7 +10205,9 @@ snapshots: zod@4.3.6: {} - zustand@5.0.12(@types/react@18.3.28)(react@18.3.1): + zustand@5.0.12(@types/react@18.3.28)(immer@11.1.4)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): optionalDependencies: '@types/react': 18.3.28 + immer: 11.1.4 react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1)