From 45aa2d9aae827050ec17df643b9297f898ba5259 Mon Sep 17 00:00:00 2001 From: domverse Date: Fri, 27 Feb 2026 14:57:21 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Phase=204=20=E2=80=94=20Cre?= =?UTF-8?q?ate=20Scan=20form=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../frontend/src/components/AirportSearch.tsx | 74 ++- .../frontend/src/pages/Scans.tsx | 481 +++++++++--------- 2 files changed, 280 insertions(+), 275 deletions(-) diff --git a/flight-comparator/frontend/src/components/AirportSearch.tsx b/flight-comparator/frontend/src/components/AirportSearch.tsx index 21242d8..a75cbc8 100644 --- a/flight-comparator/frontend/src/components/AirportSearch.tsx +++ b/flight-comparator/frontend/src/components/AirportSearch.tsx @@ -1,16 +1,23 @@ import { useState, useEffect, useRef } from 'react'; import { airportApi } from '../api'; import type { Airport } from '../api'; +import { cn } from '../lib/utils'; interface AirportSearchProps { value: string; onChange: (value: string) => void; placeholder?: string; 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 [airports, setAirports] = useState([]); const [loading, setLoading] = useState(false); @@ -23,13 +30,11 @@ export default function AirportSearch({ value, onChange, placeholder, clearAfter }, [value]); useEffect(() => { - // Close dropdown when clicking outside const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setShowDropdown(false); } }; - document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); @@ -40,7 +45,6 @@ export default function AirportSearch({ value, onChange, placeholder, clearAfter setShowDropdown(false); return; } - try { setLoading(true); 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(); setQuery(newQuery); onChange(newQuery); - - // Debounce search - if (debounceTimer.current) { - clearTimeout(debounceTimer.current); - } - - debounceTimer.current = setTimeout(() => { - searchAirports(newQuery); - }, 300); + if (debounceTimer.current) clearTimeout(debounceTimer.current); + debounceTimer.current = setTimeout(() => searchAirports(newQuery), 300); }; const handleSelectAirport = (airport: Airport) => { @@ -86,44 +83,37 @@ export default function AirportSearch({ value, onChange, placeholder, clearAfter type="text" value={query} onChange={handleInputChange} - onFocus={() => { - if (airports.length > 0) { - setShowDropdown(true); - } - }} + onFocus={() => { if (airports.length > 0) setShowDropdown(true); }} maxLength={3} - required={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={placeholder || 'Search airports...'} + placeholder={placeholder || 'Search airports…'} + className={cn( + '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 && ( -
+ {showDropdown && (airports.length > 0 || loading) && ( +
{loading && ( -
- Searching... -
+
Searching…
)} - {!loading && airports.map((airport) => ( -
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" > -
-
- {airport.iata} - - {airport.name} - -
- - {airport.city}, {airport.country} - +
+ {airport.iata} + {airport.name}
-
+ + {airport.city}, {airport.country} + + ))}
)} diff --git a/flight-comparator/frontend/src/pages/Scans.tsx b/flight-comparator/frontend/src/pages/Scans.tsx index efec05a..5a75807 100644 --- a/flight-comparator/frontend/src/pages/Scans.tsx +++ b/flight-comparator/frontend/src/pages/Scans.tsx @@ -1,9 +1,24 @@ import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Globe, PlaneTakeoff, Minus, Plus } from 'lucide-react'; import { scanApi } from '../api'; import type { CreateScanRequest } from '../api'; 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() { + const navigate = useNavigate(); const [destinationMode, setDestinationMode] = useState<'country' | 'airports'>('country'); const [formData, setFormData] = useState({ origin: '', @@ -14,24 +29,30 @@ export default function Scans() { }); const [selectedAirports, setSelectedAirports] = useState([]); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); + const [errors, setErrors] = useState({}); + 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) => { e.preventDefault(); - setError(null); - setSuccess(null); + if (!validate()) return; setLoading(true); 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 = { origin: formData.origin, window_months: formData.window_months, @@ -46,254 +67,248 @@ export default function Scans() { } const response = await scanApi.create(requestData); - setSuccess(`Scan created successfully! ID: ${response.data.id}`); - - // 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); + setToast({ message: `Scan #${response.data.id} created successfully!`, type: 'success' }); + setTimeout(() => navigate('/'), 1500); } catch (err: any) { - const errorMessage = err.response?.data?.message || 'Failed to create scan'; - setError(errorMessage); + const msg = err.response?.data?.message || err.response?.data?.detail || 'Failed to create scan'; + setToast({ message: typeof msg === 'string' ? msg : JSON.stringify(msg), type: 'error' }); } finally { setLoading(false); } }; - const handleChange = ( - e: React.ChangeEvent - ) => { - const { name, value } = e.target; - setFormData((prev) => ({ + const adjustNumber = (field: 'window_months' | 'adults', delta: number) => { + const limits: Record = { window_months: [1, 12], adults: [1, 9] }; + const [min, max] = limits[field]; + setFormData(prev => ({ ...prev, - [name]: name === 'adults' || name === 'window_months' ? parseInt(value) : value, + [field]: Math.min(max, Math.max(min, (prev[field] ?? 1) + delta)), })); }; - return ( -
-

Create New Scan

+ // Shared input class + const inputCls = (hasError?: boolean) => + `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 ( + <> +
+ + {/* ── Section: Origin ─────────────────────────────────────── */} +
+

+ Origin +

-
- - {/* Origin Airport */}
-
- - {/* Destination Mode Toggle */} -
- -
- - -
- - {/* Country Mode */} - {destinationMode === 'country' ? ( -
- - -

- ISO 2-letter country code (e.g., DE for Germany) -

-
+ {errors.origin ? ( +

{errors.origin}

) : ( - /* Airports Mode */ -
- -
- { - 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 && ( -
- {selectedAirports.map((code) => ( -
- {code} - -
- ))} -
- )} -

- {selectedAirports.length === 0 - ? 'Search and add destination airports (up to 50)' - : `${selectedAirports.length} airport(s) selected`} -

-
-
+

3-letter IATA code (e.g. BDS for Brindisi)

)}
+
- {/* Search Window */} -
- - -

- Number of months to search (1-12) -

-
+ {/* ── Section: Destination ────────────────────────────────── */} +
+

+ Destination +

- {/* Seat Class */} -
- - -
+ { + setDestinationMode(v as 'country' | 'airports'); + setErrors(prev => ({ ...prev, country: undefined, airports: undefined })); + }} + className="mb-4" + /> - {/* Number of Adults */} -
- - -

- Number of adult passengers (1-9) -

-
- - {/* Error Message */} - {error && ( -
- {error} + {destinationMode === 'country' ? ( +
+ + { + setFormData(prev => ({ ...prev, country: e.target.value.toUpperCase() })); + if (errors.country) setErrors(prev => ({ ...prev, country: undefined })); + }} + maxLength={2} + placeholder="e.g. DE, IT, ES" + className={inputCls(!!errors.country)} + /> + {errors.country ? ( +

{errors.country}

+ ) : ( +

ISO 2-letter country code (e.g. DE for Germany)

+ )} +
+ ) : ( +
+ + { + 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 && ( +
+ {selectedAirports.map((code) => ( + setSelectedAirports(prev => prev.filter(c => c !== code))} + /> + ))} +
+ )} + {errors.airports ? ( +

{errors.airports}

+ ) : ( +

+ {selectedAirports.length === 0 + ? 'Search and add destination airports (up to 50)' + : `${selectedAirports.length} airport${selectedAirports.length !== 1 ? 's' : ''} selected`} +

+ )}
)} +
- {/* Success Message */} - {success && ( -
- {success} + {/* ── Section: Parameters ─────────────────────────────────── */} +
+

+ Parameters +

+ +
+ {/* Search Window */} +
+ +
+ +
+ {formData.window_months} {formData.window_months === 1 ? 'month' : 'months'} +
+ +
+

Months to look ahead (1–12)

- )} - {/* Submit Button */} -
- - + {/* Seat Class */} +
+ + +
- -
-
+ + {/* Adults — full width below */} +
+ +
+ +
+ {formData.adults} {formData.adults === 1 ? 'adult' : 'adults'} +
+ +
+
+
+ + {/* ── Actions ─────────────────────────────────────────────── */} +
+ + +
+ + + + {/* Toast */} + {toast && ( + setToast(null)} + /> + )} + ); }