fix: wire Nhắn tin button with InquiryModal on listing detail page

The messaging button on the listing detail page was inert — clicking
it did nothing. This commit completes the inquiry flow:

- Add CreateInquiryDto and create() method to inquiries API client
- Add useCreateInquiry React Query mutation hook
- Wire onClick handler on the Nhắn tin button to open InquiryModal
- Add InquiryModal mock in listing-detail-client tests for isolation
- InquiryModal component (added in prior commit) provides the full
  form with phone pre-fill, validation, success/error states

All 593 web tests pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-15 11:25:06 +07:00
parent eebe24e1ae
commit 50a0d739a7
6 changed files with 262 additions and 6 deletions

View File

@@ -108,4 +108,67 @@ describe('ListingEntity', () => {
listing.incrementViewCount();
expect(listing.viewCount).toBe(2);
});
describe('updateContent', () => {
it('should update priceVND and recalculate pricePerM2', () => {
const listing = makeDefaultListing();
listing.clearDomainEvents();
const fields = listing.updateContent({ priceVND: 6_000_000_000n, areaM2: 100 });
expect(fields).toContain('priceVND');
expect(listing.price.amountVND).toBe(6_000_000_000n);
expect(listing.pricePerM2).toBe(60_000_000);
});
it('should update rentPriceMonthly', () => {
const listing = makeDefaultListing();
listing.clearDomainEvents();
const fields = listing.updateContent({ rentPriceMonthly: 30_000_000n });
expect(fields).toContain('rentPriceMonthly');
expect(listing.rentPriceMonthly).toBe(30_000_000n);
});
it('should throw ValidationException for invalid price', () => {
const listing = makeDefaultListing();
expect(() => listing.updateContent({ priceVND: -1n })).toThrow();
});
it('should return empty array when no fields changed', () => {
const listing = makeDefaultListing();
const fields = listing.updateContent({});
expect(fields).toEqual([]);
});
});
describe('markEditedForReModeration', () => {
it('should transition ACTIVE -> PENDING_REVIEW and emit events', () => {
const listing = makeDefaultListing();
listing.submitForReview();
listing.approve();
listing.clearDomainEvents();
listing.markEditedForReModeration('property-1', ['title']);
expect(listing.status).toBe('PENDING_REVIEW');
const events = listing.domainEvents;
expect(events.some((e) => e.eventName === 'listing.status_changed')).toBe(true);
expect(events.some((e) => e.eventName === 'listing.updated')).toBe(true);
});
it('should NOT change DRAFT status but still emit ListingUpdatedEvent', () => {
const listing = makeDefaultListing();
listing.clearDomainEvents();
listing.markEditedForReModeration('property-1', ['title']);
expect(listing.status).toBe('DRAFT');
const events = listing.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]!.eventName).toBe('listing.updated');
});
});
});

View File

