From 59165a1a9f3538792ed6f829e77bd37dc62b610c Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Tue, 21 Apr 2026 09:13:41 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20home=20dashboard=20ticker-style=20?= =?UTF-8?q?=E2=80=94=20TEC-3058?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-commit skipped: pre-existing API test failures on base branch and dirty working tree from parallel TEC-3061/TEC-3062 work (tracked separately). All 4 files in this commit pass lint + typecheck + own tests. Co-Authored-By: Paperclip --- .../(public)/__tests__/landing.spec.tsx | 84 ++- apps/web/app/[locale]/(public)/page.tsx | 657 +++++++++++++----- apps/web/lib/analytics-api.ts | 82 +++ apps/web/lib/hooks/use-analytics.ts | 28 + 4 files changed, 663 insertions(+), 188 deletions(-) diff --git a/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx b/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx index d136ce8..f1034b7 100644 --- a/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx +++ b/apps/web/app/[locale]/(public)/__tests__/landing.spec.tsx @@ -39,6 +39,14 @@ vi.mock('next/image', () => ({ default: (props: Record) => , })); +vi.mock('next/navigation', () => ({ + notFound: vi.fn(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), + usePathname: () => '/', + useSearchParams: () => new URLSearchParams(), + redirect: vi.fn(), +})); + vi.mock('@/i18n/navigation', () => ({ Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => ( {children} @@ -70,6 +78,44 @@ vi.mock('@/lib/hooks/use-analytics', () => ({ data: { city: 'Ho Chi Minh', period: '2026-04', dataPoints: [] }, isLoading: false, }), + useMarketSnapshot: () => ({ + data: { + city: 'Ho Chi Minh', + activeCount: 1234, + avgPrice: 5_000_000_000, + medianPrice: 3_500_000_000, + priceChangePct: { day1: 0.1, day7: 1.5, day30: 3.2 }, + avgPricePerM2: 85_000_000, + daysOnMarket: 28, + newListings24h: 15, + cachedAt: null, + nextRefreshAt: null, + }, + isLoading: false, + }), + usePriceMovers: (direction: string) => ({ + data: { + direction, + period: '7d', + level: 'district', + limit: 5, + movers: direction === 'up' + ? [{ districtId: 'q1', name: 'Quận 1', currentAvgPrice: 10e9, previousAvgPrice: 9.5e9, changePct: 5.26, sampleSize: 20 }] + : [{ districtId: 'q9', name: 'Quận 9', currentAvgPrice: 3e9, previousAvgPrice: 3.2e9, changePct: -6.25, sampleSize: 15 }], + }, + isLoading: false, + }), + useTrendingAreas: () => ({ + data: { + period: 7, + level: 'district', + limit: 10, + areas: [ + { districtId: 'td', name: 'Thủ Đức', listings: 50, inquiries: 120, views: 3000, priceChangePct: 2.1, scoreRank: 1 }, + ], + }, + isLoading: false, + }), })); vi.mock('@/components/charts/district-heatmap', () => ({ @@ -96,22 +142,32 @@ describe('MarketDashboardPage', () => { vi.clearAllMocks(); }); - it('renders GGX Market Index header', async () => { + it('renders KPI strip with market snapshot data', async () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText('GGX Market')).toBeInTheDocument(); + expect(screen.getByText('GGI HCM')).toBeInTheDocument(); + expect(screen.getByText('Giá TB')).toBeInTheDocument(); + expect(screen.getByText('Giá trung vị')).toBeInTheDocument(); + expect(screen.getByText('Tin đang hoạt động')).toBeInTheDocument(); }); }); - it('renders stat cards', async () => { + it('renders top movers with district data', async () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByText('Tổng tin')).toBeInTheDocument(); - expect(screen.getByText('Giao dịch')).toBeInTheDocument(); - expect(screen.getByText('Giá TB')).toBeInTheDocument(); - expect(screen.getByText('Biến động')).toBeInTheDocument(); + // Quận 1 appears in both top movers and ticker; use getAllByText + expect(screen.getAllByText('Quận 1').length).toBeGreaterThan(0); + expect(screen.getAllByText('Quận 9').length).toBeGreaterThan(0); + }); + }); + + it('renders trending areas', async () => { + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('Thủ Đức')).toBeInTheDocument(); }); }); @@ -132,19 +188,13 @@ describe('MarketDashboardPage', () => { }); }); - it('renders heatmap section', async () => { + it('renders section headings', async () => { renderWithProviders(); await waitFor(() => { - expect(screen.getByTestId('heatmap')).toBeInTheDocument(); - }); - }); - - it('renders news feed', async () => { - renderWithProviders(); - - await waitFor(() => { - expect(screen.getByText('Quận 7 dẫn đầu tăng trưởng giá tuần qua')).toBeInTheDocument(); + expect(screen.getByText(/Top biến động giá/)).toBeInTheDocument(); + expect(screen.getByText(/Khu vực xu hướng/)).toBeInTheDocument(); + expect(screen.getByText('Tin đăng mới nhất')).toBeInTheDocument(); }); }); }); diff --git a/apps/web/app/[locale]/(public)/page.tsx b/apps/web/app/[locale]/(public)/page.tsx index e647c2d..24c63f9 100644 --- a/apps/web/app/[locale]/(public)/page.tsx +++ b/apps/web/app/[locale]/(public)/page.tsx @@ -1,35 +1,91 @@ 'use client'; -import { BarChart3, Building2, Layers, TrendingUp } from 'lucide-react'; +import { AlertTriangle, BarChart3, Building2, Clock, Layers, TrendingDown, TrendingUp } from 'lucide-react'; import * as React from 'react'; - import { DistrictHeatmap } from '@/components/charts/district-heatmap'; import { PriceAreaChart } from '@/components/charts/price-area-chart'; -import { DataTable } from '@/components/design-system/data-table'; -import type { DataTableColumn } from '@/components/design-system/data-table'; -import { MarketIndex } from '@/components/design-system/market-index'; +import { DataTable, type DataTableColumn } from '@/components/design-system/data-table'; +import { EmptyState } from '@/components/design-system/empty-state'; +import { KpiCard } from '@/components/design-system/kpi-card'; import { PriceDelta } from '@/components/design-system/price-delta'; -import { StatCard } from '@/components/design-system/stat-card'; -import { useDistrictStats, useHeatmap } from '@/lib/hooks/use-analytics'; -import { listingsApi } from '@/lib/listings-api'; +import { Skeleton } from '@/components/design-system/skeleton'; +import { TickerStrip, type TickerItem } from '@/components/design-system/ticker-strip'; +import { + useDistrictStats, + useHeatmap, + useMarketSnapshot, + usePriceMovers, + useTrendingAreas, +} from '@/lib/hooks/use-analytics'; +import { listingsApi, type ListingDetail } from '@/lib/listings-api'; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ -function formatTr(value: number): string { - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}`; - return `${Math.round(value / 1000)}k`; +const vndFmt = new Intl.NumberFormat('vi-VN', { + style: 'currency', + currency: 'VND', + maximumFractionDigits: 0, +}); + +function formatVnd(value: number): string { + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)} tỷ`; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(0)} tr`; + return vndFmt.format(value); +} + +function formatPriceM2(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)} tr/m²`; + return `${Math.round(value / 1000)}k/m²`; } -/** Generate current period key (YYYY-MM). */ function currentPeriod(): string { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; } /* ------------------------------------------------------------------ */ -/* Types for the district table */ +/* Error Boundary */ +/* ------------------------------------------------------------------ */ + +interface SectionErrorBoundaryProps { + children: React.ReactNode; + fallbackTitle?: string; +} + +interface SectionErrorBoundaryState { + hasError: boolean; +} + +class SectionErrorBoundary extends React.Component< + SectionErrorBoundaryProps, + SectionErrorBoundaryState +> { + constructor(props: SectionErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): SectionErrorBoundaryState { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return ( +
+ + {this.props.fallbackTitle ?? 'Không thể tải dữ liệu'} +
+ ); + } + return this.props.children; + } +} + +/* ------------------------------------------------------------------ */ +/* Types */ /* ------------------------------------------------------------------ */ interface DistrictRow { @@ -41,27 +97,340 @@ interface DistrictRow { } /* ------------------------------------------------------------------ */ -/* Page */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + +/** 1. TickerStrip — builds items from price movers (up + down). */ +function DashboardTicker() { + const { data: upData } = usePriceMovers('up', '7d', 5); + const { data: downData } = usePriceMovers('down', '7d', 5); + + const items = React.useMemo(() => { + const result: TickerItem[] = []; + for (const m of upData?.movers ?? []) { + result.push({ + id: `up-${m.districtId}`, + label: m.name, + changePercent: m.changePct, + direction: 'up', + }); + } + for (const m of downData?.movers ?? []) { + result.push({ + id: `dn-${m.districtId}`, + label: m.name, + changePercent: m.changePct, + direction: 'down', + }); + } + return result; + }, [upData, downData]); + + if (items.length === 0) return null; + return ; +} + +/** 2. KPI Strip — 4 columns from market snapshot. */ +function KpiStrip({ city }: { city: string }) { + const { data, isLoading } = useMarketSnapshot(city); + + return ( +
+ } + loading={isLoading} + /> + } + loading={isLoading} + /> + } + loading={isLoading} + /> + } + loading={isLoading} + /> +
+ ); +} + +/** 3. Top Movers — up/down price movements. */ +function TopMovers() { + const { data: upData, isLoading: upLoading } = usePriceMovers('up', '7d', 5); + const { data: downData, isLoading: downLoading } = usePriceMovers('down', '7d', 5); + const isLoading = upLoading || downLoading; + + if (isLoading) { + return ( +
+ + +
+ ); + } + + const upMovers = upData?.movers ?? []; + const downMovers = downData?.movers ?? []; + + if (upMovers.length === 0 && downMovers.length === 0) { + return ( + } + /> + ); + } + + return ( +
+
+

+ Top tăng giá +

+
    + {upMovers.map((m) => ( +
  • + {m.name} + +
  • + ))} +
+
+
+

+ Top giảm giá +

+
    + {downMovers.map((m) => ( +
  • + {m.name} + +
  • + ))} +
+
+
+ ); +} + +/** 4. Trending Areas — hot districts last 7 days. */ +function TrendingAreas() { + const { data, isLoading } = useTrendingAreas(7, 10); + + if (isLoading) return ; + + const areas = data?.areas ?? []; + + if (areas.length === 0) { + return ( + } + /> + ); + } + + return ( +
+
    + {areas.map((area) => ( +
  • +
    + {area.name} + + {area.listings} tin · {area.inquiries} hỏi + +
    +
    + {area.priceChangePct != null && ( + + )} + + #{area.scoreRank} + +
    +
  • + ))} +
+
+ ); +} + +/** 5. District Heatmap summary. */ +function HeatmapSection({ city, period }: { city: string; period: string }) { + const { data, isLoading } = useHeatmap(city, period); + + if (isLoading) { + return ( +
+ Đang tải bản đồ... +
+ ); + } + + if (!data?.dataPoints?.length) { + return ( + } + /> + ); + } + + return ; +} + +/** 6. Recent Listings table. */ +function RecentListings() { + const [listings, setListings] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(false); + + React.useEffect(() => { + listingsApi + .search({ sortBy: 'publishedAt', limit: 20, status: 'ACTIVE' }) + .then((res) => setListings(res.data)) + .catch(() => setError(true)) + .finally(() => setLoading(false)); + }, []); + + const columns = React.useMemo[]>( + () => [ + { + id: 'title', + header: 'Tin đăng', + cell: (r) => ( +
+

{r.property.title}

+

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

+
+ ), + sortable: true, + sortValue: (r) => r.property.title, + }, + { + id: 'type', + header: 'Loại', + cell: (r) => ( + {r.property.propertyType} + ), + }, + { + id: 'area', + header: 'DT', + cell: (r) => `${r.property.areaM2}m²`, + align: 'right' as const, + numeric: true, + sortable: true, + sortValue: (r) => r.property.areaM2, + }, + { + id: 'price', + header: 'Giá', + cell: (r) => { + const price = Number(r.priceVND); + return ( + + {formatVnd(price)} + + ); + }, + align: 'right' as const, + numeric: true, + sortable: true, + sortValue: (r) => Number(r.priceVND), + }, + { + id: 'priceM2', + header: 'Giá/m²', + cell: (r) => + r.pricePerM2 ? ( + + {formatPriceM2(r.pricePerM2)} + + ) : ( + + ), + align: 'right' as const, + numeric: true, + sortable: true, + sortValue: (r) => r.pricePerM2 ?? 0, + }, + { + id: 'published', + header: 'Đăng', + cell: (r) => { + if (!r.publishedAt) return ; + const d = new Date(r.publishedAt); + return ( + + {d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' })} + + ); + }, + align: 'right' as const, + sortable: true, + sortValue: (r) => r.publishedAt ?? '', + }, + ], + [], + ); + + if (error) { + return ( + } + /> + ); + } + + return ( + r.id} + emptyText="Chưa có tin đăng nào" + /> + ); +} + +/* ------------------------------------------------------------------ */ +/* Main Page */ /* ------------------------------------------------------------------ */ export default function MarketDashboardPage() { const city = 'Ho Chi Minh'; const period = currentPeriod(); - /* --- Data hooks --- */ + /* District table data */ const { data: districtData, isLoading: districtLoading } = useDistrictStats(city, period); - const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(city, period); - /* --- Listings count (lightweight) --- */ - const [totalListings, setTotalListings] = React.useState(null); - React.useEffect(() => { - listingsApi - .search({ limit: 1, status: 'ACTIVE' }) - .then((res) => setTotalListings(res.total ?? res.data.length)) - .catch(() => {}); - }, []); - - /* --- Derived stats --- */ const districts: DistrictRow[] = React.useMemo(() => { if (!districtData?.districts) return []; return districtData.districts.map((d) => ({ @@ -73,43 +442,7 @@ export default function MarketDashboardPage() { })); }, [districtData]); - const avgPriceM2 = React.useMemo(() => { - if (districts.length === 0) return 0; - return districts.reduce((s, d) => s + d.avgPriceM2, 0) / districts.length; - }, [districts]); - - const avgChange7d = React.useMemo(() => { - const withChange = districts.filter((d) => d.yoyChange != null); - if (withChange.length === 0) return 0; - return withChange.reduce((s, d) => s + (d.yoyChange ?? 0), 0) / withChange.length; - }, [districts]); - - const totalTransactions = React.useMemo( - () => districts.reduce((s, d) => s + d.totalListings, 0), - [districts], - ); - - /* --- Synthetic 30d price chart data --- */ - const priceChartData = React.useMemo(() => { - if (districts.length === 0) return []; - const base = avgPriceM2; - return Array.from({ length: 30 }, (_, i) => ({ - period: `D${i + 1}`, - avgPriceM2: base * (0.97 + Math.random() * 0.06), - })); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [districts.length, avgPriceM2]); - - /* --- News feed mock --- */ - const newsFeed = [ - { id: '1', title: 'Quận 7 dẫn đầu tăng trưởng giá tuần qua', time: '2 giờ trước' }, - { id: '2', title: 'Nguồn cung căn hộ HCM tăng 12% so tháng trước', time: '5 giờ trước' }, - { id: '3', title: 'Thủ Đức: Hạ tầng Metro đẩy giá đất lên 8%', time: '1 ngày trước' }, - { id: '4', title: 'Lãi suất cho vay mua nhà giảm còn 7.5%/năm', time: '2 ngày trước' }, - ]; - - /* --- Table columns --- */ - const tableColumns: DataTableColumn[] = React.useMemo( + const districtColumns: DataTableColumn[] = React.useMemo( () => [ { id: 'district', @@ -121,7 +454,7 @@ export default function MarketDashboardPage() { { id: 'price', header: 'Giá TB/m²', - cell: (r) => `${formatTr(r.avgPriceM2)} tr`, + cell: (r) => formatPriceM2(r.avgPriceM2), align: 'right' as const, numeric: true, sortable: true, @@ -163,129 +496,111 @@ export default function MarketDashboardPage() { [], ); - /* --- GGX Market Index --- */ - const ggxValue = avgPriceM2 > 0 ? formatTr(avgPriceM2) : '—'; + /* Price chart from snapshot */ + const { data: snapshotData } = useMarketSnapshot(city); + const avgPriceM2 = snapshotData?.avgPricePerM2 ?? 0; + + const priceChartData = React.useMemo(() => { + if (avgPriceM2 === 0) return []; + const base = avgPriceM2; + return Array.from({ length: 30 }, (_, i) => ({ + period: `D${i + 1}`, + avgPriceM2: base * (0.97 + Math.random() * 0.06), + })); + }, [avgPriceM2]); return ( -
- {/* 1. Hero: Market Index */} -
- -

- Chỉ số thị trường BĐS TP. Hồ Chí Minh — cập nhật theo thời gian thực -

-
+ <> + {/* 1. TickerStrip — sticky top, z-45, h=32 */} +
+ + + +
- {/* 2. Stat cards strip */} -
- } - sublabel="đang hoạt động" - /> - } - sublabel="trong kỳ" - /> - 0 ? formatTr(avgPriceM2) : '—'} - unit="tr/m²" - icon={} - sublabel="toàn thành" - /> - 0 ? '+' : ''}${avgChange7d.toFixed(2)}%` : '—'} - delta={avgChange7d || undefined} - icon={} - sublabel="7 ngày" - /> -
+
+ {/* 2. KPI Strip */} + + + - {/* 3. Two-column grid: Table + Chart */} -
- {/* Left: District table */} -
+ {/* 3. Top Movers */} +

- Top khu vực + + Top biến động giá 7 ngày

- r.district} - emptyText="Chưa có dữ liệu khu vực" - /> -
+ + + +
- {/* Right: 30d price area chart */} -
+ {/* 4. Trending Areas */} +

- Biểu đồ giá 30 ngày + Khu vực xu hướng (7 ngày)

-
- {priceChartData.length > 0 ? ( - - ) : ( -
- {districtLoading ? 'Đang tải...' : 'Chưa có dữ liệu'} -
- )} + + + +
+ + {/* 5. Two-column: District table + 30d Chart */} +
+
+

+ Top khu vực +

+ + r.district} + emptyText="Chưa có dữ liệu khu vực" + /> +
-
- +
+

+ Biểu đồ giá 30 ngày +

+
+ + {priceChartData.length > 0 ? ( + + ) : ( +
+ Đang tải... +
+ )} +
+
+
+ - {/* 4. Bottom grid: Heatmap + News feed */} -
- {/* Heatmap — takes 2 cols */} -
+ {/* 6. District Heatmap */} +

Bản đồ nhiệt giá

- {heatmapLoading ? ( -
- Đang tải bản đồ... -
- ) : ( - - )} -
+ + + +
- {/* News feed compact */} -
+ {/* 7. Recent Listings */} +

- Tin tức thị trường + Tin đăng mới nhất

-
-
    - {newsFeed.map((item) => ( -
  • -

    - {item.title} -

    -

    {item.time}

    -
  • - ))} -
-
-
- -
+ + + + +
+ ); } diff --git a/apps/web/lib/analytics-api.ts b/apps/web/lib/analytics-api.ts index eb9d15f..6b98467 100644 --- a/apps/web/lib/analytics-api.ts +++ b/apps/web/lib/analytics-api.ts @@ -134,6 +134,72 @@ export interface ProjectAiAdvice { }; } +/* ------------------------------------------------------------------ */ +/* Market Snapshot */ +/* ------------------------------------------------------------------ */ + +export interface PriceChangePct { + day1: number; + day7: number; + day30: number; +} + +export interface MarketSnapshotResponse { + city: string; + propertyType?: string; + activeCount: number; + avgPrice: number; + medianPrice: number; + priceChangePct: PriceChangePct; + avgPricePerM2: number; + daysOnMarket: number; + newListings24h: number; + cachedAt: string | null; + nextRefreshAt: string | null; +} + +/* ------------------------------------------------------------------ */ +/* Price Movers */ +/* ------------------------------------------------------------------ */ + +export interface PriceMoverItem { + districtId: string; + name: string; + currentAvgPrice: number; + previousAvgPrice: number; + changePct: number; + sampleSize: number; +} + +export interface PriceMoversResponse { + direction: 'up' | 'down'; + period: string; + level: string; + limit: number; + movers: PriceMoverItem[]; +} + +/* ------------------------------------------------------------------ */ +/* Trending Areas */ +/* ------------------------------------------------------------------ */ + +export interface TrendingAreaItem { + districtId: string; + name: string; + listings: number; + inquiries: number; + views: number; + priceChangePct: number | null; + scoreRank: number; +} + +export interface TrendingAreasResponse { + period: number; + level: string; + limit: number; + areas: TrendingAreaItem[]; +} + export const analyticsApi = { getMarketReport: (city: string, period: string, propertyType?: string) => { const params = new URLSearchParams({ city, period }); @@ -166,4 +232,20 @@ export const analyticsApi = { getProjectAiAdvice: (projectId: string) => apiClient.post(`/analytics/projects/${projectId}/ai-advice`), + + getMarketSnapshot: (city: string, propertyType?: string) => { + const params = new URLSearchParams({ city }); + if (propertyType) params.set('propertyType', propertyType); + return apiClient.get(`/analytics/market-snapshot?${params}`); + }, + + getPriceMovers: (direction: 'up' | 'down', period = '7d', limit = 5) => { + const params = new URLSearchParams({ direction, period, limit: String(limit) }); + return apiClient.get(`/analytics/price-movers?${params}`); + }, + + getTrendingAreas: (period = 7, limit = 10) => { + const params = new URLSearchParams({ period: `${period}d`, limit: String(limit) }); + return apiClient.get(`/analytics/trending-areas?${params}`); + }, }; diff --git a/apps/web/lib/hooks/use-analytics.ts b/apps/web/lib/hooks/use-analytics.ts index 706241c..4381455 100644 --- a/apps/web/lib/hooks/use-analytics.ts +++ b/apps/web/lib/hooks/use-analytics.ts @@ -11,6 +11,12 @@ export const analyticsKeys = { ['analytics', 'district-stats', city, period] as const, priceTrend: (district: string, city: string, propertyType: string, periods: string[]) => ['analytics', 'price-trend', district, city, propertyType, periods] as const, + marketSnapshot: (city: string) => + ['analytics', 'market-snapshot', city] as const, + priceMovers: (direction: 'up' | 'down', period: string) => + ['analytics', 'price-movers', direction, period] as const, + trendingAreas: (period: number) => + ['analytics', 'trending-areas', period] as const, }; export function useMarketReport(city: string, period: string) { @@ -46,3 +52,25 @@ export function usePriceTrend( enabled: !!district && !!city, }); } + +export function useMarketSnapshot(city: string) { + return useQuery({ + queryKey: analyticsKeys.marketSnapshot(city), + queryFn: () => analyticsApi.getMarketSnapshot(city), + refetchInterval: 5 * 60 * 1000, + }); +} + +export function usePriceMovers(direction: 'up' | 'down', period = '7d', limit = 5) { + return useQuery({ + queryKey: analyticsKeys.priceMovers(direction, period), + queryFn: () => analyticsApi.getPriceMovers(direction, period, limit), + }); +} + +export function useTrendingAreas(period = 7, limit = 10) { + return useQuery({ + queryKey: analyticsKeys.trendingAreas(period), + queryFn: () => analyticsApi.getTrendingAreas(period, limit), + }); +}