feat: implement Phase 4 — Create Scan form redesign

- Three card sections: Origin, Destination, Parameters
- SegmentedButton replaces plain toggle buttons (Globe / PlaneTakeoff icons)
- AirportSearch updated to design tokens; hasError prop for red border state
- AirportChip tags for airports mode
- +/− stepper buttons for Search Window and Passengers (no spin arrows)
- Inline field-level validation errors (replaces browser native popups)
- Toast on success / error; useNavigate replaces window.location.href

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:57:21 +01:00
parent 1021664253
commit 45aa2d9aae
2 changed files with 280 additions and 275 deletions

View File

@@ -1,16 +1,23 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { airportApi } from '../api'; import { airportApi } from '../api';
import type { Airport } from '../api'; import type { Airport } from '../api';
import { cn } from '../lib/utils';
interface AirportSearchProps { interface AirportSearchProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
placeholder?: string; placeholder?: string;
clearAfterSelect?: boolean; clearAfterSelect?: boolean;
required?: boolean; hasError?: boolean;
} }
export default function AirportSearch({ value, onChange, placeholder, clearAfterSelect, required = true }: AirportSearchProps) { export default function AirportSearch({
value,
onChange,
placeholder,
clearAfterSelect,
hasError = false,
}: AirportSearchProps) {
const [query, setQuery] = useState(value); const [query, setQuery] = useState(value);
const [airports, setAirports] = useState<Airport[]>([]); const [airports, setAirports] = useState<Airport[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -23,13 +30,11 @@ export default function AirportSearch({ value, onChange, placeholder, clearAfter
}, [value]); }, [value]);
useEffect(() => { useEffect(() => {
// Close dropdown when clicking outside
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowDropdown(false); setShowDropdown(false);
} }
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
@@ -40,7 +45,6 @@ export default function AirportSearch({ value, onChange, placeholder, clearAfter
setShowDropdown(false); setShowDropdown(false);
return; return;
} }
try { try {
setLoading(true); setLoading(true);
const response = await airportApi.search(searchQuery, 1, 10); const response = await airportApi.search(searchQuery, 1, 10);
@@ -58,15 +62,8 @@ export default function AirportSearch({ value, onChange, placeholder, clearAfter
const newQuery = e.target.value.toUpperCase(); const newQuery = e.target.value.toUpperCase();
setQuery(newQuery); setQuery(newQuery);
onChange(newQuery); onChange(newQuery);
if (debounceTimer.current) clearTimeout(debounceTimer.current);
// Debounce search debounceTimer.current = setTimeout(() => searchAirports(newQuery), 300);
if (debounceTimer.current) {
clearTimeout(debounceTimer.current);
}
debounceTimer.current = setTimeout(() => {
searchAirports(newQuery);
}, 300);
}; };
const handleSelectAirport = (airport: Airport) => { const handleSelectAirport = (airport: Airport) => {
@@ -86,44 +83,37 @@ export default function AirportSearch({ value, onChange, placeholder, clearAfter
type="text" type="text"
value={query} value={query}
onChange={handleInputChange} onChange={handleInputChange}
onFocus={() => { onFocus={() => { if (airports.length > 0) setShowDropdown(true); }}
if (airports.length > 0) {
setShowDropdown(true);
}
}}
maxLength={3} maxLength={3}
required={required} placeholder={placeholder || 'Search airports…'}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className={cn(
placeholder={placeholder || 'Search airports...'} 'w-full h-12 px-3 border rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors',
hasError
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
: 'border-outline focus:border-primary focus:ring-2 focus:ring-primary/20',
)}
/> />
{/* Dropdown */} {showDropdown && (airports.length > 0 || loading) && (
{showDropdown && airports.length > 0 && ( <div className="absolute z-20 w-full mt-1 bg-surface border border-outline rounded-lg shadow-level-2 max-h-60 overflow-y-auto">
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{loading && ( {loading && (
<div className="px-4 py-2 text-sm text-gray-500"> <div className="px-4 py-3 text-sm text-on-surface-variant">Searching</div>
Searching...
</div>
)} )}
{!loading && airports.map((airport) => ( {!loading && airports.map((airport) => (
<div <button
key={airport.iata} key={airport.iata}
type="button"
onClick={() => handleSelectAirport(airport)} onClick={() => handleSelectAirport(airport)}
className="px-4 py-2 hover:bg-gray-100 cursor-pointer" className="w-full px-4 py-2.5 flex items-center justify-between hover:bg-surface-2 text-left transition-colors"
> >
<div className="flex items-center justify-between"> <div className="flex items-center gap-2 min-w-0">
<div> <span className="font-medium text-on-surface text-sm shrink-0">{airport.iata}</span>
<span className="font-medium text-gray-900">{airport.iata}</span> <span className="text-sm text-on-surface-variant truncate">{airport.name}</span>
<span className="ml-2 text-sm text-gray-600">
{airport.name}
</span>
</div>
<span className="text-sm text-gray-500">
{airport.city}, {airport.country}
</span>
</div> </div>
</div> <span className="text-xs text-on-surface-variant shrink-0 ml-2">
{airport.city}, {airport.country}
</span>
</button>
))} ))}
</div> </div>
)} )}

View File

@@ -1,9 +1,24 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Globe, PlaneTakeoff, Minus, Plus } from 'lucide-react';
import { scanApi } from '../api'; import { scanApi } from '../api';
import type { CreateScanRequest } from '../api'; import type { CreateScanRequest } from '../api';
import AirportSearch from '../components/AirportSearch'; import AirportSearch from '../components/AirportSearch';
import SegmentedButton from '../components/SegmentedButton';
import AirportChip from '../components/AirportChip';
import Button from '../components/Button';
import Toast from '../components/Toast';
interface FormErrors {
origin?: string;
country?: string;
airports?: string;
window_months?: string;
adults?: string;
}
export default function Scans() { export default function Scans() {
const navigate = useNavigate();
const [destinationMode, setDestinationMode] = useState<'country' | 'airports'>('country'); const [destinationMode, setDestinationMode] = useState<'country' | 'airports'>('country');
const [formData, setFormData] = useState<CreateScanRequest>({ const [formData, setFormData] = useState<CreateScanRequest>({
origin: '', origin: '',
@@ -14,24 +29,30 @@ export default function Scans() {
}); });
const [selectedAirports, setSelectedAirports] = useState<string[]>([]); const [selectedAirports, setSelectedAirports] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [errors, setErrors] = useState<FormErrors>({});
const [success, setSuccess] = useState<string | null>(null); const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const validate = (): boolean => {
const next: FormErrors = {};
if (!formData.origin || formData.origin.length !== 3) {
next.origin = 'Enter a valid 3-letter IATA code';
}
if (destinationMode === 'country' && (!formData.country || formData.country.length !== 2)) {
next.country = 'Enter a valid 2-letter country code';
}
if (destinationMode === 'airports' && selectedAirports.length === 0) {
next.airports = 'Add at least one destination airport';
}
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); if (!validate()) return;
setSuccess(null);
setLoading(true); setLoading(true);
try { try {
// Validate airports mode has at least one airport selected
if (destinationMode === 'airports' && selectedAirports.length === 0) {
setError('Please add at least one destination airport');
setLoading(false);
return;
}
// Build request based on destination mode
const requestData: any = { const requestData: any = {
origin: formData.origin, origin: formData.origin,
window_months: formData.window_months, window_months: formData.window_months,
@@ -46,254 +67,248 @@ export default function Scans() {
} }
const response = await scanApi.create(requestData); const response = await scanApi.create(requestData);
setSuccess(`Scan created successfully! ID: ${response.data.id}`); setToast({ message: `Scan #${response.data.id} created successfully!`, type: 'success' });
setTimeout(() => navigate('/'), 1500);
// Reset form
setFormData({
origin: '',
country: '',
window_months: 3,
seat_class: 'economy',
adults: 1,
});
setSelectedAirports([]);
// Redirect to dashboard after 2 seconds
setTimeout(() => {
window.location.href = '/';
}, 2000);
} catch (err: any) { } catch (err: any) {
const errorMessage = err.response?.data?.message || 'Failed to create scan'; const msg = err.response?.data?.message || err.response?.data?.detail || 'Failed to create scan';
setError(errorMessage); setToast({ message: typeof msg === 'string' ? msg : JSON.stringify(msg), type: 'error' });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleChange = ( const adjustNumber = (field: 'window_months' | 'adults', delta: number) => {
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> const limits: Record<string, [number, number]> = { window_months: [1, 12], adults: [1, 9] };
) => { const [min, max] = limits[field];
const { name, value } = e.target; setFormData(prev => ({
setFormData((prev) => ({
...prev, ...prev,
[name]: name === 'adults' || name === 'window_months' ? parseInt(value) : value, [field]: Math.min(max, Math.max(min, (prev[field] ?? 1) + delta)),
})); }));
}; };
return ( // Shared input class
<div> const inputCls = (hasError?: boolean) =>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Create New Scan</h2> `w-full h-12 px-3 border rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors ` +
(hasError
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
: 'border-outline focus:border-primary focus:ring-2 focus:ring-primary/20');
return (
<>
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
{/* ── Section: Origin ─────────────────────────────────────── */}
<div className="bg-surface rounded-lg shadow-level-1 p-6">
<p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">
Origin
</p>
<div className="bg-white rounded-lg shadow p-6 max-w-2xl">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Origin Airport */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Origin Airport (IATA Code) Origin Airport
</label> </label>
<AirportSearch <AirportSearch
value={formData.origin} value={formData.origin}
onChange={(value) => setFormData((prev) => ({ ...prev, origin: value }))} onChange={(value) => {
placeholder="e.g., BDS, MUC, FRA" setFormData(prev => ({ ...prev, origin: value }));
if (errors.origin) setErrors(prev => ({ ...prev, origin: undefined }));
}}
placeholder="e.g. BDS, MUC, FRA"
hasError={!!errors.origin}
/> />
<p className="mt-1 text-sm text-gray-500"> {errors.origin ? (
Enter 3-letter IATA code (e.g., BDS for Brindisi) <p className="mt-1 text-xs text-error">{errors.origin}</p>
</p>
</div>
{/* Destination Mode Toggle */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Destination Mode
</label>
<div className="flex space-x-2 mb-4">
<button
type="button"
onClick={() => setDestinationMode('country')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md ${
destinationMode === 'country'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Search by Country
</button>
<button
type="button"
onClick={() => setDestinationMode('airports')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md ${
destinationMode === 'airports'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Search by Airports
</button>
</div>
{/* Country Mode */}
{destinationMode === 'country' ? (
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-700 mb-2">
Destination Country (2-letter code)
</label>
<input
type="text"
id="country"
name="country"
value={formData.country}
onChange={handleChange}
maxLength={2}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., DE, IT, ES"
/>
<p className="mt-1 text-sm text-gray-500">
ISO 2-letter country code (e.g., DE for Germany)
</p>
</div>
) : ( ) : (
/* Airports Mode */ <p className="mt-1 text-xs text-on-surface-variant">3-letter IATA code (e.g. BDS for Brindisi)</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Destination Airports
</label>
<div className="space-y-2">
<AirportSearch
value=""
onChange={(code) => {
if (code && code.length === 3 && !selectedAirports.includes(code)) {
setSelectedAirports([...selectedAirports, code]);
}
}}
clearAfterSelect
required={false}
placeholder="Search and add airports..."
/>
{/* Selected airports list */}
{selectedAirports.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{selectedAirports.map((code) => (
<div
key={code}
className="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
<span className="font-medium">{code}</span>
<button
type="button"
onClick={() => setSelectedAirports(selectedAirports.filter((c) => c !== code))}
className="ml-2 text-blue-600 hover:text-blue-800"
>
×
</button>
</div>
))}
</div>
)}
<p className="text-sm text-gray-500">
{selectedAirports.length === 0
? 'Search and add destination airports (up to 50)'
: `${selectedAirports.length} airport(s) selected`}
</p>
</div>
</div>
)} )}
</div> </div>
</div>
{/* Search Window */} {/* ── Section: Destination ────────────────────────────────── */}
<div> <div className="bg-surface rounded-lg shadow-level-1 p-6">
<label htmlFor="window_months" className="block text-sm font-medium text-gray-700 mb-2"> <p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">
Search Window (months) Destination
</label> </p>
<input
type="number"
id="window_months"
name="window_months"
value={formData.window_months}
onChange={handleChange}
min={1}
max={12}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="mt-1 text-sm text-gray-500">
Number of months to search (1-12)
</p>
</div>
{/* Seat Class */} <SegmentedButton
<div> options={[
<label htmlFor="seat_class" className="block text-sm font-medium text-gray-700 mb-2"> { value: 'country', label: 'By Country', icon: Globe },
Seat Class { value: 'airports', label: 'By Airports', icon: PlaneTakeoff },
</label> ]}
<select value={destinationMode}
id="seat_class" onChange={(v) => {
name="seat_class" setDestinationMode(v as 'country' | 'airports');
value={formData.seat_class} setErrors(prev => ({ ...prev, country: undefined, airports: undefined }));
onChange={handleChange} }}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="mb-4"
> />
<option value="economy">Economy</option>
<option value="premium">Premium Economy</option>
<option value="business">Business</option>
<option value="first">First Class</option>
</select>
</div>
{/* Number of Adults */} {destinationMode === 'country' ? (
<div> <div>
<label htmlFor="adults" className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Number of Adults Destination Country
</label> </label>
<input <input
type="number" type="text"
id="adults" value={formData.country}
name="adults" onChange={(e) => {
value={formData.adults} setFormData(prev => ({ ...prev, country: e.target.value.toUpperCase() }));
onChange={handleChange} if (errors.country) setErrors(prev => ({ ...prev, country: undefined }));
min={1} }}
max={9} maxLength={2}
required placeholder="e.g. DE, IT, ES"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className={inputCls(!!errors.country)}
/> />
<p className="mt-1 text-sm text-gray-500"> {errors.country ? (
Number of adult passengers (1-9) <p className="mt-1 text-xs text-error">{errors.country}</p>
</p> ) : (
</div> <p className="mt-1 text-xs text-on-surface-variant">ISO 2-letter country code (e.g. DE for Germany)</p>
)}
{/* Error Message */} </div>
{error && ( ) : (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"> <div>
{error} <label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Destination Airports
</label>
<AirportSearch
value=""
onChange={(code) => {
if (code && code.length === 3 && !selectedAirports.includes(code)) {
setSelectedAirports(prev => [...prev, code]);
if (errors.airports) setErrors(prev => ({ ...prev, airports: undefined }));
}
}}
clearAfterSelect
placeholder="Search and add airports…"
hasError={!!errors.airports}
/>
{selectedAirports.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{selectedAirports.map((code) => (
<AirportChip
key={code}
code={code}
onRemove={() => setSelectedAirports(prev => prev.filter(c => c !== code))}
/>
))}
</div>
)}
{errors.airports ? (
<p className="mt-1 text-xs text-error">{errors.airports}</p>
) : (
<p className="mt-1 text-xs text-on-surface-variant">
{selectedAirports.length === 0
? 'Search and add destination airports (up to 50)'
: `${selectedAirports.length} airport${selectedAirports.length !== 1 ? 's' : ''} selected`}
</p>
)}
</div> </div>
)} )}
</div>
{/* Success Message */} {/* ── Section: Parameters ─────────────────────────────────── */}
{success && ( <div className="bg-surface rounded-lg shadow-level-1 p-6">
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded"> <p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">
{success} Parameters
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Search Window */}
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Search Window
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustNumber('window_months', -1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Decrease months"
>
<Minus size={14} />
</button>
<div className="flex-1 h-12 flex items-center justify-center border border-outline rounded-xs bg-surface text-on-surface text-sm font-medium">
{formData.window_months} {formData.window_months === 1 ? 'month' : 'months'}
</div>
<button
type="button"
onClick={() => adjustNumber('window_months', 1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Increase months"
>
<Plus size={14} />
</button>
</div>
<p className="mt-1 text-xs text-on-surface-variant">Months to look ahead (112)</p>
</div> </div>
)}
{/* Submit Button */} {/* Seat Class */}
<div className="flex justify-end space-x-3"> <div>
<button <label className="block text-sm font-medium text-on-surface-variant mb-1.5">
type="button" Seat Class
onClick={() => window.location.href = '/'} </label>
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50" <select
> value={formData.seat_class}
Cancel onChange={(e) => setFormData(prev => ({ ...prev, seat_class: e.target.value as 'economy' | 'premium' | 'business' | 'first' }))}
</button> className={inputCls()}
<button >
type="submit" <option value="economy">Economy</option>
disabled={loading} <option value="premium">Premium Economy</option>
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" <option value="business">Business</option>
> <option value="first">First Class</option>
{loading ? 'Creating...' : 'Create Scan'} </select>
</button> </div>
</div> </div>
</form>
</div> {/* Adults — full width below */}
</div> <div className="mt-4">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Passengers
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustNumber('adults', -1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Decrease adults"
>
<Minus size={14} />
</button>
<div className="w-32 h-12 flex items-center justify-center border border-outline rounded-xs bg-surface text-on-surface text-sm font-medium">
{formData.adults} {formData.adults === 1 ? 'adult' : 'adults'}
</div>
<button
type="button"
onClick={() => adjustNumber('adults', 1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Increase adults"
>
<Plus size={14} />
</button>
</div>
</div>
</div>
{/* ── Actions ─────────────────────────────────────────────── */}
<div className="flex justify-end gap-3 pb-4">
<Button variant="outlined" type="button" onClick={() => navigate('/')}>
Cancel
</Button>
<Button variant="filled" type="submit" loading={loading}>
Create Scan
</Button>
</div>
</form>
{/* Toast */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={() => setToast(null)}
/>
)}
</>
); );
} }