feat(T08): Bildretusche via Flux.1-Pro + Preisvergleich + Retouch UI with before/after slider
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user