Backend: - Auth controller sets httpOnly secure cookies (access_token, refresh_token, goodgo_authenticated) on login/register/refresh - JWT strategy reads token from cookie first, falls back to Authorization header - Added POST /auth/logout to clear auth cookies - Added POST /auth/exchange-token for OAuth callback token-to-cookie exchange - Refresh endpoint reads refresh_token from cookie (body fallback for backwards compat) - CSRF middleware excludes auth endpoints (login, register, refresh, exchange-token, logout) Frontend: - Removed all localStorage token storage (goodgo_tokens key) - Removed authGet/authPost/authPatch helpers from api-client (tokens sent via cookies) - All API calls use credentials:'include' for cookie-based auth - Updated auth-store: no more token state, uses isAuthenticated flag from cookie - Updated admin-api, listings-api to remove explicit token parameters - Updated all pages (admin dashboard, users, KYC, moderation, listings) to remove token passing - OAuth callbacks use exchange-token endpoint to convert URL tokens to cookies - Auth provider simplified (no client-side cookie management needed) Security improvements: - JWT no longer accessible via JavaScript (XSS-safe) - Refresh token scoped to /auth path only - Server-side goodgo_authenticated cookie with SameSite=Lax - Access token cookie with SameSite=Strict Co-Authored-By: Paperclip <noreply@paperclip.ing>
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import Link from 'next/link';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Select } from '@/components/ui/select';
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
|
|
import {
|
|
listingsApi,
|
|
type ListingDetail,
|
|
type ListingStatus,
|
|
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)} ty`;
|
|
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`;
|
|
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">Quan ly tin dang</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Quan ly, theo doi va cap nhat cac tin dang cua ban
|
|
</p>
|
|
</div>
|
|
<Link href="/listings/new">
|
|
<Button>Dang tin moi</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid gap-3 sm:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Tong tin dang</CardDescription>
|
|
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Dang hoat dong</CardDescription>
|
|
<CardTitle className="text-xl text-green-600">
|
|
{loading ? '...' : stats.active}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Cho duyet</CardDescription>
|
|
<CardTitle className="text-xl text-yellow-600">
|
|
{loading ? '...' : stats.pending}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardDescription>Tong luot 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="">Tat ca giao dich</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="">Tat ca loai BDS</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="">Tat ca trang thai</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')}
|
|
>
|
|
Luoi
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'table' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setViewMode('table')}
|
|
>
|
|
Bang
|
|
</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>Chua co tin dang nao</p>
|
|
<Link href="/listings/new" className="mt-2">
|
|
<Button variant="outline" size="sm">
|
|
Dang tin dau tien
|
|
</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 ? (
|
|
<img
|
|
src={listing.property.media[0]?.url}
|
|
alt={listing.property.title}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
Chua co anh
|
|
</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} m2
|
|
</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} luot xem</span>
|
|
<span>{listing.inquiryCount} lien he</span>
|
|
<span>{listing.saveCount} da luu</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 dang</th>
|
|
<th className="p-3 font-medium">Loai</th>
|
|
<th className="p-3 font-medium text-right">Gia</th>
|
|
<th className="p-3 font-medium text-right">Dien tich</th>
|
|
<th className="p-3 font-medium text-center">Trang thai</th>
|
|
<th className="p-3 font-medium text-right">Luot xem</th>
|
|
<th className="p-3 font-medium text-right">Lien he</th>
|
|
<th className="p-3 font-medium text-right">Ngay dang</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="h-10 w-14 flex-shrink-0 overflow-hidden rounded bg-muted">
|
|
{listing.property.media.length > 0 ? (
|
|
<img
|
|
src={listing.property.media[0]?.url}
|
|
alt=""
|
|
className="h-full w-full 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} m2</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 }))}
|
|
>
|
|
Truoc
|
|
</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 }))}
|
|
>
|
|
Tiep
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|