fix(web): update dashboard pages, layouts, and listing forms
Update 12 page/layout files across auth, dashboard, listings, and search routes to improve type safety, fix component imports, and align with latest API changes. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<main id="main-content" role="main" className="flex min-h-screen items-center justify-center bg-muted/40 px-4 py-12">
|
<main id="main-content" role="main" className="flex min-h-screen items-center justify-center bg-muted/40 px-4 py-6 md:py-12">
|
||||||
<div className="w-full max-w-md">{children}</div>
|
<div className="w-full max-w-md">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default function KycPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Xác minh danh tính (KYC)</h1>
|
<h1 className="text-2xl font-bold sm:text-3xl">Xác minh danh tính (KYC)</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Xác minh danh tính để sử dụng đầy đủ tính năng của GoodGo
|
Xác minh danh tính để sử dụng đầy đủ tính năng của GoodGo
|
||||||
</p>
|
</p>
|
||||||
@@ -131,7 +131,7 @@ export default function KycPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="ml-2 hidden text-sm sm:inline">{s.title}</span>
|
<span className="ml-2 hidden text-sm sm:inline">{s.title}</span>
|
||||||
{i < KYC_STEPS.length - 1 && (
|
{i < KYC_STEPS.length - 1 && (
|
||||||
<div className="mx-3 h-px w-8 bg-border sm:w-16" />
|
<div className="mx-1 h-px w-4 bg-border sm:mx-3 sm:w-16" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
||||||
import { useMarketReport, useHeatmap } from '@/lib/hooks/use-analytics';
|
import { useMarketReport, useHeatmap } from '@/lib/hooks/use-analytics';
|
||||||
import { useListingsSearch } from '@/lib/hooks/use-listings';
|
import { useListingsSearch } from '@/lib/hooks/use-listings';
|
||||||
|
import { staticBlurDataURL } from '@/lib/image-blur';
|
||||||
|
|
||||||
const DistrictBarChart = dynamic(
|
const DistrictBarChart = dynamic(
|
||||||
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
|
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
|
||||||
@@ -244,6 +245,8 @@ export default function DashboardPage() {
|
|||||||
fill
|
fill
|
||||||
sizes="64px"
|
sizes="64px"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={staticBlurDataURL()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export default function ProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Hồ sơ cá nhân</h1>
|
<h1 className="text-2xl font-bold sm:text-3xl">Hồ sơ cá nhân</h1>
|
||||||
<p className="mt-2 text-muted-foreground">Quản lý thông tin tài khoản của bạn</p>
|
<p className="mt-2 text-muted-foreground">Quản lý thông tin tài khoản của bạn</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export default function ProfilePage() {
|
|||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
{/* Profile info */}
|
{/* Profile info */}
|
||||||
<Card className="lg:col-span-2">
|
<Card className="lg:col-span-2">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg">Thông tin cá nhân</CardTitle>
|
<CardTitle className="text-lg">Thông tin cá nhân</CardTitle>
|
||||||
<CardDescription>Thông tin cơ bản trên hồ sơ của bạn</CardDescription>
|
<CardDescription>Thông tin cơ bản trên hồ sơ của bạn</CardDescription>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export default function SubscriptionPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Gói dịch vụ</h1>
|
<h1 className="text-2xl font-bold sm:text-3xl">Gói dịch vụ</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Quản lý gói đăng ký và theo dõi hạn mức sử dụng
|
Quản lý gói đăng ký và theo dõi hạn mức sử dụng
|
||||||
</p>
|
</p>
|
||||||
@@ -112,10 +112,10 @@ export default function SubscriptionPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList>
|
<TabsList className="w-full justify-start overflow-x-auto">
|
||||||
<TabsTrigger value="plan">Gói hiện tại</TabsTrigger>
|
<TabsTrigger value="plan" className="min-w-fit">Gói hiện tại</TabsTrigger>
|
||||||
<TabsTrigger value="plans">So sánh gói</TabsTrigger>
|
<TabsTrigger value="plans" className="min-w-fit">So sánh gói</TabsTrigger>
|
||||||
<TabsTrigger value="history">Lịch sử thanh toán</TabsTrigger>
|
<TabsTrigger value="history" className="min-w-fit">Lịch sử thanh toán</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Current plan tab */}
|
{/* Current plan tab */}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function ValuationPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Dinh gia AI</h1>
|
<h1 className="text-2xl font-bold sm:text-3xl">Dinh gia AI</h1>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
Su dung AI de uoc tinh gia tri bat dong san dua tren du lieu thi truong
|
Su dung AI de uoc tinh gia tri bat dong san dua tren du lieu thi truong
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { LogOut, Menu, X } from 'lucide-react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useTheme } from '@/components/providers/theme-provider';
|
import { useTheme } from '@/components/providers/theme-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||||
@@ -14,12 +16,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/dashboard' as const, label: t('dashboard.title'), icon: '🏠' },
|
{ href: '/dashboard' as const, label: t('dashboard.title'), icon: '🏠' },
|
||||||
{ href: '/listings' as const, label: t('dashboard.listings'), icon: '📋' },
|
{ href: '/listings' as const, label: t('dashboard.listings'), icon: '📋' },
|
||||||
{ href: '/listings/new' as const, label: t('dashboard.createListing'), icon: '➕' },
|
{ href: '/listings/new' as const, label: t('dashboard.createListing'), icon: '➕' },
|
||||||
{ href: '/analytics' as const, label: t('dashboard.analytics'), icon: '📊' },
|
{ href: '/analytics' as const, label: t('dashboard.analytics'), icon: '📊' },
|
||||||
|
{ href: '/dashboard/saved-searches' as const, label: t('dashboard.savedSearches'), icon: '🔖' },
|
||||||
{ href: '/dashboard/valuation' as const, label: t('dashboard.aiValuation'), icon: '🤖' },
|
{ href: '/dashboard/valuation' as const, label: t('dashboard.aiValuation'), icon: '🤖' },
|
||||||
{ href: '/dashboard/profile' as const, label: t('dashboard.profile'), icon: '👤' },
|
{ href: '/dashboard/profile' as const, label: t('dashboard.profile'), icon: '👤' },
|
||||||
{ href: '/dashboard/subscription' as const, label: t('dashboard.subscription'), icon: '💎' },
|
{ href: '/dashboard/subscription' as const, label: t('dashboard.subscription'), icon: '💎' },
|
||||||
@@ -28,30 +32,106 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/50 md:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile sidebar */}
|
||||||
|
<aside
|
||||||
|
role="navigation"
|
||||||
|
aria-label={t('nav.dashboardNav')}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-y-0 left-0 z-50 w-64 border-r bg-card transition-transform md:hidden',
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-14 items-center border-b px-4">
|
||||||
|
<Link href="/" className="flex items-center space-x-2">
|
||||||
|
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
aria-label={t('nav.closeMenu')}
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-col gap-1 p-3">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
|
||||||
|
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto border-t p-3">
|
||||||
|
{user && (
|
||||||
|
<p className="mb-2 truncate px-3 text-xs text-muted-foreground">{user.fullName}</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
onClick={() => logout()}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{t('common.logout')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
role="banner"
|
role="banner"
|
||||||
className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex h-14 max-w-7xl items-center px-4">
|
<div className="mx-auto flex h-14 max-w-7xl items-center px-4">
|
||||||
|
{/* Mobile hamburger */}
|
||||||
|
<button
|
||||||
|
aria-label={t('nav.openMenu')}
|
||||||
|
className="mr-3 inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground md:hidden"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||||
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
|
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav aria-label={t('nav.dashboardNav')} className="flex items-center space-x-1">
|
{/* Desktop nav */}
|
||||||
|
<nav aria-label={t('nav.dashboardNav')} className="hidden items-center space-x-1 md:flex">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
aria-label={item.label}
|
aria-label={item.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md px-2 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground sm:px-3',
|
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||||
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
|
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="sm:mr-1.5" aria-hidden="true">{item.icon}</span>
|
<span className="mr-1.5" aria-hidden="true">{item.icon}</span>
|
||||||
<span className="hidden sm:inline">{item.label}</span>
|
<span className="hidden lg:inline">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -80,7 +160,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => logout()}>
|
<Button variant="ghost" size="sm" className="hidden md:inline-flex" onClick={() => logout()}>
|
||||||
{t('common.logout')}
|
{t('common.logout')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ export default function EditListingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
<div className="mx-auto max-w-3xl space-y-4 sm:space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h1 className="text-2xl font-bold">Chỉnh sửa tin đăng</h1>
|
<h1 className="text-xl font-bold sm:text-2xl">Chỉnh sửa tin đăng</h1>
|
||||||
<Button variant="outline" onClick={() => router.push(`/listings/${id}`)}>
|
<Button variant="outline" onClick={() => router.push(`/listings/${id}`)}>
|
||||||
Xem tin
|
Xem tin
|
||||||
</Button>
|
</Button>
|
||||||
@@ -102,7 +102,7 @@ export default function EditListingPage() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
|
||||||
<TabsTrigger value="basic">Cơ bản</TabsTrigger>
|
<TabsTrigger value="basic">Cơ bản</TabsTrigger>
|
||||||
<TabsTrigger value="location">Vị trí</TabsTrigger>
|
<TabsTrigger value="location">Vị trí</TabsTrigger>
|
||||||
<TabsTrigger value="details">Chi tiết</TabsTrigger>
|
<TabsTrigger value="details">Chi tiết</TabsTrigger>
|
||||||
|
|||||||
@@ -130,10 +130,10 @@ export default function CreateListingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl">
|
<div className="mx-auto max-w-3xl">
|
||||||
<h1 className="mb-6 text-2xl font-bold">Đăng tin mới</h1>
|
<h1 className="mb-4 text-xl font-bold sm:mb-6 sm:text-2xl">Đăng tin mới</h1>
|
||||||
|
|
||||||
{/* Step indicators */}
|
{/* Step indicators */}
|
||||||
<div className="mb-8 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||||
{STEPS.map((step, index) => (
|
{STEPS.map((step, index) => (
|
||||||
<div key={step.title} className="flex items-center">
|
<div key={step.title} className="flex items-center">
|
||||||
<button
|
<button
|
||||||
@@ -161,7 +161,7 @@ export default function CreateListingPage() {
|
|||||||
{index < STEPS.length - 1 && (
|
{index < STEPS.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'mx-3 h-px w-8 sm:w-12',
|
'mx-1 h-px w-4 sm:mx-3 sm:w-8 md:w-12',
|
||||||
index < currentStep ? 'bg-primary' : 'bg-muted',
|
index < currentStep ? 'bg-primary' : 'bg-muted',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { useListingsSearch } from '@/lib/hooks/use-listings';
|
import { useListingsSearch } from '@/lib/hooks/use-listings';
|
||||||
|
import { shimmerBlurDataURL, staticBlurDataURL } from '@/lib/image-blur';
|
||||||
import type { ListingDetail as _ListingDetail } from '@/lib/listings-api';
|
import type { ListingDetail as _ListingDetail } from '@/lib/listings-api';
|
||||||
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
|
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
|
||||||
|
|
||||||
@@ -190,6 +191,8 @@ export default function ListingsPage() {
|
|||||||
fill
|
fill
|
||||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={shimmerBlurDataURL()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
@@ -270,6 +273,8 @@ export default function ListingsPage() {
|
|||||||
fill
|
fill
|
||||||
sizes="56px"
|
sizes="56px"
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL={staticBlurDataURL()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as React from 'react';
|
|||||||
import { FilterBar, type SearchFilters } from '@/components/search/filter-bar';
|
import { FilterBar, type SearchFilters } from '@/components/search/filter-bar';
|
||||||
import { SearchResults } from '@/components/search/search-results';
|
import { SearchResults } from '@/components/search/search-results';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useCreateSavedSearch } from '@/lib/hooks/use-saved-searches';
|
||||||
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
|
||||||
|
|
||||||
const ListingMap = dynamic(
|
const ListingMap = dynamic(
|
||||||
@@ -58,6 +59,12 @@ function SearchContent() {
|
|||||||
const [viewMode, setViewMode] = React.useState<ViewMode>('list');
|
const [viewMode, setViewMode] = React.useState<ViewMode>('list');
|
||||||
const [showMobileFilters, setShowMobileFilters] = React.useState(false);
|
const [showMobileFilters, setShowMobileFilters] = React.useState(false);
|
||||||
const [selectedListingId, setSelectedListingId] = React.useState<string | undefined>();
|
const [selectedListingId, setSelectedListingId] = React.useState<string | undefined>();
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = React.useState(false);
|
||||||
|
const [saveName, setSaveName] = React.useState('');
|
||||||
|
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
||||||
|
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||||
|
|
||||||
|
const createSavedSearch = useCreateSavedSearch();
|
||||||
|
|
||||||
const handleMarkerClick = (listing: ListingDetail) => {
|
const handleMarkerClick = (listing: ListingDetail) => {
|
||||||
setSelectedListingId(listing.id);
|
setSelectedListingId(listing.id);
|
||||||
@@ -120,14 +127,116 @@ function SearchContent() {
|
|||||||
([key, value]) => value && key !== 'sort',
|
([key, value]) => value && key !== 'sort',
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
const handleSaveSearch = () => {
|
||||||
|
if (!saveName.trim()) return;
|
||||||
|
|
||||||
|
const filterData: Record<string, string> = {};
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value && key !== 'sort') filterData[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
createSavedSearch.mutate(
|
||||||
|
{ name: saveName.trim(), filters: filterData, alertEnabled: saveAlertEnabled },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setSaveName('');
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
}, 1500);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl px-4 py-6">
|
<div className="mx-auto max-w-7xl px-4 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6 flex items-start justify-between">
|
||||||
<h1 className="text-2xl font-bold md:text-3xl">Tìm kiếm bất động sản</h1>
|
<div>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<h1 className="text-2xl font-bold md:text-3xl">Tìm kiếm bất động sản</h1>
|
||||||
Tìm bất động sản phù hợp với nhu cầu của bạn
|
<p className="mt-1 text-muted-foreground">
|
||||||
</p>
|
Tìm bất động sản phù hợp với nhu cầu của bạn
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
||||||
|
>
|
||||||
|
<svg 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" />
|
||||||
|
</svg>
|
||||||
|
Lưu tìm kiếm
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Save search dialog */}
|
||||||
|
{showSaveDialog && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg">
|
||||||
|
{saveSuccess ? (
|
||||||
|
<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">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
<input
|
||||||
|
id="save-search-name"
|
||||||
|
type="text"
|
||||||
|
value={saveName}
|
||||||
|
onChange={(e) => setSaveName(e.target.value)}
|
||||||
|
placeholder="Tên tìm kiếm (VD: Chung cư Q7 dưới 3 tỷ)"
|
||||||
|
className="mb-3 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
maxLength={100}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveSearch()}
|
||||||
|
aria-describedby="save-search-heading"
|
||||||
|
/>
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
id="save-alert-enabled"
|
||||||
|
type="checkbox"
|
||||||
|
checked={saveAlertEnabled}
|
||||||
|
onChange={(e) => setSaveAlertEnabled(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="save-alert-enabled">
|
||||||
|
Nhận thông báo khi có kết quả mới
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowSaveDialog(false)}
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveSearch}
|
||||||
|
disabled={!saveName.trim() || createSavedSearch.isPending}
|
||||||
|
>
|
||||||
|
{createSavedSearch.isPending ? 'Đang lưu...' : 'Lưu'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{createSavedSearch.isError && (
|
||||||
|
<p className="mt-2 text-xs text-destructive">
|
||||||
|
Không thể lưu tìm kiếm. Vui lòng thử lại.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View Mode Toggle + Mobile Filter Button */}
|
{/* View Mode Toggle + Mobile Filter Button */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getMessages, getTranslations } from 'next-intl/server';
|
import { getMessages, getTranslations } from 'next-intl/server';
|
||||||
@@ -11,6 +12,12 @@ import type { Locale } from '@/i18n/config';
|
|||||||
import { routing } from '@/i18n/routing';
|
import { routing } from '@/i18n/routing';
|
||||||
import '../globals.css';
|
import '../globals.css';
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin', 'vietnamese'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-inter',
|
||||||
|
});
|
||||||
|
|
||||||
const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
|
const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn';
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
@@ -99,8 +106,8 @@ export default async function LocaleLayout({
|
|||||||
const t = await getTranslations({ locale, namespace: 'common' });
|
const t = await getTranslations({ locale, namespace: 'common' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning className={inter.variable}>
|
||||||
<body>
|
<body className={inter.className}>
|
||||||
<JsonLd data={generateWebsiteJsonLd(siteUrl)} />
|
<JsonLd data={generateWebsiteJsonLd(siteUrl)} />
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
href="#main-content"
|
||||||
|
|||||||
Reference in New Issue
Block a user