feat(T08): Bildretusche via Flux.1-Pro + Preisvergleich + Retouch UI with before/after slider
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { RetouchUpload } from '@/components/retouch/RetouchUpload';
|
||||
import { BeforeAfterSlider } from '@/components/retouch/BeforeAfterSlider';
|
||||
import { PriceComparison } from '@/components/retouch/PriceComparison';
|
||||
import { getRetouchResult, type RetouchResultResponse } from '@/lib/retouch';
|
||||
|
||||
export default function RetouchPage() {
|
||||
const [retouchId, setRetouchId] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<RetouchResultResponse | null>(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
|
||||
const handleUploadComplete = useCallback((id: string) => {
|
||||
setRetouchId(id);
|
||||
setResult(null);
|
||||
setPolling(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!retouchId || !polling) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await getRetouchResult(retouchId);
|
||||
if (cancelled) return;
|
||||
setResult(res);
|
||||
if (res.status === 'completed' || res.status === 'failed') {
|
||||
setPolling(false);
|
||||
}
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
// Keep polling on error
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 3000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [retouchId, polling]);
|
||||
|
||||
const buildImageUrl = (path: string | null | undefined): string => {
|
||||
if (!path) return '';
|
||||
const filename = path.split('/').pop();
|
||||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
return `${base.replace('/api/v1', '')}/uploads/${filename}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="retouch-page" className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-text">Bildretusche & Preisvergleich</h1>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-text mb-3">Image Upload</h2>
|
||||
<RetouchUpload onUploadComplete={handleUploadComplete} />
|
||||
</section>
|
||||
|
||||
{result && (
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-text mb-3">Retouch Result</h2>
|
||||
<div data-testid="retouch-result" className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-text-muted">Status:</span>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-sm font-medium ${
|
||||
result.status === 'completed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: result.status === 'failed'
|
||||
? 'bg-error/10 text-error'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}
|
||||
data-testid="retouch-status"
|
||||
>
|
||||
{result.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.error_message && (
|
||||
<div data-testid="retouch-error-message" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{result.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.status === 'completed' && result.retouched_file_path && (
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={buildImageUrl(result.original_file_path)}
|
||||
afterSrc={buildImageUrl(result.retouched_file_path)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{result.status === 'processing' && (
|
||||
<div data-testid="retouch-processing" className="text-center text-text-muted py-8">
|
||||
Retouching in progress... This may take a few moments.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-text mb-3">Price Comparison</h2>
|
||||
<PriceComparison vehicleId={result?.vehicle_id || undefined} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
interface BeforeAfterSliderProps {
|
||||
beforeSrc: string;
|
||||
afterSrc: string;
|
||||
beforeLabel?: string;
|
||||
afterLabel?: string;
|
||||
}
|
||||
|
||||
export function BeforeAfterSlider({
|
||||
beforeSrc,
|
||||
afterSrc,
|
||||
beforeLabel = 'Original',
|
||||
afterLabel = 'Retuschiert',
|
||||
}: BeforeAfterSliderProps) {
|
||||
const [sliderPos, setSliderPos] = useState(50);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percent = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
setSliderPos(percent);
|
||||
},
|
||||
[isDragging]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
if (!isDragging || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.touches[0].clientX - rect.left;
|
||||
const percent = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
setSliderPos(percent);
|
||||
},
|
||||
[isDragging]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', () => setIsDragging(false));
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
document.addEventListener('touchend', () => setIsDragging(false));
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleTouchMove]);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percent = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
setSliderPos(percent);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="before-after-slider"
|
||||
className="relative w-full overflow-hidden rounded-lg select-none cursor-ew-resize"
|
||||
style={{ aspectRatio: '4/3' }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* After image (full, background) */}
|
||||
<img
|
||||
src={afterSrc}
|
||||
alt={afterLabel}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
data-testid="after-image"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Before image (clipped to left of slider) */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ width: `${sliderPos}%` }}
|
||||
>
|
||||
<img
|
||||
src={beforeSrc}
|
||||
alt={beforeLabel}
|
||||
className="absolute inset-0 h-full object-cover"
|
||||
style={{ width: `${containerRef.current?.clientWidth || 100}%` }}
|
||||
data-testid="before-image"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="absolute top-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
{beforeLabel}
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
{afterLabel}
|
||||
</div>
|
||||
|
||||
{/* Slider handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 bg-white shadow-lg"
|
||||
style={{ left: `${sliderPos}%`, transform: 'translateX(-50%)' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
data-testid="slider-handle"
|
||||
>
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7l-5 5 5 5M16 7l5 5-5 5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { comparePrices, type PriceCompareResponse, type ComparableListing } from '@/lib/retouch';
|
||||
|
||||
interface PriceComparisonProps {
|
||||
vehicleId?: string;
|
||||
}
|
||||
|
||||
export function PriceComparison({ vehicleId }: PriceComparisonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<PriceCompareResponse | null>(null);
|
||||
const [inputVehicleId, setInputVehicleId] = useState(vehicleId || '');
|
||||
|
||||
const handleCompare = useCallback(async () => {
|
||||
const vid = inputVehicleId.trim();
|
||||
if (!vid) {
|
||||
setError('Please enter a vehicle ID');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await comparePrices(vid);
|
||||
setData(result);
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Price comparison failed');
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [inputVehicleId]);
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="price-comparison" className="space-y-4">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-text mb-1">
|
||||
Vehicle ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputVehicleId}
|
||||
onChange={(e) => setInputVehicleId(e.target.value)}
|
||||
placeholder="Enter vehicle UUID"
|
||||
className="w-full px-3 py-2 border border-border rounded-lg text-text focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
data-testid="price-compare-vehicle-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCompare}
|
||||
loading={loading}
|
||||
data-testid="price-compare-button"
|
||||
>
|
||||
Compare Prices
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div data-testid="price-compare-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div data-testid="price-compare-loading" className="text-center text-text-muted py-4">
|
||||
Searching comparable listings...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !loading && (
|
||||
<div data-testid="price-compare-results" className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="flex items-center justify-between p-4 bg-primary/5 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-text-muted">Comparable Listings</p>
|
||||
<p className="text-2xl font-bold text-text">{data.listing_count}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-text-muted">Average Price</p>
|
||||
<p className="text-2xl font-bold text-primary">
|
||||
{data.average_price !== null ? formatPrice(data.average_price) : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listings table */}
|
||||
{data.comparable_listings.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border border-border rounded-lg">
|
||||
<thead className="bg-secondary/10">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-text font-medium">Title</th>
|
||||
<th className="px-4 py-2 text-right text-text font-medium">Price</th>
|
||||
<th className="px-4 py-2 text-right text-text font-medium">Mileage</th>
|
||||
<th className="px-4 py-2 text-left text-text font-medium">Location</th>
|
||||
<th className="px-4 py-2 text-left text-text font-medium">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.comparable_listings.map((listing: ComparableListing, idx: number) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className="border-t border-border hover:bg-secondary/5"
|
||||
data-testid={`price-compare-row-${idx}`}
|
||||
>
|
||||
<td className="px-4 py-2 text-text">
|
||||
{listing.url ? (
|
||||
<a
|
||||
href={listing.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{listing.title}
|
||||
</a>
|
||||
) : (
|
||||
listing.title
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-text font-medium">
|
||||
{formatPrice(listing.price)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-text-muted">
|
||||
{listing.mileage_km !== null && listing.mileage_km !== undefined
|
||||
? `${listing.mileage_km.toLocaleString('de-DE')} km`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-text-muted">
|
||||
{listing.location || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-text-muted">
|
||||
{listing.source}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="price-compare-empty" className="text-center text-text-muted py-8">
|
||||
No comparable listings found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { uploadRetouchImage } from '@/lib/retouch';
|
||||
|
||||
interface RetouchUploadProps {
|
||||
vehicleId?: string;
|
||||
onUploadComplete?: (retouchId: string) => void;
|
||||
}
|
||||
|
||||
export function RetouchUpload({ vehicleId, onUploadComplete }: RetouchUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Only image files are allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await uploadRetouchImage(file, vehicleId);
|
||||
setSuccess(`Retouch queued. Result ID: ${result.retouch_id}`);
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete(result.retouch_id);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as { error?: { message?: string } };
|
||||
setError(apiErr?.error?.message || 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[vehicleId, onUploadComplete]
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
},
|
||||
[handleFile]
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="retouch-upload" className="space-y-4">
|
||||
<div
|
||||
data-testid="retouch-dropzone"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
|
||||
${isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
data-testid="retouch-file-input"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-text-muted"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-text font-medium">
|
||||
{isDragging ? 'Drop image here' : 'Drag and drop vehicle image here'}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">or click to browse</p>
|
||||
<p className="text-xs text-text-muted">PNG, JPEG, WebP (max 50 MB)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div data-testid="retouch-uploading" className="text-center text-text-muted">
|
||||
Uploading and queuing retouch...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div data-testid="retouch-upload-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div data-testid="retouch-upload-success" className="p-4 bg-green-50 text-green-700 rounded-lg">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { apiFetch, type PaginatedResponse } from './api';
|
||||
|
||||
export interface RetouchResultResponse {
|
||||
id: string;
|
||||
vehicle_id?: string | null;
|
||||
original_file_path: string;
|
||||
original_file_name: string;
|
||||
mime_type: string;
|
||||
retouched_file_path?: string | null;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
error_message?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface RetouchProcessResponse {
|
||||
message: string;
|
||||
retouch_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ComparableListing {
|
||||
title: string;
|
||||
make: string;
|
||||
model: string;
|
||||
year?: number | null;
|
||||
price: number;
|
||||
mileage_km?: number | null;
|
||||
location?: string | null;
|
||||
url?: string | null;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface PriceCompareResponse {
|
||||
vehicle_id: string;
|
||||
comparable_listings: ComparableListing[];
|
||||
average_price: number | null;
|
||||
listing_count: number;
|
||||
}
|
||||
|
||||
export async function uploadRetouchImage(
|
||||
file: File,
|
||||
vehicleId?: string
|
||||
): Promise<RetouchProcessResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (vehicleId) {
|
||||
formData.append('vehicle_id', vehicleId);
|
||||
}
|
||||
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
|
||||
const response = await fetch(`${API_BASE_URL}/retouch/process`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ error: { code: 'UNKNOWN', message: 'Upload failed' } }));
|
||||
throw err;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getRetouchResult(id: string): Promise<RetouchResultResponse> {
|
||||
return apiFetch<RetouchResultResponse>(`/retouch/results/${id}`);
|
||||
}
|
||||
|
||||
export async function listRetouchResults(
|
||||
vehicleId?: string,
|
||||
page = 1,
|
||||
pageSize = 20
|
||||
): Promise<PaginatedResponse<RetouchResultResponse>> {
|
||||
const params = new URLSearchParams();
|
||||
if (vehicleId) params.set('vehicle_id', vehicleId);
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
return apiFetch<PaginatedResponse<RetouchResultResponse>>(`/retouch/results?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function comparePrices(vehicleId: string): Promise<PriceCompareResponse> {
|
||||
return apiFetch<PriceCompareResponse>('/retouch/price-compare', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ vehicle_id: vehicleId }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { RetouchUpload } from '@/components/retouch/RetouchUpload';
|
||||
import { BeforeAfterSlider } from '@/components/retouch/BeforeAfterSlider';
|
||||
import { PriceComparison } from '@/components/retouch/PriceComparison';
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { store = {}; },
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Mock apiFetch
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
API_BASE_URL: 'http://localhost:8000/api/v1',
|
||||
}));
|
||||
|
||||
const { apiFetch } = await import('@/lib/api');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
describe('RetouchUpload', () => {
|
||||
it('renders drag-and-drop zone', () => {
|
||||
render(<RetouchUpload />);
|
||||
expect(screen.getByTestId('retouch-dropzone')).toBeInTheDocument();
|
||||
expect(screen.getByText('Drag and drop vehicle image here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows file input with image accept', () => {
|
||||
render(<RetouchUpload />);
|
||||
const input = screen.getByTestId('retouch-file-input');
|
||||
expect(input).toHaveAttribute('type', 'file');
|
||||
expect(input).toHaveAttribute('accept', 'image/*');
|
||||
});
|
||||
|
||||
it('shows error for non-image file', async () => {
|
||||
render(<RetouchUpload />);
|
||||
const input = screen.getByTestId('retouch-file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['data'], 'doc.pdf', { type: 'application/pdf' });
|
||||
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retouch-upload-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Only image files are allowed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads image file successfully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: 'Retouch processing queued',
|
||||
retouch_id: 'new-retouch-id',
|
||||
status: 'pending',
|
||||
}),
|
||||
});
|
||||
|
||||
let uploadedId = '';
|
||||
render(<RetouchUpload onUploadComplete={(id) => { uploadedId = id; }} />);
|
||||
const input = screen.getByTestId('retouch-file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['image-data'], 'truck.png', { type: 'image/png' });
|
||||
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retouch-upload-success')).toBeInTheDocument();
|
||||
expect(uploadedId).toBe('new-retouch-id');
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call[0]).toContain('/retouch/process');
|
||||
expect(call[1].method).toBe('POST');
|
||||
});
|
||||
|
||||
it('shows error on upload failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({
|
||||
error: { code: 'INVALID_MIME_TYPE', message: 'Invalid MIME type' },
|
||||
}),
|
||||
});
|
||||
|
||||
render(<RetouchUpload />);
|
||||
const input = screen.getByTestId('retouch-file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['image-data'], 'truck.png', { type: 'image/png' });
|
||||
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('retouch-upload-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid MIME type')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BeforeAfterSlider', () => {
|
||||
it('renders both images', () => {
|
||||
render(
|
||||
<BeforeAfterSlider
|
||||
beforeSrc="https://example.com/before.png"
|
||||
afterSrc="https://example.com/after.png"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('before-after-slider')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('before-image')).toHaveAttribute('src', 'https://example.com/before.png');
|
||||
expect(screen.getByTestId('after-image')).toHaveAttribute('src', 'https://example.com/after.png');
|
||||
});
|
||||
|
||||
it('renders labels', () => {
|
||||
render(
|
||||
<BeforeAfterSlider
|
||||
beforeSrc="https://example.com/before.png"
|
||||
afterSrc="https://example.com/after.png"
|
||||
beforeLabel="Original"
|
||||
afterLabel="Retuschiert"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Original')).toBeInTheDocument();
|
||||
expect(screen.getByText('Retuschiert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders slider handle', () => {
|
||||
render(
|
||||
<BeforeAfterSlider
|
||||
beforeSrc="https://example.com/before.png"
|
||||
afterSrc="https://example.com/after.png"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('slider-handle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PriceComparison', () => {
|
||||
it('renders vehicle ID input and compare button', () => {
|
||||
render(<PriceComparison />);
|
||||
expect(screen.getByTestId('price-compare-vehicle-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('price-compare-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when comparing without vehicle ID', async () => {
|
||||
render(<PriceComparison />);
|
||||
const button = screen.getByTestId('price-compare-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('price-compare-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter a vehicle ID')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches and displays comparable listings', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce({
|
||||
vehicle_id: 'veh-123',
|
||||
comparable_listings: [
|
||||
{
|
||||
title: 'Mercedes Actros 2020',
|
||||
make: 'Mercedes',
|
||||
model: 'Actros',
|
||||
year: 2020,
|
||||
price: 42000,
|
||||
mileage_km: 110000,
|
||||
location: 'Berlin',
|
||||
url: 'https://mobile.de/listing/123',
|
||||
source: 'mobile.de',
|
||||
},
|
||||
{
|
||||
title: 'Mercedes Actros 2020',
|
||||
make: 'Mercedes',
|
||||
model: 'Actros',
|
||||
year: 2020,
|
||||
price: 48000,
|
||||
mileage_km: 95000,
|
||||
location: 'Hamburg',
|
||||
url: 'https://mobile.de/listing/456',
|
||||
source: 'mobile.de',
|
||||
},
|
||||
],
|
||||
average_price: 45000,
|
||||
listing_count: 2,
|
||||
});
|
||||
|
||||
render(<PriceComparison />);
|
||||
const input = screen.getByTestId('price-compare-vehicle-input') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'veh-123' } });
|
||||
fireEvent.click(screen.getByTestId('price-compare-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('price-compare-results')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Mercedes Actros 2020').length).toBe(2);
|
||||
expect(screen.getByTestId('price-compare-row-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('price-compare-row-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no listings found', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValueOnce({
|
||||
vehicle_id: 'veh-456',
|
||||
comparable_listings: [],
|
||||
average_price: null,
|
||||
listing_count: 0,
|
||||
});
|
||||
|
||||
render(<PriceComparison />);
|
||||
const input = screen.getByTestId('price-compare-vehicle-input') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'veh-456' } });
|
||||
fireEvent.click(screen.getByTestId('price-compare-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('price-compare-empty')).toBeInTheDocument();
|
||||
expect(screen.getByText('No comparable listings found.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error on fetch failure', async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValueOnce({
|
||||
error: { code: 'VEHICLE_NOT_FOUND', message: 'Vehicle not found' },
|
||||
});
|
||||
|
||||
render(<PriceComparison />);
|
||||
const input = screen.getByTestId('price-compare-vehicle-input') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'bad-id' } });
|
||||
fireEvent.click(screen.getByTestId('price-compare-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('price-compare-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vehicle not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user