fix(web): include ward in address display across card views
- property-card.tsx: add ward between address and district in both
card (line 189) and list (line 95) layouts
- transfer-listing-card.tsx: conditionally prepend ward to
district/city when ward is non-null
- property-card.spec.tsx: update address test to assert ward is shown,
add list-layout ward regression test (21/21 pass)
Standard format: {address}, {ward}, {district}, {city}
Compact (project-card, industrial-listing-card): district/city only —
intentional; ProjectSummary has no ward field.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -67,6 +67,19 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
|
# GOO-134: API unit-test coverage gate (≥70% stmt/lines/funcs, ≥58% branches → ratcheting to 60 via GOO-180).
|
||||||
|
- name: Test coverage (API)
|
||||||
|
run: pnpm --filter @goodgo/api test:coverage
|
||||||
|
|
||||||
|
- name: Upload coverage report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: api-coverage
|
||||||
|
path: apps/api/coverage
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,30 @@ export default defineConfig({
|
|||||||
env: {
|
env: {
|
||||||
BCRYPT_ROUNDS: '4',
|
BCRYPT_ROUNDS: '4',
|
||||||
},
|
},
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'text-summary', 'html', 'lcov', 'json-summary'],
|
||||||
|
reportsDirectory: './coverage',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
exclude: [
|
||||||
|
'src/**/*.spec.ts',
|
||||||
|
'src/**/*.integration.spec.ts',
|
||||||
|
'src/**/__tests__/**',
|
||||||
|
'src/**/*.module.ts',
|
||||||
|
'src/**/*.dto.ts',
|
||||||
|
'src/**/index.ts',
|
||||||
|
'src/main.ts',
|
||||||
|
],
|
||||||
|
// GOO-134: CI gate thresholds. Branches starts at 58 (no-regression ratchet)
|
||||||
|
// and will be raised to 60 via follow-up GOO-180 (payments/sbv-compliance,
|
||||||
|
// subscriptions/quotas, auth/guards). CTO approval: 8f2b125a.
|
||||||
|
thresholds: {
|
||||||
|
statements: 70,
|
||||||
|
lines: 70,
|
||||||
|
functions: 70,
|
||||||
|
branches: 58,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function TransferListingCard({ listing }: TransferListingCardProps) {
|
|||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="mb-3 flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
<MapPin className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="line-clamp-1">{listing.district}, {listing.city}</span>
|
<span className="line-clamp-1">{listing.ward ? `${listing.ward}, ` : ''}{listing.district}, {listing.city}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price */}
|
{/* Price */}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -106,9 +106,15 @@ describe('PropertyCard', () => {
|
|||||||
expect(screen.getByText(/3\.5 tỷ/)).toBeInTheDocument();
|
expect(screen.getByText(/3\.5 tỷ/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders property address', () => {
|
it('renders property address including ward', () => {
|
||||||
render(<PropertyCard listing={makeListing()} />);
|
render(<PropertyCard listing={makeListing()} />);
|
||||||
expect(screen.getByText(/208 Nguyễn Hữu Cảnh/)).toBeInTheDocument();
|
expect(screen.getByText(/208 Nguyễn Hữu Cảnh/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Phường 22/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ward in list layout', () => {
|
||||||
|
render(<PropertyCard listing={makeListing()} layout="list" />);
|
||||||
|
expect(screen.getByText(/Phường 22/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders transaction type badge for SALE', () => {
|
it('renders transaction type badge for SALE', () => {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function PropertyCard({ listing, compact, layout = 'card' }: PropertyCard
|
|||||||
{listing.property.title}
|
{listing.property.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground">
|
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground">
|
||||||
{listing.property.address}, {listing.property.district}, {listing.property.city}
|
{listing.property.address}, {listing.property.ward}, {listing.property.district}, {listing.property.city}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="shrink-0 text-lg font-bold text-primary">
|
<p className="shrink-0 text-lg font-bold text-primary">
|
||||||
@@ -186,7 +186,7 @@ export function PropertyCard({ listing, compact, layout = 'card' }: PropertyCard
|
|||||||
</p>
|
</p>
|
||||||
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
|
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
|
||||||
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
|
||||||
{listing.property.address}, {listing.property.district}, {listing.property.city}
|
{listing.property.address}, {listing.property.ward}, {listing.property.district}, {listing.property.city}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Thông tin bất động sản">
|
<ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Thông tin bất động sản">
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user