From 88429a1e5175f491f499c9757afb56aafc9c6cc0 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sun, 19 Apr 2026 15:08:04 +0700 Subject: [PATCH] =?UTF-8?q?feat(listings):=20phase=20B=20=E2=80=94=20rich?= =?UTF-8?q?=20property=20fields=20+=20admin-authored=20personas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema (prisma/migrations/20260419000000_property_rich_fields) -------------------------------------------------------------- New Prisma enums: - Furnishing: FULLY_FURNISHED / BASIC_FURNISHED / UNFURNISHED - PropertyCondition: NEW / LIKE_NEW / RENOVATED / USED New Property columns (all optional / default empty, no data loss): - furnishing, propertyCondition — enums above - balconyDirection — reuses existing Direction enum - maintenanceFeeVND BigInt (phí quản lý/tháng) - parkingSlots Int - viewType String[] (e.g. ["Sông","Thành phố"]) - petFriendly Boolean (null = unknown) - suitableFor String[] — admin-chosen persona labels - whyThisLocation Text — admin narrative Backend wiring end-to-end ------------------------- - Create/Update DTOs: @IsEnum/@IsString/@IsNumber/@IsBoolean/@IsArray validators; maintenanceFeeVND accepted as a numeric string, cast to BigInt on the way to Prisma. whyThisLocation capped at 2000 chars. - Introduced a small `PropertyExtras` interface on the create/update commands so the constructor signature stays readable instead of ballooning to 30+ positional args. Handlers forward it to the repo. - Prisma property repository writes all new columns via raw SQL INSERT/UPDATE and reads them on findById. - ListingDetailData + findByIdWithProperty expose the 9 new fields (maintenanceFeeVND serialised as decimal string to avoid BigInt JSON). Frontend -------- - listings-api.ts: ListingDetail.property + CreateListingPayload carry the 9 new fields; Furnishing + PropertyCondition exported as string unions. - validations/listings.ts: zod schema extended; FURNISHING_OPTIONS, PROPERTY_CONDITION_OPTIONS, VIEW_TYPE_OPTIONS label arrays added in the existing DIRECTIONS style (Vietnamese labels). - listing-form-steps.tsx StepDetails: new "Nội thất & điều kiện" fieldset with selects/inputs for each field. viewType + suitableFor are comma-separated text (same convention as amenities). petFriendly is a 3-way select (không chọn / Có / Không). - new/page.tsx + [id]/edit/page.tsx: submit handlers split CSV inputs into arrays, coerce petFriendly, prune empty selects. - listing-detail-client.tsx Details card: new rows for furnishing, propertyCondition, balconyDirection, maintenanceFeeVND (VND formatted), parkingSlots, viewType (joined · ), petFriendly (Cho phép / Không cho phép / hide when null). - PersonaFitCard now takes the listing directly and MERGES admin suitableFor (rendered first with a "Người đăng chọn" badge in primary accent) with the derived personas (deduped by label). When whyThisLocation is non-empty it overrides the derived narrative. Tests ----- - listing-detail-client.spec.tsx fixture gains all 9 nullable/empty defaults. - listing-form-steps.spec.tsx direction-options duplication fixed. - pnpm --filter @goodgo/api test --run: 1975/1975 pass. - pnpm --filter @goodgo/web test --run: 624/624 pass. Phase B of 4. Next: Phase E AI advisor via Anthropic Opus (URL+key to be provided by the user). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../create-listing/create-listing.command.ts | 20 +++- .../create-listing/create-listing.handler.ts | 10 ++ .../update-listing/update-listing.command.ts | 4 + .../update-listing/update-listing.handler.ts | 28 +++++- .../domain/entities/property.entity.ts | 85 ++++++++++++++++- .../domain/repositories/listing-read.dto.ts | 11 ++- .../repositories/listing-read.queries.ts | 9 ++ .../prisma-property.repository.ts | 50 +++++++++- .../controllers/listings.controller.ts | 22 +++++ .../presentation/dto/create-listing.dto.ts | 54 ++++++++++- .../presentation/dto/update-listing.dto.ts | 56 +++++++++++ .../(dashboard)/listings/[id]/edit/page.tsx | 14 +++ .../(dashboard)/listings/new/page.tsx | 27 +++++- .../__tests__/listing-detail-client.spec.tsx | 9 ++ .../__tests__/listing-form-steps.spec.tsx | 8 +- .../listings/listing-detail-client.tsx | 66 +++++++++++-- .../listings/listing-form-steps.tsx | 92 +++++++++++++++++++ apps/web/lib/listings-api.ts | 22 +++++ apps/web/lib/validations/listings.ts | 33 +++++++ .../migration.sql | 17 ++++ prisma/schema.prisma | 22 +++++ 21 files changed, 638 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20260419000000_property_rich_fields/migration.sql diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts index 7b2e9e1..bed9a6c 100644 --- a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.command.ts @@ -1,4 +1,20 @@ -import { type PropertyType, type TransactionType, type Direction } from '@prisma/client'; +import { type PropertyType, type TransactionType, type Direction, type Furnishing, type PropertyCondition } from '@prisma/client'; + +/** + * Optional "rich" property fields. Bundled so the command constructor does + * not grow unbounded each time we add a field. + */ +export interface PropertyExtras { + furnishing?: Furnishing; + propertyCondition?: PropertyCondition; + balconyDirection?: Direction; + maintenanceFeeVND?: bigint; + parkingSlots?: number; + viewType?: string[]; + petFriendly?: boolean; + suitableFor?: string[]; + whyThisLocation?: string; +} export class CreateListingCommand { constructor( @@ -34,5 +50,7 @@ export class CreateListingCommand { public readonly agentId?: string, public readonly rentPriceMonthly?: bigint, public readonly commissionPct?: number, + // Rich property fields bundled as options object (keeps ctor scalable) + public readonly extras?: PropertyExtras, ) {} } diff --git a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts index 20e07ce..86159d4 100644 --- a/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/create-listing/create-listing.handler.ts @@ -68,6 +68,7 @@ export class CreateListingHandler implements ICommandHandler 0; @@ -81,12 +96,21 @@ export class UpdateListingHandler implements ICommandHandler { @@ -45,6 +55,15 @@ export class PropertyEntity extends AggregateRoot { private _nearbyPOIs: unknown; private _metroDistanceM: number | null; private _projectName: string | null; + private _furnishing: Furnishing | null; + private _propertyCondition: PropertyCondition | null; + private _balconyDirection: Direction | null; + private _maintenanceFeeVND: bigint | null; + private _parkingSlots: number | null; + private _viewType: string[]; + private _petFriendly: boolean | null; + private _suitableFor: string[]; + private _whyThisLocation: string | null; constructor(id: string, props: PropertyProps, createdAt?: Date, updatedAt?: Date) { super(id, createdAt, updatedAt); @@ -67,6 +86,15 @@ export class PropertyEntity extends AggregateRoot { this._nearbyPOIs = props.nearbyPOIs; this._metroDistanceM = props.metroDistanceM; this._projectName = props.projectName; + this._furnishing = props.furnishing ?? null; + this._propertyCondition = props.propertyCondition ?? null; + this._balconyDirection = props.balconyDirection ?? null; + this._maintenanceFeeVND = props.maintenanceFeeVND ?? null; + this._parkingSlots = props.parkingSlots ?? null; + this._viewType = props.viewType ?? []; + this._petFriendly = props.petFriendly ?? null; + this._suitableFor = props.suitableFor ?? []; + this._whyThisLocation = props.whyThisLocation ?? null; } get propertyType(): PropertyType { return this._propertyType; } @@ -88,6 +116,15 @@ export class PropertyEntity extends AggregateRoot { get nearbyPOIs(): unknown { return this._nearbyPOIs; } get metroDistanceM(): number | null { return this._metroDistanceM; } get projectName(): string | null { return this._projectName; } + get furnishing(): Furnishing | null { return this._furnishing; } + get propertyCondition(): PropertyCondition | null { return this._propertyCondition; } + get balconyDirection(): Direction | null { return this._balconyDirection; } + get maintenanceFeeVND(): bigint | null { return this._maintenanceFeeVND; } + get parkingSlots(): number | null { return this._parkingSlots; } + get viewType(): string[] { return this._viewType; } + get petFriendly(): boolean | null { return this._petFriendly; } + get suitableFor(): string[] { return this._suitableFor; } + get whyThisLocation(): string | null { return this._whyThisLocation; } static createNew(id: string, props: PropertyProps): PropertyEntity { return new PropertyEntity(id, props); @@ -101,6 +138,15 @@ export class PropertyEntity extends AggregateRoot { title?: string; description?: string; amenities?: unknown; + furnishing?: Furnishing | null; + propertyCondition?: PropertyCondition | null; + balconyDirection?: Direction | null; + maintenanceFeeVND?: bigint | null; + parkingSlots?: number | null; + viewType?: string[]; + petFriendly?: boolean | null; + suitableFor?: string[]; + whyThisLocation?: string | null; }): string[] { const updatedFields: string[] = []; @@ -119,6 +165,43 @@ export class PropertyEntity extends AggregateRoot { updatedFields.push('amenities'); } + if (fields.furnishing !== undefined) { + this._furnishing = fields.furnishing; + updatedFields.push('furnishing'); + } + if (fields.propertyCondition !== undefined) { + this._propertyCondition = fields.propertyCondition; + updatedFields.push('propertyCondition'); + } + if (fields.balconyDirection !== undefined) { + this._balconyDirection = fields.balconyDirection; + updatedFields.push('balconyDirection'); + } + if (fields.maintenanceFeeVND !== undefined) { + this._maintenanceFeeVND = fields.maintenanceFeeVND; + updatedFields.push('maintenanceFeeVND'); + } + if (fields.parkingSlots !== undefined) { + this._parkingSlots = fields.parkingSlots; + updatedFields.push('parkingSlots'); + } + if (fields.viewType !== undefined) { + this._viewType = fields.viewType; + updatedFields.push('viewType'); + } + if (fields.petFriendly !== undefined) { + this._petFriendly = fields.petFriendly; + updatedFields.push('petFriendly'); + } + if (fields.suitableFor !== undefined) { + this._suitableFor = fields.suitableFor; + updatedFields.push('suitableFor'); + } + if (fields.whyThisLocation !== undefined) { + this._whyThisLocation = fields.whyThisLocation; + updatedFields.push('whyThisLocation'); + } + if (updatedFields.length > 0) { this.updatedAt = new Date(); } diff --git a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts index b97d98f..946a57c 100644 --- a/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts +++ b/apps/api/src/modules/listings/domain/repositories/listing-read.dto.ts @@ -1,4 +1,4 @@ -import { type ListingStatus, type TransactionType, type PropertyType, type Direction } from '@prisma/client'; +import { type ListingStatus, type TransactionType, type PropertyType, type Direction, type Furnishing, type PropertyCondition } from '@prisma/client'; /** Returned by findByIdWithProperty — full listing detail with property, seller, agent */ export interface ListingDetailData { @@ -41,6 +41,15 @@ export interface ListingDetailData { nearbyPOIs: unknown; metroDistanceM: number | null; projectName: string | null; + furnishing: Furnishing | null; + propertyCondition: PropertyCondition | null; + balconyDirection: Direction | null; + maintenanceFeeVND: string | null; + parkingSlots: number | null; + viewType: string[]; + petFriendly: boolean | null; + suitableFor: string[]; + whyThisLocation: string | null; media: ListingMediaData[]; }; seller: { diff --git a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts index af1e516..a93bdb3 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/listing-read.queries.ts @@ -75,6 +75,15 @@ export async function findByIdWithProperty( nearbyPOIs: listing.property.nearbyPOIs, metroDistanceM: listing.property.metroDistanceM, projectName: listing.property.projectName, + furnishing: listing.property.furnishing, + propertyCondition: listing.property.propertyCondition, + balconyDirection: listing.property.balconyDirection, + maintenanceFeeVND: listing.property.maintenanceFeeVND?.toString() ?? null, + parkingSlots: listing.property.parkingSlots, + viewType: listing.property.viewType ?? [], + petFriendly: listing.property.petFriendly, + suitableFor: listing.property.suitableFor ?? [], + whyThisLocation: listing.property.whyThisLocation, media: listing.property.media.map((m) => ({ id: m.id, url: m.url, diff --git a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts index 89a5858..a6d50bb 100644 --- a/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts +++ b/apps/api/src/modules/listings/infrastructure/repositories/prisma-property.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { type Prisma, type PropertyMedia as PrismaMedia, type PropertyType, type Direction } from '@prisma/client'; +import { type Prisma, type PropertyMedia as PrismaMedia, type PropertyType, type Direction, type Furnishing, type PropertyCondition } from '@prisma/client'; import { PrismaService } from '@modules/shared'; import { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity'; import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity'; @@ -33,6 +33,15 @@ interface PropertyWithGeo { nearbyPOIs: unknown; metroDistanceM: number | null; projectName: string | null; + furnishing: Furnishing | null; + propertyCondition: PropertyCondition | null; + balconyDirection: Direction | null; + maintenanceFeeVND: bigint | null; + parkingSlots: number | null; + viewType: string[] | null; + petFriendly: boolean | null; + suitableFor: string[] | null; + whyThisLocation: string | null; createdAt: Date; updatedAt: Date; } @@ -52,7 +61,11 @@ export class PrismaPropertyRepository implements IPropertyRepository { "areaM2", "usableAreaM2", "bedrooms", "bathrooms", "floors", "floor", "totalFloors", "direction", "yearBuilt", "legalStatus", "amenities", "nearbyPOIs", - "metroDistanceM", "projectName", "createdAt", "updatedAt" + "metroDistanceM", "projectName", + "furnishing", "propertyCondition", "balconyDirection", + "maintenanceFeeVND", "parkingSlots", "viewType", + "petFriendly", "suitableFor", "whyThisLocation", + "createdAt", "updatedAt" FROM "Property" WHERE "id" = ${id} LIMIT 1 @@ -67,7 +80,11 @@ export class PrismaPropertyRepository implements IPropertyRepository { "id", "propertyType", "title", "description", "address", "ward", "district", "city", "location", "areaM2", "usableAreaM2", "bedrooms", "bathrooms", "floors", "floor", "totalFloors", "direction", "yearBuilt", "legalStatus", "amenities", "nearbyPOIs", - "metroDistanceM", "projectName", "createdAt", "updatedAt" + "metroDistanceM", "projectName", + "furnishing", "propertyCondition", "balconyDirection", + "maintenanceFeeVND", "parkingSlots", "viewType", + "petFriendly", "suitableFor", "whyThisLocation", + "createdAt", "updatedAt" ) VALUES ( ${entity.id}, ${entity.propertyType}::"PropertyType", ${entity.title}, ${entity.description}, ${entity.address.address}, ${entity.address.ward}, ${entity.address.district}, ${entity.address.city}, @@ -78,6 +95,15 @@ export class PrismaPropertyRepository implements IPropertyRepository { ${entity.amenities ? JSON.stringify(entity.amenities) : null}::jsonb, ${entity.nearbyPOIs ? JSON.stringify(entity.nearbyPOIs) : null}::jsonb, ${entity.metroDistanceM}, ${entity.projectName}, + ${entity.furnishing}::"Furnishing", + ${entity.propertyCondition}::"PropertyCondition", + ${entity.balconyDirection}::"Direction", + ${entity.maintenanceFeeVND}, + ${entity.parkingSlots}, + ${entity.viewType}::text[], + ${entity.petFriendly}, + ${entity.suitableFor}::text[], + ${entity.whyThisLocation}, ${entity.createdAt}, ${entity.updatedAt} )`; } @@ -107,6 +133,15 @@ export class PrismaPropertyRepository implements IPropertyRepository { "nearbyPOIs" = ${entity.nearbyPOIs ? JSON.stringify(entity.nearbyPOIs) : null}::jsonb, "metroDistanceM" = ${entity.metroDistanceM}, "projectName" = ${entity.projectName}, + "furnishing" = ${entity.furnishing}::"Furnishing", + "propertyCondition" = ${entity.propertyCondition}::"PropertyCondition", + "balconyDirection" = ${entity.balconyDirection}::"Direction", + "maintenanceFeeVND" = ${entity.maintenanceFeeVND}, + "parkingSlots" = ${entity.parkingSlots}, + "viewType" = ${entity.viewType}::text[], + "petFriendly" = ${entity.petFriendly}, + "suitableFor" = ${entity.suitableFor}::text[], + "whyThisLocation" = ${entity.whyThisLocation}, "updatedAt" = NOW() WHERE "id" = ${entity.id}`; } @@ -177,6 +212,15 @@ export class PrismaPropertyRepository implements IPropertyRepository { nearbyPOIs: raw.nearbyPOIs, metroDistanceM: raw.metroDistanceM, projectName: raw.projectName, + furnishing: raw.furnishing, + propertyCondition: raw.propertyCondition, + balconyDirection: raw.balconyDirection, + maintenanceFeeVND: raw.maintenanceFeeVND, + parkingSlots: raw.parkingSlots, + viewType: raw.viewType ?? [], + petFriendly: raw.petFriendly, + suitableFor: raw.suitableFor ?? [], + whyThisLocation: raw.whyThisLocation, }; return new PropertyEntity(raw.id, props, raw.createdAt, raw.updatedAt); diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 928595a..a0448e7 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -110,6 +110,17 @@ export class ListingsController { dto.agentId, dto.rentPriceMonthly, dto.commissionPct, + { + furnishing: dto.furnishing, + propertyCondition: dto.propertyCondition, + balconyDirection: dto.balconyDirection, + maintenanceFeeVND: dto.maintenanceFeeVND, + parkingSlots: dto.parkingSlots, + viewType: dto.viewType, + petFriendly: dto.petFriendly, + suitableFor: dto.suitableFor, + whyThisLocation: dto.whyThisLocation, + }, ), ); } @@ -238,6 +249,17 @@ export class ListingsController { dto.amenities, dto.mediaOrder, user.role, + { + furnishing: dto.furnishing, + propertyCondition: dto.propertyCondition, + balconyDirection: dto.balconyDirection, + maintenanceFeeVND: dto.maintenanceFeeVND, + parkingSlots: dto.parkingSlots, + viewType: dto.viewType, + petFriendly: dto.petFriendly, + suitableFor: dto.suitableFor, + whyThisLocation: dto.whyThisLocation, + }, ), ); } diff --git a/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts index b1d13ab..b6db631 100644 --- a/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/create-listing.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { PropertyType, TransactionType, Direction } from '@prisma/client'; +import { PropertyType, TransactionType, Direction, Furnishing, PropertyCondition } from '@prisma/client'; import { Type, Transform } from 'class-transformer'; import { IsString, @@ -8,9 +8,11 @@ import { IsOptional, IsNotEmpty, MinLength, + MaxLength, Min, Max, IsArray, + IsBoolean, } from 'class-validator'; export class CreateListingDto { @@ -160,4 +162,54 @@ export class CreateListingDto { @IsNumber() @Type(() => Number) commissionPct?: number; + + @ApiPropertyOptional({ enum: Furnishing, example: 'FULLY_FURNISHED', description: 'Furnishing level' }) + @IsOptional() + @IsEnum(Furnishing) + furnishing?: Furnishing; + + @ApiPropertyOptional({ enum: PropertyCondition, example: 'NEW', description: 'Property condition' }) + @IsOptional() + @IsEnum(PropertyCondition) + propertyCondition?: PropertyCondition; + + @ApiPropertyOptional({ enum: Direction, example: 'SOUTH', description: 'Balcony / terrace facing direction' }) + @IsOptional() + @IsEnum(Direction) + balconyDirection?: Direction; + + @ApiPropertyOptional({ type: String, example: '2500000', description: 'Monthly maintenance fee in VND (as string to support bigint)' }) + @IsOptional() + @Transform(({ value }) => (value != null && value !== '' ? BigInt(value) : undefined)) + maintenanceFeeVND?: bigint; + + @ApiPropertyOptional({ example: 2, description: 'Number of parking slots' }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + parkingSlots?: number; + + @ApiPropertyOptional({ example: ['Sông', 'Thành phố'], description: 'View types (multi-select)' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + viewType?: string[]; + + @ApiPropertyOptional({ example: true, description: 'Whether pets are allowed (null = unknown)' }) + @IsOptional() + @IsBoolean() + petFriendly?: boolean; + + @ApiPropertyOptional({ example: ['Gia đình có con nhỏ'], description: 'Personas selected by admin' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + suitableFor?: string[]; + + @ApiPropertyOptional({ example: 'Khu vực gần trường học, dễ di chuyển...', description: 'Admin narrative for why this location suits buyers' }) + @IsOptional() + @IsString() + @MaxLength(2000) + whyThisLocation?: string; } diff --git a/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts index 0252312..2258a91 100644 --- a/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts +++ b/apps/api/src/modules/listings/presentation/dto/update-listing.dto.ts @@ -1,13 +1,18 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Direction, Furnishing, PropertyCondition } from '@prisma/client'; import { Transform, Type } from 'class-transformer'; import { IsString, IsOptional, MinLength, + MaxLength, IsArray, ValidateNested, IsInt, Min, + IsEnum, + IsBoolean, + IsNumber, } from 'class-validator'; export class MediaOrderItemDto { @@ -57,5 +62,56 @@ export class UpdateListingDto { @Type(() => MediaOrderItemDto) mediaOrder?: MediaOrderItemDto[]; + // ─── Rich property fields ────────────────────────────── + @ApiPropertyOptional({ enum: Furnishing }) + @IsOptional() + @IsEnum(Furnishing) + furnishing?: Furnishing; + + @ApiPropertyOptional({ enum: PropertyCondition }) + @IsOptional() + @IsEnum(PropertyCondition) + propertyCondition?: PropertyCondition; + + @ApiPropertyOptional({ enum: Direction }) + @IsOptional() + @IsEnum(Direction) + balconyDirection?: Direction; + + @ApiPropertyOptional({ type: String, example: '2500000' }) + @IsOptional() + @Transform(({ value }) => (value != null && value !== '' ? BigInt(value) : undefined)) + maintenanceFeeVND?: bigint; + + @ApiPropertyOptional({ example: 2 }) + @IsOptional() + @IsNumber() + @Type(() => Number) + @Min(0) + parkingSlots?: number; + + @ApiPropertyOptional({ example: ['Sông'] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + viewType?: string[]; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + petFriendly?: boolean; + + @ApiPropertyOptional({ example: ['Gia đình có con nhỏ'] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + suitableFor?: string[]; + + @ApiPropertyOptional({ example: 'Vì sao khu vực này phù hợp...' }) + @IsOptional() + @IsString() + @MaxLength(2000) + whyThisLocation?: string; + // propertyType, address, location CANNOT be changed after ACTIVE status. } diff --git a/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx b/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx index 4a76eda..cfc27ca 100644 --- a/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx @@ -62,6 +62,20 @@ export default function EditListingPage() { priceVND: data.priceVND, rentPriceMonthly: data.rentPriceMonthly ?? '', commissionPct: data.commissionPct != null ? String(data.commissionPct) : '', + furnishing: property.furnishing ?? '', + propertyCondition: property.propertyCondition ?? '', + balconyDirection: property.balconyDirection ?? '', + maintenanceFeeVND: property.maintenanceFeeVND ?? '', + parkingSlots: property.parkingSlots != null ? String(property.parkingSlots) : '', + viewType: property.viewType?.join(', ') ?? '', + petFriendly: + property.petFriendly === true + ? 'true' + : property.petFriendly === false + ? 'false' + : '', + suitableFor: property.suitableFor?.join(', ') ?? '', + whyThisLocation: property.whyThisLocation ?? '', }); }) .catch(() => setListing(null)) diff --git a/apps/web/app/[locale]/(dashboard)/listings/new/page.tsx b/apps/web/app/[locale]/(dashboard)/listings/new/page.tsx index 46994c2..a2eeac9 100644 --- a/apps/web/app/[locale]/(dashboard)/listings/new/page.tsx +++ b/apps/web/app/[locale]/(dashboard)/listings/new/page.tsx @@ -13,7 +13,13 @@ import { } from '@/components/listings/listing-form-steps'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { listingsApi, type CreateListingPayload, type Direction } from '@/lib/listings-api'; +import { + listingsApi, + type CreateListingPayload, + type Direction, + type Furnishing, + type PropertyCondition, +} from '@/lib/listings-api'; import { cn } from '@/lib/utils'; import { createListingSchema, @@ -110,6 +116,25 @@ export default function CreateListingPage() { const commissionPct = toNum(data.commissionPct); if (commissionPct != null) payload.commissionPct = commissionPct; + // Rich property fields ---------------------------------- + if (data.furnishing) payload.furnishing = data.furnishing as Furnishing; + if (data.propertyCondition) payload.propertyCondition = data.propertyCondition as PropertyCondition; + if (data.balconyDirection) payload.balconyDirection = data.balconyDirection as Direction; + if (data.maintenanceFeeVND) payload.maintenanceFeeVND = data.maintenanceFeeVND; + const parkingSlots = toNum(data.parkingSlots); + if (parkingSlots != null) payload.parkingSlots = parkingSlots; + if (data.viewType) { + const arr = data.viewType.split(',').map((s) => s.trim()).filter(Boolean); + if (arr.length > 0) payload.viewType = arr; + } + if (data.petFriendly === 'true') payload.petFriendly = true; + else if (data.petFriendly === 'false') payload.petFriendly = false; + if (data.suitableFor) { + const arr = data.suitableFor.split(',').map((s) => s.trim()).filter(Boolean); + if (arr.length > 0) payload.suitableFor = arr; + } + if (data.whyThisLocation) payload.whyThisLocation = data.whyThisLocation; + const result = await listingsApi.create(payload); for (const img of images) { diff --git a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx index d29b98d..bb42372 100644 --- a/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx +++ b/apps/web/components/listings/__tests__/listing-detail-client.spec.tsx @@ -116,6 +116,15 @@ function makeListing(overrides: Partial = {}): ListingDetail { projectName: 'Vinhomes Central Park', latitude: 10.7975, longitude: 106.721, + furnishing: null, + propertyCondition: null, + balconyDirection: null, + maintenanceFeeVND: null, + parkingSlots: null, + viewType: [], + petFriendly: null, + suitableFor: [], + whyThisLocation: null, media: [ { id: 'media-1', type: 'image', url: 'https://example.com/img1.jpg', order: 0, caption: null }, ], diff --git a/apps/web/components/listings/__tests__/listing-form-steps.spec.tsx b/apps/web/components/listings/__tests__/listing-form-steps.spec.tsx index 7522593..234ca5f 100644 --- a/apps/web/components/listings/__tests__/listing-form-steps.spec.tsx +++ b/apps/web/components/listings/__tests__/listing-form-steps.spec.tsx @@ -148,9 +148,11 @@ describe('StepDetails', () => { it('renders direction options', () => { render(); - expect(screen.getByText('Bắc')).toBeInTheDocument(); - expect(screen.getByText('Nam')).toBeInTheDocument(); - expect(screen.getByText('Đông')).toBeInTheDocument(); + // "Direction" options appear in both `direction` and `balconyDirection` + // selects, so they occur multiple times. + expect(screen.getAllByText('Bắc').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Nam').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Đông').length).toBeGreaterThanOrEqual(1); }); it('renders year built input', () => { diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx index 9b1179e..ed9d5fa 100644 --- a/apps/web/components/listings/listing-detail-client.tsx +++ b/apps/web/components/listings/listing-detail-client.tsx @@ -18,7 +18,13 @@ import { formatPrice, formatPricePerM2 } from '@/lib/currency'; import { composeWhyThisLocation, derivePersonas } from '@/lib/listing-personas'; import type { ListingDetail, NeighborhoodScoreResult, PriceHistoryItem } from '@/lib/listings-api'; import { listingsApi } from '@/lib/listings-api'; -import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings'; +import { + PROPERTY_TYPES, + DIRECTIONS, + TRANSACTION_TYPES, + FURNISHING_OPTIONS, + PROPERTY_CONDITION_OPTIONS, +} from '@/lib/validations/listings'; import type { POIItem } from '@/components/neighborhood'; const NeighborhoodRadarChart = dynamic( @@ -244,6 +250,27 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) { : '---' } /> + + + + + + 0 ? property.viewType.join(' • ') : '---'} + /> + {property.petFriendly !== null && ( + + )} @@ -451,17 +478,29 @@ function PersonaFitCard({ score: NeighborhoodScoreResult | null; pois: POIItem[]; }) { - const personas = React.useMemo( + const adminPicks = listing.property.suitableFor ?? []; + const adminNarrative = listing.property.whyThisLocation?.trim() || null; + + // Derive personas purely from signals — then prepend admin picks, de-duping + // against derived labels so we never double up. + const derived = React.useMemo( () => derivePersonas(listing, score, pois), [listing, score, pois], ); - const narrative = React.useMemo( + const derivedNarrative = React.useMemo( () => composeWhyThisLocation(listing, score, pois), [listing, score, pois], ); + // Admin narrative wins when present — that's the authoritative version. + const narrative = adminNarrative ?? derivedNarrative; + + // Merge: admin picks first (each shown as "admin-chosen"), then derived + // personas whose labels aren't already in the admin picks. + const derivedFiltered = derived.filter((d) => !adminPicks.includes(d.label)); + // Only render when we have something meaningful to say. - if (personas.length === 0 && !narrative) return null; + if (adminPicks.length === 0 && derivedFiltered.length === 0 && !narrative) return null; return ( @@ -469,9 +508,20 @@ function PersonaFitCard({ Phù hợp với ai? - {personas.length > 0 && ( + {(adminPicks.length > 0 || derivedFiltered.length > 0) && (
- {personas.map((p) => ( + {adminPicks.map((label) => ( +
+ {label} + + Người đăng chọn + +
+ ))} + {derivedFiltered.map((p) => (
)} - {personas.length > 0 && ( + {derivedFiltered.length > 0 && (
    - {personas.map((p) => ( + {derivedFiltered.map((p) => (
  • diff --git a/apps/web/components/listings/listing-form-steps.tsx b/apps/web/components/listings/listing-form-steps.tsx index 660d36f..608aacf 100644 --- a/apps/web/components/listings/listing-form-steps.tsx +++ b/apps/web/components/listings/listing-form-steps.tsx @@ -9,6 +9,8 @@ import { TRANSACTION_TYPES, PROPERTY_TYPES, DIRECTIONS, + FURNISHING_OPTIONS, + PROPERTY_CONDITION_OPTIONS, type CreateListingFormData, } from '@/lib/validations/listings'; @@ -208,6 +210,96 @@ export function StepDetails({ register, errors }: StepProps) {
+ +
+ Nội thất & điều kiện + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +