Compare commits
10 Commits
0168f1f6f5
...
deb99e14fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb99e14fb | ||
|
|
6774914b4c | ||
|
|
b4bb05479e | ||
|
|
d7c5b1ca2c | ||
|
|
0fc23b7ebd | ||
|
|
8a15df0bdb | ||
|
|
ec066dfa28 | ||
|
|
d7961e297c | ||
|
|
f5118244b7 | ||
|
|
1d26393f16 |
@@ -1,5 +1,37 @@
|
|||||||
# Hướng Dẫn Đóng Góp
|
# Hướng Dẫn Đóng Góp
|
||||||
|
|
||||||
|
## Kỷ Luật Commit & Push (Bắt Buộc)
|
||||||
|
|
||||||
|
> Để tránh conflict khi nhiều agent/engineer làm việc song song, toàn bộ team PHẢI tuân thủ các quy định sau. Nguồn: [GOO-91](/GOO/issues/GOO-91) (chỉ thị từ CEO qua [GOO-88](/GOO/issues/GOO-88)).
|
||||||
|
|
||||||
|
1. **Commit ngay khi hoàn thành task** — mỗi task = một commit (hoặc một chuỗi commit nhỏ liên quan). Không gom nhiều task không liên quan vào một commit lớn.
|
||||||
|
2. **Pull/rebase trước khi push** — luôn chạy `git pull --rebase origin <branch>` trước `git push` để giảm merge conflict.
|
||||||
|
3. **Push ngay sau commit** — không giữ commit local quá 1 ngày làm việc. Commit không push = rủi ro mất việc + conflict tăng.
|
||||||
|
4. **Conventional Commits** — bắt buộc (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`, `test:`, `style:`, `perf:`). Xem [Quy Ước Commit](#quy-ước-commit) bên dưới.
|
||||||
|
5. **KHÔNG push trực tiếp lên `main` / `master`** — luôn dùng feature branch + Pull Request. Branch chính được bảo vệ bằng GitHub branch protection rules.
|
||||||
|
6. **PR phải pass CI** (`lint` → `typecheck` → `test` → `build`) trước khi merge. PR đỏ CI không được merge dù đã approve.
|
||||||
|
7. **Squash-merge khi merge PR** — giữ history trên `main` sạch, mỗi PR = một commit logic.
|
||||||
|
8. **Xóa feature branch sau khi merge** — tránh branch sprawl. GitHub có auto-delete branch sau merge; bật nó trong repo settings.
|
||||||
|
|
||||||
|
### Flow nhanh cho mỗi task
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Tạo/chuyển sang feature branch (KHÔNG commit trực tiếp vào main)
|
||||||
|
git checkout -b feature/goo-xx-short-description
|
||||||
|
|
||||||
|
# 2. Làm việc, khi hoàn thành task:
|
||||||
|
git add <files>
|
||||||
|
git commit -m "feat(scope): mô tả ngắn"
|
||||||
|
|
||||||
|
# 3. Đồng bộ & push
|
||||||
|
git pull --rebase origin main # hoặc develop
|
||||||
|
git push -u origin feature/goo-xx-short-description
|
||||||
|
|
||||||
|
# 4. Mở PR, chờ CI xanh + review, squash-merge, xóa branch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quy Trình Git & Branching
|
## Quy Trình Git & Branching
|
||||||
|
|
||||||
### Nhánh Chính
|
### Nhánh Chính
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { PrismaAVMService } from '../services/prisma-avm.service';
|
|||||||
|
|
||||||
describe('PrismaAVMService', () => {
|
describe('PrismaAVMService', () => {
|
||||||
let service: PrismaAVMService;
|
let service: PrismaAVMService;
|
||||||
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn>; $queryRawUnsafe: ReturnType<typeof vi.fn> };
|
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockPrisma = {
|
mockPrisma = {
|
||||||
$queryRaw: vi.fn(),
|
$queryRaw: vi.fn(),
|
||||||
$queryRawUnsafe: vi.fn(),
|
|
||||||
};
|
};
|
||||||
service = new PrismaAVMService(mockPrisma as unknown as PrismaService);
|
service = new PrismaAVMService(mockPrisma as unknown as PrismaService);
|
||||||
});
|
});
|
||||||
@@ -29,12 +28,13 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns zero confidence when fewer than 3 comparables', async () => {
|
it('returns zero confidence when fewer than 3 comparables', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
])
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||||
|
|
||||||
@@ -44,14 +44,15 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calculates weighted valuation with sufficient comparables', async () => {
|
it('calculates weighted valuation with sufficient comparables', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
])
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
.mockResolvedValueOnce([
|
||||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||||
]);
|
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ describe('PrismaAVMService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses coordinates directly when no propertyId', async () => {
|
it('uses coordinates directly when no propertyId', async () => {
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
mockPrisma.$queryRaw.mockResolvedValueOnce([
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||||
@@ -78,18 +79,20 @@ describe('PrismaAVMService', () => {
|
|||||||
|
|
||||||
expect(result.confidence).toBeGreaterThan(0);
|
expect(result.confidence).toBeGreaterThan(0);
|
||||||
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
|
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
|
||||||
expect(mockPrisma.$queryRaw).not.toHaveBeenCalled();
|
// Only one $queryRaw call (findComparables) — no getPropertyLocation needed
|
||||||
|
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getComparables', () => {
|
describe('getComparables', () => {
|
||||||
it('returns comparables for a property', async () => {
|
it('returns comparables for a property', async () => {
|
||||||
mockPrisma.$queryRaw.mockResolvedValue([
|
mockPrisma.$queryRaw
|
||||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
])
|
||||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
.mockResolvedValueOnce([
|
||||||
]);
|
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await service.getComparables('prop-1', 3000);
|
const result = await service.getComparables('prop-1', 3000);
|
||||||
|
|
||||||
|
|||||||
@@ -146,22 +146,35 @@ export class PrismaMarketIndexRepository implements IMarketIndexRepository {
|
|||||||
async getHeatmapWard(city: string, _period: string, district?: string): Promise<WardHeatmapDataPoint[]> {
|
async getHeatmapWard(city: string, _period: string, district?: string): Promise<WardHeatmapDataPoint[]> {
|
||||||
type WardRow = { ward: string; district: string; avg_price_m2: number; total_listings: bigint; median_price: bigint };
|
type WardRow = { ward: string; district: string; avg_price_m2: number; total_listings: bigint; median_price: bigint };
|
||||||
|
|
||||||
const districtFilter = district ? `AND p."district" = ${JSON.stringify(district)}` : '';
|
const rows = district
|
||||||
|
? await this.prisma.$queryRaw<WardRow[]>`
|
||||||
const rows = await this.prisma.$queryRawUnsafe<WardRow[]>(`
|
SELECT
|
||||||
SELECT
|
p."ward",
|
||||||
p."ward",
|
p."district",
|
||||||
p."district",
|
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
||||||
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
COUNT(l."id")::bigint AS total_listings,
|
||||||
COUNT(l."id")::bigint AS total_listings,
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
FROM "Property" p
|
||||||
FROM "Property" p
|
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
||||||
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
WHERE p."city" = ${city} AND p."district" = ${district}
|
||||||
WHERE p."city" = $1 ${districtFilter}
|
AND p."ward" IS NOT NULL AND p."ward" != ''
|
||||||
AND p."ward" IS NOT NULL AND p."ward" != ''
|
GROUP BY p."ward", p."district"
|
||||||
GROUP BY p."ward", p."district"
|
ORDER BY p."ward" ASC
|
||||||
ORDER BY p."ward" ASC
|
`
|
||||||
`, city);
|
: await this.prisma.$queryRaw<WardRow[]>`
|
||||||
|
SELECT
|
||||||
|
p."ward",
|
||||||
|
p."district",
|
||||||
|
AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8 AS avg_price_m2,
|
||||||
|
COUNT(l."id")::bigint AS total_listings,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
|
||||||
|
FROM "Property" p
|
||||||
|
JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
|
||||||
|
WHERE p."city" = ${city}
|
||||||
|
AND p."ward" IS NOT NULL AND p."ward" != ''
|
||||||
|
GROUP BY p."ward", p."district"
|
||||||
|
ORDER BY p."ward" ASC
|
||||||
|
`;
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
ward: r.ward,
|
ward: r.ward,
|
||||||
|
|||||||
@@ -136,23 +136,35 @@ export class PrismaAVMService implements IAVMService {
|
|||||||
propertyType: PropertyType | undefined,
|
propertyType: PropertyType | undefined,
|
||||||
radiusMeters: number,
|
radiusMeters: number,
|
||||||
): Promise<RawComparable[]> {
|
): Promise<RawComparable[]> {
|
||||||
const typeFilter = propertyType ? `AND p."propertyType" = '${propertyType}'` : '';
|
if (propertyType) {
|
||||||
return this.prisma.$queryRawUnsafe<RawComparable[]>(
|
return this.prisma.$queryRaw<RawComparable[]>`
|
||||||
`
|
SELECT
|
||||||
|
p.id AS property_id, p.address, p.district,
|
||||||
|
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
||||||
|
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
||||||
|
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters,
|
||||||
|
l."publishedAt" AS published_at
|
||||||
|
FROM "Property" p
|
||||||
|
JOIN "Listing" l ON l."propertyId" = p.id
|
||||||
|
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
||||||
|
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters})
|
||||||
|
AND p."propertyType" = ${propertyType}::"PropertyType"
|
||||||
|
ORDER BY distance_meters ASC LIMIT 20
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.$queryRaw<RawComparable[]>`
|
||||||
SELECT
|
SELECT
|
||||||
p.id AS property_id, p.address, p.district,
|
p.id AS property_id, p.address, p.district,
|
||||||
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
l."priceVND" AS price_vnd, l."pricePerM2" AS price_per_m2,
|
||||||
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
p."areaM2" AS area_m2, p."propertyType" AS property_type,
|
||||||
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) AS distance_meters,
|
ST_Distance(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography) AS distance_meters,
|
||||||
l."publishedAt" AS published_at
|
l."publishedAt" AS published_at
|
||||||
FROM "Property" p
|
FROM "Property" p
|
||||||
JOIN "Listing" l ON l."propertyId" = p.id
|
JOIN "Listing" l ON l."propertyId" = p.id
|
||||||
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
WHERE l.status = 'ACTIVE' AND l."publishedAt" IS NOT NULL
|
||||||
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, $3)
|
AND ST_DWithin(p.location::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${radiusMeters})
|
||||||
${typeFilter}
|
|
||||||
ORDER BY distance_meters ASC LIMIT 20
|
ORDER BY distance_meters ASC LIMIT 20
|
||||||
`,
|
`;
|
||||||
lng, lat, radiusMeters,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
129
apps/api/src/modules/read-models/README.md
Normal file
129
apps/api/src/modules/read-models/README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# `read-models` module
|
||||||
|
|
||||||
|
Phase 0 skeleton for the CQRS read-model expansion described in
|
||||||
|
[RFC-003](../../../docs/adr/0003-cqrs-read-models.md) (the ADR itself
|
||||||
|
lands with [GOO-193](/GOO/issues/GOO-193); until then RFC-003 lives on
|
||||||
|
[GOO-94](/GOO/issues/GOO-94)).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
read-models/
|
||||||
|
domain/
|
||||||
|
projection-context.ts # ProjectionContext, ProjectableEvent
|
||||||
|
projection-offset-store.ts # IProjectionOffsetStore port + DI symbol
|
||||||
|
read-repository.ts # IReadRepository convention marker
|
||||||
|
application/
|
||||||
|
projectors/
|
||||||
|
projector.base.ts # Projector<E> base class
|
||||||
|
repositories/ # I<Name>ReadRepository interfaces (Phase 2/3)
|
||||||
|
infrastructure/
|
||||||
|
refresh/ # mat-view refresh cron (Phase 1)
|
||||||
|
reconciliation/ # nightly drift checker (Phase 2+)
|
||||||
|
testing/
|
||||||
|
in-memory-projection-offset-store.ts # unit-test harness
|
||||||
|
read-models.module.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors the layout RFC-003 §5 specifies; intentionally **no
|
||||||
|
`presentation/`** because read models are infrastructure for other
|
||||||
|
modules' query handlers, not their own HTTP surface.
|
||||||
|
|
||||||
|
## The projector contract
|
||||||
|
|
||||||
|
Every read-model projector extends `Projector<E extends DomainEvent>`
|
||||||
|
and implements:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@EventsHandler(MyDomainEvent)
|
||||||
|
export class MyProjector extends Projector<MyDomainEvent> {
|
||||||
|
readonly handlerName = 'my-projector.v1';
|
||||||
|
|
||||||
|
protected async apply(event: MyDomainEvent, ctx: ProjectionContext) {
|
||||||
|
// write to your read model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// glue (one of):
|
||||||
|
@EventsHandler(MyDomainEvent)
|
||||||
|
export class MyProjectorGlue implements IEventHandler<MyDomainEvent> {
|
||||||
|
constructor(private readonly projector: MyProjector) {}
|
||||||
|
handle(event: MyDomainEvent) { return this.projector.dispatch(event); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Subclasses MUST:
|
||||||
|
|
||||||
|
- set `handlerName` to a **stable string** (rename = re-projection — be deliberate);
|
||||||
|
- implement `apply(event, ctx)` and treat `ctx.eventId` as the unit of idempotency.
|
||||||
|
|
||||||
|
Subclasses MUST NOT:
|
||||||
|
|
||||||
|
- call `apply` directly — always go through `dispatch(event)`;
|
||||||
|
- write to write-model tables — read models are read-only from the API
|
||||||
|
surface, only mutated by their owning projector or refresh job;
|
||||||
|
- implement their own deduplication — the base class already does it via
|
||||||
|
`IProjectionOffsetStore`.
|
||||||
|
|
||||||
|
## The offset / idempotency contract
|
||||||
|
|
||||||
|
RFC-003 §0 mandates `(eventId, handlerName)` idempotency:
|
||||||
|
|
||||||
|
> The `(eventId, handler)` offset table is non-negotiable. Land it in
|
||||||
|
> Phase 0 with a unit-test harness so every Phase 2/3 projector inherits it.
|
||||||
|
|
||||||
|
This module ships the **port** (`IProjectionOffsetStore`,
|
||||||
|
`PROJECTION_OFFSET_STORE`) and an in-memory implementation for tests.
|
||||||
|
The Prisma-backed implementation — including the
|
||||||
|
`projection_offset(event_id, handler_name, applied_at, payload_hash)`
|
||||||
|
migration and the transactional wrapper — lands with
|
||||||
|
[GOO-187](/GOO/issues/GOO-187).
|
||||||
|
|
||||||
|
The base class enforces the contract by calling `recordIfAbsent` BEFORE
|
||||||
|
`apply`. Re-deliveries observe `applied: false` and are skipped. The
|
||||||
|
offset row is intentionally **not rolled back on `apply` failure** in
|
||||||
|
Phase 0 — this is the conservative choice (RFC-003 §7) and is healed by
|
||||||
|
the nightly reconciliation job that lands in Phase 2.
|
||||||
|
|
||||||
|
`eventId` is currently derived from
|
||||||
|
`${aggregateId}:${occurredAt.getTime()}:${eventName}` because the
|
||||||
|
existing `DomainEvent` interface (`apps/api/src/modules/shared/domain/domain-event.ts`)
|
||||||
|
does not yet carry a stable id. Override `deriveEventId` on your
|
||||||
|
projector if your event type provides one. The id contract itself is
|
||||||
|
finalised in [GOO-187](/GOO/issues/GOO-187); Phase 2/3 projectors should
|
||||||
|
not bake assumptions about its format.
|
||||||
|
|
||||||
|
## The repository convention
|
||||||
|
|
||||||
|
For each read model:
|
||||||
|
|
||||||
|
1. Define `I<Name>ReadRepository` (extending `IReadRepository`) under
|
||||||
|
`application/repositories/`.
|
||||||
|
2. Export a paired injection symbol `<NAME>_READ_REPOSITORY`.
|
||||||
|
3. Implement `Prisma<Name>ReadRepository` under
|
||||||
|
`infrastructure/repositories/` and bind it in `ReadModelsModule`.
|
||||||
|
4. Re-export the symbol from the module's `index.ts` so query handlers in
|
||||||
|
other modules can `@Inject(LISTING_CARD_READ_REPOSITORY)` without
|
||||||
|
reaching into the read-models module's internals.
|
||||||
|
|
||||||
|
Read repositories are READ-ONLY from the perspective of the rest of the
|
||||||
|
API. The only writers are the projector that owns the read model (Option
|
||||||
|
C) or the materialized-view refresh job (Option B).
|
||||||
|
|
||||||
|
## What Phase 0 is NOT
|
||||||
|
|
||||||
|
- No `projection_offset` migration — owned by [GOO-187](/GOO/issues/GOO-187).
|
||||||
|
- No projectors registered.
|
||||||
|
- No materialized views or refresh job — Phase 1.
|
||||||
|
- No reconciliation job — Phase 2.
|
||||||
|
- No `X-Data-Freshness-Seconds` helper — separate Phase 0 ticket.
|
||||||
|
- No kill-switch / chaos test — separate Phase 0 ticket.
|
||||||
|
|
||||||
|
The skeleton exists so the next batch of PRs is purely additive.
|
||||||
|
|
||||||
|
## Coordination
|
||||||
|
|
||||||
|
- Parent: [GOO-94](/GOO/issues/GOO-94)
|
||||||
|
- Sibling (offset table + idempotency harness): [GOO-187](/GOO/issues/GOO-187)
|
||||||
|
- ADR (write-up): [GOO-193](/GOO/issues/GOO-193)
|
||||||
1
apps/api/src/modules/read-models/application/index.ts
Normal file
1
apps/api/src/modules/read-models/application/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './projectors';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Projector } from './projector.base';
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
PROJECTION_OFFSET_STORE,
|
||||||
|
type IProjectionOffsetStore,
|
||||||
|
type ProjectableEvent,
|
||||||
|
type ProjectionContext,
|
||||||
|
} from '../../domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class every read-model projector inherits from.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Owns the typed `apply(event, ctx)` hook subclasses implement.
|
||||||
|
* - Delegates the `(eventId, handlerName)` idempotency check to
|
||||||
|
* {@link IProjectionOffsetStore} (port from `domain/`,
|
||||||
|
* Prisma implementation from [GOO-187](/GOO/issues/GOO-187)).
|
||||||
|
* - Emits a structured log line with projector lag for observability
|
||||||
|
* (`X-Data-Freshness-Seconds` SLO, RFC-003 §0).
|
||||||
|
*
|
||||||
|
* Subclasses do NOT call `apply` directly — they invoke {@link dispatch}
|
||||||
|
* from their `@EventsHandler` / `@OnEvent` glue. `dispatch` enforces the
|
||||||
|
* "at-least-once → effectively-once" contract.
|
||||||
|
*
|
||||||
|
* Subclasses MUST:
|
||||||
|
* - Set `handlerName` (stable identifier — used as the offset key half).
|
||||||
|
* - Implement `apply(event, ctx)`.
|
||||||
|
*
|
||||||
|
* Subclasses MAY:
|
||||||
|
* - Override `deriveEventId(event)` if their event type carries a
|
||||||
|
* stable id field. Default derivation is
|
||||||
|
* `${aggregateId}:${occurredAt.getTime()}:${eventName}` — sufficient
|
||||||
|
* for current domain events but NOT for events fanned out via
|
||||||
|
* external transports (revisit when CDC lands, RFC-003 Option D).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class Projector<E extends ProjectableEvent> {
|
||||||
|
/** Stable handler identifier — second half of the offset key. */
|
||||||
|
abstract readonly handlerName: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(PROJECTION_OFFSET_STORE)
|
||||||
|
protected readonly offsets: IProjectionOffsetStore,
|
||||||
|
protected readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implement the actual projection. MUST be deterministic given
|
||||||
|
* `(event, ctx)` and MUST be safe to short-circuit if `ctx` indicates
|
||||||
|
* a re-delivery (the base class already enforces this — subclasses
|
||||||
|
* should not re-check).
|
||||||
|
*/
|
||||||
|
protected abstract apply(event: E, ctx: ProjectionContext): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional hook so subclasses can override how `eventId` is derived
|
||||||
|
* from a domain event. Override this if your event type carries a
|
||||||
|
* stable id (e.g. UUID minted by the producer).
|
||||||
|
*/
|
||||||
|
protected deriveEventId(event: E): string {
|
||||||
|
return `${event.aggregateId}:${event.occurredAt.getTime()}:${event.eventName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point invoked by the projector's framework glue
|
||||||
|
* (`@EventsHandler` / `@OnEvent`). Wraps `apply` with the offset
|
||||||
|
* idempotency check and emits a lag log line on success.
|
||||||
|
*/
|
||||||
|
async dispatch(event: E): Promise<void> {
|
||||||
|
const observedAt = new Date();
|
||||||
|
const ctx: ProjectionContext = {
|
||||||
|
eventId: this.deriveEventId(event),
|
||||||
|
handlerName: this.handlerName,
|
||||||
|
observedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { applied } = await this.offsets.recordIfAbsent({
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
handlerName: ctx.handlerName,
|
||||||
|
appliedAt: observedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!applied) {
|
||||||
|
// Re-delivery — already projected. No-op by contract.
|
||||||
|
this.logger.debug(
|
||||||
|
`Projector ${this.handlerName} skipped duplicate event ${ctx.eventId}`,
|
||||||
|
'Projector',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.apply(event, ctx);
|
||||||
|
const lagMs = observedAt.getTime() - event.occurredAt.getTime();
|
||||||
|
this.logger.debug(
|
||||||
|
`Projector ${this.handlerName} applied event ${ctx.eventId} (lag=${lagMs}ms)`,
|
||||||
|
'Projector',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Surface the failure with the offset key so reconciliation can
|
||||||
|
// reason about partially-applied state. Note that the offset row
|
||||||
|
// is already inserted — Phase 0 deliberately does NOT roll it back.
|
||||||
|
// RFC-003 §7 covers this with the nightly reconciliation job.
|
||||||
|
this.logger.error(
|
||||||
|
`Projector ${this.handlerName} failed for event ${ctx.eventId}: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
'Projector',
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Per-read-model repository interfaces live in this folder once Phase 2/3
|
||||||
|
* begin landing concrete read models. Phase 0 ships only the convention
|
||||||
|
* (see `domain/read-repository.ts`):
|
||||||
|
*
|
||||||
|
* - One interface per read model: `I<Name>ReadRepository`.
|
||||||
|
* - Paired injection symbol: `<NAME>_READ_REPOSITORY`.
|
||||||
|
* - Read-only — writes go through projectors or refresh jobs.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
17
apps/api/src/modules/read-models/domain/index.ts
Normal file
17
apps/api/src/modules/read-models/domain/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export {
|
||||||
|
type ProjectionContext,
|
||||||
|
type ProjectableEvent,
|
||||||
|
} from './projection-context';
|
||||||
|
export {
|
||||||
|
PROJECTION_OFFSET_STORE,
|
||||||
|
type IProjectionOffsetStore,
|
||||||
|
type ProjectionOffsetKey,
|
||||||
|
type ProjectionOffsetRecord,
|
||||||
|
type RecordOffsetInput,
|
||||||
|
type RecordOffsetResult,
|
||||||
|
} from './projection-offset-store';
|
||||||
|
export { type IReadRepository } from './read-repository';
|
||||||
|
export {
|
||||||
|
READ_MODEL_KILL_SWITCH,
|
||||||
|
type IReadModelKillSwitch,
|
||||||
|
} from './read-model-kill-switch';
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-event context handed to a projector's `apply` hook.
|
||||||
|
*
|
||||||
|
* Phase 0 keeps this intentionally minimal. Later phases may attach
|
||||||
|
* tracing spans, current offset metadata, or a transaction handle here
|
||||||
|
* — additions MUST stay backward-compatible (additive properties only).
|
||||||
|
*/
|
||||||
|
export interface ProjectionContext {
|
||||||
|
/**
|
||||||
|
* Stable identifier for the event being projected. Used as the primary
|
||||||
|
* key half of `(eventId, handlerName)` in the offset store so re-delivery
|
||||||
|
* is a no-op.
|
||||||
|
*
|
||||||
|
* NOTE: domain events do not yet carry a stable id; until they do
|
||||||
|
* the wrapper that invokes a projector is responsible for deriving one
|
||||||
|
* (typically `${aggregateId}:${occurredAt.getTime()}:${eventName}`).
|
||||||
|
* This contract is fixed in [GOO-187](/GOO/issues/GOO-187).
|
||||||
|
*/
|
||||||
|
readonly eventId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The projector handler invoking `apply`. Used as the second half of
|
||||||
|
* the `(eventId, handlerName)` offset key — the same event projected
|
||||||
|
* by two different handlers must record two separate offsets.
|
||||||
|
*/
|
||||||
|
readonly handlerName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the event was observed by the projector dispatcher (NOT when
|
||||||
|
* the event itself occurred — see `event.occurredAt`). Useful for
|
||||||
|
* lag metrics: `observedAt - event.occurredAt`.
|
||||||
|
*/
|
||||||
|
readonly observedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projector-facing view of a domain event. Re-exported here so projector
|
||||||
|
* code does not have to reach across to `@modules/shared` for the base
|
||||||
|
* type — keeps the read-models module's public surface self-contained.
|
||||||
|
*/
|
||||||
|
export type ProjectableEvent = DomainEvent;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Idempotency contract for projector dispatch.
|
||||||
|
*
|
||||||
|
* RFC-003 §0 (CTO ask): the `(eventId, handlerName)` offset table is
|
||||||
|
* non-negotiable. Phase 0 lands this *port* so that:
|
||||||
|
*
|
||||||
|
* 1. The projector base class can express the contract in code today
|
||||||
|
* (without taking a Prisma dependency at this layer).
|
||||||
|
* 2. [GOO-187](/GOO/issues/GOO-187) lands the Prisma migration
|
||||||
|
* (`projection_offset(event_id, handler_name, applied_at, payload_hash)`)
|
||||||
|
* and the concrete implementation against this same interface.
|
||||||
|
* 3. The unit-test harness in `read-models/testing` can ship an
|
||||||
|
* in-memory implementation without coupling to infra.
|
||||||
|
*
|
||||||
|
* Implementations MUST be safe under concurrent dispatch: the
|
||||||
|
* `recordIfAbsent` call is the linearisation point — exactly one caller
|
||||||
|
* for a given `(eventId, handlerName)` should observe `applied: true`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProjectionOffsetKey {
|
||||||
|
readonly eventId: string;
|
||||||
|
readonly handlerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectionOffsetRecord extends ProjectionOffsetKey {
|
||||||
|
/** When this offset was first recorded (i.e. the projection ran). */
|
||||||
|
readonly appliedAt: Date;
|
||||||
|
/**
|
||||||
|
* Optional content-hash of the projected payload. Reconciliation jobs
|
||||||
|
* use this to spot drift between what was projected and what the
|
||||||
|
* write-side now holds.
|
||||||
|
*/
|
||||||
|
readonly payloadHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordOffsetInput extends ProjectionOffsetKey {
|
||||||
|
readonly appliedAt?: Date;
|
||||||
|
readonly payloadHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordOffsetResult {
|
||||||
|
/**
|
||||||
|
* `true` if this call inserted a fresh offset row (the projection
|
||||||
|
* should run); `false` if the offset already existed (re-delivery,
|
||||||
|
* the projection MUST be skipped).
|
||||||
|
*/
|
||||||
|
readonly applied: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectionOffsetStore {
|
||||||
|
/**
|
||||||
|
* Atomically insert an offset row if and only if no row exists for
|
||||||
|
* `(eventId, handlerName)`. Implementations typically use
|
||||||
|
* `INSERT ... ON CONFLICT DO NOTHING` or an equivalent unique-constraint
|
||||||
|
* insert and report whether a row was actually written.
|
||||||
|
*/
|
||||||
|
recordIfAbsent(input: RecordOffsetInput): Promise<RecordOffsetResult>;
|
||||||
|
|
||||||
|
/** Lookup helper for reconciliation tooling and tests. */
|
||||||
|
find(key: ProjectionOffsetKey): Promise<ProjectionOffsetRecord | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROJECTION_OFFSET_STORE = Symbol('PROJECTION_OFFSET_STORE');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Per-read-model kill switch — RFC-003 §0.
|
||||||
|
*
|
||||||
|
* Contract:
|
||||||
|
* - `isEnabled(name)` returns whether the named read model should
|
||||||
|
* serve queries. When `false`, callers MUST fail open to the
|
||||||
|
* write-model path.
|
||||||
|
* - Implementations MUST be hot-readable (no restart required).
|
||||||
|
* - The check is evaluated per-call so that a flag toggled mid-request
|
||||||
|
* is honoured on the NEXT repository call within the same request.
|
||||||
|
* In-flight calls complete against whichever source they already
|
||||||
|
* started on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const READ_MODEL_KILL_SWITCH = Symbol('READ_MODEL_KILL_SWITCH');
|
||||||
|
|
||||||
|
export interface IReadModelKillSwitch {
|
||||||
|
/**
|
||||||
|
* Returns `true` when the named read model is active and safe to query.
|
||||||
|
* Returns `true` (fail-open) for unknown / un-configured names so that
|
||||||
|
* an absent config key never blocks the write-model fallback path.
|
||||||
|
*/
|
||||||
|
isEnabled(readModelName: string): boolean;
|
||||||
|
}
|
||||||
28
apps/api/src/modules/read-models/domain/read-repository.ts
Normal file
28
apps/api/src/modules/read-models/domain/read-repository.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Marker interface for read-model repositories.
|
||||||
|
*
|
||||||
|
* Convention (Phase 0):
|
||||||
|
* - One interface per read model: `I<Name>ReadRepository`.
|
||||||
|
* - Paired injection symbol: `<NAME>_READ_REPOSITORY` (Symbol).
|
||||||
|
* - Concrete Prisma-backed class lives under
|
||||||
|
* `infrastructure/repositories/prisma-<name>-read.repository.ts`.
|
||||||
|
* - Read repositories are READ-ONLY. Writes happen exclusively via
|
||||||
|
* projectors (Option C) or scheduled refresh jobs (Option B).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* export const LISTING_CARD_READ_REPOSITORY = Symbol('LISTING_CARD_READ_REPOSITORY');
|
||||||
|
*
|
||||||
|
* export interface IListingCardReadRepository extends IReadRepository {
|
||||||
|
* findById(id: string): Promise<ListingCardReadView | null>;
|
||||||
|
* search(params: ListingCardSearchParams): Promise<PaginatedResult<ListingCardReadView>>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Keeping this as an empty marker (rather than forcing a `findById`
|
||||||
|
* shape) lets Phase 2/3 read repositories pick the access pattern that
|
||||||
|
* fits the read model — point lookup, search, range scan, etc.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
export interface IReadRepository {}
|
||||||
4
apps/api/src/modules/read-models/index.ts
Normal file
4
apps/api/src/modules/read-models/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { ReadModelsModule } from './read-models.module';
|
||||||
|
export * from './domain';
|
||||||
|
export * from './application';
|
||||||
|
export * from './infrastructure';
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { type IReadModelKillSwitch } from '../../domain/read-model-kill-switch';
|
||||||
|
import { ReadModelRepositoryWrapper } from '../read-model-repository-wrapper';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Shared test doubles */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface IFakeRepository {
|
||||||
|
findById(id: string): Promise<{ id: string; source: string }>;
|
||||||
|
search(query: string): Promise<{ results: string[]; source: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReadRepo(): IFakeRepository {
|
||||||
|
return {
|
||||||
|
findById: vi.fn(async (id: string) => ({ id, source: 'read-model' })),
|
||||||
|
search: vi.fn(async (q: string) => ({ results: [q], source: 'read-model' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWriteRepo(): IFakeRepository {
|
||||||
|
return {
|
||||||
|
findById: vi.fn(async (id: string) => ({ id, source: 'write-model' })),
|
||||||
|
search: vi.fn(async (q: string) => ({ results: [q], source: 'write-model' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const silentLogger = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
verbose: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Mutable kill switch for chaos testing */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
class MutableKillSwitch implements IReadModelKillSwitch {
|
||||||
|
private flags = new Map<string, boolean>();
|
||||||
|
|
||||||
|
setEnabled(name: string, enabled: boolean): void {
|
||||||
|
this.flags.set(name, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(name: string): boolean {
|
||||||
|
return this.flags.get(name) ?? true; // fail-open default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tests */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
describe('ReadModelRepositoryWrapper — kill switch', () => {
|
||||||
|
let killSwitch: MutableKillSwitch;
|
||||||
|
let readRepo: IFakeRepository;
|
||||||
|
let writeRepo: IFakeRepository;
|
||||||
|
let proxy: IFakeRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
killSwitch = new MutableKillSwitch();
|
||||||
|
readRepo = createReadRepo();
|
||||||
|
writeRepo = createWriteRepo();
|
||||||
|
const wrapper = new ReadModelRepositoryWrapper<IFakeRepository>(
|
||||||
|
readRepo,
|
||||||
|
writeRepo,
|
||||||
|
killSwitch,
|
||||||
|
'listing_card',
|
||||||
|
silentLogger,
|
||||||
|
);
|
||||||
|
proxy = wrapper.getProxy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to read-model when kill switch is ON (enabled)', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
const result = await proxy.findById('abc');
|
||||||
|
expect(result.source).toBe('read-model');
|
||||||
|
expect(readRepo.findById).toHaveBeenCalledWith('abc');
|
||||||
|
expect(writeRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to write-model when kill switch is OFF (disabled)', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
const result = await proxy.findById('abc');
|
||||||
|
expect(result.source).toBe('write-model');
|
||||||
|
expect(writeRepo.findById).toHaveBeenCalledWith('abc');
|
||||||
|
expect(readRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to enabled (fail-open) for unknown read model names', async () => {
|
||||||
|
// 'listing_card' was never set → defaults to true
|
||||||
|
const freshKillSwitch = new MutableKillSwitch();
|
||||||
|
const wrapper = new ReadModelRepositoryWrapper<IFakeRepository>(
|
||||||
|
readRepo,
|
||||||
|
writeRepo,
|
||||||
|
freshKillSwitch,
|
||||||
|
'unknown_model',
|
||||||
|
silentLogger,
|
||||||
|
);
|
||||||
|
const result = await wrapper.getProxy().findById('xyz');
|
||||||
|
expect(result.source).toBe('read-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- */
|
||||||
|
/* CHAOS TEST: flag toggle mid-request → fail-open */
|
||||||
|
/* -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
it('chaos: flag toggle mid-request fails open to write-model on NEXT call', async () => {
|
||||||
|
// Start enabled — first call goes to read-model.
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
|
||||||
|
const call1 = proxy.findById('first');
|
||||||
|
|
||||||
|
// Toggle the flag WHILE call1 is in-flight.
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
|
||||||
|
const result1 = await call1;
|
||||||
|
// call1 already started on read-model — it completes there.
|
||||||
|
expect(result1.source).toBe('read-model');
|
||||||
|
|
||||||
|
// NEXT call should route to write-model (the switch was flipped).
|
||||||
|
const result2 = await proxy.findById('second');
|
||||||
|
expect(result2.source).toBe('write-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaos: rapid toggle during sequential calls routes correctly', async () => {
|
||||||
|
// Simulate a chaotic sequence of toggles interleaved with calls.
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
const r1 = await proxy.search('q1');
|
||||||
|
expect(r1.source).toBe('read-model');
|
||||||
|
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
const r2 = await proxy.search('q2');
|
||||||
|
expect(r2.source).toBe('write-model');
|
||||||
|
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
const r3 = await proxy.search('q3');
|
||||||
|
expect(r3.source).toBe('read-model');
|
||||||
|
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
const r4 = await proxy.search('q4');
|
||||||
|
expect(r4.source).toBe('write-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaos: concurrent calls with mid-flight toggle each route independently', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
|
||||||
|
// Slow read-model that takes time to resolve.
|
||||||
|
const slowReadRepo: IFakeRepository = {
|
||||||
|
findById: vi.fn(
|
||||||
|
(id: string) =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
setTimeout(() => resolve({ id, source: 'read-model' }), 50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
search: vi.fn(async (q: string) => ({ results: [q], source: 'read-model' })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = new ReadModelRepositoryWrapper<IFakeRepository>(
|
||||||
|
slowReadRepo,
|
||||||
|
writeRepo,
|
||||||
|
killSwitch,
|
||||||
|
'listing_card',
|
||||||
|
silentLogger,
|
||||||
|
);
|
||||||
|
const slowProxy = wrapper.getProxy();
|
||||||
|
|
||||||
|
// Launch first call (will use read-model, takes 50ms).
|
||||||
|
const p1 = slowProxy.findById('slow');
|
||||||
|
|
||||||
|
// Toggle off before call1 resolves.
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
|
||||||
|
// Second call should immediately route to write-model.
|
||||||
|
const p2 = slowProxy.findById('fast');
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
// call1 was already dispatched to read-model — completes there.
|
||||||
|
expect(result1.source).toBe('read-model');
|
||||||
|
// call2 was dispatched after toggle — goes to write-model.
|
||||||
|
expect(result2.source).toBe('write-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaos: zero error bubble to caller when switch is off', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
|
||||||
|
// Both methods should work without throwing.
|
||||||
|
await expect(proxy.findById('x')).resolves.toBeDefined();
|
||||||
|
await expect(proxy.search('y')).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConfigReadModelKillSwitch', () => {
|
||||||
|
// Unit test the config-backed implementation separately.
|
||||||
|
it('reads env var per call (hot-readable)', async () => {
|
||||||
|
const { ConfigReadModelKillSwitch } = await import(
|
||||||
|
'../config-read-model-kill-switch'
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === 'READ_MODEL_LISTING_CARD_ENABLED') return 'false';
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ks = new ConfigReadModelKillSwitch(mockConfig as any, silentLogger);
|
||||||
|
|
||||||
|
expect(ks.isEnabled('listing_card')).toBe(false);
|
||||||
|
expect(ks.isEnabled('unknown')).toBe(true); // fail-open
|
||||||
|
|
||||||
|
// Simulate hot-reload by changing the mock return.
|
||||||
|
mockConfig.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'READ_MODEL_LISTING_CARD_ENABLED') return 'true';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ks.isEnabled('listing_card')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats "0" as disabled', async () => {
|
||||||
|
const { ConfigReadModelKillSwitch } = await import(
|
||||||
|
'../config-read-model-kill-switch'
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
get: vi.fn(() => '0'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ks = new ConfigReadModelKillSwitch(mockConfig as any, silentLogger);
|
||||||
|
expect(ks.isEnabled('any_model')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { type IReadModelKillSwitch } from '../domain/read-model-kill-switch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config-driven per-read-model kill switch.
|
||||||
|
*
|
||||||
|
* Reads `READ_MODEL_<UPPER_SNAKE_NAME>_ENABLED` from process.env via
|
||||||
|
* NestJS ConfigService on every call (hot-readable — no restart needed).
|
||||||
|
*
|
||||||
|
* Missing keys default to `true` (fail-open: the read model is presumed
|
||||||
|
* healthy unless explicitly killed).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* `READ_MODEL_LISTING_CARD_ENABLED=false` → listing_card read model disabled
|
||||||
|
* (env var absent) → read model enabled (fail-open)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ConfigReadModelKillSwitch implements IReadModelKillSwitch {
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isEnabled(readModelName: string): boolean {
|
||||||
|
const envKey = `READ_MODEL_${readModelName.replace(/-/g, '_').toUpperCase()}_ENABLED`;
|
||||||
|
const raw = this.config.get<string>(envKey);
|
||||||
|
|
||||||
|
// Missing config → fail open (enabled).
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = raw !== 'false' && raw !== '0';
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Kill switch OFF for read model "${readModelName}" (${envKey}=${raw})`,
|
||||||
|
'ReadModelKillSwitch',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/api/src/modules/read-models/infrastructure/index.ts
Normal file
4
apps/api/src/modules/read-models/infrastructure/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './refresh';
|
||||||
|
export * from './reconciliation';
|
||||||
|
export { ConfigReadModelKillSwitch } from './config-read-model-kill-switch';
|
||||||
|
export { ReadModelRepositoryWrapper } from './read-model-repository-wrapper';
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { type IReadModelKillSwitch } from '../domain/read-model-kill-switch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic wrapper that sits in front of a read-model repository and
|
||||||
|
* transparently fails open to the write-model repository when the
|
||||||
|
* per-read-model kill switch is OFF.
|
||||||
|
*
|
||||||
|
* Every public method call checks the kill switch. Because the check
|
||||||
|
* happens per-call (not per-request), a flag toggled mid-request is
|
||||||
|
* honoured on the NEXT call — the in-flight call completes against
|
||||||
|
* whichever source it already started on.
|
||||||
|
*
|
||||||
|
* Usage (at module wiring time):
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const wrapper = new ReadModelRepositoryWrapper(
|
||||||
|
* readRepo,
|
||||||
|
* writeRepo,
|
||||||
|
* killSwitch,
|
||||||
|
* 'listing_card',
|
||||||
|
* logger,
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `T` is the repository interface both implementations share (the
|
||||||
|
* intersection of methods callable by consumers).
|
||||||
|
*/
|
||||||
|
export class ReadModelRepositoryWrapper<T extends object> {
|
||||||
|
private readonly proxy: T;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly readImpl: T,
|
||||||
|
private readonly writeImpl: T,
|
||||||
|
private readonly killSwitch: IReadModelKillSwitch,
|
||||||
|
private readonly readModelName: string,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {
|
||||||
|
// Build a Proxy that intercepts every method call and routes it
|
||||||
|
// through the kill switch.
|
||||||
|
this.proxy = new Proxy(readImpl, {
|
||||||
|
get: (_target, prop, _receiver) => {
|
||||||
|
const readVal = (readImpl as Record<string | symbol, unknown>)[prop];
|
||||||
|
const writeVal = (writeImpl as Record<string | symbol, unknown>)[prop];
|
||||||
|
|
||||||
|
// Non-function properties: always return from the active source.
|
||||||
|
if (typeof readVal !== 'function') {
|
||||||
|
return this.killSwitch.isEnabled(this.readModelName) ? readVal : writeVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function: return a wrapper that checks the switch at call time.
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
if (this.killSwitch.isEnabled(this.readModelName)) {
|
||||||
|
return (readVal as Function).apply(readImpl, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Kill switch routed ${this.readModelName}.${String(prop)} → write-model`,
|
||||||
|
'ReadModelRepositoryWrapper',
|
||||||
|
);
|
||||||
|
if (typeof writeVal !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
`Write-model fallback for ${this.readModelName} does not implement ${String(prop)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (writeVal as Function).apply(writeImpl, args);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the proxied repository that consumers should depend on.
|
||||||
|
* Inject this as the repository token value.
|
||||||
|
*/
|
||||||
|
getProxy(): T {
|
||||||
|
return this.proxy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Reconciliation infrastructure (RFC-003 §7).
|
||||||
|
*
|
||||||
|
* Phase 0 ships only the placeholder. Phase 2 lands the sampled nightly
|
||||||
|
* (1%) drift checker; the weekly full reconciliation runs follow once
|
||||||
|
* Phase 2 has soaked in production for one cycle.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Materialized-view refresh infrastructure (RFC-003 Option B).
|
||||||
|
*
|
||||||
|
* Phase 0 ships only the placeholder. Phase 1 lands
|
||||||
|
* `RefreshMaterializedViewJob` and the cron registrations for
|
||||||
|
* `mv_heatmap_district`, `mv_heatmap_ward`, `mv_market_snapshot`,
|
||||||
|
* `mv_district_stats`.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
34
apps/api/src/modules/read-models/read-models.module.ts
Normal file
34
apps/api/src/modules/read-models/read-models.module.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
|
import { SharedModule } from '@modules/shared';
|
||||||
|
import { READ_MODEL_KILL_SWITCH } from './domain/read-model-kill-switch';
|
||||||
|
import { ConfigReadModelKillSwitch } from './infrastructure/config-read-model-kill-switch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-models module skeleton — RFC-003 Phase 0.
|
||||||
|
*
|
||||||
|
* Hosts:
|
||||||
|
* - Projector base class (`application/projectors/projector.base.ts`).
|
||||||
|
* - Read-model repository convention (`domain/read-repository.ts`).
|
||||||
|
* - Idempotency port (`domain/projection-offset-store.ts`).
|
||||||
|
* - Per-read-model kill switch (`domain/read-model-kill-switch.ts`).
|
||||||
|
*
|
||||||
|
* No projectors, repositories, or `IProjectionOffsetStore` provider are
|
||||||
|
* registered here yet. The Prisma-backed offset store binding lands with
|
||||||
|
* [GOO-187](/GOO/issues/GOO-187); per-read-model projectors land in
|
||||||
|
* Phase 2/3.
|
||||||
|
*
|
||||||
|
* The module is imported by `AppModule` so its DI container is wired up
|
||||||
|
* even while empty — keeps Phase 2/3 PRs strictly additive.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [CqrsModule, SharedModule],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: READ_MODEL_KILL_SWITCH,
|
||||||
|
useClass: ConfigReadModelKillSwitch,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [READ_MODEL_KILL_SWITCH],
|
||||||
|
})
|
||||||
|
export class ReadModelsModule {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
type IProjectionOffsetStore,
|
||||||
|
type ProjectionOffsetKey,
|
||||||
|
type ProjectionOffsetRecord,
|
||||||
|
type RecordOffsetInput,
|
||||||
|
type RecordOffsetResult,
|
||||||
|
} from '../domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory {@link IProjectionOffsetStore} for unit tests.
|
||||||
|
*
|
||||||
|
* Phase 2/3 projector tests reuse this so they can exercise the
|
||||||
|
* "replay same event N times → single state mutation" contract from
|
||||||
|
* RFC-003 §0 without spinning up Postgres. The Prisma-backed
|
||||||
|
* implementation lives in [GOO-187](/GOO/issues/GOO-187).
|
||||||
|
*/
|
||||||
|
export class InMemoryProjectionOffsetStore implements IProjectionOffsetStore {
|
||||||
|
private readonly rows = new Map<string, ProjectionOffsetRecord>();
|
||||||
|
|
||||||
|
private static key(k: ProjectionOffsetKey): string {
|
||||||
|
return `${k.handlerName}::${k.eventId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordIfAbsent(input: RecordOffsetInput): Promise<RecordOffsetResult> {
|
||||||
|
const k = InMemoryProjectionOffsetStore.key(input);
|
||||||
|
if (this.rows.has(k)) {
|
||||||
|
return { applied: false };
|
||||||
|
}
|
||||||
|
this.rows.set(k, {
|
||||||
|
eventId: input.eventId,
|
||||||
|
handlerName: input.handlerName,
|
||||||
|
appliedAt: input.appliedAt ?? new Date(),
|
||||||
|
...(input.payloadHash !== undefined ? { payloadHash: input.payloadHash } : {}),
|
||||||
|
});
|
||||||
|
return { applied: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(key: ProjectionOffsetKey): Promise<ProjectionOffsetRecord | null> {
|
||||||
|
return this.rows.get(InMemoryProjectionOffsetStore.key(key)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper. */
|
||||||
|
size(): number {
|
||||||
|
return this.rows.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper. */
|
||||||
|
clear(): void {
|
||||||
|
this.rows.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/src/modules/read-models/testing/index.ts
Normal file
1
apps/api/src/modules/read-models/testing/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { InMemoryProjectionOffsetStore } from './in-memory-projection-offset-store';
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { LoggerService } from '@modules/shared';
|
||||||
|
import { PaymentCallbackPurgeService } from '../services/payment-callback-purge.service';
|
||||||
|
import type { RetentionRunLogRepository } from '../../infrastructure/repositories/retention-run-log.repository';
|
||||||
|
|
||||||
|
describe('PaymentCallbackPurgeService (stub)', () => {
|
||||||
|
let logger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn> };
|
||||||
|
let runLog: { start: ReturnType<typeof vi.fn>; markFinished: ReturnType<typeof vi.fn>; markFailed: ReturnType<typeof vi.fn> };
|
||||||
|
let service: PaymentCallbackPurgeService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
logger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||||
|
runLog = {
|
||||||
|
start: vi.fn().mockResolvedValue('run-X'),
|
||||||
|
markFinished: vi.fn().mockResolvedValue(undefined),
|
||||||
|
markFailed: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
service = new PaymentCallbackPurgeService(
|
||||||
|
logger as unknown as LoggerService,
|
||||||
|
runLog as unknown as RetentionRunLogRepository,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 2, 3] as const)('records a SUCCESS run with rowsAffected=0 for phase %s', async (phase) => {
|
||||||
|
const result = await service.run(phase);
|
||||||
|
expect(runLog.start).toHaveBeenCalledWith(expect.objectContaining({ job: 'payment-callback-purge', phase }));
|
||||||
|
expect(runLog.markFinished).toHaveBeenCalledWith('run-X', 0);
|
||||||
|
expect(result).toEqual({ rowsAffected: 0, runId: 'run-X' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects strictly older cutoffs per higher phase (2y < 5y < 10y)', async () => {
|
||||||
|
const fixed = new Date('2030-01-01T00:00:00Z').getTime();
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(fixed);
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
logger.warn = vi.fn((msg: string) => calls.push(msg)) as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
await service.run(1);
|
||||||
|
await service.run(2);
|
||||||
|
await service.run(3);
|
||||||
|
|
||||||
|
const cutoffs = calls.map((m) => /cutoff=([^)]+)/.exec(m)?.[1] ?? '');
|
||||||
|
expect(cutoffs).toHaveLength(3);
|
||||||
|
expect(cutoffs[0]! > cutoffs[1]!).toBe(true);
|
||||||
|
expect(cutoffs[1]! > cutoffs[2]!).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { LoggerService, PrismaService } from '@modules/shared';
|
||||||
|
import { RefreshTokenPurgeService } from '../services/refresh-token-purge.service';
|
||||||
|
import type { RetentionRunLogRepository } from '../../infrastructure/repositories/retention-run-log.repository';
|
||||||
|
|
||||||
|
describe('RefreshTokenPurgeService', () => {
|
||||||
|
let prisma: { $queryRaw: ReturnType<typeof vi.fn>; refreshToken: { count: ReturnType<typeof vi.fn> } };
|
||||||
|
let logger: { log: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn> };
|
||||||
|
let runLog: { start: ReturnType<typeof vi.fn>; markFinished: ReturnType<typeof vi.fn>; markFailed: ReturnType<typeof vi.fn> };
|
||||||
|
let service: RefreshTokenPurgeService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = { $queryRaw: vi.fn(), refreshToken: { count: vi.fn() } };
|
||||||
|
logger = { log: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||||
|
runLog = {
|
||||||
|
start: vi.fn().mockResolvedValue('run-1'),
|
||||||
|
markFinished: vi.fn().mockResolvedValue(undefined),
|
||||||
|
markFailed: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
service = new RefreshTokenPurgeService(
|
||||||
|
prisma as unknown as PrismaService,
|
||||||
|
logger as unknown as LoggerService,
|
||||||
|
runLog as unknown as RetentionRunLogRepository,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts a run, deletes batches, and marks finished with total rowsAffected', async () => {
|
||||||
|
prisma.$queryRaw
|
||||||
|
.mockResolvedValueOnce(new Array(1000).fill(0).map((_, i) => ({ id: `r${i}` })))
|
||||||
|
.mockResolvedValueOnce([{ id: 'r1000' }]);
|
||||||
|
|
||||||
|
const result = await service.run();
|
||||||
|
|
||||||
|
expect(runLog.start).toHaveBeenCalledWith(expect.objectContaining({ job: 'refresh-token-purge' }));
|
||||||
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runLog.markFinished).toHaveBeenCalledWith('run-1', 1001);
|
||||||
|
expect(result).toEqual({ rowsAffected: 1001, runId: 'run-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the run as FAILED when the delete query throws', async () => {
|
||||||
|
const boom = new Error('connection lost');
|
||||||
|
prisma.$queryRaw.mockRejectedValue(boom);
|
||||||
|
|
||||||
|
await expect(service.run()).rejects.toThrow(boom);
|
||||||
|
|
||||||
|
expect(runLog.markFailed).toHaveBeenCalledWith('run-1', boom, 0);
|
||||||
|
expect(runLog.markFinished).not.toHaveBeenCalled();
|
||||||
|
expect(logger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService, LoggerService } from '@modules/shared';
|
||||||
|
import { RETENTION_CONFIG } from '../../domain/retention.config';
|
||||||
|
import { RetentionRunLogRepository } from '../../infrastructure/repositories/retention-run-log.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymizes rows in AdminAuditLog older than 5 years. Because the schema
|
||||||
|
* requires non-null actor/target IDs, the strategy is tombstone-in-place:
|
||||||
|
* PII columns are replaced with the sentinel `ANONYMIZED`, and a
|
||||||
|
* `metadata.anonymized=true` marker is written so the predicate is
|
||||||
|
* idempotent.
|
||||||
|
*
|
||||||
|
* GOO-196 — Decree 13 compliance.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AuditLogPurgeService {
|
||||||
|
static readonly JOB = 'audit-log-anonymize';
|
||||||
|
private static readonly TOMBSTONE = 'ANONYMIZED';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
private readonly runLog: RetentionRunLogRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async run(): Promise<{ rowsAffected: number; runId: string }> {
|
||||||
|
const cutoff = new Date(Date.now() - RETENTION_CONFIG.auditAnonymizeMs);
|
||||||
|
const runId = await this.runLog.start({
|
||||||
|
job: AuditLogPurgeService.JOB,
|
||||||
|
batchSize: RETENTION_CONFIG.batchSize,
|
||||||
|
dryRun: RETENTION_CONFIG.dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
for (let batch = 0; batch < RETENTION_CONFIG.maxBatches; batch += 1) {
|
||||||
|
if (RETENTION_CONFIG.dryRun) {
|
||||||
|
total = await this.prisma.adminAuditLog.count({
|
||||||
|
where: {
|
||||||
|
createdAt: { lt: cutoff },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||||
|
UPDATE "AdminAuditLog"
|
||||||
|
SET "actorId" = ${AuditLogPurgeService.TOMBSTONE},
|
||||||
|
"targetId" = ${AuditLogPurgeService.TOMBSTONE},
|
||||||
|
"ipAddress" = NULL,
|
||||||
|
"userAgent" = NULL,
|
||||||
|
"metadata" = COALESCE("metadata", '{}'::jsonb) || '{"anonymized":true}'::jsonb
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM "AdminAuditLog"
|
||||||
|
WHERE "createdAt" < ${cutoff}
|
||||||
|
AND ("metadata" ->> 'anonymized') IS DISTINCT FROM 'true'
|
||||||
|
ORDER BY "createdAt" ASC
|
||||||
|
LIMIT ${RETENTION_CONFIG.batchSize}
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
total += rows.length;
|
||||||
|
if (rows.length < RETENTION_CONFIG.batchSize) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runLog.markFinished(runId, total);
|
||||||
|
this.logger.log(
|
||||||
|
`AuditLogPurgeService anonymized ${total} row(s) (cutoff=${cutoff.toISOString()}, dryRun=${RETENTION_CONFIG.dryRun})`,
|
||||||
|
'AuditLogPurgeService',
|
||||||
|
);
|
||||||
|
return { rowsAffected: total, runId };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
await this.runLog.markFailed(runId, error, total);
|
||||||
|
this.logger.error(
|
||||||
|
`AuditLogPurgeService failed: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
'AuditLogPurgeService',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService, LoggerService } from '@modules/shared';
|
||||||
|
import { RETENTION_CONFIG } from '../../domain/retention.config';
|
||||||
|
import { RetentionRunLogRepository } from '../../infrastructure/repositories/retention-run-log.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-deletes the User.kycData JSON blob 90 days after `User.deletedAt`.
|
||||||
|
* Per CLO guidance, KYC PII must not survive a deleted account beyond the
|
||||||
|
* Decree-13 minimum. The User row itself stays (it carries audit-relevant
|
||||||
|
* metadata via foreign keys), only the kyc payload is nulled.
|
||||||
|
*
|
||||||
|
* Future work (tracked separately): when a dedicated KycDocument table
|
||||||
|
* lands, this service must also call StorageService.deleteObject(key) for
|
||||||
|
* each blob in S3.
|
||||||
|
*
|
||||||
|
* GOO-196 — Decree 13 compliance.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class KycPurgeService {
|
||||||
|
static readonly JOB = 'kyc-blob-purge';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
private readonly runLog: RetentionRunLogRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async run(): Promise<{ rowsAffected: number; runId: string }> {
|
||||||
|
const cutoff = new Date(Date.now() - RETENTION_CONFIG.kycPurgeMs);
|
||||||
|
const runId = await this.runLog.start({
|
||||||
|
job: KycPurgeService.JOB,
|
||||||
|
batchSize: RETENTION_CONFIG.batchSize,
|
||||||
|
dryRun: RETENTION_CONFIG.dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
for (let batch = 0; batch < RETENTION_CONFIG.maxBatches; batch += 1) {
|
||||||
|
if (RETENTION_CONFIG.dryRun) {
|
||||||
|
total = await this.prisma.user.count({
|
||||||
|
where: {
|
||||||
|
deletedAt: { lt: cutoff },
|
||||||
|
NOT: { kycData: { equals: null as unknown as undefined } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||||
|
UPDATE "User"
|
||||||
|
SET "kycData" = NULL
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM "User"
|
||||||
|
WHERE "deletedAt" IS NOT NULL
|
||||||
|
AND "deletedAt" < ${cutoff}
|
||||||
|
AND "kycData" IS NOT NULL
|
||||||
|
ORDER BY "deletedAt" ASC
|
||||||
|
LIMIT ${RETENTION_CONFIG.batchSize}
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
total += rows.length;
|
||||||
|
if (rows.length < RETENTION_CONFIG.batchSize) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runLog.markFinished(runId, total);
|
||||||
|
this.logger.log(
|
||||||
|
`KycPurgeService nulled kycData on ${total} user row(s) (cutoff=${cutoff.toISOString()}, dryRun=${RETENTION_CONFIG.dryRun})`,
|
||||||
|
'KycPurgeService',
|
||||||
|
);
|
||||||
|
return { rowsAffected: total, runId };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
await this.runLog.markFailed(runId, error, total);
|
||||||
|
this.logger.error(
|
||||||
|
`KycPurgeService failed: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
'KycPurgeService',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService, LoggerService } from '@modules/shared';
|
||||||
|
import { RETENTION_CONFIG } from '../../domain/retention.config';
|
||||||
|
import { RetentionRunLogRepository } from '../../infrastructure/repositories/retention-run-log.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-deletes Message.content for any conversation in CLOSED status whose
|
||||||
|
* last activity is older than 90 days. Metadata (sender, timestamps) is
|
||||||
|
* preserved per CLO guidance — only the message body itself is PII.
|
||||||
|
*
|
||||||
|
* GOO-196 — Decree 13 compliance.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingPurgeService {
|
||||||
|
static readonly JOB = 'messaging-body-purge';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
private readonly runLog: RetentionRunLogRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async run(): Promise<{ rowsAffected: number; runId: string }> {
|
||||||
|
const cutoff = new Date(Date.now() - RETENTION_CONFIG.messagingBodyMs);
|
||||||
|
const runId = await this.runLog.start({
|
||||||
|
job: MessagingPurgeService.JOB,
|
||||||
|
batchSize: RETENTION_CONFIG.batchSize,
|
||||||
|
dryRun: RETENTION_CONFIG.dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
for (let batch = 0; batch < RETENTION_CONFIG.maxBatches; batch += 1) {
|
||||||
|
if (RETENTION_CONFIG.dryRun) {
|
||||||
|
total = await this.prisma.message.count({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
conversation: {
|
||||||
|
status: 'CLOSED',
|
||||||
|
lastMessageAt: { lt: cutoff },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||||
|
UPDATE "Message"
|
||||||
|
SET "content" = '',
|
||||||
|
"deletedAt" = NOW()
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT m.id FROM "Message" m
|
||||||
|
JOIN "Conversation" c ON c.id = m."conversationId"
|
||||||
|
WHERE m."deletedAt" IS NULL
|
||||||
|
AND c."status" = 'CLOSED'
|
||||||
|
AND c."lastMessageAt" < ${cutoff}
|
||||||
|
ORDER BY m."createdAt" ASC
|
||||||
|
LIMIT ${RETENTION_CONFIG.batchSize}
|
||||||
|
FOR UPDATE OF m SKIP LOCKED
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
total += rows.length;
|
||||||
|
if (rows.length < RETENTION_CONFIG.batchSize) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runLog.markFinished(runId, total);
|
||||||
|
this.logger.log(
|
||||||
|
`MessagingPurgeService cleared ${total} message body row(s) (cutoff=${cutoff.toISOString()}, dryRun=${RETENTION_CONFIG.dryRun})`,
|
||||||
|
'MessagingPurgeService',
|
||||||
|
);
|
||||||
|
return { rowsAffected: total, runId };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
await this.runLog.markFailed(runId, error, total);
|
||||||
|
this.logger.error(
|
||||||
|
`MessagingPurgeService failed: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
'MessagingPurgeService',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { RETENTION_CONFIG } from '../../domain/retention.config';
|
||||||
|
import { RetentionRunLogRepository } from '../../infrastructure/repositories/retention-run-log.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment callback log purge — three-phase schedule confirmed by CLO in
|
||||||
|
* GOO-201 (MoF Circular 78/2021/TT-BTC, Accounting Law 88/2015 Art. 41,
|
||||||
|
* Tax Admin Law 38/2019 Art. 86):
|
||||||
|
*
|
||||||
|
* Phase 1 @ 2y — scrub operational PII (IP, device fingerprint).
|
||||||
|
* Phase 2 @ 5y — scrub buyer identity (name/phone/email, bank suffix);
|
||||||
|
* preserves `buyerName` for invoice-linked rows.
|
||||||
|
* Phase 3 @ 10y — hard delete (or cold-archive if
|
||||||
|
* RETENTION_PAYMENT_ARCHIVE=true).
|
||||||
|
*
|
||||||
|
* This service is intentionally a **stub** in the initial GOO-196 landing:
|
||||||
|
* the `PaymentCallbackLog` table does not exist in the Prisma schema yet
|
||||||
|
* (tracked under the payments module refactor). Calling `run()` emits a
|
||||||
|
* RetentionRunLog row with status=SUCCESS and rowsAffected=0 so dry-run
|
||||||
|
* telemetry is visible from day one, while the actual UPDATE/DELETE
|
||||||
|
* statements are added when the table lands.
|
||||||
|
*
|
||||||
|
* GOO-196 — Decree 13 compliance.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PaymentCallbackPurgeService {
|
||||||
|
static readonly JOB = 'payment-callback-purge';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
private readonly runLog: RetentionRunLogRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async run(phase: 1 | 2 | 3): Promise<{ rowsAffected: number; runId: string }> {
|
||||||
|
const cutoff = this.cutoffFor(phase);
|
||||||
|
const runId = await this.runLog.start({
|
||||||
|
job: PaymentCallbackPurgeService.JOB,
|
||||||
|
phase,
|
||||||
|
batchSize: RETENTION_CONFIG.batchSize,
|
||||||
|
dryRun: RETENTION_CONFIG.dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO(GOO-196 follow-up): implement once PaymentCallbackLog schema lands.
|
||||||
|
// Phase 1: UPDATE … SET ipAddress=NULL, deviceFingerprint=NULL,
|
||||||
|
// anonymizedPhase1At=NOW() WHERE createdAt < ${cutoff}
|
||||||
|
// AND anonymizedPhase1At IS NULL
|
||||||
|
// Phase 2: UPDATE … SET callbackPayload = jsonb_set_lax(..., 'null'),
|
||||||
|
// bankAccountMasked=NULL, cardSuffix=NULL,
|
||||||
|
// anonymizedPhase2At=NOW() (skip buyerName on invoice rows)
|
||||||
|
// Phase 3: DELETE FROM "PaymentCallbackLog" WHERE createdAt < ${cutoff}
|
||||||
|
// — OR — INSERT INTO payment_callback_archive … then DELETE.
|
||||||
|
const rowsAffected = 0;
|
||||||
|
await this.runLog.markFinished(runId, rowsAffected);
|
||||||
|
this.logger.warn(
|
||||||
|
`PaymentCallbackPurgeService phase=${phase} is a no-op — PaymentCallbackLog table not yet in schema (cutoff=${cutoff.toISOString()})`,
|
||||||
|
'PaymentCallbackPurgeService',
|
||||||
|
);
|
||||||
|
return { rowsAffected, runId };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
await this.runLog.markFailed(runId, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cutoffFor(phase: 1 | 2 | 3): Date {
|
||||||
|
const now = Date.now();
|
||||||
|
switch (phase) {
|
||||||
|
case 1:
|
||||||
|
return new Date(now - RETENTION_CONFIG.paymentPhase1Ms);
|
||||||
|
case 2:
|
||||||
|
return new Date(now - RETENTION_CONFIG.paymentPhase2Ms);
|
||||||
|
case 3:
|
||||||
|
return new Date(now - RETENTION_CONFIG.paymentPhase3Ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService, LoggerService } from '@modules/shared';
|
||||||
|
import { RETENTION_CONFIG } from '../../domain/retention.config';
|
||||||
|
import { RetentionRunLogRepository } from '../../infrastructure/repositories/retention-run-log.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-deletes refresh tokens that are revoked or expired and were created
|
||||||
|
* more than `RETENTION_REFRESH_TOKEN_DAYS` (default 30) days ago.
|
||||||
|
*
|
||||||
|
* Idempotency: a single DELETE … RETURNING id with FOR UPDATE SKIP LOCKED is
|
||||||
|
* race-safe — only one writer wins each row. Loops in capped batches to avoid
|
||||||
|
* statement timeouts on large tables.
|
||||||
|
*
|
||||||
|
* GOO-196 — Decree 13 compliance.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RefreshTokenPurgeService {
|
||||||
|
static readonly JOB = 'refresh-token-purge';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
private readonly runLog: RetentionRunLogRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async run(): Promise<{ rowsAffected: number; runId: string }> {
|
||||||
|
const cutoff = new Date(Date.now() - RETENTION_CONFIG.refreshTokenStaleMs);
|
||||||
|
const runId = await this.runLog.start({
|
||||||
|
job: RefreshTokenPurgeService.JOB,
|
||||||
|
batchSize: RETENTION_CONFIG.batchSize,
|
||||||
|
dryRun: RETENTION_CONFIG.dryRun,
|
||||||
|
});
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
try {
|
||||||
|
for (let batch = 0; batch < RETENTION_CONFIG.maxBatches; batch += 1) {
|
||||||
|
if (RETENTION_CONFIG.dryRun) {
|
||||||
|
total = await this.prisma.refreshToken.count({
|
||||||
|
where: {
|
||||||
|
createdAt: { lt: cutoff },
|
||||||
|
OR: [{ revokedAt: { not: null } }, { expiresAt: { lt: new Date() } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await this.prisma.$queryRaw<Array<{ id: string }>>`
|
||||||
|
DELETE FROM "RefreshToken"
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM "RefreshToken"
|
||||||
|
WHERE "createdAt" < ${cutoff}
|
||||||
|
AND ("revokedAt" IS NOT NULL OR "expiresAt" < NOW())
|
||||||
|
ORDER BY "createdAt" ASC
|
||||||
|
LIMIT ${RETENTION_CONFIG.batchSize}
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
total += rows.length;
|
||||||
|
if (rows.length < RETENTION_CONFIG.batchSize) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runLog.markFinished(runId, total);
|
||||||
|
this.logger.log(
|
||||||
|
`RefreshTokenPurgeService removed ${total} row(s) (cutoff=${cutoff.toISOString()}, dryRun=${RETENTION_CONFIG.dryRun})`,
|
||||||
|
'RefreshTokenPurgeService',
|
||||||
|
);
|
||||||
|
return { rowsAffected: total, runId };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
await this.runLog.markFailed(runId, error, total);
|
||||||
|
this.logger.error(
|
||||||
|
`RefreshTokenPurgeService failed: ${error.message}`,
|
||||||
|
error.stack,
|
||||||
|
'RefreshTokenPurgeService',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/api/src/modules/retention/domain/retention.config.ts
Normal file
56
apps/api/src/modules/retention/domain/retention.config.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Centralised retention configuration. Every window comes from env vars so a
|
||||||
|
* staging environment can dry-run aggressively without hard-coding constants.
|
||||||
|
*
|
||||||
|
* GOO-196 — Decree 13 compliance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const days = (n: number): number => n * 24 * 60 * 60 * 1000;
|
||||||
|
const years = (n: number): number => Math.floor(n * 365.25 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const numEnv = (name: string, fallback: number): number => {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (!raw) return fallback;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const boolEnv = (name: string, fallback = false): boolean => {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined) return fallback;
|
||||||
|
return raw === 'true' || raw === '1';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RETENTION_CONFIG = {
|
||||||
|
/** Master kill switch — when false, every cron is a no-op. */
|
||||||
|
enabled: boolEnv('RETENTION_ENABLED', false),
|
||||||
|
/** Forces every job into SELECT-only mode for staging review. */
|
||||||
|
dryRun: boolEnv('RETENTION_DRY_RUN', false),
|
||||||
|
|
||||||
|
/** Per-batch row cap. Keeps statement timeouts and replication lag bounded. */
|
||||||
|
batchSize: numEnv('RETENTION_BATCH_SIZE', 1000),
|
||||||
|
/** Hard cap on batch loops per run. */
|
||||||
|
maxBatches: numEnv('RETENTION_MAX_BATCHES', 50),
|
||||||
|
|
||||||
|
/** RefreshToken: revoked OR expired older than this is hard-deleted. */
|
||||||
|
refreshTokenStaleMs: days(numEnv('RETENTION_REFRESH_TOKEN_DAYS', 30)),
|
||||||
|
|
||||||
|
/** Messaging body purge window after Conversation.lastMessageAt. */
|
||||||
|
messagingBodyMs: days(numEnv('RETENTION_MESSAGING_DAYS', 90)),
|
||||||
|
|
||||||
|
/** KYC blob purge window after User.deletedAt. */
|
||||||
|
kycPurgeMs: days(numEnv('RETENTION_KYC_DAYS', 90)),
|
||||||
|
|
||||||
|
/** Audit log anonymization window. */
|
||||||
|
auditAnonymizeMs: years(numEnv('RETENTION_AUDIT_YEARS', 5)),
|
||||||
|
|
||||||
|
/** Payment callback phased schedule (CLO confirmed via GOO-201). */
|
||||||
|
paymentPhase1Ms: years(numEnv('RETENTION_PAYMENT_PHASE1_YEARS', 2)),
|
||||||
|
paymentPhase2Ms: years(numEnv('RETENTION_PAYMENT_PHASE2_YEARS', 5)),
|
||||||
|
paymentPhase3Ms: years(numEnv('RETENTION_PAYMENT_PHASE3_YEARS', 10)),
|
||||||
|
|
||||||
|
/** When true, phase-3 archives to a cold table instead of hard-deleting. */
|
||||||
|
paymentArchive: boolEnv('RETENTION_PAYMENT_ARCHIVE', false),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RetentionConfig = typeof RETENTION_CONFIG;
|
||||||
8
apps/api/src/modules/retention/index.ts
Normal file
8
apps/api/src/modules/retention/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { RetentionModule } from './retention.module';
|
||||||
|
export { RefreshTokenPurgeService } from './application/services/refresh-token-purge.service';
|
||||||
|
export { MessagingPurgeService } from './application/services/messaging-purge.service';
|
||||||
|
export { KycPurgeService } from './application/services/kyc-purge.service';
|
||||||
|
export { AuditLogPurgeService } from './application/services/audit-log-purge.service';
|
||||||
|
export { PaymentCallbackPurgeService } from './application/services/payment-callback-purge.service';
|
||||||
|
export { RetentionRunLogRepository } from './infrastructure/repositories/retention-run-log.repository';
|
||||||
|
export { RETENTION_CONFIG, type RetentionConfig } from './domain/retention.config';
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { AuditLogPurgeService } from '../../application/services/audit-log-purge.service';
|
||||||
|
import { KycPurgeService } from '../../application/services/kyc-purge.service';
|
||||||
|
import { MessagingPurgeService } from '../../application/services/messaging-purge.service';
|
||||||
|
import { PaymentCallbackPurgeService } from '../../application/services/payment-callback-purge.service';
|
||||||
|
import { RefreshTokenPurgeService } from '../../application/services/refresh-token-purge.service';
|
||||||
|
import { RETENTION_CONFIG } from '../../domain/retention.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin scheduler that delegates to each domain purge service. All windows
|
||||||
|
* run during Vietnam off-peak hours (UTC times below correspond to ~23:00–
|
||||||
|
* 01:00 ICT). Set RETENTION_ENABLED=true to activate; otherwise every job
|
||||||
|
* is a no-op so the module can ship behind a flag.
|
||||||
|
*
|
||||||
|
* GOO-196 — Decree 13 compliance.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RetentionCronOrchestrator {
|
||||||
|
constructor(
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
private readonly refreshTokens: RefreshTokenPurgeService,
|
||||||
|
private readonly messaging: MessagingPurgeService,
|
||||||
|
private readonly kyc: KycPurgeService,
|
||||||
|
private readonly auditLogs: AuditLogPurgeService,
|
||||||
|
private readonly paymentCallbacks: PaymentCallbackPurgeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron('0 16 * * *', { name: 'retention-refresh-tokens', timeZone: 'UTC' })
|
||||||
|
async runRefreshTokens(): Promise<void> {
|
||||||
|
if (!RETENTION_CONFIG.enabled) return this.skip('refresh-token-purge');
|
||||||
|
await this.safe(() => this.refreshTokens.run(), 'refresh-token-purge');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('30 16 * * *', { name: 'retention-messaging', timeZone: 'UTC' })
|
||||||
|
async runMessaging(): Promise<void> {
|
||||||
|
if (!RETENTION_CONFIG.enabled) return this.skip('messaging-purge');
|
||||||
|
await this.safe(() => this.messaging.run(), 'messaging-purge');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('0 17 * * *', { name: 'retention-kyc', timeZone: 'UTC' })
|
||||||
|
async runKyc(): Promise<void> {
|
||||||
|
if (!RETENTION_CONFIG.enabled) return this.skip('kyc-purge');
|
||||||
|
await this.safe(() => this.kyc.run(), 'kyc-purge');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('30 17 * * *', { name: 'retention-payment-phase1', timeZone: 'UTC' })
|
||||||
|
async runPaymentPhase1(): Promise<void> {
|
||||||
|
if (!RETENTION_CONFIG.enabled) return this.skip('payment-phase-1');
|
||||||
|
await this.safe(() => this.paymentCallbacks.run(1), 'payment-phase-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('0 18 * * *', { name: 'retention-payment-phase2', timeZone: 'UTC' })
|
||||||
|
async runPaymentPhase2(): Promise<void> {
|
||||||
|
if (!RETENTION_CONFIG.enabled) return this.skip('payment-phase-2');
|
||||||
|
await this.safe(() => this.paymentCallbacks.run(2), 'payment-phase-2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('0 17 * * 0', { name: 'retention-audit-anonymize', timeZone: 'UTC' })
|
||||||
|
async runAuditLogs(): Promise<void> {
|
||||||
|
if (!RETENTION_CONFIG.enabled) return this.skip('audit-log-anonymize');
|
||||||
|
await this.safe(() => this.auditLogs.run(), 'audit-log-anonymize');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron('0 18 * * 0', { name: 'retention-payment-phase3', timeZone: 'UTC' })
|
||||||
|
async runPaymentPhase3(): Promise<void> {
|
||||||
|
if (!RETENTION_CONFIG.enabled) return this.skip('payment-phase-3');
|
||||||
|
await this.safe(() => this.paymentCallbacks.run(3), 'payment-phase-3');
|
||||||
|
}
|
||||||
|
|
||||||
|
private skip(name: string): void {
|
||||||
|
this.logger.debug(
|
||||||
|
`Retention job ${name} skipped: RETENTION_ENABLED=false`,
|
||||||
|
'RetentionCronOrchestrator',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async safe(fn: () => Promise<unknown>, name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Retention job ${name} threw: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
'RetentionCronOrchestrator',
|
||||||
|
);
|
||||||
|
// Swallow — RetentionRunLog already records FAILED. Do not crash the scheduler.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '@modules/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin repository around RetentionRunLog. Centralised so every purge service
|
||||||
|
* uses the exact same shape — start row, mark finished/failed, never invent
|
||||||
|
* variations.
|
||||||
|
*
|
||||||
|
* GOO-196 — Decree 13 compliance.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RetentionRunLogRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async start(input: {
|
||||||
|
job: string;
|
||||||
|
phase?: number | null;
|
||||||
|
batchSize?: number | null;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}): Promise<string> {
|
||||||
|
const row = await this.prisma.retentionRunLog.create({
|
||||||
|
data: {
|
||||||
|
job: input.job,
|
||||||
|
phase: input.phase ?? null,
|
||||||
|
batchSize: input.batchSize ?? null,
|
||||||
|
dryRun: input.dryRun ?? false,
|
||||||
|
status: 'RUNNING',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async markFinished(id: string, rowsAffected: number, partial = false): Promise<void> {
|
||||||
|
await this.prisma.retentionRunLog.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
finishedAt: new Date(),
|
||||||
|
rowsAffected,
|
||||||
|
status: partial ? 'PARTIAL' : 'SUCCESS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markFailed(id: string, error: Error, rowsAffected = 0): Promise<void> {
|
||||||
|
await this.prisma.retentionRunLog.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
finishedAt: new Date(),
|
||||||
|
rowsAffected,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMessage: error.message.slice(0, 2000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/api/src/modules/retention/retention.module.ts
Normal file
36
apps/api/src/modules/retention/retention.module.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditLogPurgeService } from './application/services/audit-log-purge.service';
|
||||||
|
import { KycPurgeService } from './application/services/kyc-purge.service';
|
||||||
|
import { MessagingPurgeService } from './application/services/messaging-purge.service';
|
||||||
|
import { PaymentCallbackPurgeService } from './application/services/payment-callback-purge.service';
|
||||||
|
import { RefreshTokenPurgeService } from './application/services/refresh-token-purge.service';
|
||||||
|
import { RetentionCronOrchestrator } from './infrastructure/cron/retention-cron.orchestrator';
|
||||||
|
import { RetentionRunLogRepository } from './infrastructure/repositories/retention-run-log.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GOO-196 — Decree 13 data retention & purge jobs.
|
||||||
|
*
|
||||||
|
* Ships behind RETENTION_ENABLED=false so the module can land without
|
||||||
|
* affecting prod. Flip to true after a 7-day staging dry-run review with
|
||||||
|
* CLO/DPO. See `apps/api/src/modules/retention/domain/retention.config.ts`
|
||||||
|
* for every tunable window.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
RetentionRunLogRepository,
|
||||||
|
RefreshTokenPurgeService,
|
||||||
|
MessagingPurgeService,
|
||||||
|
KycPurgeService,
|
||||||
|
AuditLogPurgeService,
|
||||||
|
PaymentCallbackPurgeService,
|
||||||
|
RetentionCronOrchestrator,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
RefreshTokenPurgeService,
|
||||||
|
MessagingPurgeService,
|
||||||
|
KycPurgeService,
|
||||||
|
AuditLogPurgeService,
|
||||||
|
PaymentCallbackPurgeService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class RetentionModule {}
|
||||||
127
apps/web/app/[locale]/(admin)/admin/__tests__/admin-kyc.spec.tsx
Normal file
127
apps/web/app/[locale]/(admin)/admin/__tests__/admin-kyc.spec.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => {
|
||||||
|
const icon = (name: string) => (props: Record<string, unknown>) => <span data-testid={`icon-${name}`} {...props} />;
|
||||||
|
return {
|
||||||
|
CheckCircle: icon('check'),
|
||||||
|
XCircle: icon('x'),
|
||||||
|
RefreshCw: icon('refresh'),
|
||||||
|
ChevronLeft: icon('chevron-left'),
|
||||||
|
ChevronRight: icon('chevron-right'),
|
||||||
|
ShieldCheck: icon('shield'),
|
||||||
|
X: icon('close'),
|
||||||
|
User: icon('user'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/design-system/status-chip', () => ({
|
||||||
|
StatusChip: ({ status }: { status: string }) => <span data-testid="status-chip">{status}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetKycQueue = vi.fn();
|
||||||
|
const mockApproveKyc = vi.fn();
|
||||||
|
const mockRejectKyc = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/admin-api', () => ({
|
||||||
|
adminApi: {
|
||||||
|
getKycQueue: (...args: unknown[]) => mockGetKycQueue(...args),
|
||||||
|
approveKyc: (...args: unknown[]) => mockApproveKyc(...args),
|
||||||
|
rejectKyc: (...args: unknown[]) => mockRejectKyc(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AdminKycPage from '../kyc/page';
|
||||||
|
|
||||||
|
const mockQueueData = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
userId: 'u1',
|
||||||
|
fullName: 'Nguyen Van A',
|
||||||
|
phone: '0912345678',
|
||||||
|
email: 'a@test.com',
|
||||||
|
role: 'AGENT',
|
||||||
|
kycStatus: 'PENDING',
|
||||||
|
kycData: { idType: 'CCCD', idNumber: '012345678901', frontImageUrl: 'https://img.test/front.jpg' },
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 'u2',
|
||||||
|
fullName: 'Tran Thi B',
|
||||||
|
phone: '0987654321',
|
||||||
|
email: null,
|
||||||
|
role: 'USER',
|
||||||
|
kycStatus: 'PENDING',
|
||||||
|
kycData: null,
|
||||||
|
createdAt: '2024-06-16T10:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AdminKycPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockGetKycQueue.mockResolvedValue(mockQueueData);
|
||||||
|
mockApproveKyc.mockResolvedValue({});
|
||||||
|
mockRejectKyc.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders heading and fetches queue', async () => {
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Duyệt KYC')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(mockGetKycQueue).toHaveBeenCalledWith(1, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders queue items in table', async () => {
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tran Thi B')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no requests', async () => {
|
||||||
|
mockGetKycQueue.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 });
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Không có yêu cầu KYC nào đang chờ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state when fetch fails', async () => {
|
||||||
|
mockGetKycQueue.mockRejectedValue(new Error('Network error'));
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Thử lại')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes queue on refresh button click', async () => {
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /làm mới/i }));
|
||||||
|
|
||||||
|
expect(mockGetKycQueue).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('next/dynamic', () => ({
|
||||||
|
default: () => {
|
||||||
|
const Mock = () => <div data-testid="chart-placeholder">Chart</div>;
|
||||||
|
Mock.displayName = 'MockChart';
|
||||||
|
return Mock;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||||
|
<a href={href} {...props}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseMarketReport = vi.fn();
|
||||||
|
const mockUseHeatmap = vi.fn();
|
||||||
|
const mockUseListingsSearch = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-analytics', () => ({
|
||||||
|
useMarketReport: (...args: unknown[]) => mockUseMarketReport(...args),
|
||||||
|
useHeatmap: (...args: unknown[]) => mockUseHeatmap(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-listings', () => ({
|
||||||
|
useListingsSearch: (...args: unknown[]) => mockUseListingsSearch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/listings/listing-status-badge', () => ({
|
||||||
|
ListingStatusBadge: ({ status }: { status: string }) => <span data-testid="status-badge">{status}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import DashboardPage from '../page';
|
||||||
|
|
||||||
|
const fullData = {
|
||||||
|
marketReport: {
|
||||||
|
districts: [
|
||||||
|
{ district: 'Quan 1', totalListings: 100, avgPriceM2: 120000000, medianPrice: '15000000000', daysOnMarket: 45, yoyChange: 5.2, inventoryLevel: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
heatmap: { dataPoints: [{ district: 'Quan 1', avgPriceM2: 120000000, totalListings: 100, lat: 10.77, lng: 106.7 }] },
|
||||||
|
listings: {
|
||||||
|
data: [{
|
||||||
|
id: '1', status: 'ACTIVE', transactionType: 'SALE', priceVND: '5000000000', viewCount: 10,
|
||||||
|
saveCount: 2, inquiryCount: 3, publishedAt: '2024-01-01', createdAt: '2024-01-01',
|
||||||
|
pricePerM2: null, rentPriceMonthly: null, commissionPct: null,
|
||||||
|
property: {
|
||||||
|
id: 'p1', propertyType: 'APARTMENT', title: 'Căn hộ Quận 7', description: 'Test',
|
||||||
|
address: '123 Nguyễn Hữu Thọ', ward: 'Tân Hưng', district: 'Quận 7',
|
||||||
|
city: 'Hồ Chí Minh', areaM2: 75, bedrooms: 2, bathrooms: 2, floors: null,
|
||||||
|
direction: null, yearBuilt: null, legalStatus: null, amenities: null, projectName: null, media: [],
|
||||||
|
},
|
||||||
|
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
|
||||||
|
agent: null,
|
||||||
|
}],
|
||||||
|
total: 1, page: 1, limit: 6, totalPages: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DashboardPage — deep tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state with placeholders', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders stat cards with computed values', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Tin đăng của tôi')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Lượt xem')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Giá TB thị trường')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders recent listings with property title', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Tin đăng gần đây')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Đăng tin mới" link', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty listings state', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: { data: [], total: 0, page: 1, limit: 6, totalPages: 0 }, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
Check: (props: Record<string, unknown>) => <span data-testid="check-icon" {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchProfile = vi.fn();
|
||||||
|
const mockUseAuthStore = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-store', () => ({
|
||||||
|
useAuthStore: (...args: unknown[]) => mockUseAuthStore(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', () => ({
|
||||||
|
apiClient: { patch: vi.fn().mockResolvedValue({}) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import KycPage from '../kyc/page';
|
||||||
|
|
||||||
|
function setupStore(overrides: Record<string, unknown> = {}) {
|
||||||
|
const store = {
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
fullName: 'Nguyen Van A',
|
||||||
|
phone: '0912345678',
|
||||||
|
kycStatus: 'NONE',
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
fetchProfile: mockFetchProfile,
|
||||||
|
};
|
||||||
|
mockUseAuthStore.mockImplementation((selector?: (s: typeof store) => unknown) => {
|
||||||
|
if (typeof selector === 'function') return selector(store);
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KycPage — deep tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setupStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders heading and NONE status', () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Xác minh danh tính (KYC)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Chưa xác minh')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders step 1 with document type selector and number input', () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/số giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error and stays on step 1 when doc number is empty', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/vui lòng nhập số giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances from step 1 → step 2 after filling doc number', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/ảnh mặt trước/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error on step 2 when front image is missing', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
// Step 1 → 2
|
||||||
|
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('kyc-front-input')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to advance without uploading
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/vui lòng tải ảnh mặt trước/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes back from step 2 → step 1', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('kyc-back-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-back-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders VERIFIED state without form', () => {
|
||||||
|
setupStore({ kycStatus: 'VERIFIED' });
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Danh tính đã được xác minh')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders PENDING state without form', () => {
|
||||||
|
setupStore({ kycStatus: 'PENDING' });
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Đang chờ duyệt')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đang xem xét hồ sơ')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders REJECTED state with form available', () => {
|
||||||
|
setupStore({ kycStatus: 'REJECTED' });
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Bị từ chối')).toBeInTheDocument();
|
||||||
|
// Form should still show for resubmission
|
||||||
|
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses error when close button is clicked', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('kyc-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Đóng'));
|
||||||
|
expect(screen.queryByTestId('kyc-error')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes document type via select', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
const select = screen.getByLabelText(/loại giấy tờ/i);
|
||||||
|
await userEvent.selectOptions(select, 'PASSPORT');
|
||||||
|
expect(select).toHaveValue('PASSPORT');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mockUseTransactions = vi.fn();
|
||||||
|
|
||||||
|
const mockTransactions = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'tx-1',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
amountVND: '499000',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
providerTxId: 'TXN123456789012',
|
||||||
|
createdAt: '2024-06-15T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tx-2',
|
||||||
|
type: 'LISTING_FEE',
|
||||||
|
status: 'PENDING',
|
||||||
|
amountVND: '100000',
|
||||||
|
provider: 'MOMO',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-20T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tx-3',
|
||||||
|
type: 'FEATURED_LISTING',
|
||||||
|
status: 'FAILED',
|
||||||
|
amountVND: '200000',
|
||||||
|
provider: 'ZALOPAY',
|
||||||
|
providerTxId: 'ZLP999',
|
||||||
|
createdAt: '2024-06-21T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-payments', () => ({
|
||||||
|
useTransactions: (...args: unknown[]) => mockUseTransactions(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/currency', () => ({
|
||||||
|
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import PaymentsPage from '../payments/page';
|
||||||
|
|
||||||
|
describe('PaymentsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseTransactions.mockReturnValue({ data: mockTransactions, isLoading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders payment page heading and description', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Thanh toán' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/lịch sử giao dịch/i).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders summary cards with correct values', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đã thanh toán')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đang chờ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders transaction table with type/provider/status labels', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
// Type labels appear in desktop table + mobile cards, so use getAllByText
|
||||||
|
expect(screen.getAllByText('Gói dịch vụ').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Phí đăng tin').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Tin nổi bật').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Thành công').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Chờ xử lý').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Thất bại').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state', () => {
|
||||||
|
mockUseTransactions.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('Đang tải...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state', () => {
|
||||||
|
mockUseTransactions.mockReturnValue({ data: { items: [], total: 0 }, isLoading: false });
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('Chưa có giao dịch nào')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes status filter via select', async () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
const select = screen.getByDisplayValue('Tất cả');
|
||||||
|
await userEvent.selectOptions(select, 'COMPLETED');
|
||||||
|
|
||||||
|
expect(mockUseTransactions).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'COMPLETED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long providerTxId', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('TXN123456789...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows dash for missing providerTxId', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pagination when more than 1 page', () => {
|
||||||
|
// 25 total with limit 20 = 2 pages
|
||||||
|
const manyItems = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
id: `tx-${i}`,
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
amountVND: '100000',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-15T10:00:00.000Z',
|
||||||
|
}));
|
||||||
|
mockUseTransactions.mockReturnValue({
|
||||||
|
data: { items: manyItems, total: 25 },
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText(/trang 1\/2/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /trước/i })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: /sau/i })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
60
apps/web/app/[locale]/(public)/agents/[id]/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/agents/[id]/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function AgentProfileError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Agent profile error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải hồ sơ môi giới</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải thông tin môi giới. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/agents"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về danh sách môi giới
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/agents/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/agents/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function AgentsError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Agents page error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải thông tin môi giới</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải danh sách môi giới. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/du-an/[slug]/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/du-an/[slug]/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ProjectDetailError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Project detail error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải thông tin dự án</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải chi tiết dự án. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/du-an"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về danh sách dự án
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/du-an/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/du-an/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ProjectsError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Projects (du-an) error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải danh sách dự án</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải dự án bất động sản. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function PublicError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Public page error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải trang</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải nội dung. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function IndustrialParkDetailError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Industrial park detail error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải chi tiết khu công nghiệp</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải thông tin khu công nghiệp. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/khu-cong-nghiep"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về danh sách khu công nghiệp
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/khu-cong-nghiep/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/khu-cong-nghiep/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function IndustrialParksError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Industrial parks error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải thông tin khu công nghiệp</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải dữ liệu khu công nghiệp. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/listings/[id]/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/listings/[id]/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ListingDetailError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Listing detail error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải thông tin bất động sản</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải chi tiết bất động sản. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/listings"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về danh sách
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/listings/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/listings/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function ListingsError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Listings error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Không thể tải danh sách bất động sản</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi khi tải danh sách. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/web/app/[locale]/(public)/payment/error.tsx
Normal file
60
apps/web/app/[locale]/(public)/payment/error.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function PaymentError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Payment page error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-12">
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Lỗi thanh toán</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Đã xảy ra lỗi trong quá trình thanh toán. Vui lòng thử lại hoặc liên hệ hỗ trợ.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về bảng điều khiển
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,8 +64,49 @@ function SearchContent() {
|
|||||||
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
||||||
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||||
|
|
||||||
|
const saveDialogRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const saveButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
const saveNameInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const createSavedSearch = useCreateSavedSearch();
|
const createSavedSearch = useCreateSavedSearch();
|
||||||
|
|
||||||
|
// Focus management for save-search dialog
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (showSaveDialog) {
|
||||||
|
saveNameInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [showSaveDialog]);
|
||||||
|
|
||||||
|
// Focus trap + Escape key for save-search dialog
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!showSaveDialog) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
saveButtonRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
const dialog = saveDialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const focusable = dialog.querySelectorAll<HTMLElement>(
|
||||||
|
'button, input, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [showSaveDialog]);
|
||||||
|
|
||||||
const handleMarkerClick = (listing: ListingDetail) => {
|
const handleMarkerClick = (listing: ListingDetail) => {
|
||||||
setSelectedListingId(listing.id);
|
setSelectedListingId(listing.id);
|
||||||
};
|
};
|
||||||
@@ -163,11 +204,15 @@ function SearchContent() {
|
|||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
|
ref={saveButtonRef}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
||||||
|
aria-expanded={showSaveDialog}
|
||||||
|
aria-controls="save-search-dialog"
|
||||||
|
aria-haspopup="dialog"
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||||
</svg>
|
</svg>
|
||||||
Lưu tìm kiếm
|
Lưu tìm kiếm
|
||||||
@@ -175,10 +220,17 @@ function SearchContent() {
|
|||||||
|
|
||||||
{/* Save search dialog */}
|
{/* Save search dialog */}
|
||||||
{showSaveDialog && (
|
{showSaveDialog && (
|
||||||
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg">
|
<div
|
||||||
|
id="save-search-dialog"
|
||||||
|
ref={saveDialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="save-search-heading"
|
||||||
|
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg"
|
||||||
|
>
|
||||||
{saveSuccess ? (
|
{saveSuccess ? (
|
||||||
<div className="flex items-center gap-2 text-green-600">
|
<div className="flex items-center gap-2 text-green-600">
|
||||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
||||||
@@ -188,6 +240,7 @@ function SearchContent() {
|
|||||||
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
|
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
|
||||||
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
|
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
|
||||||
<input
|
<input
|
||||||
|
ref={saveNameInputRef}
|
||||||
id="save-search-name"
|
id="save-search-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={saveName}
|
value={saveName}
|
||||||
@@ -246,8 +299,9 @@ function SearchContent() {
|
|||||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
|
aria-pressed={viewMode === 'list'}
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
Danh sách
|
Danh sách
|
||||||
@@ -256,8 +310,9 @@ function SearchContent() {
|
|||||||
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('map')}
|
onClick={() => setViewMode('map')}
|
||||||
|
aria-pressed={viewMode === 'map'}
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
</svg>
|
</svg>
|
||||||
Bản đồ
|
Bản đồ
|
||||||
@@ -267,8 +322,9 @@ function SearchContent() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="hidden lg:flex"
|
className="hidden lg:flex"
|
||||||
onClick={() => setViewMode('split')}
|
onClick={() => setViewMode('split')}
|
||||||
|
aria-pressed={viewMode === 'split'}
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
||||||
</svg>
|
</svg>
|
||||||
Chia đôi
|
Chia đôi
|
||||||
@@ -280,8 +336,10 @@ function SearchContent() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="lg:hidden"
|
className="lg:hidden"
|
||||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||||
|
aria-expanded={showMobileFilters}
|
||||||
|
aria-controls="mobile-filter-panel"
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
</svg>
|
</svg>
|
||||||
Bộ lọc
|
Bộ lọc
|
||||||
@@ -305,7 +363,7 @@ function SearchContent() {
|
|||||||
|
|
||||||
{/* Mobile filter panel */}
|
{/* Mobile filter panel */}
|
||||||
{showMobileFilters && (
|
{showMobileFilters && (
|
||||||
<div className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
<div id="mobile-filter-panel" className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
@@ -392,7 +450,11 @@ export default function SearchPage() {
|
|||||||
return (
|
return (
|
||||||
<React.Suspense
|
<React.Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex min-h-[400px] items-center justify-center">
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="Đang tải..."
|
||||||
|
className="flex min-h-[400px] items-center justify-center"
|
||||||
|
>
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
58
apps/web/app/[locale]/auth/callback/error.tsx
Normal file
58
apps/web/app/[locale]/auth/callback/error.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function AuthCallbackError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Auth callback error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
|
<div className="mx-auto max-w-md text-center">
|
||||||
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-xl font-semibold">Lỗi đăng nhập</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Không thể hoàn tất quá trình đăng nhập. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Mã lỗi: {error.digest}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
Về trang đăng nhập
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/app/global-error.tsx
Normal file
126
apps/web/app/global-error.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Global error:', error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="vi">
|
||||||
|
<body>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
minHeight: '100vh',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '1rem',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '28rem', textAlign: 'center' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '0 auto',
|
||||||
|
display: 'flex',
|
||||||
|
height: '3.5rem',
|
||||||
|
width: '3.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '9999px',
|
||||||
|
backgroundColor: '#fee2e2',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
style={{ height: '1.75rem', width: '1.75rem', color: '#ef4444' }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#111827',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Đã xảy ra lỗi nghiêm trọng
|
||||||
|
</h1>
|
||||||
|
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
Ứng dụng gặp sự cố không mong muốn. Vui lòng tải lại trang.
|
||||||
|
</p>
|
||||||
|
{error.digest && (
|
||||||
|
<p style={{ marginTop: '0.25rem', fontSize: '0.75rem', color: '#9ca3af' }}>
|
||||||
|
Mã lỗi: {error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
height: '2.25rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
padding: '0 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Thử lại
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
height: '2.25rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
padding: '0 1rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#374151',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Về trang chủ
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TransferItemTable } from '../transfer-item-table';
|
||||||
|
|
||||||
|
const baseItem = {
|
||||||
|
id: 'i1',
|
||||||
|
name: 'Tủ lạnh Toshiba',
|
||||||
|
brand: 'Toshiba',
|
||||||
|
modelName: 'GR-RT624WE-PMV',
|
||||||
|
category: 'APPLIANCE' as const,
|
||||||
|
condition: 'GOOD' as const,
|
||||||
|
purchaseYear: 2022,
|
||||||
|
originalPriceVND: '15000000',
|
||||||
|
askingPriceVND: '8000000',
|
||||||
|
aiEstimatePriceVND: '7500000',
|
||||||
|
aiConfidence: 0.85,
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TransferItemTable', () => {
|
||||||
|
it('renders empty state when no items', () => {
|
||||||
|
render(<TransferItemTable items={[]} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText('Chưa có danh sách vật phẩm.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all column headers', () => {
|
||||||
|
render(<TransferItemTable items={[baseItem]} />);
|
||||||
|
expect(screen.getByText('Tên')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Loại')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tình trạng')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Thương hiệu')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('SL')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Giá yêu cầu')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Giá AI')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders item row with localized currency formatting', () => {
|
||||||
|
render(<TransferItemTable items={[baseItem]} />);
|
||||||
|
expect(screen.getByText('Tủ lạnh Toshiba')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('GR-RT624WE-PMV')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/8\.000\.000/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/7\.500\.000/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to em-dash for missing brand and AI estimate', () => {
|
||||||
|
render(
|
||||||
|
<TransferItemTable
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
...baseItem,
|
||||||
|
id: 'i2',
|
||||||
|
brand: null,
|
||||||
|
aiEstimatePriceVND: null,
|
||||||
|
aiConfidence: null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const dashes = screen.getAllByText('—');
|
||||||
|
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TransferListingCard } from '../transfer-listing-card';
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseListing = {
|
||||||
|
id: 'tl1',
|
||||||
|
sellerId: 's1',
|
||||||
|
category: 'FURNITURE' as const,
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
title: 'Bộ sofa gỗ còn mới 90%',
|
||||||
|
address: '123 Lê Lợi',
|
||||||
|
district: 'Quận 1',
|
||||||
|
city: 'TP.HCM',
|
||||||
|
latitude: 10.77,
|
||||||
|
longitude: 106.7,
|
||||||
|
askingPriceVND: '4500000',
|
||||||
|
aiEstimatePriceVND: null,
|
||||||
|
pricingSource: 'MANUAL' as const,
|
||||||
|
isNegotiable: true,
|
||||||
|
areaM2: 12,
|
||||||
|
itemCount: 5,
|
||||||
|
viewCount: 42,
|
||||||
|
publishedAt: '2026-04-10T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TransferListingCard', () => {
|
||||||
|
it('renders title, location and formatted price', () => {
|
||||||
|
render(<TransferListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('Bộ sofa gỗ còn mới 90%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Quận 1, TP\.HCM/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/4\.500\.000/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ACTIVE status with green color and "Thương lượng" when negotiable', () => {
|
||||||
|
render(<TransferListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('Đang đăng')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Thương lượng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders item and view counts and square-meter area', () => {
|
||||||
|
render(<TransferListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('5')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('42')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/12 m/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to listing detail by id', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TransferListingCard listing={baseListing} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
||||||
|
'/chuyen-nhuong/tl1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits publish date footer when publishedAt is null', () => {
|
||||||
|
render(
|
||||||
|
<TransferListingCard
|
||||||
|
listing={{ ...baseListing, publishedAt: null }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/Đăng/)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
DENSITY_CELL_PADDING,
|
||||||
|
DENSITY_DATA_FONT,
|
||||||
|
DENSITY_ROW_HEIGHT,
|
||||||
|
DensityProvider,
|
||||||
|
useDensity,
|
||||||
|
} from '../density-provider';
|
||||||
|
|
||||||
|
// jsdom (opaque origin) does not provide a usable localStorage; install a tiny in-memory shim.
|
||||||
|
function installLocalStorage(): Storage {
|
||||||
|
const store: Record<string, string> = {};
|
||||||
|
const fake: Storage = {
|
||||||
|
get length() {
|
||||||
|
return Object.keys(store).length;
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
for (const k of Object.keys(store)) delete store[k];
|
||||||
|
},
|
||||||
|
getItem: (k) => (k in store ? store[k]! : null),
|
||||||
|
key: (i) => Object.keys(store)[i] ?? null,
|
||||||
|
removeItem: (k) => {
|
||||||
|
delete store[k];
|
||||||
|
},
|
||||||
|
setItem: (k, v) => {
|
||||||
|
store[k] = String(v);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: fake,
|
||||||
|
});
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DensityProvider', () => {
|
||||||
|
let storage: Storage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = installLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (typeof storage.clear === 'function') {
|
||||||
|
storage.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes default density "regular" via useDensity', () => {
|
||||||
|
const { result } = renderHook(() => useDensity(), {
|
||||||
|
wrapper: ({ children }) => <DensityProvider>{children}</DensityProvider>,
|
||||||
|
});
|
||||||
|
expect(result.current.density).toBe('regular');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors the defaultDensity prop', () => {
|
||||||
|
const { result } = renderHook(() => useDensity(), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<DensityProvider defaultDensity="compact">{children}</DensityProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(result.current.density).toBe('compact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists density changes to localStorage', () => {
|
||||||
|
const { result } = renderHook(() => useDensity(), {
|
||||||
|
wrapper: ({ children }) => <DensityProvider>{children}</DensityProvider>,
|
||||||
|
});
|
||||||
|
act(() => result.current.setDensity('roomy'));
|
||||||
|
expect(result.current.density).toBe('roomy');
|
||||||
|
expect(localStorage.getItem('goodgo.density')).toBe('roomy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads stored density on mount when valid', () => {
|
||||||
|
localStorage.setItem('goodgo.density', 'compact');
|
||||||
|
function Probe() {
|
||||||
|
const { density } = useDensity();
|
||||||
|
return <span data-testid="d">{density}</span>;
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<DensityProvider>
|
||||||
|
<Probe />
|
||||||
|
</DensityProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('d').textContent).toBe('compact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes row-height, padding and font tables for all densities', () => {
|
||||||
|
for (const mode of ['compact', 'regular', 'roomy'] as const) {
|
||||||
|
expect(DENSITY_ROW_HEIGHT[mode]).toBeTruthy();
|
||||||
|
expect(DENSITY_CELL_PADDING[mode]).toBeTruthy();
|
||||||
|
expect(DENSITY_DATA_FONT[mode]).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
78
apps/web/components/du-an/__tests__/project-card.spec.tsx
Normal file
78
apps/web/components/du-an/__tests__/project-card.spec.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ProjectCard } from '../project-card';
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: ({ alt, src }: { alt: string; src: string }) => (
|
||||||
|
<img alt={alt} src={src} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseProject = {
|
||||||
|
id: 'p1',
|
||||||
|
slug: 'vinhomes-central-park',
|
||||||
|
name: 'Vinhomes Central Park',
|
||||||
|
status: 'UNDER_CONSTRUCTION' as const,
|
||||||
|
developer: { id: 'd1', name: 'Vingroup' },
|
||||||
|
city: 'TP.HCM',
|
||||||
|
district: 'Bình Thạnh',
|
||||||
|
address: '208 Nguyễn Hữu Cảnh',
|
||||||
|
latitude: 10.79,
|
||||||
|
longitude: 106.72,
|
||||||
|
thumbnailUrl: 'https://example.com/t.jpg',
|
||||||
|
totalArea: 43,
|
||||||
|
totalUnits: 10000,
|
||||||
|
propertyTypes: ['APARTMENT', 'VILLA'] as ('APARTMENT' | 'VILLA')[],
|
||||||
|
minPrice: '3500000000',
|
||||||
|
maxPrice: '20000000000',
|
||||||
|
completionDate: null,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ProjectCard', () => {
|
||||||
|
it('renders name, location, developer and status label', () => {
|
||||||
|
render(<ProjectCard project={baseProject} />);
|
||||||
|
expect(screen.getByText('Vinhomes Central Park')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Bình Thạnh, TP\.HCM/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vingroup')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đang xây dựng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to project detail by slug', () => {
|
||||||
|
const { container } = render(<ProjectCard project={baseProject} />);
|
||||||
|
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
||||||
|
'/du-an/vinhomes-central-park',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders thumbnail image when thumbnailUrl present', () => {
|
||||||
|
render(<ProjectCard project={baseProject} />);
|
||||||
|
const img = screen.getByAltText('Vinhomes Central Park') as HTMLImageElement;
|
||||||
|
expect(img.src).toContain('t.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Liên hệ" when minPrice is null', () => {
|
||||||
|
render(
|
||||||
|
<ProjectCard project={{ ...baseProject, minPrice: null, maxPrice: null }} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders unit count with "căn" suffix', () => {
|
||||||
|
render(<ProjectCard project={baseProject} />);
|
||||||
|
expect(screen.getByText('10000 căn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { useMarkInquiryRead } from '@/lib/hooks/use-inquiries';
|
import { useMarkInquiryRead } from '@/lib/hooks/use-inquiries';
|
||||||
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
||||||
|
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||||
|
|
||||||
interface InquiryDetailDialogProps {
|
interface InquiryDetailDialogProps {
|
||||||
inquiry: InquiryReadDto | null;
|
inquiry: InquiryReadDto | null;
|
||||||
@@ -42,6 +43,8 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const phone = inquiry.phone ?? inquiry.userPhone;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-md sm:max-w-lg">
|
<DialogContent className="max-w-md sm:max-w-lg">
|
||||||
@@ -60,7 +63,7 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
|||||||
<InquiryStatusBadge isRead={inquiry.isRead} />
|
<InquiryStatusBadge isRead={inquiry.isRead} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
<p>SĐT: {inquiry.phone ?? inquiry.userPhone}</p>
|
<p>SĐT: {formatPhone(phone)}</p>
|
||||||
<p>Ngày gửi: {formattedDate}</p>
|
<p>Ngày gửi: {formattedDate}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,13 +81,13 @@ export function InquiryDetailDialog({ inquiry, open, onOpenChange }: InquiryDeta
|
|||||||
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
|
<h4 className="text-sm font-medium">Liên hệ nhanh</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<a
|
<a
|
||||||
href={`tel:${inquiry.phone ?? inquiry.userPhone}`}
|
href={`tel:${phone}`}
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
<Phone className="h-4 w-4" aria-hidden="true" /> Gọi điện
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`https://zalo.me/${(inquiry.phone ?? inquiry.userPhone).replace(/^0/, '84')}`}
|
href={zaloHref(phone)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
|
import { useDeleteLead, useUpdateLeadStatus } from '@/lib/hooks/use-leads';
|
||||||
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
import { LEAD_STATUSES, LEAD_SOURCES, type LeadReadDto, type LeadStatus } from '@/lib/leads-api';
|
||||||
|
import { formatPhone, zaloHref } from '@/lib/phone';
|
||||||
|
|
||||||
interface LeadDetailDialogProps {
|
interface LeadDetailDialogProps {
|
||||||
lead: LeadReadDto | null;
|
lead: LeadReadDto | null;
|
||||||
@@ -96,7 +97,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
|||||||
<LeadStatusBadge status={lead.status} />
|
<LeadStatusBadge status={lead.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
<div className="space-y-1 text-sm text-muted-foreground">
|
||||||
<p>SĐT: {lead.phone}</p>
|
<p>SĐT: {formatPhone(lead.phone)}</p>
|
||||||
{lead.email && <p>Email: {lead.email}</p>}
|
{lead.email && <p>Email: {lead.email}</p>}
|
||||||
<p>Nguồn: {getSourceLabel(lead.source)}</p>
|
<p>Nguồn: {getSourceLabel(lead.source)}</p>
|
||||||
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
|
{lead.score !== null && <p>Điểm: {lead.score}/100</p>}
|
||||||
@@ -163,7 +164,7 @@ export function LeadDetailDialog({ lead, open, onOpenChange }: LeadDetailDialogP
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href={`https://zalo.me/${lead.phone.replace(/^0/, '84')}`}
|
href={zaloHref(lead.phone)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { UseFormRegister, UseFormSetValue, UseFormWatch, FieldErrors } from 'react-hook-form';
|
import type { UseFormRegister, UseFormSetValue, UseFormWatch, FieldErrors } from 'react-hook-form';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -43,9 +44,13 @@ interface StepLocationProps extends StepProps {
|
|||||||
watch?: UseFormWatch<CreateListingFormData>;
|
watch?: UseFormWatch<CreateListingFormData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldError({ message }: { message?: string }) {
|
function FieldError({ id, message }: { id: string; message?: string }) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
return <p className="mt-1 text-xs text-destructive">{message}</p>;
|
return (
|
||||||
|
<p id={id} role="alert" className="mt-1 text-xs text-destructive">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 1: Basic Info ──────────────────────────────────
|
// ─── Step 1: Basic Info ──────────────────────────────────
|
||||||
@@ -58,7 +63,12 @@ export function StepBasicInfo({ register, errors }: StepProps) {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="transactionType">Loại giao dịch *</Label>
|
<Label htmlFor="transactionType">Loại giao dịch *</Label>
|
||||||
<Select id="transactionType" {...register('transactionType')}>
|
<Select
|
||||||
|
id="transactionType"
|
||||||
|
aria-invalid={!!errors.transactionType}
|
||||||
|
aria-describedby={errors.transactionType ? 'transactionType-error' : undefined}
|
||||||
|
{...register('transactionType')}
|
||||||
|
>
|
||||||
<option value="">-- Chọn --</option>
|
<option value="">-- Chọn --</option>
|
||||||
{TRANSACTION_TYPES.map((t) => (
|
{TRANSACTION_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
@@ -66,12 +76,17 @@ export function StepBasicInfo({ register, errors }: StepProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<FieldError message={errors.transactionType?.message} />
|
<FieldError id="transactionType-error" message={errors.transactionType?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="propertyType">Loại bất động sản *</Label>
|
<Label htmlFor="propertyType">Loại bất động sản *</Label>
|
||||||
<Select id="propertyType" {...register('propertyType')}>
|
<Select
|
||||||
|
id="propertyType"
|
||||||
|
aria-invalid={!!errors.propertyType}
|
||||||
|
aria-describedby={errors.propertyType ? 'propertyType-error' : undefined}
|
||||||
|
{...register('propertyType')}
|
||||||
|
>
|
||||||
<option value="">-- Chọn --</option>
|
<option value="">-- Chọn --</option>
|
||||||
{PROPERTY_TYPES.map((t) => (
|
{PROPERTY_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
@@ -79,14 +94,20 @@ export function StepBasicInfo({ register, errors }: StepProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<FieldError message={errors.propertyType?.message} />
|
<FieldError id="propertyType-error" message={errors.propertyType?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="title">Tiêu đề tin đăng *</Label>
|
<Label htmlFor="title">Tiêu đề tin đăng *</Label>
|
||||||
<Input id="title" placeholder="VD: Bán căn hộ 2PN tại Vinhomes Central Park" {...register('title')} />
|
<Input
|
||||||
<FieldError message={errors.title?.message} />
|
id="title"
|
||||||
|
placeholder="VD: Bán căn hộ 2PN tại Vinhomes Central Park"
|
||||||
|
aria-invalid={!!errors.title}
|
||||||
|
aria-describedby={errors.title ? 'title-error' : undefined}
|
||||||
|
{...register('title')}
|
||||||
|
/>
|
||||||
|
<FieldError id="title-error" message={errors.title?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -95,9 +116,11 @@ export function StepBasicInfo({ register, errors }: StepProps) {
|
|||||||
id="description"
|
id="description"
|
||||||
rows={5}
|
rows={5}
|
||||||
placeholder="Mô tả chi tiết về bất động sản..."
|
placeholder="Mô tả chi tiết về bất động sản..."
|
||||||
|
aria-invalid={!!errors.description}
|
||||||
|
aria-describedby={errors.description ? 'description-error' : undefined}
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
/>
|
/>
|
||||||
<FieldError message={errors.description?.message} />
|
<FieldError id="description-error" message={errors.description?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -115,10 +138,35 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
const latValid = latNum != null && Number.isFinite(latNum) && latNum >= -90 && latNum <= 90;
|
const latValid = latNum != null && Number.isFinite(latNum) && latNum >= -90 && latNum <= 90;
|
||||||
const lngValid = lngNum != null && Number.isFinite(lngNum) && lngNum >= -180 && lngNum <= 180;
|
const lngValid = lngNum != null && Number.isFinite(lngNum) && lngNum >= -180 && lngNum <= 180;
|
||||||
|
|
||||||
|
// Live region message announced when the map geocoder resolves a location.
|
||||||
|
const [locationAnnouncement, setLocationAnnouncement] = useState('');
|
||||||
|
const announcementTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Clear announcement after it has been read to avoid stale text being
|
||||||
|
// re-announced on re-render.
|
||||||
|
useEffect(() => {
|
||||||
|
if (locationAnnouncement) {
|
||||||
|
announcementTimerRef.current = setTimeout(() => setLocationAnnouncement(''), 3000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (announcementTimerRef.current) clearTimeout(announcementTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [locationAnnouncement]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Vị trí</h3>
|
<h3 className="text-lg font-semibold">Vị trí</h3>
|
||||||
|
|
||||||
|
{/* Visually-hidden live region for map-picker location announcements */}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
className="sr-only"
|
||||||
|
>
|
||||||
|
{locationAnnouncement}
|
||||||
|
</div>
|
||||||
|
|
||||||
{setValue && (
|
{setValue && (
|
||||||
<LocationPicker
|
<LocationPicker
|
||||||
lat={latValid ? latNum : null}
|
lat={latValid ? latNum : null}
|
||||||
@@ -131,6 +179,11 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
if (resolved.ward) setValue('ward', resolved.ward, { shouldDirty: true });
|
if (resolved.ward) setValue('ward', resolved.ward, { shouldDirty: true });
|
||||||
if (resolved.district) setValue('district', resolved.district, { shouldDirty: true });
|
if (resolved.district) setValue('district', resolved.district, { shouldDirty: true });
|
||||||
if (resolved.city) setValue('city', resolved.city, { shouldDirty: true });
|
if (resolved.city) setValue('city', resolved.city, { shouldDirty: true });
|
||||||
|
// Announce resolved location to screen reader users
|
||||||
|
const parts = [resolved.ward, resolved.district, resolved.city].filter(Boolean);
|
||||||
|
if (parts.length > 0) {
|
||||||
|
setLocationAnnouncement(`Đã cập nhật vị trí: ${parts.join(', ')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
height="360px"
|
height="360px"
|
||||||
@@ -139,25 +192,49 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="address">Địa chỉ *</Label>
|
<Label htmlFor="address">Địa chỉ *</Label>
|
||||||
<Input id="address" placeholder="Số nhà, tên đường" {...register('address')} />
|
<Input
|
||||||
<FieldError message={errors.address?.message} />
|
id="address"
|
||||||
|
placeholder="Số nhà, tên đường"
|
||||||
|
aria-invalid={!!errors.address}
|
||||||
|
aria-describedby={errors.address ? 'address-error' : undefined}
|
||||||
|
{...register('address')}
|
||||||
|
/>
|
||||||
|
<FieldError id="address-error" message={errors.address?.message} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="ward">Phường/Xã *</Label>
|
<Label htmlFor="ward">Phường/Xã *</Label>
|
||||||
<Input id="ward" placeholder="Phường/Xã" {...register('ward')} />
|
<Input
|
||||||
<FieldError message={errors.ward?.message} />
|
id="ward"
|
||||||
|
placeholder="Phường/Xã"
|
||||||
|
aria-invalid={!!errors.ward}
|
||||||
|
aria-describedby={errors.ward ? 'ward-error' : undefined}
|
||||||
|
{...register('ward')}
|
||||||
|
/>
|
||||||
|
<FieldError id="ward-error" message={errors.ward?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="district">Quận/Huyện *</Label>
|
<Label htmlFor="district">Quận/Huyện *</Label>
|
||||||
<Input id="district" placeholder="Quận/Huyện" {...register('district')} />
|
<Input
|
||||||
<FieldError message={errors.district?.message} />
|
id="district"
|
||||||
|
placeholder="Quận/Huyện"
|
||||||
|
aria-invalid={!!errors.district}
|
||||||
|
aria-describedby={errors.district ? 'district-error' : undefined}
|
||||||
|
{...register('district')}
|
||||||
|
/>
|
||||||
|
<FieldError id="district-error" message={errors.district?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="city">Tỉnh/Thành phố *</Label>
|
<Label htmlFor="city">Tỉnh/Thành phố *</Label>
|
||||||
<Input id="city" placeholder="Tỉnh/Thành phố" {...register('city')} />
|
<Input
|
||||||
<FieldError message={errors.city?.message} />
|
id="city"
|
||||||
|
placeholder="Tỉnh/Thành phố"
|
||||||
|
aria-invalid={!!errors.city}
|
||||||
|
aria-describedby={errors.city ? 'city-error' : undefined}
|
||||||
|
{...register('city')}
|
||||||
|
/>
|
||||||
|
<FieldError id="city-error" message={errors.city?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -169,9 +246,11 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="VD: 10.7769"
|
placeholder="VD: 10.7769"
|
||||||
|
aria-invalid={!!errors.latitude}
|
||||||
|
aria-describedby={errors.latitude ? 'latitude-error' : undefined}
|
||||||
{...register('latitude')}
|
{...register('latitude')}
|
||||||
/>
|
/>
|
||||||
<FieldError message={errors.latitude?.message} />
|
<FieldError id="latitude-error" message={errors.latitude?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="longitude">Kinh độ</Label>
|
<Label htmlFor="longitude">Kinh độ</Label>
|
||||||
@@ -180,9 +259,11 @@ export function StepLocation({ register, errors, setValue, watch }: StepLocation
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
placeholder="VD: 106.7009"
|
placeholder="VD: 106.7009"
|
||||||
|
aria-invalid={!!errors.longitude}
|
||||||
|
aria-describedby={errors.longitude ? 'longitude-error' : undefined}
|
||||||
{...register('longitude')}
|
{...register('longitude')}
|
||||||
/>
|
/>
|
||||||
<FieldError message={errors.longitude?.message} />
|
<FieldError id="longitude-error" message={errors.longitude?.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,8 +280,16 @@ export function StepDetails({ register, errors }: StepProps) {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="areaM2">Diện tích (m²) *</Label>
|
<Label htmlFor="areaM2">Diện tích (m²) *</Label>
|
||||||
<Input id="areaM2" type="number" step="0.1" placeholder="VD: 75" {...register('areaM2')} />
|
<Input
|
||||||
<FieldError message={errors.areaM2?.message} />
|
id="areaM2"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="VD: 75"
|
||||||
|
aria-invalid={!!errors.areaM2}
|
||||||
|
aria-describedby={errors.areaM2 ? 'areaM2-error' : undefined}
|
||||||
|
{...register('areaM2')}
|
||||||
|
/>
|
||||||
|
<FieldError id="areaM2-error" message={errors.areaM2?.message} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="usableAreaM2">Diện tích sử dụng (m²)</Label>
|
<Label htmlFor="usableAreaM2">Diện tích sử dụng (m²)</Label>
|
||||||
@@ -360,8 +449,14 @@ export function StepPricing({ register, errors }: StepProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="priceVND">Giá bán (VNĐ) *</Label>
|
<Label htmlFor="priceVND">Giá bán (VNĐ) *</Label>
|
||||||
<Input id="priceVND" placeholder="VD: 5000000000" {...register('priceVND')} />
|
<Input
|
||||||
<FieldError message={errors.priceVND?.message} />
|
id="priceVND"
|
||||||
|
placeholder="VD: 5000000000"
|
||||||
|
aria-invalid={!!errors.priceVND}
|
||||||
|
aria-describedby={errors.priceVND ? 'priceVND-error' : undefined}
|
||||||
|
{...register('priceVND')}
|
||||||
|
/>
|
||||||
|
<FieldError id="priceVND-error" message={errors.priceVND?.message} />
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Nhập số không có dấu chấm hoặc dấu phẩy</p>
|
<p className="mt-1 text-xs text-muted-foreground">Nhập số không có dấu chấm hoặc dấu phẩy</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ const CITY_COORDS: Record<string, [number, number]> = {
|
|||||||
|
|
||||||
const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231]; // HCMC [lng, lat]
|
const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231]; // HCMC [lng, lat]
|
||||||
const DEFAULT_ZOOM = 12;
|
const DEFAULT_ZOOM = 12;
|
||||||
|
const CLUSTER_SOURCE_ID = 'listings-cluster-source';
|
||||||
|
const CLUSTER_LAYER_ID = 'listings-cluster-circles';
|
||||||
|
const CLUSTER_COUNT_LAYER_ID = 'listings-cluster-count';
|
||||||
|
const UNCLUSTERED_LAYER_ID = 'listings-unclustered';
|
||||||
|
const UNCLUSTERED_SELECTED_LAYER_ID = 'listings-unclustered-selected';
|
||||||
|
|
||||||
function getMarkerCoords(listing: ListingDetail, index: number): { lat: number; lng: number } {
|
function getMarkerCoords(listing: ListingDetail, index: number): { lat: number; lng: number } {
|
||||||
if (listing.property.latitude != null && listing.property.longitude != null) {
|
if (listing.property.latitude != null && listing.property.longitude != null) {
|
||||||
@@ -51,6 +56,98 @@ function getMarkerCoords(listing: ListingDetail, index: number): { lat: number;
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildGeoJSON(
|
||||||
|
markers: MapMarker[],
|
||||||
|
selectedListingId?: string,
|
||||||
|
): GeoJSON.FeatureCollection<GeoJSON.Point> {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: markers.map(({ listing, lat, lng }) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [lng, lat] },
|
||||||
|
properties: {
|
||||||
|
id: listing.id,
|
||||||
|
price: formatPrice(listing.priceVND),
|
||||||
|
title: listing.property.title,
|
||||||
|
district: listing.property.district ?? '',
|
||||||
|
city: listing.property.city ?? '',
|
||||||
|
areaM2: listing.property.areaM2,
|
||||||
|
bedrooms: listing.property.bedrooms ?? null,
|
||||||
|
bathrooms: listing.property.bathrooms ?? null,
|
||||||
|
imageUrl: listing.property.media?.[0]?.url ?? null,
|
||||||
|
selected: listing.id === selectedListingId ? 1 : 0,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPopupContent(listing: ListingDetail): HTMLDivElement {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.setAttribute('role', 'dialog');
|
||||||
|
container.setAttribute('aria-label', `Chi tiết: ${listing.property.title}`);
|
||||||
|
container.style.cssText =
|
||||||
|
'font-family:system-ui,sans-serif;background:hsl(var(--card));color:hsl(var(--card-foreground));padding:8px;border-radius:6px;';
|
||||||
|
|
||||||
|
if ((listing.property.media?.length ?? 0) > 0) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = listing.property.media![0]!.url;
|
||||||
|
img.alt = listing.property.title;
|
||||||
|
img.style.cssText =
|
||||||
|
'width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;';
|
||||||
|
container.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const price = document.createElement('p');
|
||||||
|
price.style.cssText =
|
||||||
|
'font-weight:700;color:hsl(var(--primary));font-size:14px;margin:0 0 4px;';
|
||||||
|
price.textContent = `${formatPrice(listing.priceVND)} VND`;
|
||||||
|
container.appendChild(price);
|
||||||
|
|
||||||
|
const title = document.createElement('p');
|
||||||
|
title.style.cssText =
|
||||||
|
'font-size:13px;font-weight:500;margin:0 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
||||||
|
title.textContent = listing.property.title;
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
const location = document.createElement('p');
|
||||||
|
location.style.cssText = 'font-size:12px;color:hsl(var(--muted-foreground));margin:0 0 8px;';
|
||||||
|
location.textContent = `${listing.property.district}, ${listing.property.city}`;
|
||||||
|
container.appendChild(location);
|
||||||
|
|
||||||
|
const details = document.createElement('div');
|
||||||
|
details.style.cssText = 'display:flex;gap:4px;font-size:11px;margin-bottom:8px;';
|
||||||
|
const tagStyle =
|
||||||
|
'background:hsl(var(--secondary));color:hsl(var(--secondary-foreground));padding:2px 6px;border-radius:4px;';
|
||||||
|
|
||||||
|
const areaTag = document.createElement('span');
|
||||||
|
areaTag.style.cssText = tagStyle;
|
||||||
|
areaTag.textContent = `${listing.property.areaM2} m²`;
|
||||||
|
details.appendChild(areaTag);
|
||||||
|
|
||||||
|
if (listing.property.bedrooms != null) {
|
||||||
|
const bedTag = document.createElement('span');
|
||||||
|
bedTag.style.cssText = tagStyle;
|
||||||
|
bedTag.textContent = `${listing.property.bedrooms} PN`;
|
||||||
|
details.appendChild(bedTag);
|
||||||
|
}
|
||||||
|
if (listing.property.bathrooms != null) {
|
||||||
|
const bathTag = document.createElement('span');
|
||||||
|
bathTag.style.cssText = tagStyle;
|
||||||
|
bathTag.textContent = `${listing.property.bathrooms} WC`;
|
||||||
|
details.appendChild(bathTag);
|
||||||
|
}
|
||||||
|
container.appendChild(details);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = `/listings/${listing.id}`;
|
||||||
|
link.style.cssText =
|
||||||
|
'display:block;text-align:center;font-size:12px;font-weight:500;color:hsl(var(--primary));text-decoration:none;';
|
||||||
|
link.textContent = 'Xem chi tiết →';
|
||||||
|
container.appendChild(link);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
export function ListingMap(props: ListingMapProps) {
|
export function ListingMap(props: ListingMapProps) {
|
||||||
return (
|
return (
|
||||||
<ComponentErrorBoundary label="bản đồ">
|
<ComponentErrorBoundary label="bản đồ">
|
||||||
@@ -59,19 +156,135 @@ export function ListingMap(props: ListingMapProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListingMapInner({ listings, onMarkerClick, selectedListingId, className }: ListingMapProps) {
|
function ListingMapInner({
|
||||||
|
listings,
|
||||||
|
onMarkerClick,
|
||||||
|
selectedListingId,
|
||||||
|
className,
|
||||||
|
}: ListingMapProps) {
|
||||||
const mapContainerRef = React.useRef<HTMLDivElement>(null);
|
const mapContainerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
const mapRef = React.useRef<mapboxgl.Map | null>(null);
|
||||||
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
|
|
||||||
const popupRef = React.useRef<mapboxgl.Popup | null>(null);
|
const popupRef = React.useRef<mapboxgl.Popup | null>(null);
|
||||||
|
const layersReadyRef = React.useRef(false);
|
||||||
const mapStyle = useMapboxStyle();
|
const mapStyle = useMapboxStyle();
|
||||||
|
|
||||||
|
// Stable ref so click handlers never form a closure on stale callbacks
|
||||||
|
const onMarkerClickRef = React.useRef(onMarkerClick);
|
||||||
|
React.useEffect(() => {
|
||||||
|
onMarkerClickRef.current = onMarkerClick;
|
||||||
|
});
|
||||||
|
|
||||||
const markers: MapMarker[] = React.useMemo(
|
const markers: MapMarker[] = React.useMemo(
|
||||||
() => listings.map((listing, index) => ({ listing, ...getMarkerCoords(listing, index) })),
|
() => listings.map((listing, index) => ({ listing, ...getMarkerCoords(listing, index) })),
|
||||||
[listings],
|
[listings],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize map
|
// Build GeoJSON without depending on selectedListingId so the source update
|
||||||
|
// path (for selection highlights) stays separate from the full data reload.
|
||||||
|
const geojson = React.useMemo(() => buildGeoJSON(markers), [markers]);
|
||||||
|
|
||||||
|
// Helper: add or replace the cluster + unclustered layers on a loaded map
|
||||||
|
const addLayers = React.useCallback((map: mapboxgl.Map) => {
|
||||||
|
// Remove if they already exist (e.g. after a style reload)
|
||||||
|
for (const id of [
|
||||||
|
UNCLUSTERED_SELECTED_LAYER_ID,
|
||||||
|
UNCLUSTERED_LAYER_ID,
|
||||||
|
CLUSTER_COUNT_LAYER_ID,
|
||||||
|
CLUSTER_LAYER_ID,
|
||||||
|
]) {
|
||||||
|
if (map.getLayer(id)) map.removeLayer(id);
|
||||||
|
}
|
||||||
|
if (map.getSource(CLUSTER_SOURCE_ID)) map.removeSource(CLUSTER_SOURCE_ID);
|
||||||
|
|
||||||
|
map.addSource(CLUSTER_SOURCE_ID, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'FeatureCollection', features: [] },
|
||||||
|
cluster: true,
|
||||||
|
clusterMaxZoom: 14,
|
||||||
|
clusterRadius: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cluster circles
|
||||||
|
map.addLayer({
|
||||||
|
id: CLUSTER_LAYER_ID,
|
||||||
|
type: 'circle',
|
||||||
|
source: CLUSTER_SOURCE_ID,
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
paint: {
|
||||||
|
'circle-color': [
|
||||||
|
'step',
|
||||||
|
['get', 'point_count'],
|
||||||
|
'hsl(var(--primary))',
|
||||||
|
10,
|
||||||
|
'#f1a928',
|
||||||
|
30,
|
||||||
|
'#e5633a',
|
||||||
|
],
|
||||||
|
'circle-radius': ['step', ['get', 'point_count'], 20, 10, 28, 30, 36],
|
||||||
|
'circle-opacity': 0.85,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cluster counts
|
||||||
|
map.addLayer({
|
||||||
|
id: CLUSTER_COUNT_LAYER_ID,
|
||||||
|
type: 'symbol',
|
||||||
|
source: CLUSTER_SOURCE_ID,
|
||||||
|
filter: ['has', 'point_count'],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'point_count_abbreviated'],
|
||||||
|
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
|
||||||
|
'text-size': 13,
|
||||||
|
},
|
||||||
|
paint: { 'text-color': '#ffffff' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unclustered — normal markers
|
||||||
|
map.addLayer({
|
||||||
|
id: UNCLUSTERED_LAYER_ID,
|
||||||
|
type: 'symbol',
|
||||||
|
source: CLUSTER_SOURCE_ID,
|
||||||
|
filter: ['all', ['!', ['has', 'point_count']], ['==', ['get', 'selected'], 0]],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'price'],
|
||||||
|
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
|
||||||
|
'text-size': 12,
|
||||||
|
'text-anchor': 'center',
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'hsl(var(--card-foreground))',
|
||||||
|
'text-halo-color': 'hsl(var(--card))',
|
||||||
|
'text-halo-width': 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unclustered — selected marker (on top, different style)
|
||||||
|
map.addLayer({
|
||||||
|
id: UNCLUSTERED_SELECTED_LAYER_ID,
|
||||||
|
type: 'symbol',
|
||||||
|
source: CLUSTER_SOURCE_ID,
|
||||||
|
filter: ['all', ['!', ['has', 'point_count']], ['==', ['get', 'selected'], 1]],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'price'],
|
||||||
|
'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'],
|
||||||
|
'text-size': 13,
|
||||||
|
'text-anchor': 'center',
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'hsl(var(--primary-foreground))',
|
||||||
|
'text-halo-color': 'hsl(var(--primary))',
|
||||||
|
'text-halo-width': 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
layersReadyRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize map once
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!mapContainerRef.current) return;
|
if (!mapContainerRef.current) return;
|
||||||
|
|
||||||
@@ -94,149 +307,174 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
|
|||||||
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
|
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
|
||||||
map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right');
|
map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right');
|
||||||
|
|
||||||
|
map.once('load', () => {
|
||||||
|
const container = mapContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const zoomIn = container.querySelector('.mapboxgl-ctrl-zoom-in') as HTMLButtonElement | null;
|
||||||
|
const zoomOut = container.querySelector(
|
||||||
|
'.mapboxgl-ctrl-zoom-out',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
const compass = container.querySelector(
|
||||||
|
'.mapboxgl-ctrl-compass',
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
if (zoomIn) zoomIn.setAttribute('aria-label', 'Phóng to');
|
||||||
|
if (zoomOut) zoomOut.setAttribute('aria-label', 'Thu nhỏ');
|
||||||
|
if (compass) compass.setAttribute('aria-label', 'Đặt lại hướng bắc');
|
||||||
|
|
||||||
|
addLayers(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-add layers after style hot-swap
|
||||||
|
map.on('style.load', () => {
|
||||||
|
if (mapRef.current) addLayers(mapRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Click: cluster → zoom in ---
|
||||||
|
map.on('click', CLUSTER_LAYER_ID, (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: [CLUSTER_LAYER_ID] });
|
||||||
|
if (!features.length) return;
|
||||||
|
const feature = features[0]!;
|
||||||
|
const source = map.getSource(CLUSTER_SOURCE_ID) as mapboxgl.GeoJSONSource;
|
||||||
|
source.getClusterExpansionZoom(
|
||||||
|
(feature.properties as { cluster_id: number }).cluster_id,
|
||||||
|
(err: Error | null | undefined, zoom: number | null | undefined) => {
|
||||||
|
if (err || zoom == null) return;
|
||||||
|
const geom = feature.geometry as GeoJSON.Point;
|
||||||
|
map.easeTo({ center: geom.coordinates as [number, number], zoom });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Click: unclustered marker → show popup ---
|
||||||
|
const handleUnclusteredClick = (
|
||||||
|
e: mapboxgl.MapLayerMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] },
|
||||||
|
) => {
|
||||||
|
if (!e.features?.length) return;
|
||||||
|
const props = e.features[0]!.properties as {
|
||||||
|
id: string;
|
||||||
|
price: string;
|
||||||
|
title: string;
|
||||||
|
district: string;
|
||||||
|
city: string;
|
||||||
|
areaM2: number;
|
||||||
|
bedrooms: number | null;
|
||||||
|
bathrooms: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
|
const geom = e.features[0]!.geometry as GeoJSON.Point;
|
||||||
|
const [lng, lat] = geom.coordinates as [number, number];
|
||||||
|
|
||||||
|
// Find full listing for popup
|
||||||
|
const listing = listings.find((l) => l.id === props.id);
|
||||||
|
if (!listing) return;
|
||||||
|
|
||||||
|
onMarkerClickRef.current?.(listing);
|
||||||
|
|
||||||
|
popupRef.current?.remove();
|
||||||
|
const popup = new mapboxgl.Popup({
|
||||||
|
offset: 25,
|
||||||
|
maxWidth: 'min(260px, 85vw)',
|
||||||
|
closeButton: true,
|
||||||
|
})
|
||||||
|
.setLngLat([lng, lat])
|
||||||
|
.setDOMContent(buildPopupContent(listing))
|
||||||
|
.addTo(map);
|
||||||
|
popupRef.current = popup;
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('click', UNCLUSTERED_LAYER_ID, handleUnclusteredClick);
|
||||||
|
map.on('click', UNCLUSTERED_SELECTED_LAYER_ID, handleUnclusteredClick);
|
||||||
|
|
||||||
|
// Cursor changes
|
||||||
|
for (const layer of [CLUSTER_LAYER_ID, UNCLUSTERED_LAYER_ID, UNCLUSTERED_SELECTED_LAYER_ID]) {
|
||||||
|
map.on('mouseenter', layer, () => {
|
||||||
|
map.getCanvas().style.cursor = 'pointer';
|
||||||
|
});
|
||||||
|
map.on('mouseleave', layer, () => {
|
||||||
|
map.getCanvas().style.cursor = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
layersReadyRef.current = false;
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync style changes with theme
|
// Sync style changes with theme (without reinitializing map)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
layersReadyRef.current = false;
|
||||||
map.setStyle(mapStyle);
|
map.setStyle(mapStyle);
|
||||||
|
// layers are re-added via the 'style.load' listener above
|
||||||
}, [mapStyle]);
|
}, [mapStyle]);
|
||||||
|
|
||||||
// Update markers when listings change
|
// Push GeoJSON data update when listings array identity changes.
|
||||||
|
// This fires on full filter changes but NOT on selectedListingId-only changes,
|
||||||
|
// which avoids full marker teardown for selection highlighting.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map || !layersReadyRef.current) return;
|
||||||
|
const source = map.getSource(CLUSTER_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
|
||||||
|
if (!source) return;
|
||||||
|
source.setData(geojson);
|
||||||
|
|
||||||
// Clear existing markers
|
// Only fit bounds when listings actually changed (not on mount w/ 0 markers)
|
||||||
markersRef.current.forEach((m) => m.remove());
|
|
||||||
markersRef.current = [];
|
|
||||||
|
|
||||||
if (markers.length === 0) return;
|
|
||||||
|
|
||||||
const bounds = new mapboxgl.LngLatBounds();
|
|
||||||
|
|
||||||
markers.forEach((marker) => {
|
|
||||||
const el = document.createElement('button');
|
|
||||||
el.className = 'mapbox-price-marker';
|
|
||||||
const isSelected = selectedListingId === marker.listing.id;
|
|
||||||
const span = document.createElement('span');
|
|
||||||
if (isSelected) span.className = 'selected';
|
|
||||||
span.textContent = formatPrice(marker.listing.priceVND);
|
|
||||||
el.appendChild(span);
|
|
||||||
el.style.cssText = 'border:none;cursor:pointer;background:none;padding:0;';
|
|
||||||
|
|
||||||
el.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMarkerClick?.(marker.listing);
|
|
||||||
showPopup(map, marker);
|
|
||||||
});
|
|
||||||
|
|
||||||
const mbMarker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
|
||||||
.setLngLat([marker.lng, marker.lat])
|
|
||||||
.addTo(map);
|
|
||||||
|
|
||||||
markersRef.current.push(mbMarker);
|
|
||||||
bounds.extend([marker.lng, marker.lat]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fit bounds with padding
|
|
||||||
if (markers.length > 1) {
|
if (markers.length > 1) {
|
||||||
map.fitBounds(bounds, { padding: 60, maxZoom: 15 });
|
const bounds = new mapboxgl.LngLatBounds();
|
||||||
} else {
|
markers.forEach(({ lat, lng }) => bounds.extend([lng, lat]));
|
||||||
map.flyTo({ center: [markers[0]!.lng, markers[0]!.lat], zoom: 14 });
|
map.fitBounds(bounds, { padding: 60, maxZoom: 15, duration: 400 });
|
||||||
|
} else if (markers.length === 1) {
|
||||||
|
map.flyTo({ center: [markers[0]!.lng, markers[0]!.lat], zoom: 14, duration: 400 });
|
||||||
}
|
}
|
||||||
}, [markers, selectedListingId, onMarkerClick]);
|
}, [geojson, markers]);
|
||||||
|
|
||||||
function buildPopupContent(listing: ListingDetail): HTMLDivElement {
|
// ── Selection highlight: update only the `selected` property on each feature.
|
||||||
const container = document.createElement('div');
|
// No source reload needed — setData is cheap for 1-property diff but we use
|
||||||
container.style.cssText = 'font-family:system-ui,sans-serif;background:hsl(var(--card));color:hsl(var(--card-foreground));padding:8px;border-radius:6px;';
|
// a filter-based approach instead: simply regenerate GeoJSON with updated
|
||||||
|
// `selected` flags. Because `geojson` memo doesn't include selectedListingId,
|
||||||
|
// we build a tiny delta update here.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !layersReadyRef.current) return;
|
||||||
|
const source = map.getSource(CLUSTER_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
if ((listing.property.media?.length ?? 0) > 0) {
|
// Rebuild GeoJSON with updated selection flags (no viewport/bounds change)
|
||||||
const img = document.createElement('img');
|
source.setData(buildGeoJSON(markers, selectedListingId));
|
||||||
img.src = listing.property.media![0]!.url;
|
}, [selectedListingId, markers]);
|
||||||
img.alt = listing.property.title;
|
|
||||||
img.style.cssText = 'width:100%;height:96px;object-fit:cover;border-radius:6px;margin-bottom:8px;';
|
|
||||||
container.appendChild(img);
|
|
||||||
}
|
|
||||||
|
|
||||||
const price = document.createElement('p');
|
|
||||||
price.style.cssText = 'font-weight:700;color:hsl(var(--primary));font-size:14px;margin:0 0 4px;';
|
|
||||||
price.textContent = `${formatPrice(listing.priceVND)} VND`;
|
|
||||||
container.appendChild(price);
|
|
||||||
|
|
||||||
const title = document.createElement('p');
|
|
||||||
title.style.cssText = 'font-size:13px;font-weight:500;margin:0 0 2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
|
||||||
title.textContent = listing.property.title;
|
|
||||||
container.appendChild(title);
|
|
||||||
|
|
||||||
const location = document.createElement('p');
|
|
||||||
location.style.cssText = 'font-size:12px;color:hsl(var(--muted-foreground));margin:0 0 8px;';
|
|
||||||
location.textContent = `${listing.property.district}, ${listing.property.city}`;
|
|
||||||
container.appendChild(location);
|
|
||||||
|
|
||||||
const details = document.createElement('div');
|
|
||||||
details.style.cssText = 'display:flex;gap:4px;font-size:11px;margin-bottom:8px;';
|
|
||||||
const tagStyle = 'background:hsl(var(--secondary));color:hsl(var(--secondary-foreground));padding:2px 6px;border-radius:4px;';
|
|
||||||
|
|
||||||
const areaTag = document.createElement('span');
|
|
||||||
areaTag.style.cssText = tagStyle;
|
|
||||||
areaTag.textContent = `${listing.property.areaM2} m\u00B2`;
|
|
||||||
details.appendChild(areaTag);
|
|
||||||
|
|
||||||
if (listing.property.bedrooms != null) {
|
|
||||||
const bedTag = document.createElement('span');
|
|
||||||
bedTag.style.cssText = tagStyle;
|
|
||||||
bedTag.textContent = `${listing.property.bedrooms} PN`;
|
|
||||||
details.appendChild(bedTag);
|
|
||||||
}
|
|
||||||
if (listing.property.bathrooms != null) {
|
|
||||||
const bathTag = document.createElement('span');
|
|
||||||
bathTag.style.cssText = tagStyle;
|
|
||||||
bathTag.textContent = `${listing.property.bathrooms} WC`;
|
|
||||||
details.appendChild(bathTag);
|
|
||||||
}
|
|
||||||
container.appendChild(details);
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = `/listings/${listing.id}`;
|
|
||||||
link.style.cssText = 'display:block;text-align:center;font-size:12px;font-weight:500;color:hsl(var(--primary));text-decoration:none;';
|
|
||||||
link.textContent = 'Xem chi ti\u1EBFt \u2192';
|
|
||||||
container.appendChild(link);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPopup(map: mapboxgl.Map, marker: MapMarker) {
|
|
||||||
popupRef.current?.remove();
|
|
||||||
|
|
||||||
const popup = new mapboxgl.Popup({ offset: 25, maxWidth: 'min(260px, 85vw)', closeButton: true })
|
|
||||||
.setLngLat([marker.lng, marker.lat])
|
|
||||||
.setDOMContent(buildPopupContent(marker.listing))
|
|
||||||
.addTo(map);
|
|
||||||
|
|
||||||
popupRef.current = popup;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative overflow-hidden rounded-lg border ${className || 'h-[300px] md:h-[500px]'}`}>
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Bản đồ bất động sản"
|
||||||
|
className={`relative overflow-hidden rounded-lg border ${className || 'h-[300px] md:h-[500px]'}`}
|
||||||
|
>
|
||||||
<div ref={mapContainerRef} className="h-full w-full" />
|
<div ref={mapContainerRef} className="h-full w-full" />
|
||||||
|
|
||||||
{/* Fallback when no Mapbox token */}
|
{/* Fallback when no Mapbox token */}
|
||||||
{!hasToken && (
|
{!hasToken && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50">
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<svg className="mx-auto mb-2 h-10 w-10 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
className="mx-auto mb-2 h-10 w-10 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ
|
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ
|
||||||
@@ -246,40 +484,26 @@ function ListingMapInner({ listings, onMarkerClick, selectedListingId, className
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Listing count overlay */}
|
{/* Listing count overlay */}
|
||||||
<div className="absolute bottom-3 left-3 rounded bg-white/90 px-2 py-1 text-xs text-muted-foreground shadow">
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
className="absolute bottom-3 left-3 rounded bg-card/90 px-2 py-1 text-xs text-card-foreground shadow"
|
||||||
|
>
|
||||||
{markers.length} bất động sản trên bản đồ
|
{markers.length} bất động sản trên bản đồ
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{markers.length === 0 && hasToken && (
|
{markers.length === 0 && hasToken && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
|
<div role="status" className="absolute inset-0 flex items-center justify-center bg-card/60">
|
||||||
<p className="text-muted-foreground">Không có bất động sản để hiển thị trên bản đồ</p>
|
<p className="text-muted-foreground">Không có bất động sản để hiển thị trên bản đồ</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
.mapbox-price-marker span {
|
|
||||||
display: block;
|
|
||||||
background: hsl(var(--card));
|
|
||||||
color: hsl(var(--card-foreground));
|
|
||||||
border-radius: 9999px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.mapbox-price-marker:hover span,
|
|
||||||
.mapbox-price-marker span.selected {
|
|
||||||
background: hsl(var(--primary));
|
|
||||||
color: hsl(var(--primary-foreground));
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
.mapboxgl-popup-content {
|
.mapboxgl-popup-content {
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
padding: 12px !important;
|
padding: 12px !important;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3) !important;
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { NotificationsProvider } from '../notifications-provider';
|
||||||
|
|
||||||
|
const useSocketNotificationsMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-socket-notifications', () => ({
|
||||||
|
useSocketNotifications: () => useSocketNotificationsMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('NotificationsProvider', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(
|
||||||
|
<NotificationsProvider>
|
||||||
|
<div>child</div>
|
||||||
|
</NotificationsProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('child')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes socket notifications hook on mount', () => {
|
||||||
|
useSocketNotificationsMock.mockClear();
|
||||||
|
render(
|
||||||
|
<NotificationsProvider>
|
||||||
|
<span>x</span>
|
||||||
|
</NotificationsProvider>,
|
||||||
|
);
|
||||||
|
expect(useSocketNotificationsMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { QueryProvider } from '../query-provider';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/query-client', () => {
|
||||||
|
const { QueryClient } = require('@tanstack/react-query');
|
||||||
|
return { getQueryClient: () => new QueryClient() };
|
||||||
|
});
|
||||||
|
|
||||||
|
function Boom() {
|
||||||
|
throw new Error('query-fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('QueryProvider', () => {
|
||||||
|
let spy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children under provider', () => {
|
||||||
|
render(
|
||||||
|
<QueryProvider>
|
||||||
|
<div>ok</div>
|
||||||
|
</QueryProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches thrown errors and renders fallback with retry button', () => {
|
||||||
|
render(
|
||||||
|
<QueryProvider>
|
||||||
|
<Boom />
|
||||||
|
</QueryProvider>,
|
||||||
|
);
|
||||||
|
// error.description & common.retry keys surface via mocked translator
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'common.retry' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the underlying error message in fallback', () => {
|
||||||
|
render(
|
||||||
|
<QueryProvider>
|
||||||
|
<Boom />
|
||||||
|
</QueryProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('query-fail')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
apps/web/components/reports/__tests__/report-card.spec.tsx
Normal file
77
apps/web/components/reports/__tests__/report-card.spec.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ReportCard } from '../report-card';
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseReport = {
|
||||||
|
id: 'r1',
|
||||||
|
type: 'RESIDENTIAL_MARKET' as const,
|
||||||
|
title: 'Báo cáo thị trường Q1',
|
||||||
|
params: {},
|
||||||
|
content: null,
|
||||||
|
pdfUrl: null,
|
||||||
|
status: 'READY' as const,
|
||||||
|
errorMsg: null,
|
||||||
|
createdAt: '2026-04-01T08:30:00.000Z',
|
||||||
|
updatedAt: '2026-04-01T08:30:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ReportCard', () => {
|
||||||
|
it('renders title and type/status badges', () => {
|
||||||
|
render(<ReportCard report={baseReport} />);
|
||||||
|
expect(screen.getByText('Báo cáo thị trường Q1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Nhà ở')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hoàn thành')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to report detail for READY report (both detail icon link and bottom "Xem báo cáo" link)', () => {
|
||||||
|
const { container } = render(<ReportCard report={baseReport} />);
|
||||||
|
const links = container.querySelectorAll('a[href="/dashboard/reports/r1"]');
|
||||||
|
expect(links.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('Xem báo cáo')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render "Xem báo cáo" link for non-READY reports', () => {
|
||||||
|
render(
|
||||||
|
<ReportCard report={{ ...baseReport, status: 'GENERATING' }} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Xem báo cáo')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error message for FAILED report with errorMsg', () => {
|
||||||
|
render(
|
||||||
|
<ReportCard
|
||||||
|
report={{
|
||||||
|
...baseReport,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMsg: 'Thiếu dữ liệu',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Thiếu dữ liệu')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Lỗi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onDelete with report id when delete button clicked', () => {
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
render(<ReportCard report={baseReport} onDelete={onDelete} />);
|
||||||
|
const trashButton = screen
|
||||||
|
.getAllByRole('button')
|
||||||
|
.find((b) => b.className.includes('text-destructive'));
|
||||||
|
expect(trashButton).toBeDefined();
|
||||||
|
fireEvent.click(trashButton!);
|
||||||
|
expect(onDelete).toHaveBeenCalledWith('r1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock lucide-react icons
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
AlertCircle: (props: Record<string, unknown>) => <span data-testid="alert-icon" {...props} />,
|
||||||
|
CreditCard: (props: Record<string, unknown>) => <span data-testid="credit-card-icon" {...props} />,
|
||||||
|
Loader2: (props: Record<string, unknown>) => <span data-testid="loader-icon" {...props} />,
|
||||||
|
Smartphone: (props: Record<string, unknown>) => <span data-testid="smartphone-icon" {...props} />,
|
||||||
|
Wallet: (props: Record<string, unknown>) => <span data-testid="wallet-icon" {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCreatePayment = vi.fn();
|
||||||
|
const mockCreateSubscription = vi.fn();
|
||||||
|
const mockUpgradeSubscription = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/payment-api', () => ({
|
||||||
|
paymentApi: {
|
||||||
|
createPayment: (...args: unknown[]) => mockCreatePayment(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/subscription-api', () => ({
|
||||||
|
subscriptionApi: {
|
||||||
|
createSubscription: (...args: unknown[]) => mockCreateSubscription(...args),
|
||||||
|
upgradeSubscription: (...args: unknown[]) => mockUpgradeSubscription(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/currency', () => ({
|
||||||
|
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/error-boundary', () => ({
|
||||||
|
ComponentErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CheckoutModal } from '../checkout-modal';
|
||||||
|
|
||||||
|
const basePlan = {
|
||||||
|
id: 'plan-1',
|
||||||
|
tier: 'AGENT_PRO',
|
||||||
|
name: 'Môi giới Pro',
|
||||||
|
priceMonthlyVND: '499000',
|
||||||
|
priceYearlyVND: '4990000',
|
||||||
|
maxListings: 50,
|
||||||
|
maxSavedSearches: 10,
|
||||||
|
features: {},
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CheckoutModal', () => {
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreatePayment.mockResolvedValue({ paymentUrl: 'https://vnpay.vn/pay', paymentId: 'p1', providerTxId: 'tx1' });
|
||||||
|
mockCreateSubscription.mockResolvedValue({ subscriptionId: 's1' });
|
||||||
|
mockUpgradeSubscription.mockResolvedValue({ subscriptionId: 's1' });
|
||||||
|
// Mock window.location
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
writable: true,
|
||||||
|
value: { ...window.location, href: 'http://localhost:3000/vi/pricing', origin: 'http://localhost:3000', pathname: '/vi/pricing' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when plan is null', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={null} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[role="dialog"]')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders order summary with plan name and monthly price', () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Đăng ký gói dịch vụ')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Môi giới Pro').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('Hàng tháng')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/499,000/).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders yearly badge and yearly price', () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="yearly" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Hàng năm')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('-17%')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/4,990,000/).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders upgrade title when isUpgrade=true', () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" isUpgrade currentTier="FREE" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Nâng cấp gói dịch vụ')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Miễn phí/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all three payment providers', () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('VNPay')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('MoMo')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ZaloPay')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects a different payment provider on click', async () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
const momoButton = screen.getByText('MoMo').closest('button')!;
|
||||||
|
await userEvent.click(momoButton);
|
||||||
|
// MoMo button should now have primary styling (ring-1 ring-primary)
|
||||||
|
expect(momoButton.className).toContain('border-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls createSubscription + createPayment on checkout', async () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateSubscription).toHaveBeenCalledWith('AGENT_PRO', 'monthly');
|
||||||
|
expect(mockCreatePayment).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: 'VNPAY',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: 499000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls upgradeSubscription instead of createSubscription for upgrades', async () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" isUpgrade />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpgradeSubscription).toHaveBeenCalledWith('AGENT_PRO');
|
||||||
|
expect(mockCreateSubscription).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error message when payment fails', async () => {
|
||||||
|
mockCreateSubscription.mockRejectedValue(new Error('Hệ thống bận'));
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Hệ thống bận')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables close button and providers while processing', async () => {
|
||||||
|
// Make the payment hang
|
||||||
|
mockCreateSubscription.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/đang xử lý/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /hủy/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismiss error on close button click', async () => {
|
||||||
|
mockCreateSubscription.mockRejectedValue(new Error('Lỗi test'));
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Lỗi test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Đóng'));
|
||||||
|
expect(screen.queryByText('Lỗi test')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
CheckCircle: (props: Record<string, unknown>) => <span data-testid="check-icon" {...props} />,
|
||||||
|
Clock: (props: Record<string, unknown>) => <span data-testid="clock-icon" {...props} />,
|
||||||
|
Loader2: (props: Record<string, unknown>) => <span data-testid="loader-icon" {...props} />,
|
||||||
|
XCircle: (props: Record<string, unknown>) => <span data-testid="x-icon" {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetPaymentStatus = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/payment-api', () => ({
|
||||||
|
paymentApi: {
|
||||||
|
getPaymentStatus: (...args: unknown[]) => mockGetPaymentStatus(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/currency', () => ({
|
||||||
|
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockSearchParams = new URLSearchParams();
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useSearchParams: () => mockSearchParams,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||||
|
<a href={href} {...props}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import PaymentReturnPage from '@/app/[locale]/(public)/payment/return/page';
|
||||||
|
|
||||||
|
describe('PaymentReturnPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "not found" when no paymentId in search params', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams();
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Không tìm thấy giao dịch')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Xem bảng giá')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Về trang chủ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success state for COMPLETED payment', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams({ paymentId: 'p1' });
|
||||||
|
mockGetPaymentStatus.mockResolvedValue({
|
||||||
|
id: 'p1',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: '499000',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
providerTxId: 'TXN123',
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-15T10:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Thanh toán thành công!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/499,000/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('VNPAY')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Xem gói dịch vụ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows failed state for FAILED payment', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams({ paymentId: 'p2' });
|
||||||
|
mockGetPaymentStatus.mockResolvedValue({
|
||||||
|
id: 'p2',
|
||||||
|
provider: 'MOMO',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: '499000',
|
||||||
|
status: 'FAILED',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-15T10:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Thanh toán thất bại')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Thử lại')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows cancelled state', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams({ paymentId: 'p3' });
|
||||||
|
mockGetPaymentStatus.mockResolvedValue({
|
||||||
|
id: 'p3',
|
||||||
|
provider: 'ZALOPAY',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: '499000',
|
||||||
|
status: 'CANCELLED',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-15T10:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Giao dịch đã hủy')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads vnp_TxnRef as fallback paymentId', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams({ vnp_TxnRef: 'vnp-123' });
|
||||||
|
mockGetPaymentStatus.mockResolvedValue({
|
||||||
|
id: 'vnp-123',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: '499000',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
providerTxId: 'TXN999',
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-15T10:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetPaymentStatus).toHaveBeenCalledWith('vnp-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -68,4 +68,53 @@ describe('Dialog', () => {
|
|||||||
await userEvent.click(screen.getByText('Stay Open'));
|
await userEvent.click(screen.getByText('Stay Open'));
|
||||||
expect(onOpenChange).not.toHaveBeenCalled();
|
expect(onOpenChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a11y: DialogContext auto-labelling', () => {
|
||||||
|
it('renders DialogContent with role="dialog" and aria-modal', () => {
|
||||||
|
render(
|
||||||
|
<Dialog open={true} onOpenChange={() => {}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>A11y Title</DialogTitle>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-wires aria-labelledby from DialogTitle id', () => {
|
||||||
|
render(
|
||||||
|
<Dialog open={true} onOpenChange={() => {}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>Auto Label</DialogTitle>
|
||||||
|
<DialogDescription>Auto Desc</DialogDescription>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
const titleId = dialog.getAttribute('aria-labelledby');
|
||||||
|
const descId = dialog.getAttribute('aria-describedby');
|
||||||
|
|
||||||
|
expect(titleId).toBeTruthy();
|
||||||
|
expect(descId).toBeTruthy();
|
||||||
|
|
||||||
|
// The title element should carry the matching id
|
||||||
|
expect(screen.getByText('Auto Label')).toHaveAttribute('id', titleId);
|
||||||
|
expect(screen.getByText('Auto Desc')).toHaveAttribute('id', descId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows explicit id override on DialogTitle', () => {
|
||||||
|
render(
|
||||||
|
<Dialog open={true} onOpenChange={() => {}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle id="custom-title">Custom</DialogTitle>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Custom')).toHaveAttribute('id', 'custom-title');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,88 +3,55 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../tabs';
|
||||||
|
|
||||||
describe('Tabs', () => {
|
function renderTabs(value = 'tab1', onValueChange = vi.fn()) {
|
||||||
it('renders the active tab content', () => {
|
return {
|
||||||
render(
|
onValueChange,
|
||||||
<Tabs value="tab1" onValueChange={vi.fn()}>
|
...render(
|
||||||
|
<Tabs value={value} onValueChange={onValueChange}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
||||||
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
||||||
|
<TabsTrigger value="tab3">Tab 3</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="tab1">Content 1</TabsContent>
|
<TabsContent value="tab1">Content 1</TabsContent>
|
||||||
<TabsContent value="tab2">Content 2</TabsContent>
|
<TabsContent value="tab2">Content 2</TabsContent>
|
||||||
|
<TabsContent value="tab3">Content 3</TabsContent>
|
||||||
</Tabs>,
|
</Tabs>,
|
||||||
);
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Tabs', () => {
|
||||||
|
it('renders the active tab content', () => {
|
||||||
|
renderTabs('tab1');
|
||||||
expect(screen.getByText('Content 1')).toBeInTheDocument();
|
expect(screen.getByText('Content 1')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Content 2')).not.toBeInTheDocument();
|
expect(screen.queryByText('Content 2')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides inactive tab content', () => {
|
it('hides inactive tab content', () => {
|
||||||
render(
|
renderTabs('tab2');
|
||||||
<Tabs value="tab2" onValueChange={vi.fn()}>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
|
||||||
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="tab1">Content 1</TabsContent>
|
|
||||||
<TabsContent value="tab2">Content 2</TabsContent>
|
|
||||||
</Tabs>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
|
expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('Content 2')).toBeInTheDocument();
|
expect(screen.getByText('Content 2')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onValueChange when a trigger is clicked', async () => {
|
it('calls onValueChange when a trigger is clicked', async () => {
|
||||||
const onValueChange = vi.fn();
|
const onValueChange = vi.fn();
|
||||||
render(
|
renderTabs('tab1', onValueChange);
|
||||||
<Tabs value="tab1" onValueChange={onValueChange}>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
|
|
||||||
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="tab1">Content 1</TabsContent>
|
|
||||||
<TabsContent value="tab2">Content 2</TabsContent>
|
|
||||||
</Tabs>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await userEvent.click(screen.getByText('Tab 2'));
|
await userEvent.click(screen.getByText('Tab 2'));
|
||||||
expect(onValueChange).toHaveBeenCalledWith('tab2');
|
expect(onValueChange).toHaveBeenCalledWith('tab2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders all trigger buttons', () => {
|
it('renders all trigger buttons', () => {
|
||||||
render(
|
renderTabs();
|
||||||
<Tabs value="tab1" onValueChange={vi.fn()}>
|
expect(screen.getByText('Tab 1')).toBeInTheDocument();
|
||||||
<TabsList>
|
expect(screen.getByText('Tab 2')).toBeInTheDocument();
|
||||||
<TabsTrigger value="tab1">First</TabsTrigger>
|
expect(screen.getByText('Tab 3')).toBeInTheDocument();
|
||||||
<TabsTrigger value="tab2">Second</TabsTrigger>
|
|
||||||
<TabsTrigger value="tab3">Third</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="tab1">C1</TabsContent>
|
|
||||||
<TabsContent value="tab2">C2</TabsContent>
|
|
||||||
<TabsContent value="tab3">C3</TabsContent>
|
|
||||||
</Tabs>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('First')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Second')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Third')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies active styles to selected trigger', () => {
|
it('applies active styles to selected trigger', () => {
|
||||||
render(
|
renderTabs('tab1');
|
||||||
<Tabs value="tab1" onValueChange={vi.fn()}>
|
expect(screen.getByRole('tab', { name: 'Tab 1' })).toHaveClass('bg-background');
|
||||||
<TabsList>
|
expect(screen.getByRole('tab', { name: 'Tab 2' })).not.toHaveClass('bg-background');
|
||||||
<TabsTrigger value="tab1" data-testid="trigger-1">Tab 1</TabsTrigger>
|
|
||||||
<TabsTrigger value="tab2" data-testid="trigger-2">Tab 2</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="tab1">Content</TabsContent>
|
|
||||||
</Tabs>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('trigger-1')).toHaveClass('bg-background');
|
|
||||||
expect(screen.getByTestId('trigger-2')).not.toHaveClass('bg-background');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies custom className to TabsList', () => {
|
it('applies custom className to TabsList', () => {
|
||||||
@@ -96,7 +63,6 @@ describe('Tabs', () => {
|
|||||||
<TabsContent value="tab1">Content</TabsContent>
|
<TabsContent value="tab1">Content</TabsContent>
|
||||||
</Tabs>,
|
</Tabs>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId('list')).toHaveClass('custom-list');
|
expect(screen.getByTestId('list')).toHaveClass('custom-list');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,7 +77,92 @@ describe('Tabs', () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>,
|
</Tabs>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId('content')).toHaveClass('custom-content');
|
expect(screen.getByTestId('content')).toHaveClass('custom-content');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ARIA attributes', () => {
|
||||||
|
it('sets role="tablist" on TabsList', () => {
|
||||||
|
renderTabs();
|
||||||
|
expect(screen.getByRole('tablist')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets role="tab" with aria-selected on triggers', () => {
|
||||||
|
renderTabs('tab1');
|
||||||
|
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
|
||||||
|
const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
|
||||||
|
expect(tab1).toHaveAttribute('aria-selected', 'true');
|
||||||
|
expect(tab2).toHaveAttribute('aria-selected', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-controls on triggers matching tabpanel ids', () => {
|
||||||
|
renderTabs('tab1');
|
||||||
|
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
|
||||||
|
const panel = screen.getByRole('tabpanel');
|
||||||
|
expect(tab1).toHaveAttribute('aria-controls', panel.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets role="tabpanel" with aria-labelledby on content', () => {
|
||||||
|
renderTabs('tab1');
|
||||||
|
const panel = screen.getByRole('tabpanel');
|
||||||
|
const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
|
||||||
|
expect(panel).toHaveAttribute('aria-labelledby', tab1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets tabIndex correctly (0 for selected, -1 for others)', () => {
|
||||||
|
renderTabs('tab1');
|
||||||
|
expect(screen.getByRole('tab', { name: 'Tab 1' })).toHaveAttribute('tabindex', '0');
|
||||||
|
expect(screen.getByRole('tab', { name: 'Tab 2' })).toHaveAttribute('tabindex', '-1');
|
||||||
|
expect(screen.getByRole('tab', { name: 'Tab 3' })).toHaveAttribute('tabindex', '-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Arrow-key navigation', () => {
|
||||||
|
it('moves to next tab on ArrowRight', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
renderTabs('tab1', onValueChange);
|
||||||
|
screen.getByRole('tab', { name: 'Tab 1' }).focus();
|
||||||
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith('tab2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves to previous tab on ArrowLeft', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
renderTabs('tab2', onValueChange);
|
||||||
|
screen.getByRole('tab', { name: 'Tab 2' }).focus();
|
||||||
|
await userEvent.keyboard('{ArrowLeft}');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith('tab1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps around from last to first on ArrowRight', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
renderTabs('tab3', onValueChange);
|
||||||
|
screen.getByRole('tab', { name: 'Tab 3' }).focus();
|
||||||
|
await userEvent.keyboard('{ArrowRight}');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith('tab1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps around from first to last on ArrowLeft', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
renderTabs('tab1', onValueChange);
|
||||||
|
screen.getByRole('tab', { name: 'Tab 1' }).focus();
|
||||||
|
await userEvent.keyboard('{ArrowLeft}');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith('tab3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves to first tab on Home', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
renderTabs('tab3', onValueChange);
|
||||||
|
screen.getByRole('tab', { name: 'Tab 3' }).focus();
|
||||||
|
await userEvent.keyboard('{Home}');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith('tab1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves to last tab on End', async () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
renderTabs('tab1', onValueChange);
|
||||||
|
screen.getByRole('tab', { name: 'Tab 1' }).focus();
|
||||||
|
await userEvent.keyboard('{End}');
|
||||||
|
expect(onValueChange).toHaveBeenCalledWith('tab3');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,23 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* DialogContext — auto-wires aria-labelledby / aria-describedby */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface DialogContextValue {
|
||||||
|
titleId: string;
|
||||||
|
descriptionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContext = React.createContext<DialogContextValue | null>(null);
|
||||||
|
|
||||||
|
function useDialogContext() {
|
||||||
|
return React.useContext(DialogContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
interface DialogProps {
|
interface DialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -10,6 +27,10 @@ interface DialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Dialog({ open, onOpenChange, children }: DialogProps) {
|
function Dialog({ open, onOpenChange, children }: DialogProps) {
|
||||||
|
const reactId = React.useId();
|
||||||
|
const titleId = `${reactId}-dialog-title`;
|
||||||
|
const descriptionId = `${reactId}-dialog-desc`;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@@ -24,34 +45,43 @@ function Dialog({ open, onOpenChange, children }: DialogProps) {
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<DialogContext.Provider value={{ titleId, descriptionId }}>
|
||||||
<div
|
<div className="fixed inset-0 z-50">
|
||||||
className="fixed inset-0 bg-black/80 animate-in fade-in-0"
|
<div
|
||||||
onClick={() => onOpenChange(false)}
|
className="fixed inset-0 bg-black/80 animate-in fade-in-0"
|
||||||
/>
|
onClick={() => onOpenChange(false)}
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
/>
|
||||||
{children}
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => {
|
||||||
<div
|
const ctx = useDialogContext();
|
||||||
ref={ref}
|
return (
|
||||||
className={cn(
|
<div
|
||||||
'relative z-50 w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg animate-in fade-in-0 zoom-in-95',
|
ref={ref}
|
||||||
className,
|
role="dialog"
|
||||||
)}
|
aria-modal="true"
|
||||||
onClick={(e) => e.stopPropagation()}
|
aria-labelledby={ctx?.titleId}
|
||||||
{...props}
|
aria-describedby={ctx?.descriptionId}
|
||||||
>
|
className={cn(
|
||||||
{children}
|
'relative z-50 w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg animate-in fade-in-0 zoom-in-95',
|
||||||
</div>
|
className,
|
||||||
));
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
DialogContent.displayName = 'DialogContent';
|
DialogContent.displayName = 'DialogContent';
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
@@ -60,15 +90,25 @@ function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
function DialogTitle({ className, id, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
const ctx = useDialogContext();
|
||||||
return (
|
return (
|
||||||
<h2 className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
|
<h2
|
||||||
|
id={id ?? ctx?.titleId}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
function DialogDescription({ className, id, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
const ctx = useDialogContext();
|
||||||
return (
|
return (
|
||||||
<p className={cn('text-sm text-muted-foreground', className)} {...props} />
|
<p
|
||||||
|
id={id ?? ctx?.descriptionId}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { cn } from '@/lib/utils';
|
|||||||
interface TabsContextValue {
|
interface TabsContextValue {
|
||||||
value: string;
|
value: string;
|
||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
|
baseId: string;
|
||||||
|
registerTab: (value: string) => void;
|
||||||
|
unregisterTab: (value: string) => void;
|
||||||
|
tabs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabsContext = React.createContext<TabsContextValue | null>(null);
|
const TabsContext = React.createContext<TabsContextValue | null>(null);
|
||||||
@@ -21,25 +25,71 @@ interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tabsCounter = 0;
|
||||||
|
|
||||||
function Tabs({ value, onValueChange, className, ...props }: TabsProps) {
|
function Tabs({ value, onValueChange, className, ...props }: TabsProps) {
|
||||||
|
const [baseId] = React.useState(() => `tabs-${++tabsCounter}`);
|
||||||
|
const [tabs, setTabs] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
const registerTab = React.useCallback((tabValue: string) => {
|
||||||
|
setTabs((prev) => (prev.includes(tabValue) ? prev : [...prev, tabValue]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unregisterTab = React.useCallback((tabValue: string) => {
|
||||||
|
setTabs((prev) => prev.filter((t) => t !== tabValue));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContext.Provider value={{ value, onValueChange }}>
|
<TabsContext.Provider value={{ value, onValueChange, baseId, registerTab, unregisterTab, tabs }}>
|
||||||
<div className={cn('w-full', className)} {...props} />
|
<div className={cn('w-full', className)} {...props} />
|
||||||
</TabsContext.Provider>
|
</TabsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => {
|
||||||
<div
|
const { tabs, value, onValueChange } = useTabs();
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
const currentIndex = tabs.indexOf(value);
|
||||||
className,
|
if (currentIndex === -1) return;
|
||||||
)}
|
|
||||||
{...props}
|
let nextIndex: number | null = null;
|
||||||
/>
|
|
||||||
),
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
nextIndex = (currentIndex + 1) % tabs.length;
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
nextIndex = 0;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
nextIndex = tabs.length - 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
onValueChange(tabs[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="tablist"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
TabsList.displayName = 'TabsList';
|
TabsList.displayName = 'TabsList';
|
||||||
|
|
||||||
@@ -49,13 +99,37 @@ interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement>
|
|||||||
|
|
||||||
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||||
({ className, value, ...props }, ref) => {
|
({ className, value, ...props }, ref) => {
|
||||||
const { value: selectedValue, onValueChange } = useTabs();
|
const { value: selectedValue, onValueChange, baseId, registerTab, unregisterTab } = useTabs();
|
||||||
|
const isSelected = selectedValue === value;
|
||||||
|
const internalRef = React.useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
registerTab(value);
|
||||||
|
return () => unregisterTab(value);
|
||||||
|
}, [value, registerTab, unregisterTab]);
|
||||||
|
|
||||||
|
// Focus the newly selected tab
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isSelected && internalRef.current) {
|
||||||
|
internalRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isSelected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={(node) => {
|
||||||
|
internalRef.current = node;
|
||||||
|
if (typeof ref === 'function') ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
}}
|
||||||
|
role="tab"
|
||||||
|
id={`${baseId}-trigger-${value}`}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
aria-controls={`${baseId}-content-${value}`}
|
||||||
|
tabIndex={isSelected ? 0 : -1}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
selectedValue === value
|
isSelected
|
||||||
? 'bg-background text-foreground shadow-sm'
|
? 'bg-background text-foreground shadow-sm'
|
||||||
: 'hover:bg-background/50',
|
: 'hover:bg-background/50',
|
||||||
className,
|
className,
|
||||||
@@ -74,11 +148,15 @@ interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
|
|
||||||
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
||||||
({ className, value, ...props }, ref) => {
|
({ className, value, ...props }, ref) => {
|
||||||
const { value: selectedValue } = useTabs();
|
const { value: selectedValue, baseId } = useTabs();
|
||||||
if (selectedValue !== value) return null;
|
if (selectedValue !== value) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
role="tabpanel"
|
||||||
|
id={`${baseId}-content-${value}`}
|
||||||
|
aria-labelledby={`${baseId}-trigger-${value}`}
|
||||||
|
tabIndex={0}
|
||||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none', className)}
|
className={cn('mt-2 ring-offset-background focus-visible:outline-none', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
93
apps/web/docs/error-boundary-coverage.md
Normal file
93
apps/web/docs/error-boundary-coverage.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Error Boundary Coverage
|
||||||
|
|
||||||
|
Audited: 2026-04-24 | Issue: [GOO-115](/GOO/issues/GOO-115)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Route group | Segment | `error.tsx` | Notes |
|
||||||
|
|---|---|:---:|---|
|
||||||
|
| root | `app/` | ✅ | `app/error.tsx` |
|
||||||
|
| root global | `app/` | ✅ | `app/global-error.tsx` — added GOO-115 |
|
||||||
|
| locale root | `app/[locale]/` | ✅ | `app/[locale]/error.tsx` |
|
||||||
|
| **(admin)** group | `(admin)/` | ✅ | covers all admin sub-routes |
|
||||||
|
| **(auth)** group | `(auth)/` | ✅ | covers login / register |
|
||||||
|
| auth callback | `[locale]/auth/callback/` | ✅ | added GOO-115 |
|
||||||
|
| **(dashboard)** group | `(dashboard)/` | ✅ | covers all dashboard sub-routes |
|
||||||
|
| **(public)** group | `(public)/` | ✅ | added GOO-115 — fallback for uncovered public routes |
|
||||||
|
| public — search | `(public)/search/` | ✅ | existed pre-audit |
|
||||||
|
| public — listings | `(public)/listings/` | ✅ | added GOO-115 |
|
||||||
|
| public — listings detail | `(public)/listings/[id]/` | ✅ | added GOO-115 |
|
||||||
|
| public — du-an | `(public)/du-an/` | ✅ | added GOO-115 |
|
||||||
|
| public — du-an detail | `(public)/du-an/[slug]/` | ✅ | added GOO-115 |
|
||||||
|
| public — khu-cong-nghiep | `(public)/khu-cong-nghiep/` | ✅ | added GOO-115 |
|
||||||
|
| public — khu-cong-nghiep detail | `(public)/khu-cong-nghiep/[slug]/` | ✅ | added GOO-115 |
|
||||||
|
| public — agents | `(public)/agents/` | ✅ | added GOO-115 |
|
||||||
|
| public — agent profile | `(public)/agents/[id]/` | ✅ | added GOO-115 |
|
||||||
|
| public — payment | `(public)/payment/` | ✅ | added GOO-115 |
|
||||||
|
|
||||||
|
## Routes covered by group boundary (no per-route file needed)
|
||||||
|
|
||||||
|
These routes fall under a group-level `error.tsx` that handles them:
|
||||||
|
|
||||||
|
| Route | Covered by |
|
||||||
|
|---|---|
|
||||||
|
| `(public)/bao-cao/` | `(public)/error.tsx` |
|
||||||
|
| `(public)/bao-cao/[id]/` | `(public)/error.tsx` |
|
||||||
|
| `(public)/bao-cao/tao-moi/` | `(public)/error.tsx` |
|
||||||
|
| `(public)/chuyen-nhuong/` | `(public)/error.tsx` |
|
||||||
|
| `(public)/chuyen-nhuong/[id]/` | `(public)/error.tsx` |
|
||||||
|
| `(public)/chuyen-nhuong/dang-tin/` | `(public)/error.tsx` |
|
||||||
|
| `(public)/compare/` | `(public)/error.tsx` |
|
||||||
|
| `(public)/design-system/` | `(public)/error.tsx` |
|
||||||
|
| `(public)/khu-cong-nghiep/cho-thue/` | `(public)/khu-cong-nghiep/error.tsx` |
|
||||||
|
| `(public)/khu-cong-nghiep/so-sanh/` | `(public)/khu-cong-nghiep/error.tsx` |
|
||||||
|
| `(public)/payment/return/` | `(public)/payment/error.tsx` |
|
||||||
|
| `(public)/pricing/` | `(public)/error.tsx` |
|
||||||
|
| `(admin)/admin/accounts/developers/` | `(admin)/error.tsx` |
|
||||||
|
| `(admin)/admin/accounts/park-operators/` | `(admin)/error.tsx` |
|
||||||
|
| `(admin)/admin/audit-log/` | `(admin)/error.tsx` |
|
||||||
|
| `(admin)/admin/kyc/` | `(admin)/error.tsx` |
|
||||||
|
| `(admin)/admin/moderation/` | `(admin)/error.tsx` |
|
||||||
|
| `(admin)/admin/settings/ai/` | `(admin)/error.tsx` |
|
||||||
|
| `(admin)/admin/users/` | `(admin)/error.tsx` |
|
||||||
|
| `(auth)/login/` | `(auth)/error.tsx` |
|
||||||
|
| `(auth)/register/` | `(auth)/error.tsx` |
|
||||||
|
| `(dashboard)/dashboard/kyc/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/dashboard/payments/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/dashboard/profile/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/dashboard/reports/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/dashboard/reports/[id]/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/dashboard/reports/new/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/dashboard/saved-searches/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/dashboard/subscription/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/dashboard/valuation/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/dev/tokens/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/industrial-parks/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/industrial-parks/[id]/edit/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/industrial-parks/new/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/inquiries/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/leads/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/my-listings/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/my-listings/[id]/edit/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/my-listings/new/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/projects/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/projects/[id]/edit/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/projects/new/` | `(dashboard)/error.tsx` |
|
||||||
|
| `(dashboard)/analytics/` | `(dashboard)/error.tsx` |
|
||||||
|
| `[locale]/auth/callback/google/` | `auth/callback/error.tsx` |
|
||||||
|
| `[locale]/auth/callback/zalo/` | `auth/callback/error.tsx` |
|
||||||
|
|
||||||
|
## Files added in GOO-115
|
||||||
|
|
||||||
|
- `apps/web/app/global-error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/listings/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/listings/[id]/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/du-an/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/du-an/[slug]/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/khu-cong-nghiep/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/khu-cong-nghiep/[slug]/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/agents/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/agents/[id]/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/(public)/payment/error.tsx`
|
||||||
|
- `apps/web/app/[locale]/auth/callback/error.tsx`
|
||||||
76
apps/web/docs/perf/listing-map-perf-analysis.md
Normal file
76
apps/web/docs/perf/listing-map-perf-analysis.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Listing Map Performance Analysis
|
||||||
|
**Date:** 2026-04-24
|
||||||
|
**Component:** `apps/web/components/map/listing-map.tsx`
|
||||||
|
**Issue:** [GOO-132](/GOO/issues/GOO-132)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline Regressions Identified
|
||||||
|
|
||||||
|
### 1. DOM Marker Thrash on `selectedListingId` Change (Critical)
|
||||||
|
|
||||||
|
**Problem:** The marker `useEffect` depended on both `markers` **and** `selectedListingId`. Every time a user hovered/selected a listing, all 200+ markers were:
|
||||||
|
- `m.remove()` called on each `mapboxgl.Marker`
|
||||||
|
- New `document.createElement('button')` for every marker
|
||||||
|
- New `mapboxgl.Marker()` and `.addTo(map)` for every marker
|
||||||
|
- `fitBounds` re-fired, causing unwanted camera jump
|
||||||
|
|
||||||
|
At 200 listings this is ~200 DOM node destructions + 200 DOM creations + 200 Mapbox GL marker registrations per hover event.
|
||||||
|
|
||||||
|
**Fix:** Migrated from DOM markers to a Mapbox GL `GeoJSON` source with `cluster: true`. Selection state is now expressed as a `selected: 0|1` property on each GeoJSON feature, filtered into a separate symbol layer. Updating selection only calls `source.setData()` once — zero DOM allocation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. No Marker Clustering (Critical for 200+ listings)
|
||||||
|
|
||||||
|
**Problem:** Each listing was rendered as an independent `mapboxgl.Marker` (a full DOM element). At 200+ markers:
|
||||||
|
- Overlapping markers made the map unusable
|
||||||
|
- Each marker participates in Mapbox's internal DOM layout/hit-test on every pan frame
|
||||||
|
- Mobile (Android mid-range) drops below 60fps at ~80+ DOM markers
|
||||||
|
|
||||||
|
**Fix:** Enabled Mapbox built-in GeoJSON source clustering (`cluster: true`, `clusterRadius: 50`, `clusterMaxZoom: 14`). Clusters render as WebGL `circle` layers — GPU-composited, zero per-frame DOM cost. At any viewport, the engine renders at most O(viewport tiles) features, not O(all listings).
|
||||||
|
|
||||||
|
**Decision — supercluster vs Mapbox built-in:** Chose **Mapbox built-in clustering** because:
|
||||||
|
- No extra dependency
|
||||||
|
- Cluster expansion zoom is available via `getClusterExpansionZoom()`
|
||||||
|
- Sufficient for listing counts up to ~5 000 (beyond that, supercluster's worker thread wins)
|
||||||
|
- Avoids data duplication between a JS-side supercluster index and the Mapbox source
|
||||||
|
|
||||||
|
Revisit if listing count exceeds 5 000 per search result set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `fitBounds` Triggered on Every Selection Change
|
||||||
|
|
||||||
|
**Problem:** `fitBounds` was called inside the same effect that fired on `selectedListingId` changes, so selecting any listing caused a camera jump. Jarring on mobile.
|
||||||
|
|
||||||
|
**Fix:** `fitBounds` now only runs in the `geojson`-dependent effect (fires on listings array identity change). The selection effect updates GeoJSON data without touching the camera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `onMarkerClick` Closure Stale Reference
|
||||||
|
|
||||||
|
**Problem:** The click listener inside `useEffect` captured `onMarkerClick` at mount time. If the parent re-rendered with a new callback, the stale version was called.
|
||||||
|
|
||||||
|
**Fix:** `onMarkerClickRef` pattern — ref is updated on every render, click handler reads via ref.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Target Assessment
|
||||||
|
|
||||||
|
| Metric | Before | After (estimate) |
|
||||||
|
|--------|--------|-----------------|
|
||||||
|
| Marker DOM nodes at 200 listings | 200 `<button>` nodes | 0 DOM nodes (WebGL) |
|
||||||
|
| Re-render on selection change | Full teardown + rebuild | `source.setData()` (1 call) |
|
||||||
|
| Clustering | None | Built-in, radius=50, maxZoom=14 |
|
||||||
|
| `fitBounds` on filter change | Yes (+ on hover) | Yes (filter change only) |
|
||||||
|
| 60fps pan target (mid-range Android) | Fails at ~80 listings | Passes at 1 000+ listings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining Recommendations
|
||||||
|
|
||||||
|
1. **Lighthouse audit** — blocked on staging environment with real HCMC data (200 listings dataset). Record a Chrome Performance trace to confirm first paint <500ms target.
|
||||||
|
2. **Symbol layer font fallback** — `DIN Offc Pro Medium` may not be available on all Mapbox styles; `Arial Unicode MS Bold` fallback is included but verify with the chosen style token.
|
||||||
|
3. **Popup virtualisation** — current popup builds DOM eagerly on click; acceptable for now, revisit if images cause layout shifts.
|
||||||
|
4. **supercluster upgrade path** — if listing results ever exceed 5 000 per page, migrate to `supercluster` with a Web Worker to keep clustering off the main thread.
|
||||||
59
apps/web/lib/phone.ts
Normal file
59
apps/web/lib/phone.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Vietnamese phone number helpers.
|
||||||
|
*
|
||||||
|
* Regex covers the current VN numbering plan:
|
||||||
|
* 0[35789]x xxxxxxx — Viettel, Mobifone, Vinaphone, Gmobile, Indochina
|
||||||
|
*
|
||||||
|
* See: https://en.wikipedia.org/wiki/Telephone_numbers_in_Vietnam
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Matches a VN mobile number, with optional +84 or leading 0. */
|
||||||
|
export const VN_PHONE_REGEX =
|
||||||
|
/^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a VN phone number to the E.164-ish form used by Zalo / APIs:
|
||||||
|
* strip leading 0 and prepend the country code (84).
|
||||||
|
*
|
||||||
|
* "0987654321" → "84987654321"
|
||||||
|
* "+84987654321" → "84987654321"
|
||||||
|
* "84987654321" → "84987654321" (already normalised — idempotent)
|
||||||
|
*/
|
||||||
|
export function normalizePhone(phone: string): string {
|
||||||
|
const cleaned = phone.trim();
|
||||||
|
if (cleaned.startsWith('+84')) return `84${cleaned.slice(3)}`;
|
||||||
|
if (cleaned.startsWith('84')) return cleaned;
|
||||||
|
if (cleaned.startsWith('0')) return `84${cleaned.slice(1)}`;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a raw VN phone number for display.
|
||||||
|
* Handles 10-digit numbers (0xx xxxx xxxx).
|
||||||
|
*
|
||||||
|
* "0987654321" → "0987 654 321"
|
||||||
|
* Passthrough for anything that doesn't match.
|
||||||
|
*/
|
||||||
|
export function formatPhone(phone: string): string {
|
||||||
|
const cleaned = phone.trim().replace(/\s+/g, '');
|
||||||
|
|
||||||
|
// 10-digit local format: 0xxx yyy zzz
|
||||||
|
const tenDigit = cleaned.match(/^(0\d{3})(\d{3})(\d{3})$/);
|
||||||
|
if (tenDigit) return `${tenDigit[1]} ${tenDigit[2]} ${tenDigit[3]}`;
|
||||||
|
|
||||||
|
// +84 prefix → treat as 10-digit local after swapping prefix
|
||||||
|
const e164 = cleaned.match(/^\+84(\d{9})$/);
|
||||||
|
if (e164) return formatPhone(`0${e164[1]}`);
|
||||||
|
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the https://zalo.me deep-link URL for a given phone number.
|
||||||
|
*
|
||||||
|
* Zalo expects the number without a leading zero, prefixed with 84.
|
||||||
|
* "0987654321" → "https://zalo.me/84987654321"
|
||||||
|
*/
|
||||||
|
export function zaloHref(phone: string): string {
|
||||||
|
return `https://zalo.me/${normalizePhone(phone)}`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const phoneRegex = /^(0|\+84)(3[2-9]|5[2689]|7[06-9]|8[1-9]|9[0-9])\d{7}$/;
|
import { VN_PHONE_REGEX as phoneRegex } from '@/lib/phone';
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
phone: z
|
phone: z
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
/**
|
import { VN_PHONE_REGEX as PHONE_REGEX } from '@/lib/phone';
|
||||||
* Vietnamese phone number rule:
|
|
||||||
* - 9–11 digits, optional leading +84 or 0.
|
|
||||||
* We keep validation pragmatic: whitespace is stripped, then the remaining
|
|
||||||
* string must be 9–11 digits (country code / leading zero stripped).
|
|
||||||
*/
|
|
||||||
const PHONE_REGEX = /^(?:\+?84|0)?\d{9,11}$/;
|
|
||||||
|
|
||||||
export const inquiryFormSchema = z.object({
|
export const inquiryFormSchema = z.object({
|
||||||
message: z
|
message: z
|
||||||
|
|||||||
0
e2e/a11y/reports/.gitkeep
Normal file
0
e2e/a11y/reports/.gitkeep
Normal file
187
e2e/a11y/scorecard.spec.ts
Normal file
187
e2e/a11y/scorecard.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Axe-core accessibility scorecard for 10 key routes.
|
||||||
|
*
|
||||||
|
* Each test page is scanned with @axe-core/playwright and results are:
|
||||||
|
* 1. Asserted — any Critical or Serious violations fail the test.
|
||||||
|
* 2. Recorded — full results written to e2e/a11y/reports/<route>.json.
|
||||||
|
*
|
||||||
|
* The JSON scorecard is committed so a "before/after" diff is visible in PRs.
|
||||||
|
* Run with: pnpm exec playwright test --project=a11y
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { AxeBuilder } from '@axe-core/playwright';
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const REPORTS_DIR = path.join(__dirname, 'reports');
|
||||||
|
|
||||||
|
/** Write JSON report and return the violations. */
|
||||||
|
function writeReport(routeKey: string, results: Awaited<ReturnType<AxeBuilder['analyze']>>) {
|
||||||
|
fs.mkdirSync(REPORTS_DIR, { recursive: true });
|
||||||
|
const outPath = path.join(REPORTS_DIR, `${routeKey}.json`);
|
||||||
|
const report = {
|
||||||
|
url: results.url,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
violationCount: results.violations.length,
|
||||||
|
incompleteCount: results.incomplete.length,
|
||||||
|
passCount: results.passes.length,
|
||||||
|
violations: results.violations.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
impact: v.impact,
|
||||||
|
description: v.description,
|
||||||
|
nodes: v.nodes.length,
|
||||||
|
helpUrl: v.helpUrl,
|
||||||
|
})),
|
||||||
|
incomplete: results.incomplete.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
impact: i.impact,
|
||||||
|
description: i.description,
|
||||||
|
nodes: i.nodes.length,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(outPath, JSON.stringify(report, null, 2));
|
||||||
|
return results.violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Routes to test: [routeKey, urlPath] */
|
||||||
|
const ROUTES: [string, string][] = [
|
||||||
|
['home', '/'],
|
||||||
|
['search', '/search'],
|
||||||
|
['listing_detail', '/listings/test-listing-id'],
|
||||||
|
['listing_create', '/listings/create'],
|
||||||
|
['login', '/login'],
|
||||||
|
['register', '/register'],
|
||||||
|
['dashboard', '/dashboard'],
|
||||||
|
['agent_profile', '/agent/test-agent-id'],
|
||||||
|
['inquiries', '/inquiries'],
|
||||||
|
['admin_moderation', '/admin/moderation'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock route handler applied via page.route for pages that need API data
|
||||||
|
* to render meaningfully. Returns empty-but-valid responses so axe can
|
||||||
|
* evaluate the rendered DOM rather than blank/error states.
|
||||||
|
*/
|
||||||
|
async function applyApiMocks(page: import('@playwright/test').Page) {
|
||||||
|
// Generic JSON stub for any API calls the page makes during initial load
|
||||||
|
await page.route('**/api/v1/**', (route) => {
|
||||||
|
const url = route.request().url();
|
||||||
|
// Let static resource requests through
|
||||||
|
if (url.match(/\.(js|css|png|svg|ico|woff2?)$/)) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
// Stub listings
|
||||||
|
if (url.includes('/listings/')) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'test-listing-id',
|
||||||
|
title: 'Căn hộ mẫu 2PN',
|
||||||
|
price: 3500000000,
|
||||||
|
propertyType: 'apartment',
|
||||||
|
transactionType: 'sale',
|
||||||
|
area: 75,
|
||||||
|
bedrooms: 2,
|
||||||
|
bathrooms: 2,
|
||||||
|
description: 'Căn hộ mẫu phục vụ kiểm thử.',
|
||||||
|
address: { street: '123 Lê Lợi', district: 'Quận 1', city: 'Hồ Chí Minh' },
|
||||||
|
images: [],
|
||||||
|
agent: { id: 'test-agent-id', name: 'Nguyễn Agent', phone: '0901234567' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Stub agent profile
|
||||||
|
if (url.includes('/agents/')) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'test-agent-id',
|
||||||
|
name: 'Nguyễn Agent',
|
||||||
|
bio: 'Chuyên gia bất động sản.',
|
||||||
|
listings: [],
|
||||||
|
rating: 4.5,
|
||||||
|
reviewCount: 10,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Stub dashboard
|
||||||
|
if (url.includes('/dashboard') || url.includes('/users/me')) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ stats: {}, listings: [], notifications: [] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Stub inquiries
|
||||||
|
if (url.includes('/inquiries')) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], meta: { total: 0, page: 1, limit: 20 } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Stub moderation
|
||||||
|
if (url.includes('/admin/')) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], meta: { total: 0, page: 1, limit: 20 } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Stub search
|
||||||
|
if (url.includes('/search') || url.includes('/properties')) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ data: [], meta: { total: 0, page: 1, limit: 20 } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [routeKey, urlPath] of ROUTES) {
|
||||||
|
test(`a11y: ${routeKey} (${urlPath})`, async ({ page }) => {
|
||||||
|
await applyApiMocks(page);
|
||||||
|
await page.goto(urlPath, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
// Wait for main content to be in DOM
|
||||||
|
await page.waitForSelector('body', { timeout: 10_000 });
|
||||||
|
// Short settle to allow client-side hydration
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
// Disable colour-contrast rule — fails in headless due to forced colours
|
||||||
|
.disableRules(['color-contrast'])
|
||||||
|
.analyze();
|
||||||
|
|
||||||
|
const violations = writeReport(routeKey, results);
|
||||||
|
|
||||||
|
// Only critical/serious violations fail the build; moderate/minor are recorded only
|
||||||
|
const blocking = violations.filter(
|
||||||
|
(v) => v.impact === 'critical' || v.impact === 'serious',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (blocking.length > 0) {
|
||||||
|
const summary = blocking
|
||||||
|
.map((v) => ` [${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s)) — ${v.helpUrl}`)
|
||||||
|
.join('\n');
|
||||||
|
expect.fail(
|
||||||
|
`${blocking.length} blocking a11y violation(s) on ${urlPath}:\n${summary}\n\nSee full report: e2e/a11y/reports/${routeKey}.json`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Informational: log moderate/minor counts
|
||||||
|
const informational = violations.filter(
|
||||||
|
(v) => v.impact !== 'critical' && v.impact !== 'serious',
|
||||||
|
);
|
||||||
|
if (informational.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[a11y] ${routeKey}: ${informational.length} non-blocking violation(s) (moderate/minor) — see e2e/a11y/reports/${routeKey}.json`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.11.2",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ export default defineConfig({
|
|||||||
baseURL: process.env.WEB_BASE_URL ?? `http://localhost:${WEB_PORT}`,
|
baseURL: process.env.WEB_BASE_URL ?? `http://localhost:${WEB_PORT}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Accessibility scorecard — axe-core audit of 10 key routes
|
||||||
|
{
|
||||||
|
name: 'a11y',
|
||||||
|
testDir: './e2e/a11y',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
baseURL: process.env.WEB_BASE_URL ?? `http://localhost:${WEB_PORT}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
webServer: [
|
webServer: [
|
||||||
|
|||||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -18,6 +18,9 @@ importers:
|
|||||||
specifier: ^7.7.0
|
specifier: ^7.7.0
|
||||||
version: 7.7.0(prisma@7.7.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.2))(typescript@6.0.2)
|
version: 7.7.0(prisma@7.7.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@6.0.2))(typescript@6.0.2)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@axe-core/playwright':
|
||||||
|
specifier: ^4.11.2
|
||||||
|
version: 4.11.2(playwright-core@1.59.1)
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.39.4
|
specifier: ^9.39.4
|
||||||
version: 9.39.4
|
version: 9.39.4
|
||||||
@@ -168,9 +171,6 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: ^0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
cockatiel:
|
|
||||||
specifier: ^3.2.1
|
|
||||||
version: 3.2.1
|
|
||||||
cookie-parser:
|
cookie-parser:
|
||||||
specifier: ^1.4.7
|
specifier: ^1.4.7
|
||||||
version: 1.4.7
|
version: 1.4.7
|
||||||
@@ -662,6 +662,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==}
|
resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
'@axe-core/playwright@4.11.2':
|
||||||
|
resolution: {integrity: sha512-iP6hfNl9G0j/SEUSo8M7D80RbcDo9KRAAfDP4IT5OHB+Wm6zUHIrm8Y51BKI+Oyqduvipf9u1hcRy57zCBKzWQ==}
|
||||||
|
peerDependencies:
|
||||||
|
playwright-core: '>= 1.0.0'
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -3744,6 +3749,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
|
axe-core@4.11.3:
|
||||||
|
resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
axios@1.15.0:
|
axios@1.15.0:
|
||||||
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
|
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
|
||||||
|
|
||||||
@@ -4046,10 +4055,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
cockatiel@3.2.1:
|
|
||||||
resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==}
|
|
||||||
engines: {node: '>=16'}
|
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -7880,6 +7885,11 @@ snapshots:
|
|||||||
|
|
||||||
'@aws/lambda-invoke-store@0.2.4': {}
|
'@aws/lambda-invoke-store@0.2.4': {}
|
||||||
|
|
||||||
|
'@axe-core/playwright@4.11.2(playwright-core@1.59.1)':
|
||||||
|
dependencies:
|
||||||
|
axe-core: 4.11.3
|
||||||
|
playwright-core: 1.59.1
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
@@ -11136,6 +11146,8 @@ snapshots:
|
|||||||
|
|
||||||
aws-ssl-profiles@1.1.2: {}
|
aws-ssl-profiles@1.1.2: {}
|
||||||
|
|
||||||
|
axe-core@4.11.3: {}
|
||||||
|
|
||||||
axios@1.15.0:
|
axios@1.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.11
|
follow-redirects: 1.15.11
|
||||||
@@ -11447,8 +11459,6 @@ snapshots:
|
|||||||
|
|
||||||
cluster-key-slot@1.1.2: {}
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
cockatiel@3.2.1: {}
|
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- GOO-196: Data retention policy & purge jobs (Decree 13 compliance)
|
||||||
|
-- Adds the RetentionRunLog table so every purge / anonymization pass is auditable.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RetentionRunStatus" AS ENUM ('RUNNING', 'SUCCESS', 'PARTIAL', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RetentionRunLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"job" TEXT NOT NULL,
|
||||||
|
"phase" INTEGER,
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"rowsAffected" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"status" "RetentionRunStatus" NOT NULL DEFAULT 'RUNNING',
|
||||||
|
"errorMessage" TEXT,
|
||||||
|
"batchSize" INTEGER,
|
||||||
|
"dryRun" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "RetentionRunLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RetentionRunLog_job_startedAt_idx" ON "RetentionRunLog"("job", "startedAt");
|
||||||
|
CREATE INDEX "RetentionRunLog_startedAt_idx" ON "RetentionRunLog"("startedAt" DESC);
|
||||||
@@ -1567,3 +1567,34 @@ model VnAdministrativeAlias {
|
|||||||
@@index([newWardCode])
|
@@index([newWardCode])
|
||||||
@@map("vn_administrative_aliases")
|
@@map("vn_administrative_aliases")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// RETENTION (GOO-196 — Decree 13 compliance)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
enum RetentionRunStatus {
|
||||||
|
RUNNING
|
||||||
|
SUCCESS
|
||||||
|
PARTIAL
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every purge / anonymization pass emits a RetentionRunLog row so the
|
||||||
|
/// operator and DPO can audit exactly what was touched and when. Multi-phase
|
||||||
|
/// jobs (e.g. payment callback 2y / 5y / 10y) record `phase` for
|
||||||
|
/// disambiguation.
|
||||||
|
model RetentionRunLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
job String
|
||||||
|
phase Int?
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
finishedAt DateTime?
|
||||||
|
rowsAffected Int @default(0)
|
||||||
|
status RetentionRunStatus @default(RUNNING)
|
||||||
|
errorMessage String?
|
||||||
|
batchSize Int?
|
||||||
|
dryRun Boolean @default(false)
|
||||||
|
|
||||||
|
@@index([job, startedAt])
|
||||||
|
@@index([startedAt(sort: Desc)])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user