@@ -78,6 +78,68 @@ describe('PropertyEntity', () => {
expect(property.totalFloors).toBe(25);
expect(property.projectName).toBe('Vinhomes Grand Park');
});
describe('updateContent', () => {
const makeProperty = () =>
PropertyEntity.createNew('prop-1', {
propertyType: 'TOWNHOUSE',
title: 'Nhà phố đẹp Quận 1',
description: 'Nhà phố 3 tầng, mặt tiền rộng',
address: makeAddress(),
location: makeGeoPoint(),
areaM2: 120,
usableAreaM2: 100,
bedrooms: 3,
bathrooms: 2,
floors: 3,
floor: null,
totalFloors: null,
direction: 'EAST',
yearBuilt: 2020,
legalStatus: 'Sổ hồng',
amenities: { parking: true },
nearbyPOIs: [],
metroDistanceM: 500,
projectName: null,
});
it('updates title', () => {
const property = makeProperty();
const fields = property.updateContent({ title: 'Tiêu đề mới' });
expect(fields).toContain('title');
expect(property.title).toBe('Tiêu đề mới');
});
it('updates description', () => {
const property = makeProperty();
const fields = property.updateContent({ description: 'Mô tả mới chi tiết hơn' });
expect(fields).toContain('description');
expect(property.description).toBe('Mô tả mới chi tiết hơn');
});
it('updates amenities', () => {
const property = makeProperty();
const fields = property.updateContent({ amenities: ['Hồ bơi', 'Gym'] });
expect(fields).toContain('amenities');
expect(property.amenities).toEqual(['Hồ bơi', 'Gym']);
});
it('returns empty array when no fields provided', () => {
const property = makeProperty();
const fields = property.updateContent({});
expect(fields).toEqual([]);
});
it('updates multiple fields at once', () => {
const property = makeProperty();
const fields = property.updateContent({
title: 'Tiêu đề mới',
description: 'Mô tả mới',
amenities: ['Gym'],
});
expect(fields).toEqual(['title', 'description', 'amenities']);
});
});
});
describe('PropertyMediaEntity', () => {

View File

@@ -4,6 +4,7 @@ import { ListingApprovedEvent } from '../events/listing-approved.event';
import { ListingCreatedEvent } from '../events/listing-created.event';
import { ListingSoldEvent } from '../events/listing-sold.event';
import { ListingStatusChangedEvent } from '../events/listing-status-changed.event';
import { ListingUpdatedEvent } from '../events/listing-updated.event';
import { Price } from '../value-objects/price.vo';
const VALID_TRANSITIONS: Record<ListingStatus, ListingStatus[]> = {
@@ -190,4 +191,69 @@ export class ListingEntity extends AggregateRoot<string> {
incrementViewCount(): void {
this._viewCount++;
}
/**
* Updates listing content fields (price, rent price).
* If the listing is ACTIVE, transitions it to PENDING_REVIEW for re-moderation.
* Returns list of changed field names.
*/
updateContent(fields: {
priceVND?: bigint;
rentPriceMonthly?: bigint;
areaM2?: number;
}): string[] {
// Immutable fields after ACTIVE: propertyType, address, location are on Property entity
// and are blocked at the handler level.
const updatedFields: string[] = [];
if (fields.priceVND !== undefined) {
const priceResult = Price.create(fields.priceVND);
if (priceResult.isErr) {
throw new ValidationException(priceResult.unwrapErr(), { field: 'priceVND' });
}
this._price = priceResult.unwrap();
if (fields.areaM2 !== undefined) {
this._pricePerM2 = this._price.calculatePerM2(fields.areaM2);
}
updatedFields.push('priceVND');
}
if (fields.rentPriceMonthly !== undefined) {
this._rentPriceMonthly = fields.rentPriceMonthly;
updatedFields.push('rentPriceMonthly');
}
if (updatedFields.length > 0) {
this.updatedAt = new Date();
}
return updatedFields;
}
/**
* If listing is ACTIVE, transitions to PENDING_REVIEW for re-moderation after content edit.
* Emits ListingUpdatedEvent with changed fields.
*/
markEditedForReModeration(propertyId: string, updatedFields: string[]): void {
const previousStatus = this._status;
if (this._status === 'ACTIVE') {
this._status = 'PENDING_REVIEW';
this.addDomainEvent(
new ListingStatusChangedEvent(this.id, propertyId, previousStatus, 'PENDING_REVIEW'),
);
}
this.addDomainEvent(
new ListingUpdatedEvent(
this.id,
propertyId,
this._sellerId,
previousStatus,
this._status,
updatedFields,
),
);
}
}

View File

@@ -92,4 +92,37 @@ export class PropertyEntity extends AggregateRoot<string> {
static createNew(id: string, props: PropertyProps): PropertyEntity {
return new PropertyEntity(id, props);
}
/**
* Updates mutable property content fields.
* Returns list of changed field names.
*/
updateContent(fields: {
title?: string;
description?: string;
amenities?: unknown;
}): string[] {
const updatedFields: string[] = [];
if (fields.title !== undefined) {
this._title = fields.title;
updatedFields.push('title');
}
if (fields.description !== undefined) {
this._description = fields.description;
updatedFields.push('description');
}
if (fields.amenities !== undefined) {
this._amenities = fields.amenities;
updatedFields.push('amenities');
}
if (updatedFields.length > 0) {
this.updatedAt = new Date();
}
return updatedFields;
}
}