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 { CreateLeadCommand } from '../../commands/create-lead/create-lead.command';
|
||||
import { InquiryCreatedToLeadListener } from '../inquiry-created-to-lead.listener';
|
||||
import { CreateLeadCommand } from '../commands/create-lead/create-lead.command';
|
||||
import { InquiryCreatedToLeadListener } from '../event-handlers/inquiry-created-to-lead.listener';
|
||||
|
||||
describe('InquiryCreatedToLeadListener', () => {
|
||||
let listener: InquiryCreatedToLeadListener;
|
||||
|
||||
@@ -29,6 +29,9 @@ export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery>
|
||||
query.bedrooms?.toString(),
|
||||
String(query.page),
|
||||
String(query.limit),
|
||||
query.sortBy,
|
||||
query.newSince?.toISOString(),
|
||||
query.order,
|
||||
);
|
||||
|
||||
return this.cacheService.getOrSet(
|
||||
@@ -47,6 +50,9 @@ export class SearchListingsHandler implements IQueryHandler<SearchListingsQuery>
|
||||
bedrooms: query.bedrooms,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
sortBy: query.sortBy,
|
||||
newSince: query.newSince,
|
||||
order: query.order,
|
||||
}),
|
||||
CacheTTL.SEARCH_RESULTS,
|
||||
'listing_search',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ListingStatus, type TransactionType, type PropertyType } from '@prisma/client';
|
||||
import { type ListingSortBy, type ListingSortOrder } from '../../../domain/repositories/listing.repository';
|
||||
|
||||
export class SearchListingsQuery {
|
||||
constructor(
|
||||
@@ -14,5 +15,8 @@ export class SearchListingsQuery {
|
||||
public readonly bedrooms?: number,
|
||||
public readonly page: number = 1,
|
||||
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 type ListingSortBy = 'publishedAt' | 'priceAsc' | 'priceDesc' | 'createdAt';
|
||||
export type ListingSortOrder = 'asc' | 'desc';
|
||||
|
||||
export interface ListingSearchParams {
|
||||
status?: ListingStatus;
|
||||
@@ -19,8 +20,10 @@ export interface ListingSearchParams {
|
||||
bedrooms?: number;
|
||||
page?: 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;
|
||||
/** Sort direction (asc | desc). Defaults to desc. */
|
||||
order?: ListingSortOrder;
|
||||
/** Return only listings with publishedAt > newSince (delta pull for FE ticker). */
|
||||
newSince?: Date;
|
||||
}
|
||||
|
||||
@@ -169,25 +169,29 @@ export async function searchListings(
|
||||
where.publishedAt = { gt: params.newSince };
|
||||
}
|
||||
|
||||
// Build orderBy based on sortBy param
|
||||
// Build orderBy based on sortBy + order params
|
||||
type OrderByClause = Prisma.ListingOrderByWithRelationInput;
|
||||
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[];
|
||||
switch (sortBy) {
|
||||
case 'priceAsc':
|
||||
sortClauses = [{ priceVND: 'asc' }];
|
||||
// sortBy already pins direction; allow override only if explicitly set
|
||||
sortClauses = [{ priceVND: params.order ?? 'asc' }];
|
||||
break;
|
||||
case 'priceDesc':
|
||||
sortClauses = [{ priceVND: 'desc' }];
|
||||
sortClauses = [{ priceVND: params.order ?? 'desc' }];
|
||||
break;
|
||||
case 'createdAt':
|
||||
sortClauses = [{ createdAt: 'desc' }];
|
||||
sortClauses = [{ createdAt: order }];
|
||||
break;
|
||||
case 'publishedAt':
|
||||
default:
|
||||
sortClauses = [
|
||||
{ featuredUntil: { sort: 'desc', nulls: 'last' } },
|
||||
{ publishedAt: { sort: 'desc', nulls: 'last' } },
|
||||
{ publishedAt: { sort: order, nulls: 'last' } },
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -74,4 +74,34 @@ describe('SearchListingsDto', () => {
|
||||
expect(dto.maxArea).toBe(200);
|
||||
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.sortBy,
|
||||
dto.newSince != null ? new Date(dto.newSince) : undefined,
|
||||
dto.order,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ListingStatus, PropertyType, TransactionType } from '@prisma/client';
|
||||
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 {
|
||||
@ApiPropertyOptional({ enum: ListingStatus, example: 'ACTIVE', description: 'Filter by listing status' })
|
||||
@@ -71,4 +76,31 @@ export class SearchListingsDto {
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/listings/new">
|
||||
<Link href="/my-listings/new">
|
||||
<Button>Đăng tin mới</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -209,7 +209,7 @@ export default function DashboardPage() {
|
||||
<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>
|
||||
</div>
|
||||
<Link href="/listings">
|
||||
<Link href="/my-listings">
|
||||
<Button variant="outline" size="sm">
|
||||
Xem tất cả
|
||||
</Button>
|
||||
@@ -223,7 +223,7 @@ export default function DashboardPage() {
|
||||
) : !listings || listings.data.length === 0 ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
|
||||
<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">
|
||||
Đăng tin đầu tiên
|
||||
</Button>
|
||||
|
||||
@@ -127,8 +127,8 @@ export default function AppDashboardLayout({ children }: { children: React.React
|
||||
{ href: '/dashboard', label: t('dashboard.title'), icon: Home },
|
||||
...(showListings
|
||||
? [
|
||||
{ href: '/listings', label: t('dashboard.listings'), icon: List },
|
||||
{ href: '/listings/new', label: t('dashboard.createListing'), icon: Plus },
|
||||
{ href: '/my-listings', label: t('dashboard.listings'), icon: List },
|
||||
{ href: '/my-listings/new', label: t('dashboard.createListing'), icon: Plus },
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function EditListingPage() {
|
||||
return (
|
||||
<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>
|
||||
<Button variant="outline" onClick={() => router.push('/listings')}>
|
||||
<Button variant="outline" onClick={() => router.push('/my-listings')}>
|
||||
Quay lại
|
||||
</Button>
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/listings/new">
|
||||
<Link href="/my-listings/new">
|
||||
<Button>Đăng tin mới</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -198,7 +198,7 @@ export default function ListingsPage() {
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
|
||||
<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">
|
||||
Đăng tin đầu tiên
|
||||
</Button>
|
||||
@@ -270,7 +270,7 @@ export default function ListingsPage() {
|
||||
</div>
|
||||
</Link>
|
||||
<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">
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
Sửa
|
||||
@@ -370,7 +370,7 @@ export default function ListingsPage() {
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<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">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -84,7 +84,7 @@ vi.mock('@/lib/hooks/use-analytics', () => ({
|
||||
activeCount: 1234,
|
||||
avgPrice: 5_000_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,
|
||||
daysOnMarket: 28,
|
||||
newListings24h: 15,
|
||||
|
||||
@@ -95,9 +95,9 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
];
|
||||
|
||||
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 */}
|
||||
<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} />
|
||||
</div>
|
||||
<header
|
||||
|
||||
@@ -139,7 +139,7 @@ function KpiStrip({ city }: { city: string }) {
|
||||
<KpiCard
|
||||
label="GGI HCM"
|
||||
value={data ? formatPriceM2(data.avgPricePerM2) : '—'}
|
||||
delta={data?.priceChangePct.day7}
|
||||
delta={data?.priceChangePct.d7}
|
||||
footnote="Chỉ số giá TB/m²"
|
||||
icon={<BarChart3 className="h-3.5 w-3.5" />}
|
||||
loading={isLoading}
|
||||
@@ -147,7 +147,7 @@ function KpiStrip({ city }: { city: string }) {
|
||||
<KpiCard
|
||||
label="Giá TB"
|
||||
value={data ? formatVnd(data.avgPrice) : '—'}
|
||||
delta={data?.priceChangePct.day30}
|
||||
delta={data?.priceChangePct.d30}
|
||||
footnote="Toàn thành phố"
|
||||
icon={<Building2 className="h-3.5 w-3.5" />}
|
||||
loading={isLoading}
|
||||
@@ -510,15 +510,15 @@ export default function MarketDashboardPage() {
|
||||
}, [avgPriceM2]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full overflow-x-clip">
|
||||
{/* 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">
|
||||
<DashboardTicker />
|
||||
</SectionErrorBoundary>
|
||||
</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 */}
|
||||
<SectionErrorBoundary fallbackTitle="Không thể tải KPI">
|
||||
<KpiStrip city={city} />
|
||||
@@ -601,6 +601,6 @@ export default function MarketDashboardPage() {
|
||||
</SectionErrorBoundary>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@ export interface ProjectAiAdvice {
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface PriceChangePct {
|
||||
day1: number;
|
||||
day7: number;
|
||||
day30: number;
|
||||
d1: number;
|
||||
d7: number;
|
||||
d30: number;
|
||||
}
|
||||
|
||||
export interface MarketSnapshotResponse {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { routing } from '@/i18n/routing';
|
||||
|
||||
const intlMiddleware = createIntlMiddleware(routing);
|
||||
|
||||
const publicPaths = ['/login', '/register', '/search', '/auth/callback'];
|
||||
const publicPaths = ['/login', '/register', '/search', '/listings', '/auth/callback'];
|
||||
const publicExactPaths = ['/'];
|
||||
const authOnlyPaths = ['/login', '/register'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user