fix: production readiness — resolve build, lint, and code quality issues
- Fix Next.js build failure: remove duplicate route at (dashboard)/listings/[id] that conflicted with (public)/listings/[id] (same URL path in two route groups) - Fix 772 ESLint errors: auto-fix import ordering (import-x/order), remove unused imports/variables, convert empty interfaces to type aliases, replace require() with ESM imports, fix consistent-type-imports violations - Add CLAUDE.md for developer onboarding documentation - All checks pass: 0 lint errors, typecheck clean, 230 tests passing, build success Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
@@ -12,11 +10,11 @@ import {
|
||||
ShieldCheck,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,6 +23,8 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { adminApi, type KycQueueItem, type PaginatedResult } from '@/lib/admin-api';
|
||||
|
||||
function kycStatusBadge(status: string) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
@@ -10,11 +9,10 @@ import {
|
||||
AlertTriangle,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,6 +21,8 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { adminApi, type ModerationQueueItem, type PaginatedResult } from '@/lib/admin-api';
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Home,
|
||||
@@ -13,8 +12,9 @@ import {
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { adminApi, type DashboardStats, type RevenueStatsItem } from '@/lib/admin-api';
|
||||
|
||||
interface StatCardProps {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
@@ -11,12 +10,10 @@ import {
|
||||
Eye,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,6 +22,9 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
adminApi,
|
||||
type UserListItem,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
@@ -12,10 +9,12 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const adminNavItems = [
|
||||
{ href: '/admin', label: 'Dashboard', icon: LayoutDashboard },
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
||||
import { loginSchema, type LoginFormData } from '@/lib/validations/auth';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { loginSchema, type LoginFormData } from '@/lib/validations/auth';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
||||
import { registerSchema, type RegisterFormData } from '@/lib/validations/auth';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { registerSchema, type RegisterFormData } from '@/lib/validations/auth';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
analyticsApi,
|
||||
type MarketReportDistrict,
|
||||
type HeatmapDataPoint,
|
||||
type DistrictStats,
|
||||
type PriceTrendPoint,
|
||||
} from '@/lib/analytics-api';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -23,6 +13,16 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import {
|
||||
analyticsApi,
|
||||
type MarketReportDistrict,
|
||||
type HeatmapDataPoint,
|
||||
type DistrictStats,
|
||||
type PriceTrendPoint,
|
||||
} from '@/lib/analytics-api';
|
||||
|
||||
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
|
||||
const CURRENT_PERIOD = '2026-Q1';
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
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 { ListingStatusBadge } from '@/components/listings/listing-status-badge';
|
||||
import {
|
||||
analyticsApi,
|
||||
type MarketReportDistrict,
|
||||
type HeatmapDataPoint,
|
||||
} from '@/lib/analytics-api';
|
||||
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
@@ -22,6 +12,15 @@ import {
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
analyticsApi,
|
||||
type MarketReportDistrict,
|
||||
type HeatmapDataPoint,
|
||||
} from '@/lib/analytics-api';
|
||||
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
||||
|
||||
const CITY = 'Ho Chi Minh';
|
||||
const PERIOD = '2026-Q1';
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Bảng điều khiển', icon: '🏠' },
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
StepBasicInfo,
|
||||
StepLocation,
|
||||
StepDetails,
|
||||
StepPricing,
|
||||
} from '@/components/listings/listing-form-steps';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||
import {
|
||||
createListingSchema,
|
||||
type CreateListingFormData,
|
||||
} from '@/lib/validations/listings';
|
||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
|
||||
export default function EditListingPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chi tiết tin đăng',
|
||||
description: 'Xem chi tiết bất động sản trên GoodGo.',
|
||||
};
|
||||
|
||||
export default function ListingDetailLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ImageGallery } from '@/components/listings/image-gallery';
|
||||
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
|
||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } 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 getLabel(list: readonly { value: string; label: string }[], value: string | null) {
|
||||
if (!value) return '—';
|
||||
return list.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
export default function ListingDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [listing, setListing] = React.useState<ListingDetail | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
listingsApi
|
||||
.getById(id)
|
||||
.then(setListing)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Không tải được tin đăng'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !listing) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
|
||||
<p className="text-destructive">{error || 'Không tìm thấy tin đăng'}</p>
|
||||
<Link href="/listings">
|
||||
<Button variant="outline">Quay lại danh sách</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { property, seller, agent } = listing;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<ListingStatusBadge status={listing.status} />
|
||||
<Badge variant="outline">
|
||||
{getLabel(TRANSACTION_TYPES, listing.transactionType)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{getLabel(PROPERTY_TYPES, property.propertyType)}
|
||||
</Badge>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">{property.title}</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{property.address}, {property.ward}, {property.district}, {property.city}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold text-primary">{formatPrice(listing.priceVND)} VNĐ</p>
|
||||
{listing.pricePerM2 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
~{listing.pricePerM2.toLocaleString('vi-VN')} VNĐ/m²
|
||||
</p>
|
||||
)}
|
||||
{listing.rentPriceMonthly && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thuê: {formatPrice(listing.rentPriceMonthly)}/tháng
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image gallery */}
|
||||
<ImageGallery media={property.media} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Key specs */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Thông tin chung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<InfoItem label="Diện tích" value={`${property.areaM2} m²`} />
|
||||
<InfoItem label="Phòng ngủ" value={property.bedrooms != null ? `${property.bedrooms}` : '—'} />
|
||||
<InfoItem label="Phòng tắm" value={property.bathrooms != null ? `${property.bathrooms}` : '—'} />
|
||||
<InfoItem label="Số tầng" value={property.floors != null ? `${property.floors}` : '—'} />
|
||||
<InfoItem label="Hướng" value={getLabel(DIRECTIONS, property.direction)} />
|
||||
<InfoItem label="Năm xây" value={property.yearBuilt ? `${property.yearBuilt}` : '—'} />
|
||||
<InfoItem label="Pháp lý" value={property.legalStatus || '—'} />
|
||||
<InfoItem label="Dự án" value={property.projectName || '—'} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mô tả</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">{property.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Amenities */}
|
||||
{property.amenities && property.amenities.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tiện ích</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{property.amenities.map((a) => (
|
||||
<Badge key={a} variant="secondary">
|
||||
{a}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Seller info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Liên hệ</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<p className="font-medium">{seller.fullName}</p>
|
||||
<p className="text-sm text-muted-foreground">{seller.phone}</p>
|
||||
</div>
|
||||
<Button className="w-full">Gọi ngay</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
Nhắn tin
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agent info */}
|
||||
{agent && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Môi giới</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{agent.agency && <p className="text-sm text-muted-foreground">{agent.agency}</p>}
|
||||
{listing.commissionPct != null && (
|
||||
<p className="mt-1 text-sm">Hoa hồng: {listing.commissionPct}%</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Thống kê</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Lượt xem</span>
|
||||
<span className="font-medium">{listing.viewCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Lượt lưu</span>
|
||||
<span className="font-medium">{listing.saveCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Liên hệ</span>
|
||||
<span className="font-medium">{listing.inquiryCount}</span>
|
||||
</div>
|
||||
{listing.publishedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Đăng ngày</span>
|
||||
<span className="font-medium">
|
||||
{new Date(listing.publishedAt).toLocaleDateString('vi-VN')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="font-medium">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ImageUpload, type ImageFile } from '@/components/listings/image-upload';
|
||||
import {
|
||||
StepBasicInfo,
|
||||
@@ -13,6 +11,10 @@ import {
|
||||
StepDetails,
|
||||
StepPricing,
|
||||
} from '@/components/listings/listing-form-steps';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { listingsApi, type CreateListingPayload, type Direction } from '@/lib/listings-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
createListingSchema,
|
||||
listingBasicSchema,
|
||||
@@ -21,8 +23,6 @@ import {
|
||||
listingPricingSchema,
|
||||
type CreateListingFormData,
|
||||
} from '@/lib/validations/listings';
|
||||
import { listingsApi, type CreateListingPayload, type Direction } from '@/lib/listings-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STEPS = [
|
||||
{ title: 'Thông tin', schemaKeys: Object.keys(listingBasicSchema.shape) },
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Image from 'next/image';
|
||||
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 * 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 ListingStatus,
|
||||
type PaginatedResult,
|
||||
} from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import * as React from 'react';
|
||||
import { ImageGallery } from '@/components/listings/image-gallery';
|
||||
import { ListingMap } from '@/components/map/listing-map';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { PropertyCard } from '@/components/search/property-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PropertyCard } from '@/components/search/property-card';
|
||||
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
|
||||
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function LandingPage() {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [transactionType, setTransactionType] = React.useState('');
|
||||
const [propertyType, setPropertyType] = React.useState('');
|
||||
const [propertyType, _setPropertyType] = React.useState('');
|
||||
const [featuredListings, setFeaturedListings] = React.useState<ListingDetail[]>([]);
|
||||
const [loadingFeatured, setLoadingFeatured] = React.useState(true);
|
||||
const [featuredError, setFeaturedError] = React.useState(false);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as React from 'react';
|
||||
import { ListingMap } from '@/components/map/listing-map';
|
||||
import { FilterBar, type SearchFilters } from '@/components/search/filter-bar';
|
||||
import { SearchResults } from '@/components/search/search-results';
|
||||
import { ListingMap } from '@/components/map/listing-map';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
||||
|
||||
type ViewMode = 'list' | 'map' | 'split';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
export default function GoogleCallbackPage() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
export default function ZaloCallbackPage() {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as React from 'react';
|
||||
import type { PropertyMedia } from '@/lib/listings-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ImageGalleryProps {
|
||||
media: PropertyMedia[];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ImageFile {
|
||||
file: File;
|
||||
@@ -79,8 +79,7 @@ export function ImageUpload({ images, onChange, maxFiles = 20, className }: Imag
|
||||
return () => {
|
||||
images.forEach((img) => URL.revokeObjectURL(img.preview));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, []); // intentionally empty: runs only on unmount to revoke object URLs
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import type { UseFormRegister, FieldErrors } from 'react-hook-form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
TRANSACTION_TYPES,
|
||||
PROPERTY_TYPES,
|
||||
DIRECTIONS,
|
||||
} from '@/lib/validations/listings';
|
||||
import type { UseFormRegister, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateListingFormData } from '@/lib/validations/listings';
|
||||
|
||||
interface StepProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { LISTING_STATUSES } from '@/lib/validations/listings';
|
||||
import type { ListingStatus } from '@/lib/listings-api';
|
||||
import { LISTING_STATUSES } from '@/lib/validations/listings';
|
||||
|
||||
interface ListingStatusBadgeProps {
|
||||
status: ListingStatus;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import * as React from 'react';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { ListingDetail } from '@/lib/listings-api';
|
||||
|
||||
function formatPrice(priceVND: string): string {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { ListingDetail } from '@/lib/listings-api';
|
||||
|
||||
function formatPrice(priceVND: string): string {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { PropertyCard } from './property-card';
|
||||
import type { ListingDetail, PaginatedResult } from '@/lib/listings-api';
|
||||
import { PropertyCard } from './property-card';
|
||||
|
||||
interface SearchResultsProps {
|
||||
result: PaginatedResult<ListingDetail> | null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
||||
export type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { authApi, type UserProfile, type LoginPayload, type RegisterPayload } from './auth-api';
|
||||
import { ApiError } from './api-client';
|
||||
import { authApi, type UserProfile, type LoginPayload, type RegisterPayload } from './auth-api';
|
||||
|
||||
function hasAuthCookie(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user