Files
goodgo-platform/apps/web/components/valuation/valuation-form.tsx
Ho Ngoc Hai 4ee01294a9 feat(web): image upload progress + validation on AVM valuation form
- Add upload progress bar (role=progressbar) with aria labels and size/MIME
  validation before accepting the image preview.
- Surface validation errors inline (role=alert, data-testid=image-upload-error).
- Keeps the existing v2 field wiring (distances, amenities, quality scores,
  useV2 toggle, flood-risk select, collapsible sections) that drives the
  new AVM v2 result card.

Refs: TEC-2736

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-18 00:36:02 +07:00

675 lines
24 KiB
TypeScript

'use client';
import { zodResolver } from '@hookform/resolvers/zod';
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';
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 { useProjectSearch } from '@/lib/hooks/use-valuation';
import {
valuationFormSchema,
type ValuationFormData,
VALUATION_PROPERTY_TYPES,
CITIES,
FLOOD_RISK_OPTIONS,
QUALITY_LABELS,
} from '@/lib/validations/valuation';
import type { ValuationRequest } from '@/lib/valuation-api';
interface ValuationFormProps {
onSubmit: (data: ValuationRequest) => void;
isLoading?: boolean;
}
function CollapsibleSection({
title,
icon,
description,
children,
}: {
title: string;
icon: React.ReactNode;
description: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<div className="rounded-lg border">
<button
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-muted/50"
onClick={() => setOpen(!open)}
>
<div className="flex items-center gap-2">
{icon}
<div>
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && <div className="border-t px-4 pb-4 pt-4">{children}</div>}
</div>
);
}
function QualitySlider({
id,
label,
register,
isInverted,
}: {
id: string;
label: string;
register: ReturnType<typeof useForm<ValuationFormData>>['register'];
isInverted?: boolean;
}) {
const [value, setValue] = useState(50);
const displayValue = Math.round(value * 100) / 100;
const normalizedForApi = value / 100;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
<span className="text-xs font-medium text-muted-foreground">{displayValue}%</span>
</div>
<input
type="range"
id={id}
min="0"
max="100"
step="5"
value={value}
onChange={(e) => 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`}
/>
<input
type="hidden"
{...register(id as keyof ValuationFormData)}
value={normalizedForApi}
/>
</div>
);
}
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,
setValue,
formState: { errors },
} = useForm<ValuationFormData>({
resolver: zodResolver(valuationFormSchema),
defaultValues: {
city: 'Ho Chi Minh',
hasLegalPaper: true,
deepAnalysis: false,
},
});
// Project autocomplete state
const [projectQuery, setProjectQuery] = useState('');
const [projectName, setProjectName] = useState('');
const [showProjectDropdown, setShowProjectDropdown] = useState(false);
const { data: projectResults } = useProjectSearch(projectQuery);
const projectInputRef = useRef<HTMLInputElement>(null);
// Image upload state
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const handleProjectSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setProjectQuery(value);
setProjectName(value);
setShowProjectDropdown(value.length >= 2);
if (!value) {
setValue('projectId', '');
}
}, [setValue]);
const handleSelectProject = useCallback(
(id: string, name: string) => {
setValue('projectId', id);
setProjectName(name);
setProjectQuery('');
setShowProjectDropdown(false);
},
[setValue],
);
const handleImageChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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) => {
setImagePreview(ev.target?.result as string);
};
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 object URL for preview purposes
setImageUrl(URL.createObjectURL(file));
},
[],
);
const handleClearImage = useCallback(() => {
setImagePreview(null);
setImageUrl(null);
setUploadProgress(null);
setUploadError(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, []);
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,
projectId: data.projectId || undefined,
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),
});
};
return (
<Card>
<CardHeader>
<CardTitle>Đnh giá bất đng sản</CardTitle>
<CardDescription>
Nhập thông tin bất đng sản đ nhận ưc tính giá từ AI
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Project selector (autocomplete) */}
<div className="space-y-2">
<Label htmlFor="projectSearch">Dự án (tùy chọn)</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="projectSearch"
ref={projectInputRef}
value={projectName}
onChange={handleProjectSearch}
onFocus={() => projectQuery.length >= 2 && setShowProjectDropdown(true)}
onBlur={() => setTimeout(() => setShowProjectDropdown(false), 200)}
placeholder="Tìm kiếm dự án..."
className="pl-9"
/>
{projectName && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => {
setProjectName('');
setProjectQuery('');
setValue('projectId', '');
}}
>
<X className="h-4 w-4" />
</button>
)}
{showProjectDropdown && projectResults?.data && projectResults.data.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-48 overflow-auto rounded-md border bg-popover p-1 shadow-md">
{projectResults.data.map((project) => (
<button
key={project.id}
type="button"
className="flex w-full items-start gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent"
onClick={() => handleSelectProject(project.id, project.name)}
>
<div>
<p className="font-medium">{project.name}</p>
<p className="text-xs text-muted-foreground">
{project.district}, {project.city}
</p>
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Row 1: Property type + City */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="propertyType">Loại bất đng sản *</Label>
<Select id="propertyType" {...register('propertyType')}>
<option value="">-- Chọn loại --</option>
{VALUATION_PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
{errors.propertyType && (
<p className="text-sm text-destructive">{errors.propertyType.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="city">Tỉnh/Thành phố *</Label>
<Select id="city" {...register('city')}>
{CITIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</Select>
{errors.city && (
<p className="text-sm text-destructive">{errors.city.message}</p>
)}
</div>
</div>
{/* Row 2: District + Area */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="district">Quận/Huyện *</Label>
<Input
id="district"
placeholder="VD: Quận 1, Bình Thạnh..."
{...register('district')}
/>
{errors.district && (
<p className="text-sm text-destructive">{errors.district.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="area">Diện tích (m²) *</Label>
<Input
id="area"
type="number"
step="0.1"
placeholder="VD: 80"
{...register('area')}
/>
{errors.area && (
<p className="text-sm text-destructive">{errors.area.message}</p>
)}
</div>
</div>
{/* Row 3: Bedrooms + Bathrooms + Floors */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="bedrooms">Phòng ngủ</Label>
<Input
id="bedrooms"
type="number"
placeholder="VD: 3"
{...register('bedrooms')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bathrooms">Phòng tắm</Label>
<Input
id="bathrooms"
type="number"
placeholder="VD: 2"
{...register('bathrooms')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="floors">Số tầng</Label>
<Input
id="floors"
type="number"
placeholder="VD: 4"
{...register('floors')}
/>
</div>
</div>
{/* Row 4: Frontage + Road Width + Year Built */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="frontage">Mặt tiền (m)</Label>
<Input
id="frontage"
type="number"
step="0.1"
placeholder="VD: 5"
{...register('frontage')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="roadWidth">Đ rộng đưng (m)</Label>
<Input
id="roadWidth"
type="number"
step="0.1"
placeholder="VD: 8"
{...register('roadWidth')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="yearBuilt">Năm xây dựng</Label>
<Input
id="yearBuilt"
type="number"
placeholder="VD: 2020"
{...register('yearBuilt')}
/>
</div>
</div>
{/* Image upload */}
<div className="space-y-2">
<Label>Hình nh (tùy chọn)</Label>
<div className="flex items-start gap-4">
{imagePreview ? (
<div className="relative h-24 w-24 overflow-hidden rounded-lg border">
<img
src={imagePreview}
alt="Ảnh bất động sản"
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={handleClearImage}
className="absolute right-1 top-1 rounded-full bg-background/80 p-0.5 hover:bg-background"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex h-24 w-24 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-muted-foreground/25 text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground"
>
<ImagePlus className="h-6 w-6" />
<span className="text-xs">Tải nh</span>
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageChange}
/>
<div className="flex-1 space-y-1.5">
<p className="text-xs text-muted-foreground">
Tải nh bất đng sản đ AI phân tích trực quan (JPG, PNG, tối đa 5MB)
</p>
{uploadProgress !== null && (
<div
className="flex items-center gap-2"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={uploadProgress}
aria-label="Tiến trình tải ảnh"
data-testid="image-upload-progress"
>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<span className="text-xs font-medium text-muted-foreground">
{uploadProgress}%
</span>
</div>
)}
{uploadError && (
<p
role="alert"
data-testid="image-upload-error"
className="text-xs text-destructive"
>
{uploadError}
</p>
)}
</div>
</div>
</div>
{/* Description textarea */}
<div className="space-y-2">
<Label htmlFor="description"> tả thêm (tùy chọn)</Label>
<textarea
id="description"
{...register('description')}
rows={3}
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Mô tả chi tiết về bất động sản: vị trí, hướng, tình trạng, tiện ích lân cận..."
/>
</div>
{/* Toggles row */}
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<input
id="hasLegalPaper"
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register('hasLegalPaper')}
/>
<Label htmlFor="hasLegalPaper"> sổ đ/giấy tờ hợp pháp</Label>
</div>
<div className="flex items-center gap-2">
<input
id="deepAnalysis"
type="checkbox"
className="h-4 w-4 rounded border-input accent-primary"
{...register('deepAnalysis')}
/>
<Label htmlFor="deepAnalysis" className="flex items-center gap-1.5">
<Bot className="h-4 w-4 text-primary" />
Phân tích chuyên sâu
</Label>
</div>
<div className="flex items-center gap-2">
<input
id="useV2"
type="checkbox"
className="h-4 w-4 rounded border-input accent-primary"
{...register('useV2')}
/>
<Label htmlFor="useV2" className="flex items-center gap-1.5">
<Sparkles className="h-4 w-4 text-primary" />
hình v2 (Ensemble)
</Label>
</div>
</div>
{/* ─── v2 Advanced: Infrastructure Proximity ─── */}
<CollapsibleSection
title="Khoảng cách hạ tầng"
icon={<MapPin className="h-4 w-4" />}
description="Khoảng cách đến các tiện ích xung quanh (km)"
>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="distanceToCbdKm">Đến trung tâm (km)</Label>
<Input id="distanceToCbdKm" type="number" step="0.1" placeholder="VD: 5" {...register('distanceToCbdKm')} />
</div>
<div className="space-y-2">
<Label htmlFor="distanceToMetroKm">Đến metro (km)</Label>
<Input id="distanceToMetroKm" type="number" step="0.1" placeholder="VD: 1.5" {...register('distanceToMetroKm')} />
</div>
<div className="space-y-2">
<Label htmlFor="distanceToSchoolKm">Đến trường học (km)</Label>
<Input id="distanceToSchoolKm" type="number" step="0.1" placeholder="VD: 0.5" {...register('distanceToSchoolKm')} />
</div>
<div className="space-y-2">
<Label htmlFor="distanceToHospitalKm">Đến bệnh viện (km)</Label>
<Input id="distanceToHospitalKm" type="number" step="0.1" placeholder="VD: 2" {...register('distanceToHospitalKm')} />
</div>
<div className="space-y-2">
<Label htmlFor="distanceToParkKm">Đến công viên (km)</Label>
<Input id="distanceToParkKm" type="number" step="0.1" placeholder="VD: 0.3" {...register('distanceToParkKm')} />
</div>
<div className="space-y-2">
<Label htmlFor="distanceToMallKm">Đến TTTM (km)</Label>
<Input id="distanceToMallKm" type="number" step="0.1" placeholder="VD: 1" {...register('distanceToMallKm')} />
</div>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="floodZoneRisk">Nguy ngập</Label>
<Select id="floodZoneRisk" {...register('floodZoneRisk')}>
<option value="">-- Không --</option>
{FLOOD_RISK_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</Select>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<input id="hasElevator" type="checkbox" className="h-4 w-4 rounded border-input" {...register('hasElevator')} />
<Label htmlFor="hasElevator">Thang máy</Label>
</div>
<div className="flex items-center gap-2">
<input id="hasParking" type="checkbox" className="h-4 w-4 rounded border-input" {...register('hasParking')} />
<Label htmlFor="hasParking">Bãi đu xe</Label>
</div>
<div className="flex items-center gap-2">
<input id="hasPool" type="checkbox" className="h-4 w-4 rounded border-input" {...register('hasPool')} />
<Label htmlFor="hasPool">Hồ bơi</Label>
</div>
</div>
</CollapsibleSection>
{/* ─── v2 Advanced: Quality Scores ─── */}
<CollapsibleSection
title="Đánh giá chất lượng"
icon={<Star className="h-4 w-4" />}
description="Đánh giá chủ quan về chất lượng bất động sản (0-100%)"
>
<div className="space-y-5">
{(Object.entries(QUALITY_LABELS) as [keyof typeof QUALITY_LABELS, string][]).map(
([field, label]) => (
<QualitySlider
key={field}
id={field}
label={label}
register={register}
isInverted={field === 'noiseLevel'}
/>
),
)}
</div>
</CollapsibleSection>
<Button type="submit" disabled={isLoading} className="w-full sm:w-auto">
{isLoading ? 'Đang định giá...' : 'Định giá ngay'}
</Button>
</form>
</CardContent>
</Card>
);
}