diff --git a/apps/web/app/(dashboard)/dashboard/valuation/page.tsx b/apps/web/app/(dashboard)/dashboard/valuation/page.tsx new file mode 100644 index 0000000..8fee1cb --- /dev/null +++ b/apps/web/app/(dashboard)/dashboard/valuation/page.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useState } from 'react'; +import { ValuationForm } from '@/components/valuation/valuation-form'; +import { ValuationHistory } from '@/components/valuation/valuation-history'; +import { ValuationResults } from '@/components/valuation/valuation-results'; +import { + useValuationPredict, + useValuationHistory, + useValuationDetail, +} from '@/lib/hooks/use-valuation'; +import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api'; + +export default function ValuationPage() { + const [historyPage, setHistoryPage] = useState(1); + const [selectedId, setSelectedId] = useState(null); + + const predictMutation = useValuationPredict(); + const { data: historyData, isLoading: historyLoading } = useValuationHistory(historyPage); + const { data: selectedResult } = useValuationDetail(selectedId ?? ''); + + const currentResult: ValuationResult | undefined = + predictMutation.data ?? selectedResult; + + const handleSubmit = (data: ValuationRequest) => { + setSelectedId(null); + predictMutation.mutate(data); + }; + + const handleSelectHistory = (id: string) => { + setSelectedId(id); + }; + + return ( +
+
+

Dinh gia AI

+

+ Su dung AI de uoc tinh gia tri bat dong san dua tren du lieu thi truong +

