Compare commits

..

6 Commits

Author SHA1 Message Date
Ho Ngoc Hai
53580d444b fix(web): add /listings to middleware publicPaths (TEC-3090)
Unauthenticated requests to /listings were being 302-redirected to /login
because '/listings' was missing from the publicPaths allowlist. /listings
is the public marketplace board and must be accessible without auth.

Unblocks 5 Playwright DataTable specs + smoke test (TEC-3040).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 12:50:15 +07:00
Ho Ngoc Hai
846ea652d8 fix(web): align PriceChangePct keys with API (d1/d7/d30)
API's market-snapshot returns priceChangePct with keys d1/d7/d30 but the
FE interface and KpiStrip accessor used day1/day7/day30, causing a
TypeError crash on the home page for authenticated users. Rename the
FE type, update KpiStrip accessors, and fix the landing test fixture.

Fixes TEC-3091.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 12:41:30 +07:00
Ho Ngoc Hai
ceab711dc6 fix(web): prevent horizontal overflow at 768px on home dashboard (TEC-3089)
Add overflow-x-clip on the public layout and home page root wrappers,
plus min-w-0 / overflow-hidden guards on the ticker strip containers.
The ticker strip renders a whitespace-nowrap w-max flex row that can
push documentElement.scrollWidth past clientWidth at narrow viewports;
constraining its parent prevents the Playwright regression at 768p.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 12:16:13 +07:00
Ho Ngoc Hai
ef1bdcad1c fix(listings): add 'order' param to SearchListingsDto (TEC-3088)
FE sends ?sortBy=publishedAt&order=desc on /listings and was getting 400
"property order should not exist". Add optional order ('asc'|'desc') to
the DTO, plumb through query/handler/cache key, and apply direction in
the Prisma orderBy. priceAsc/priceDesc still encode their own default
direction but honour an explicit order override.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 12:13:10 +07:00
Ho Ngoc Hai
7b6e99edef fix: correct broken imports in inquiry-created-to-lead.listener.spec.ts
The spec file had two wrong relative imports:
- InquiryCreatedToLeadListener: `../` → `../event-handlers/`
- CreateLeadCommand: `../../commands/` → `../commands/`

Both were off by one directory level since the test lives in
`application/__tests__/` but referenced paths as if it were in
`application/` directly. All 6 tests now pass.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-04-21 11:58:03 +07:00
Ho Ngoc Hai
0df087b372 fix(web): resolve /listings route conflict by moving dashboard CRUD to /my-listings (TEC-3086)
Two parallel pages resolved to /[locale]/listings, breaking the entire
Next.js app with a webpack parallel-pages error:

- (public)/listings    — high-density marketplace board (TEC-3059)
- (dashboard)/listings — owner's CRUD "My Listings"

Renamed the dashboard route to /my-listings and updated nav, dashboard
landing CTAs, and edit-page back-links to match. Public marketplace and
the public detail page (/listings/[id]) are unchanged.

Verification: pnpm --filter @goodgo/web test → 705/705 passed.

Note: --no-verify was used because the repo-wide pre-commit hook runs
`npm test`, which fails on a pre-existing broken import in
apps/api/src/modules/leads/application/__tests__/inquiry-created-to-lead.listener.spec.ts
(unrelated to this change). Tracked for follow-up as a separate subtask.
Hotfix scope-verified per CTO guidance on TEC-3086.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 11:55:53 +07:00
19 changed files with 112 additions and 32 deletions

View File

@@ -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;

View File

@@ -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',

View File

@@ -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,
) {} ) {}
} }

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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();
});
}); });

View File

@@ -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,
), ),
); );
} }

View File

@@ -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;
} }

View File

@@ -102,7 +102,7 @@ export default function DashboardPage() {
Tổng quan thị trường tin đăng của bạn Tổng quan thị trường 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 tin đăng nào</p> <p>Chưa 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>

View File

@@ -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 },
] ]
: []), : []),
], ],

View File

@@ -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>

View File

@@ -78,7 +78,7 @@ export default function ListingsPage() {
Quản , theo dõi cập nhật các tin đăng của bạn Quản , theo dõi 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 tin đăng nào</p> <p>Chưa 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>

View File

@@ -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,

View File

@@ -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

View File

@@ -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>
); );
} }

View File

@@ -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 {

View File

@@ -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'];