feat(T08): Bildretusche via Flux.1-Pro + Preisvergleich + Retouch UI with before/after slider

This commit is contained in:
2026-07-17 09:31:28 +02:00
parent 5cc9f8e9a9
commit f1d6de0e47
16 changed files with 2278 additions and 30 deletions
@@ -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>
);
}