Compare commits
6 Commits
4c09d82989
...
53580d444b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53580d444b | ||
|
|
846ea652d8 | ||
|
|
ceab711dc6 | ||
|
|
ef1bdcad1c | ||
|
|
7b6e99edef | ||
|
|
0df087b372 |
@@ -1,6 +1,6 @@
|
|||||||
import { InquiryCreatedEvent } from '@modules/inquiries/domain/events/inquiry-created.event';
|
import { InquiryCreatedEvent } from '@modules/inquiries/domain/events/inquiry-created.event';
|
||||||
import { CreateLeadCommand } from '../../commands/create-lead/create-lead.command';
|
import { CreateLeadCommand } from '../commands/create-lead/create-lead.command';
|
||||||
import { InquiryCreatedToLeadListener } from '../inquiry-created-to-lead.listener';
|
import { InquiryCreatedToLeadListener } from '../event-handlers/inquiry-created-to-lead.listener';
|
||||||
|
|
||||||
describe('InquiryCreatedToLeadListener', () => {
|
describe('InquiryCreatedToLeadListener', () => {
|
||||||
let listener: InquiryCreatedToLeadListener;
|
let listener: InquiryCreatedToLeadListener;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery>
|
|||||||
query.bedrooms?.toString(),
|
query.bedrooms?.toString(),
|
||||||
String(query.page),
|
String(query.page),
|
||||||
String(query.limit),
|
String(query.limit),
|
||||||
|
query.sortBy,
|
||||||
|
query.newSince?.toISOString(),
|
||||||
|
query.order,
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.cacheService.getOrSet(
|
return this.cacheService.getOrSet(
|
||||||
@@ -47,6 +50,9 @@ export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery>
|
|||||||
bedrooms: query.bedrooms,
|
bedrooms: query.bedrooms,
|
||||||
page: query.page,
|
page: query.page,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
|
sortBy: query.sortBy,
|
||||||
|
newSince: query.newSince,
|
||||||
|
order: query.order,
|
||||||
}),
|
}),
|
||||||
CacheTTL.SEARCH_RESULTS,
|
CacheTTL.SEARCH_RESULTS,
|
||||||
'listing_search',
|
'listing_search',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type ListingStatus, type TransactionType, type PropertyType } from '@prisma/client';
|
import { type ListingStatus, type TransactionType, type PropertyType } from '@prisma/client';
|
||||||
|
import { type ListingSortBy, type ListingSortOrder } from '../../../domain/repositories/listing.repository';
|
||||||
|
|
||||||
export class SearchListingsQuery {
|
export class SearchListingsQuery {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -14,5 +15,8 @@ export class SearchListingsQuery {
|
|||||||
public readonly bedrooms?: number,
|
public readonly bedrooms?: number,
|
||||||
public readonly page: number = 1,
|
public readonly page: number = 1,
|
||||||
public readonly limit: number = 20,
|
public readonly limit: number = 20,
|
||||||
|
public readonly sortBy?: ListingSortBy,
|
||||||
|
public readonly newSince?: Date,
|
||||||
|
public readonly order?: ListingSortOrder,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem,
|
|||||||
export const LISTING_REPOSITORY = Symbol('LISTING_REPOSITORY');
|
export const LISTING_REPOSITORY = Symbol('LISTING_REPOSITORY');
|
||||||
|
|
||||||
export type ListingSortBy = 'publishedAt' | 'priceAsc' | 'priceDesc' | 'createdAt';
|
export type ListingSortBy = 'publishedAt' | 'priceAsc' | 'priceDesc' | 'createdAt';
|
||||||
|
export type ListingSortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
export interface ListingSearchParams {
|
export interface ListingSearchParams {
|
||||||
status?: ListingStatus;
|
status?: ListingStatus;
|
||||||
@@ -19,8 +20,10 @@ export interface ListingSearchParams {
|
|||||||
bedrooms?: number;
|
bedrooms?: number;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/** Sort field + direction. Defaults to publishedAt DESC with featured listings first. */
|
/** Sort field. Defaults to publishedAt with featured listings first. */
|
||||||
sortBy?: ListingSortBy;
|
sortBy?: ListingSortBy;
|
||||||
|
/** Sort direction (asc | desc). Defaults to desc. */
|
||||||
|
order?: ListingSortOrder;
|
||||||
/** Return only listings with publishedAt > newSince (delta pull for FE ticker). */
|
/** Return only listings with publishedAt > newSince (delta pull for FE ticker). */
|
||||||
newSince?: Date;
|
newSince?: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,25 +169,29 @@ export async function searchListings(
|
|||||||
where.publishedAt = { gt: params.newSince };
|
where.publishedAt = { gt: params.newSince };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build orderBy based on sortBy param
|
// Build orderBy based on sortBy + order params
|
||||||
type OrderByClause = Prisma.ListingOrderByWithRelationInput;
|
type OrderByClause = Prisma.ListingOrderByWithRelationInput;
|
||||||
const sortBy = params.sortBy ?? 'publishedAt';
|
const sortBy = params.sortBy ?? 'publishedAt';
|
||||||
|
// Default direction depends on sortBy: priceAsc/priceDesc encode their own direction;
|
||||||
|
// publishedAt/createdAt default to desc; explicit `order` overrides where applicable.
|
||||||
|
const order: 'asc' | 'desc' = params.order === 'asc' ? 'asc' : params.order === 'desc' ? 'desc' : 'desc';
|
||||||
let sortClauses: OrderByClause[];
|
let sortClauses: OrderByClause[];
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'priceAsc':
|
case 'priceAsc':
|
||||||
sortClauses = [{ priceVND: 'asc' }];
|
// sortBy already pins direction; allow override only if explicitly set
|
||||||
|
sortClauses = [{ priceVND: params.order ?? 'asc' }];
|
||||||
break;
|
break;
|
||||||
case 'priceDesc':
|
case 'priceDesc':
|
||||||
sortClauses = [{ priceVND: 'desc' }];
|
sortClauses = [{ priceVND: params.order ?? 'desc' }];
|
||||||
break;
|
break;
|
||||||
case 'createdAt':
|
case 'createdAt':
|
||||||
sortClauses = [{ createdAt: 'desc' }];
|
sortClauses = [{ createdAt: order }];
|
||||||
break;
|
break;
|
||||||
case 'publishedAt':
|
case 'publishedAt':
|
||||||
default:
|
default:
|
||||||
sortClauses = [
|
sortClauses = [
|
||||||
{ featuredUntil: { sort: 'desc', nulls: 'last' } },
|
{ featuredUntil: { sort: 'desc', nulls: 'last' } },
|
||||||
{ publishedAt: { sort: 'desc', nulls: 'last' } },
|
{ publishedAt: { sort: order, nulls: 'last' } },
|
||||||
];
|
];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,4 +74,34 @@ describe('SearchListingsDto', () => {
|
|||||||
expect(dto.maxArea).toBe(200);
|
expect(dto.maxArea).toBe(200);
|
||||||
expect(dto.bedrooms).toBe(2);
|
expect(dto.bedrooms).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should accept order=desc alongside sortBy=publishedAt (TEC-3088)', async () => {
|
||||||
|
const dto = plainToInstance(SearchListingsDto, {
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortBy: 'publishedAt',
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(dto.sortBy).toBe('publishedAt');
|
||||||
|
expect(dto.order).toBe('desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept order=asc', async () => {
|
||||||
|
const dto = plainToInstance(SearchListingsDto, { order: 'asc' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(dto.order).toBe('asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid order value', async () => {
|
||||||
|
const dto = plainToInstance(SearchListingsDto, { order: 'sideways' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
const orderError = errors.find((e) => e.property === 'order');
|
||||||
|
expect(orderError).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ export class ListingsController {
|
|||||||
dto.limit,
|
dto.limit,
|
||||||
dto.sortBy,
|
dto.sortBy,
|
||||||
dto.newSince != null ? new Date(dto.newSince) : undefined,
|
dto.newSince != null ? new Date(dto.newSince) : undefined,
|
||||||
|
dto.order,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { ListingStatus, PropertyType, TransactionType } from '@prisma/client';
|
import { ListingStatus, PropertyType, TransactionType } from '@prisma/client';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { IsEnum, IsIn, IsISO8601, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { type ListingSortBy } from '../../domain/repositories/listing.repository';
|
||||||
|
|
||||||
|
const LISTING_SORT_BY_VALUES: ListingSortBy[] = ['publishedAt', 'priceAsc', 'priceDesc', 'createdAt'];
|
||||||
|
const LISTING_SORT_ORDER_VALUES = ['asc', 'desc'] as const;
|
||||||
|
export type ListingSortOrder = (typeof LISTING_SORT_ORDER_VALUES)[number];
|
||||||
|
|
||||||
export class SearchListingsDto {
|
export class SearchListingsDto {
|
||||||
@ApiPropertyOptional({ enum: ListingStatus, example: 'ACTIVE', description: 'Filter by listing status' })
|
@ApiPropertyOptional({ enum: ListingStatus, example: 'ACTIVE', description: 'Filter by listing status' })
|
||||||
@@ -71,4 +76,31 @@ export class SearchListingsDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(100)
|
@Max(100)
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: LISTING_SORT_BY_VALUES,
|
||||||
|
example: 'publishedAt',
|
||||||
|
description: 'Sort field. Defaults to publishedAt with featured listings first.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(LISTING_SORT_BY_VALUES)
|
||||||
|
sortBy?: ListingSortBy;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: LISTING_SORT_ORDER_VALUES,
|
||||||
|
example: 'desc',
|
||||||
|
description: 'Sort direction (asc | desc). Defaults to desc.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(LISTING_SORT_ORDER_VALUES)
|
||||||
|
order?: ListingSortOrder;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
example: '2026-04-21T00:00:00.000Z',
|
||||||
|
description: 'Return only listings with publishedAt > newSince (ISO-8601 timestamp). Used for delta pulls by the FE ticker.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
newSince?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function DashboardPage() {
|
|||||||
Tổng quan thị trường và tin đăng của bạn
|
Tổng quan thị trường và tin đăng của bạn
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/listings/new">
|
<Link href="/my-listings/new">
|
||||||
<Button>Đăng tin mới</Button>
|
<Button>Đăng tin mới</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +209,7 @@ export default function DashboardPage() {
|
|||||||
<CardTitle className="text-lg">Tin đăng gần đây</CardTitle>
|
<CardTitle className="text-lg">Tin đăng gần đây</CardTitle>
|
||||||
<CardDescription>Danh sách tin đăng mới nhất của bạn</CardDescription>
|
<CardDescription>Danh sách tin đăng mới nhất của bạn</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/listings">
|
<Link href="/my-listings">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Xem tất cả
|
Xem tất cả
|
||||||
</Button>
|
</Button>
|
||||||
@@ -223,7 +223,7 @@ export default function DashboardPage() {
|
|||||||
) : !listings || listings.data.length === 0 ? (
|
) : !listings || listings.data.length === 0 ? (
|
||||||
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
|
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
|
||||||
<p>Chưa có tin đăng nào</p>
|
<p>Chưa có tin đăng nào</p>
|
||||||
<Link href="/listings/new" className="mt-2">
|
<Link href="/my-listings/new" className="mt-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Đăng tin đầu tiên
|
Đăng tin đầu tiên
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ export default function AppDashboardLayout({ children }: { children: React.React
|
|||||||
{ href: '/dashboard', label: t('dashboard.title'), icon: Home },
|
{ href: '/dashboard', label: t('dashboard.title'), icon: Home },
|
||||||
...(showListings
|
...(showListings
|
||||||
? [
|
? [
|
||||||
{ href: '/listings', label: t('dashboard.listings'), icon: List },
|
{ href: '/my-listings', label: t('dashboard.listings'), icon: List },
|
||||||
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
|
{ href: '/my-listings/new', label: t('dashboard.createListing'), icon: Plus },
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export default function EditListingPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
|
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
|
||||||
<p className="text-destructive">Không tìm thấy tin đăng</p>
|
<p className="text-destructive">Không tìm thấy tin đăng</p>
|
||||||
<Button variant="outline" onClick={() => router.push('/listings')}>
|
<Button variant="outline" onClick={() => router.push('/my-listings')}>
|
||||||
Quay lại
|
Quay lại
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +78,7 @@ export default function ListingsPage() {
|
|||||||
Quản lý, theo dõi và cập nhật các tin đăng của bạn
|
Quản lý, theo dõi và cập nhật các tin đăng của bạn
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/listings/new">
|
<Link href="/my-listings/new">
|
||||||
<Button>Đăng tin mới</Button>
|
<Button>Đăng tin mới</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,7 +198,7 @@ export default function ListingsPage() {
|
|||||||
) : !result || result.data.length === 0 ? (
|
) : !result || result.data.length === 0 ? (
|
||||||
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
||||||
<p>Chưa có tin đăng nào</p>
|
<p>Chưa có tin đăng nào</p>
|
||||||
<Link href="/listings/new" className="mt-2">
|
<Link href="/my-listings/new" className="mt-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
Đăng tin đầu tiên
|
Đăng tin đầu tiên
|
||||||
</Button>
|
</Button>
|
||||||
@@ -270,7 +270,7 @@ export default function ListingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex justify-end gap-2 border-t px-4 py-2">
|
<div className="flex justify-end gap-2 border-t px-4 py-2">
|
||||||
<Link href={`/listings/${listing.id}/edit`}>
|
<Link href={`/my-listings/${listing.id}/edit`}>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||||
Sửa
|
Sửa
|
||||||
@@ -370,7 +370,7 @@ export default function ListingsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-3 text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Link href={`/listings/${listing.id}/edit`}>
|
<Link href={`/my-listings/${listing.id}/edit`}>
|
||||||
<Button variant="ghost" size="sm" aria-label="Sửa tin">
|
<Button variant="ghost" size="sm" aria-label="Sửa tin">
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -84,7 +84,7 @@ vi.mock('@/lib/hooks/use-analytics', () => ({
|
|||||||
activeCount: 1234,
|
activeCount: 1234,
|
||||||
avgPrice: 5_000_000_000,
|
avgPrice: 5_000_000_000,
|
||||||
medianPrice: 3_500_000_000,
|
medianPrice: 3_500_000_000,
|
||||||
priceChangePct: { day1: 0.1, day7: 1.5, day30: 3.2 },
|
priceChangePct: { d1: 0.1, d7: 1.5, d30: 3.2 },
|
||||||
avgPricePerM2: 85_000_000,
|
avgPricePerM2: 85_000_000,
|
||||||
daysOnMarket: 28,
|
daysOnMarket: 28,
|
||||||
newListings24h: 15,
|
newListings24h: 15,
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen w-full overflow-x-clip bg-background">
|
||||||
{/* Ticker strip — biến động 7d top 8 quận */}
|
{/* Ticker strip — biến động 7d top 8 quận */}
|
||||||
<div className="h-ticker-bar border-b border-border bg-background-elevated">
|
<div className="h-ticker-bar w-full min-w-0 overflow-hidden border-b border-border bg-background-elevated">
|
||||||
<TickerStrip items={tickerItems} />
|
<TickerStrip items={tickerItems} />
|
||||||
</div>
|
</div>
|
||||||
<header
|
<header
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function KpiStrip({ city }: { city: string }) {
|
|||||||
<KpiCard
|
<KpiCard
|
||||||
label="GGI HCM"
|
label="GGI HCM"
|
||||||
value={data ? formatPriceM2(data.avgPricePerM2) : '—'}
|
value={data ? formatPriceM2(data.avgPricePerM2) : '—'}
|
||||||
delta={data?.priceChangePct.day7}
|
delta={data?.priceChangePct.d7}
|
||||||
footnote="Chỉ số giá TB/m²"
|
footnote="Chỉ số giá TB/m²"
|
||||||
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@@ -147,7 +147,7 @@ function KpiStrip({ city }: { city: string }) {
|
|||||||
<KpiCard
|
<KpiCard
|
||||||
label="Giá TB"
|
label="Giá TB"
|
||||||
value={data ? formatVnd(data.avgPrice) : '—'}
|
value={data ? formatVnd(data.avgPrice) : '—'}
|
||||||
delta={data?.priceChangePct.day30}
|
delta={data?.priceChangePct.d30}
|
||||||
footnote="Toàn thành phố"
|
footnote="Toàn thành phố"
|
||||||
icon={<Building2 className="h-3.5 w-3.5" />}
|
icon={<Building2 className="h-3.5 w-3.5" />}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
@@ -510,15 +510,15 @@ export default function MarketDashboardPage() {
|
|||||||
}, [avgPriceM2]);
|
}, [avgPriceM2]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="w-full overflow-x-clip">
|
||||||
{/* 1. TickerStrip — sticky top, z-45, h=32 */}
|
{/* 1. TickerStrip — sticky top, z-45, h=32 */}
|
||||||
<div className="sticky top-0 z-[45] h-8 border-b border-border bg-background-elevated">
|
<div className="sticky top-0 z-[45] h-8 w-full min-w-0 overflow-hidden border-b border-border bg-background-elevated">
|
||||||
<SectionErrorBoundary fallbackTitle="Ticker không khả dụng">
|
<SectionErrorBoundary fallbackTitle="Ticker không khả dụng">
|
||||||
<DashboardTicker />
|
<DashboardTicker />
|
||||||
</SectionErrorBoundary>
|
</SectionErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto max-w-7xl px-4 py-6 md:py-8">
|
<div className="mx-auto w-full min-w-0 max-w-7xl px-4 py-6 md:py-8">
|
||||||
{/* 2. KPI Strip */}
|
{/* 2. KPI Strip */}
|
||||||
<SectionErrorBoundary fallbackTitle="Không thể tải KPI">
|
<SectionErrorBoundary fallbackTitle="Không thể tải KPI">
|
||||||
<KpiStrip city={city} />
|
<KpiStrip city={city} />
|
||||||
@@ -601,6 +601,6 @@ export default function MarketDashboardPage() {
|
|||||||
</SectionErrorBoundary>
|
</SectionErrorBoundary>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ export interface ProjectAiAdvice {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export interface PriceChangePct {
|
export interface PriceChangePct {
|
||||||
day1: number;
|
d1: number;
|
||||||
day7: number;
|
d7: number;
|
||||||
day30: number;
|
d30: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketSnapshotResponse {
|
export interface MarketSnapshotResponse {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { routing } from '@/i18n/routing';
|
|||||||
|
|
||||||
const intlMiddleware = createIntlMiddleware(routing);
|
const intlMiddleware = createIntlMiddleware(routing);
|
||||||
|
|
||||||
const publicPaths = ['/login', '/register', '/search', '/auth/callback'];
|
const publicPaths = ['/login', '/register', '/search', '/listings', '/auth/callback'];
|
||||||
const publicExactPaths = ['/'];
|
const publicExactPaths = ['/'];
|
||||||
const authOnlyPaths = ['/login', '/register'];
|
const authOnlyPaths = ['/login', '/register'];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user