Files
goodgo-platform/apps/web/app/(dashboard)/listings/page.tsx
Ho Ngoc Hai 36c1e3b39a fix(web): add proper Vietnamese diacritics to all dashboard and listing pages
Vietnamese text throughout the frontend was missing accent marks (diacritics),
using plain ASCII instead of proper Unicode characters. Fixed all user-visible
text across dashboard, analytics, listings, search, and chart components.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 13:21:37 +07:00

359 lines
14 KiB
TypeScript

'use client';
import Image from 'next/image';
import Link from 'next/link';
import * as React from 'react';
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import {
listingsApi,
type ListingDetail,
type PaginatedResult,
} from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
function formatPrice(priceVND: string): string {
const num = Number(priceVND);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
return num.toLocaleString('vi-VN');
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
type ViewMode = 'grid' | 'table';
export default function ListingsPage() {
const [result, setResult] = React.useState<PaginatedResult<ListingDetail> | null>(null);
const [loading, setLoading] = React.useState(true);
const [viewMode, setViewMode] = React.useState<ViewMode>('grid');
const [filters, setFilters] = React.useState({
transactionType: '',
propertyType: '',
status: '' as string,
page: 1,
});
const fetchListings = React.useCallback(() => {
setLoading(true);
const params: Record<string, string | number> = { page: filters.page, limit: 12 };
if (filters.transactionType) params['transactionType'] = filters.transactionType;
if (filters.propertyType) params['propertyType'] = filters.propertyType;
if (filters.status) params['status'] = filters.status;
listingsApi
.search(params)
.then(setResult)
.catch(() => setResult(null))
.finally(() => setLoading(false));
}, [filters]);
React.useEffect(() => {
fetchListings();
}, [fetchListings]);
// Stats from current page data
const stats = React.useMemo(() => {
if (!result) return { total: 0, active: 0, pending: 0, views: 0 };
return {
total: result.total,
active: result.data.filter((l) => l.status === 'ACTIVE').length,
pending: result.data.filter((l) => l.status === 'PENDING_REVIEW').length,
views: result.data.reduce((s, l) => s + l.viewCount, 0),
};
}, [result]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold">Quản tin đăng</h1>
<p className="text-sm text-muted-foreground">
Quản , theo dõi cập nhật các tin đăng của bạn
</p>
</div>
<Link href="/listings/new">
<Button>Đăng tin mới</Button>
</Link>
</div>
{/* Stats */}
<div className="grid gap-3 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng tin đăng</CardDescription>
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Đang hoạt đng</CardDescription>
<CardTitle className="text-xl text-green-600">
{loading ? '...' : stats.active}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Chờ duyệt</CardDescription>
<CardTitle className="text-xl text-yellow-600">
{loading ? '...' : stats.pending}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng lượt xem</CardDescription>
<CardTitle className="text-xl">{loading ? '...' : stats.views}</CardTitle>
</CardHeader>
</Card>
</div>
{/* Filters + View Toggle */}
<div className="flex flex-wrap items-center gap-3">
<Select
value={filters.transactionType}
onChange={(e) =>
setFilters((f) => ({ ...f, transactionType: e.target.value, page: 1 }))
}
className="w-40"
>
<option value="">Tất cả giao dịch</option>
{TRANSACTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<Select
value={filters.propertyType}
onChange={(e) =>
setFilters((f) => ({ ...f, propertyType: e.target.value, page: 1 }))
}
className="w-44"
>
<option value="">Tất cả loại BĐS</option>
{PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<Select
value={filters.status}
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value, page: 1 }))}
className="w-40"
>
<option value="">Tất cả trạng thái</option>
{Object.entries(LISTING_STATUSES).map(([key, { label }]) => (
<option key={key} value={key}>
{label}
</option>
))}
</Select>
<div className="ml-auto flex gap-1">
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
>
Lưới
</Button>
<Button
variant={viewMode === 'table' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('table')}
>
Bảng
</Button>
</div>
</div>
{/* Content */}
{loading ? (
<div className="flex min-h-[300px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : !result || result.data.length === 0 ? (
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
<p>Chưa tin đăng nào</p>
<Link href="/listings/new" className="mt-2">
<Button variant="outline" size="sm">
Đăng tin đu tiên
</Button>
</Link>
</div>
) : viewMode === 'grid' ? (
/* Grid View */
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{result.data.map((listing) => (
<Link key={listing.id} href={`/listings/${listing.id}`}>
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
<div className="relative aspect-[4/3] bg-muted">
{listing.property.media.length > 0 ? (
<Image
src={listing.property.media[0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
Chưa nh
</div>
)}
<div className="absolute left-2 top-2">
<ListingStatusBadge status={listing.status} />
</div>
</div>
<CardContent className="p-4">
<p className="text-lg font-bold text-primary">
{formatPrice(listing.priceVND)} VND
</p>
<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">
{listing.property.district}, {listing.property.city}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<Badge variant="secondary" className="text-xs">
{listing.property.areaM2} m²
</Badge>
{listing.property.bedrooms != null && (
<Badge variant="secondary" className="text-xs">
{listing.property.bedrooms} PN
</Badge>
)}
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
<Badge variant="secondary" className="text-xs">
{listing.property.bathrooms} PT
</Badge>
)}
</div>
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
<span>{listing.viewCount} lượt xem</span>
<span>{listing.inquiryCount} liên hệ</span>
<span>{listing.saveCount} đã lưu</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
/* Table View */
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="p-3 font-medium">Tin đăng</th>
<th className="p-3 font-medium">Loại</th>
<th className="p-3 font-medium text-right">Giá</th>
<th className="p-3 font-medium text-right">Diện tích</th>
<th className="p-3 font-medium text-center">Trạng thái</th>
<th className="p-3 font-medium text-right">Lượt xem</th>
<th className="p-3 font-medium text-right">Liên hệ</th>
<th className="p-3 font-medium text-right">Ngày đăng</th>
</tr>
</thead>
<tbody>
{result.data.map((listing) => (
<tr
key={listing.id}
className="border-b last:border-0 transition-colors hover:bg-accent/50"
>
<td className="p-3">
<Link
href={`/listings/${listing.id}`}
className="group flex items-center gap-3"
>
<div className="relative h-10 w-14 flex-shrink-0 overflow-hidden rounded bg-muted">
{listing.property.media.length > 0 ? (
<Image
src={listing.property.media[0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="56px"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
N/A
</div>
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium group-hover:text-primary">
{listing.property.title}
</p>
<p className="truncate text-xs text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
</div>
</Link>
</td>
<td className="p-3 text-xs text-muted-foreground">
{listing.property.propertyType}
</td>
<td className="p-3 text-right font-medium text-primary">
{formatPrice(listing.priceVND)}
</td>
<td className="p-3 text-right">{listing.property.areaM2} m²</td>
<td className="p-3 text-center">
<ListingStatusBadge status={listing.status} />
</td>
<td className="p-3 text-right">{listing.viewCount}</td>
<td className="p-3 text-right">{listing.inquiryCount}</td>
<td className="p-3 text-right text-xs text-muted-foreground">
{formatDate(listing.publishedAt ?? listing.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Pagination */}
{result && result.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page <= 1}
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {result.page} / {result.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={filters.page >= result.totalPages}
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
>
Tiếp
</Button>
</div>
)}
</div>
);
}