diff --git a/apps/web/components/valuation/valuation-form.tsx b/apps/web/components/valuation/valuation-form.tsx
index 8a82344..ed0f21f 100644
--- a/apps/web/components/valuation/valuation-form.tsx
+++ b/apps/web/components/valuation/valuation-form.tsx
@@ -1,7 +1,16 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
-import { Bot, ImagePlus, Search, X } from 'lucide-react';
+import {
+ Bot,
+ ChevronDown,
+ ImagePlus,
+ MapPin,
+ Search,
+ Sparkles,
+ Star,
+ X,
+} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
@@ -21,6 +30,8 @@ import {
type ValuationFormData,
VALUATION_PROPERTY_TYPES,
CITIES,
+ FLOOD_RISK_OPTIONS,
+ QUALITY_LABELS,
} from '@/lib/validations/valuation';
import type { ValuationRequest } from '@/lib/valuation-api';
@@ -29,6 +40,85 @@ interface ValuationFormProps {
isLoading?: boolean;
}
+function CollapsibleSection({
+ title,
+ icon,
+ description,
+ children,
+}: {
+ title: string;
+ icon: React.ReactNode;
+ description: string;
+ children: React.ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+ {open &&
{children}
}
+
+ );
+}
+
+function QualitySlider({
+ id,
+ label,
+ register,
+ isInverted,
+}: {
+ id: string;
+ label: string;
+ register: ReturnType>['register'];
+ isInverted?: boolean;
+}) {
+ const [value, setValue] = useState(50);
+ const displayValue = Math.round(value * 100) / 100;
+ const normalizedForApi = value / 100;
+
+ return (
+
+
+
+ {displayValue}%
+
+
setValue(Number(e.target.value))}
+ className={`h-2 w-full cursor-pointer appearance-none rounded-full ${
+ isInverted
+ ? 'bg-gradient-to-r from-green-200 via-yellow-200 to-red-200'
+ : 'bg-gradient-to-r from-red-200 via-yellow-200 to-green-200'
+ } accent-primary`}
+ />
+
+
+ );
+}
+
function toNum(val: string | undefined): number | undefined {
if (!val || val === '') return undefined;
const n = Number(val);
@@ -60,8 +150,12 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
// Image upload state
const [imagePreview, setImagePreview] = useState(null);
const [imageUrl, setImageUrl] = useState(null);
+ const [uploadProgress, setUploadProgress] = useState(null);
+ const [uploadError, setUploadError] = useState(null);
const fileInputRef = useRef(null);
+ const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
+
const handleProjectSearch = useCallback((e: React.ChangeEvent) => {
const value = e.target.value;
setProjectQuery(value);
@@ -87,6 +181,17 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
const file = e.target.files?.[0];
if (!file) return;
+ setUploadError(null);
+
+ if (!file.type.startsWith('image/')) {
+ setUploadError('Định dạng không hợp lệ. Vui lòng chọn ảnh JPG hoặc PNG.');
+ return;
+ }
+ if (file.size > MAX_IMAGE_SIZE_BYTES) {
+ setUploadError('Ảnh vượt quá giới hạn 5MB.');
+ return;
+ }
+
// Show local preview
const reader = new FileReader();
reader.onload = (ev) => {
@@ -94,8 +199,25 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
};
reader.readAsDataURL(file);
+ // Simulated upload progress for local preview flow — the valuation
+ // endpoint accepts a public imageUrl, so the real upload is a no-op
+ // here, but users still get feedback for files being processed.
+ setUploadProgress(0);
+ const start = Date.now();
+ const tick = () => {
+ const elapsed = Date.now() - start;
+ const pct = Math.min(100, Math.round((elapsed / 400) * 100));
+ setUploadProgress(pct);
+ if (pct < 100) {
+ requestAnimationFrame(tick);
+ } else {
+ setTimeout(() => setUploadProgress(null), 500);
+ }
+ };
+ requestAnimationFrame(tick);
+
// In production, upload to server and get URL
- // For now we store as data URL for preview purposes
+ // For now we store as object URL for preview purposes
setImageUrl(URL.createObjectURL(file));
},
[],
@@ -104,6 +226,8 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
const handleClearImage = useCallback(() => {
setImagePreview(null);
setImageUrl(null);
+ setUploadProgress(null);
+ setUploadError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
@@ -126,6 +250,23 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
description: data.description || undefined,
deepAnalysis: data.deepAnalysis,
imageUrl: imageUrl || undefined,
+ // v2 fields
+ useV2: data.useV2,
+ distanceToCbdKm: toNum(data.distanceToCbdKm),
+ distanceToMetroKm: toNum(data.distanceToMetroKm),
+ distanceToSchoolKm: toNum(data.distanceToSchoolKm),
+ distanceToHospitalKm: toNum(data.distanceToHospitalKm),
+ distanceToParkKm: toNum(data.distanceToParkKm),
+ distanceToMallKm: toNum(data.distanceToMallKm),
+ floodZoneRisk: toNum(data.floodZoneRisk),
+ hasElevator: data.hasElevator,
+ hasParking: data.hasParking,
+ hasPool: data.hasPool,
+ renovationScore: toNum(data.renovationScore),
+ viewQuality: toNum(data.viewQuality),
+ interiorQuality: toNum(data.interiorQuality),
+ noiseLevel: toNum(data.noiseLevel),
+ naturalLight: toNum(data.naturalLight),
});
};
@@ -354,9 +495,41 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
className="hidden"
onChange={handleImageChange}
/>
-
- Tải ảnh bất động sản để AI phân tích trực quan (JPG, PNG, tối đa 5MB)
-
+
+
+ Tải ảnh bất động sản để AI phân tích trực quan (JPG, PNG, tối đa 5MB)
+
+ {uploadProgress !== null && (
+
+
+
+ {uploadProgress}%
+
+
+ )}
+ {uploadError && (
+
+ {uploadError}
+
+ )}
+
@@ -396,8 +569,101 @@ export function ValuationForm({ onSubmit, isLoading }: ValuationFormProps) {
Phân tích chuyên sâu
+
+
+
+
+
+ {/* ─── v2 Advanced: Infrastructure Proximity ─── */}
+ }
+ description="Khoảng cách đến các tiện ích xung quanh (km)"
+ >
+
+
+
+
+
+
+
+
+
+
+ {/* ─── v2 Advanced: Quality Scores ─── */}
+ }
+ description="Đánh giá chủ quan về chất lượng bất động sản (0-100%)"
+ >
+
+ {(Object.entries(QUALITY_LABELS) as [keyof typeof QUALITY_LABELS, string][]).map(
+ ([field, label]) => (
+
+ ),
+ )}
+
+
+