+
+ +
+ {/* Form + Results */} +
+ + + {predictMutation.isError && ( +
+ Khong the dinh gia. Vui long thu lai sau. +
+ )} + + {currentResult && } +
+ + {/* History sidebar */} +
+ +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index 2e7af9a..0e71e02 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -12,6 +12,7 @@ const navItems = [ { href: '/listings', label: 'Tin Δ‘Δƒng', icon: 'πŸ“‹' }, { href: '/listings/new', label: 'Đăng tin', icon: 'βž•' }, { href: '/analytics', label: 'PhΓ’n tΓ­ch', icon: 'πŸ“Š' }, + { href: '/dashboard/valuation', label: 'Định giΓ‘ AI', icon: 'πŸ€–' }, { href: '/dashboard/profile', label: 'Hα»“ sΖ‘', icon: 'πŸ‘€' }, { href: '/dashboard/subscription', label: 'GΓ³i dα»‹ch vα»₯', icon: 'πŸ’Ž' }, { href: '/dashboard/payments', label: 'Thanh toΓ‘n', icon: 'πŸ’³' }, diff --git a/apps/web/app/(public)/listings/[id]/page.tsx b/apps/web/app/(public)/listings/[id]/page.tsx index aa7e3c3..129e31b 100644 --- a/apps/web/app/(public)/listings/[id]/page.tsx +++ b/apps/web/app/(public)/listings/[id]/page.tsx @@ -8,6 +8,7 @@ import { ImageGallery } from '@/components/listings/image-gallery'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { AiEstimateButton } from '@/components/valuation/ai-estimate-button'; import { listingsApi, type ListingDetail } from '@/lib/listings-api'; import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings'; @@ -264,6 +265,9 @@ export default function PublicListingDetailPage() { + {/* AI Estimate */} + + {/* Stats */} diff --git a/apps/web/components/valuation/ai-estimate-button.tsx b/apps/web/components/valuation/ai-estimate-button.tsx new file mode 100644 index 0000000..fac49f8 --- /dev/null +++ b/apps/web/components/valuation/ai-estimate-button.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useValuationPredictForListing } from '@/lib/hooks/use-valuation'; + +interface AiEstimateButtonProps { + listingId: string; +} + +function formatPrice(num: number): string { + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} ty`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`; + return num.toLocaleString('vi-VN'); +} + +export function AiEstimateButton({ listingId }: AiEstimateButtonProps) { + const [showResult, setShowResult] = useState(false); + const mutation = useValuationPredictForListing(); + + const handleClick = () => { + mutation.mutate(listingId, { + onSuccess: () => setShowResult(true), + }); + }; + + if (showResult && mutation.data) { + const result = mutation.data; + const confidencePct = Math.round(result.confidence * 100); + + return ( + + + Gia uoc tinh AI + + {formatPrice(result.estimatedPriceVND)} VND + + + +
+ Do tin cay + {confidencePct}% +
+
+
+
+
+ Khoang gia + + {formatPrice(result.priceRangeLow)} - {formatPrice(result.priceRangeHigh)} + +
+ + + ); + } + + return ( + + ); +} diff --git a/apps/web/components/valuation/valuation-form.tsx b/apps/web/components/valuation/valuation-form.tsx new file mode 100644 index 0000000..6082735 --- /dev/null +++ b/apps/web/components/valuation/valuation-form.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select } from '@/components/ui/select'; +import { + valuationFormSchema, + type ValuationFormData, + VALUATION_PROPERTY_TYPES, + CITIES, +} from '@/lib/validations/valuation'; +import type { ValuationRequest } from '@/lib/valuation-api'; + +interface ValuationFormProps { + onSubmit: (data: ValuationRequest) => void; + isLoading?: boolean; +} + +function toNum(val: string | undefined): number | undefined { + if (!val || val === '') return undefined; + const n = Number(val); + return isNaN(n) ? undefined : n; +} + +export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(valuationFormSchema), + defaultValues: { + city: 'Ho Chi Minh', + hasLegalPaper: true, + }, + }); + + const handleFormSubmit = (data: ValuationFormData) => { + onSubmit({ + propertyType: data.propertyType, + area: Number(data.area), + district: data.district, + city: data.city, + bedrooms: toNum(data.bedrooms), + bathrooms: toNum(data.bathrooms), + floors: toNum(data.floors), + frontage: toNum(data.frontage), + roadWidth: toNum(data.roadWidth), + yearBuilt: toNum(data.yearBuilt), + hasLegalPaper: data.hasLegalPaper, + }); + }; + + return ( + + + Dinh gia bat dong san + + Nhap thong tin bat dong san de nhan uoc tinh gia tu AI + + + +
+ {/* Row 1: Property type + City */} +
+
+ + + {errors.propertyType && ( +

{errors.propertyType.message}

+ )} +
+ +
+ + + {errors.city && ( +

{errors.city.message}

+ )} +
+
+ + {/* Row 2: District + Area */} +
+
+ + + {errors.district && ( +

{errors.district.message}

+ )} +
+ +
+ + + {errors.area && ( +

{errors.area.message}

+ )} +
+
+ + {/* Row 3: Bedrooms + Bathrooms + Floors */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* Row 4: Frontage + Road Width + Year Built */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* Legal paper checkbox */} +
+ + +
+ + +
+
+
+ ); +} diff --git a/apps/web/components/valuation/valuation-history.tsx b/apps/web/components/valuation/valuation-history.tsx new file mode 100644 index 0000000..d8f4c08 --- /dev/null +++ b/apps/web/components/valuation/valuation-history.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import type { ValuationHistoryItem } from '@/lib/valuation-api'; + +interface ValuationHistoryProps { + items: ValuationHistoryItem[]; + total: number; + page: number; + onPageChange: (page: number) => void; + onSelect: (id: string) => void; + isLoading?: boolean; +} + +function formatPrice(num: number): string { + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} ty`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`; + return num.toLocaleString('vi-VN'); +} + +const PROPERTY_TYPE_LABELS: Record = { + APARTMENT: 'Can ho', + HOUSE: 'Nha rieng', + VILLA: 'Biet thu', + LAND: 'Dat nen', + OFFICE: 'Van phong', + SHOPHOUSE: 'Shophouse', +}; + +export function ValuationHistory({ + items, + total, + page, + onPageChange, + onSelect, + isLoading, +}: ValuationHistoryProps) { + const totalPages = Math.ceil(total / 10); + + return ( + + + Lich su dinh gia + + {total} lan dinh gia truoc do + + + + {isLoading ? ( +
+ Dang tai... +
+ ) : items.length === 0 ? ( +
+ Chua co lich su dinh gia +
+ ) : ( + <> +
+ {items.map((item) => ( + + ))} +
+ + {totalPages > 1 && ( +
+ + + Trang {page}/{totalPages} + + +
+ )} + + )} +
+
+ ); +} diff --git a/apps/web/components/valuation/valuation-results.tsx b/apps/web/components/valuation/valuation-results.tsx new file mode 100644 index 0000000..e628a32 --- /dev/null +++ b/apps/web/components/valuation/valuation-results.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import type { ValuationResult } from '@/lib/valuation-api'; + +interface ValuationResultsProps { + result: ValuationResult; +} + +function formatPrice(num: number): string { + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)} ty`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} trieu`; + return num.toLocaleString('vi-VN'); +} + +function formatPriceM2(price: number): string { + if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m2`; + return `${price.toLocaleString('vi-VN')} d/m2`; +} + +export function ValuationResults({ result }: ValuationResultsProps) { + const confidencePct = Math.round(result.confidence * 100); + + return ( +
+ {/* Main estimate */} + + + Gia uoc tinh boi AI + + {formatPrice(result.estimatedPriceVND)} VND + + + +
+
+

Do tin cay

+
+
+
+
+ {confidencePct}% +
+
+
+

Gia/m2

+

{formatPriceM2(result.pricePerM2)}

+
+
+

Khoang gia

+

+ {formatPrice(result.priceRangeLow)} - {formatPrice(result.priceRangeHigh)} +

+
+
+ + + + {/* Price drivers */} + {result.priceDrivers.length > 0 && ( + + + Yeu to anh huong gia + Cac yeu to chinh tac dong den gia tri bat dong san + + +
+ {result.priceDrivers.map((driver) => ( +
+ + {driver.direction === 'positive' ? '+' : '-'} + {Math.abs(driver.impact).toFixed(1)}% + +
+
+ {driver.feature} +
+
+
+
+
+
+ ))} +
+ + + )} + + {/* Comparables */} + {result.comparables.length > 0 && ( + + + Bat dong san tuong tu + + {result.comparables.length} bat dong san co dac diem tuong tu trong khu vuc + + + +
+ {result.comparables.map((comp) => ( +
+
+

{comp.title}

+

+ {comp.district} · {comp.areaM2} m2 +

+
+
+

{formatPrice(Number(comp.priceVND))}

+

+ {formatPriceM2(comp.pricePerM2)} +

+
+
+ + {Math.round(comp.similarity * 100)}% tuong tu + +
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/lib/hooks/use-valuation.ts b/apps/web/lib/hooks/use-valuation.ts new file mode 100644 index 0000000..2e83789 --- /dev/null +++ b/apps/web/lib/hooks/use-valuation.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { valuationApi, type ValuationRequest } from '@/lib/valuation-api'; + +export const valuationKeys = { + all: ['valuation'] as const, + history: (page: number) => ['valuation', 'history', page] as const, + detail: (id: string) => ['valuation', 'detail', id] as const, +}; + +export function useValuationPredict() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: ValuationRequest) => valuationApi.predict(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: valuationKeys.all }); + }, + }); +} + +export function useValuationPredictForListing() { + return useMutation({ + mutationFn: (listingId: string) => valuationApi.predictForListing(listingId), + }); +} + +export function useValuationHistory(page = 1) { + return useQuery({ + queryKey: valuationKeys.history(page), + queryFn: () => valuationApi.getHistory(page), + }); +} + +export function useValuationDetail(id: string) { + return useQuery({ + queryKey: valuationKeys.detail(id), + queryFn: () => valuationApi.getById(id), + enabled: !!id, + }); +} diff --git a/apps/web/lib/validations/valuation.ts b/apps/web/lib/validations/valuation.ts new file mode 100644 index 0000000..ae5cf30 --- /dev/null +++ b/apps/web/lib/validations/valuation.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +export const VALUATION_PROPERTY_TYPES = [ + { value: 'APARTMENT', label: 'Can ho' }, + { value: 'HOUSE', label: 'Nha rieng' }, + { value: 'VILLA', label: 'Biet thu' }, + { value: 'LAND', label: 'Dat nen' }, + { value: 'OFFICE', label: 'Van phong' }, + { value: 'SHOPHOUSE', label: 'Shophouse' }, +] as const; + +export const CITIES = [ + { value: 'Ho Chi Minh', label: 'TP. Ho Chi Minh' }, + { value: 'Ha Noi', label: 'Ha Noi' }, + { value: 'Da Nang', label: 'Da Nang' }, +] as const; + +export const valuationFormSchema = z.object({ + propertyType: z.string().min(1, 'Vui long chon loai bat dong san'), + area: z.string().min(1, 'Vui long nhap dien tich').refine( + (val) => !isNaN(Number(val)) && Number(val) > 0, + 'Dien tich phai lon hon 0', + ), + district: z.string().min(1, 'Vui long nhap quan/huyen'), + city: z.string().min(1, 'Vui long chon tinh/thanh pho'), + bedrooms: z.string().optional(), + bathrooms: z.string().optional(), + floors: z.string().optional(), + frontage: z.string().optional(), + roadWidth: z.string().optional(), + yearBuilt: z.string().optional(), + hasLegalPaper: z.boolean().optional(), +}); + +export type ValuationFormData = z.infer; diff --git a/apps/web/lib/valuation-api.ts b/apps/web/lib/valuation-api.ts new file mode 100644 index 0000000..d018474 --- /dev/null +++ b/apps/web/lib/valuation-api.ts @@ -0,0 +1,85 @@ +import { apiClient } from './api-client'; + +// ─── Types ────────────────────────────────────────────── + +export interface ValuationRequest { + propertyType: string; + area: number; + district: string; + city: string; + bedrooms?: number; + bathrooms?: number; + floors?: number; + frontage?: number; + roadWidth?: number; + yearBuilt?: number; + hasLegalPaper?: boolean; +} + +export interface ValuationComparable { + id: string; + title: string; + address: string; + district: string; + priceVND: string; + areaM2: number; + pricePerM2: number; + similarity: number; + latitude?: number; + longitude?: number; +} + +export interface PriceDriver { + feature: string; + impact: number; + direction: 'positive' | 'negative'; +} + +export interface ValuationResult { + id: string; + estimatedPriceVND: number; + confidence: number; + pricePerM2: number; + priceRangeLow: number; + priceRangeHigh: number; + comparables: ValuationComparable[]; + priceDrivers: PriceDriver[]; + modelVersion: string; + createdAt: string; +} + +export interface ValuationHistoryItem { + id: string; + propertyType: string; + district: string; + city: string; + area: number; + estimatedPriceVND: number; + confidence: number; + createdAt: string; +} + +export interface ValuationHistoryResponse { + data: ValuationHistoryItem[]; + total: number; + page: number; + limit: number; +} + +// ─── API ──────────────────────────────────────────────── + +export const valuationApi = { + predict: (data: ValuationRequest) => + apiClient.post('/valuation/predict', data), + + getHistory: (page = 1, limit = 10) => + apiClient.get( + `/valuation/history?page=${page}&limit=${limit}`, + ), + + getById: (id: string) => + apiClient.get(`/valuation/${id}`), + + predictForListing: (listingId: string) => + apiClient.post(`/valuation/predict-listing/${listingId}`), +};