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:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 (1–12)</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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user