feat: implement Phase 6 — Airports page redesign

- Search icon visually inside input (pl-11 + absolute positioned icon)
- No search button; debounced search on keystroke, Enter to force-search
- Desktop: data table with IATA mono chip, Copy button → Check + "Copied!" for 2s
- Mobile: compact list view (md:hidden / hidden md:block responsive split)
- Initial EmptyState (Search icon) and no-results EmptyState (MapPin icon)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 15:02:15 +01:00
parent d87bbe5148
commit 9693fa8031

View File

@@ -1,129 +1,190 @@
import { useState } from 'react'; import { useRef, useState } from 'react';
import { Search, MapPin, Copy, Check } from 'lucide-react';
import { airportApi } from '../api'; import { airportApi } from '../api';
import type { Airport } from '../api'; import type { Airport } from '../api';
import EmptyState from '../components/EmptyState';
export default function Airports() { export default function Airports() {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [airports, setAirports] = useState<Airport[]>([]); const [airports, setAirports] = useState<Airport[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [searched, setSearched] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleSearch = async (searchQuery: string, searchPage = 1) => { const doSearch = async (q: string, p = 1) => {
if (searchQuery.length < 2) { if (q.length < 2) {
setAirports([]); setAirports([]);
setSearched(false);
return; return;
} }
try { try {
setLoading(true); setLoading(true);
const response = await airportApi.search(searchQuery, searchPage, 20); const response = await airportApi.search(q, p, 20);
setAirports(response.data.data); setAirports(response.data.data);
setTotalPages(response.data.pagination.pages); setTotalPages(response.data.pagination.pages);
setTotal(response.data.pagination.total); setTotal(response.data.pagination.total);
setPage(searchPage); setPage(p);
} catch (error) { setSearched(true);
console.error('Failed to search airports:', error); } catch (err) {
console.error('Failed to search airports:', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleSubmit = (e: React.FormEvent) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault(); const val = e.target.value;
handleSearch(query, 1); setQuery(val);
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => doSearch(val, 1), 300);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
doSearch(query, 1);
}
};
const copyCode = (code: string) => {
navigator.clipboard.writeText(code);
setCopied(code);
setTimeout(() => setCopied(null), 2000);
}; };
return ( return (
<div> <div className="space-y-5">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Airport Search</h2>
{/* Search Form */} {/* ── Search bar ────────────────────────────────────────────── */}
<div className="bg-white rounded-lg shadow p-6 mb-6"> <div className="relative">
<form onSubmit={handleSubmit}> <Search
<div className="flex space-x-4"> size={20}
<input className="absolute left-3.5 top-1/2 -translate-y-1/2 text-on-surface-variant pointer-events-none"
type="text" aria-hidden="true"
value={query} />
onChange={(e) => setQuery(e.target.value)} <input
placeholder="Search by IATA code, city, or airport name..." type="text"
className="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" value={query}
/> onChange={handleInputChange}
<button onKeyDown={handleKeyDown}
type="submit" placeholder="Search by IATA code, city, or airport name…"
disabled={loading || query.length < 2} className="w-full h-12 pl-11 pr-4 border border-outline rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/20"
className="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md font-medium disabled:opacity-50 disabled:cursor-not-allowed" aria-label="Search airports"
> />
{loading ? 'Searching...' : 'Search'}
</button>
</div>
<p className="mt-2 text-sm text-gray-500">
Enter at least 2 characters to search
</p>
</form>
</div> </div>
{/* Results */} {/* ── Results ───────────────────────────────────────────────── */}
{airports.length > 0 && ( {loading ? (
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="text-sm text-on-surface-variant text-center py-8">Searching</div>
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center"> ) : airports.length > 0 ? (
<h3 className="text-lg font-semibold text-gray-900"> <div className="bg-surface rounded-lg shadow-level-1 overflow-hidden">
Search Results {/* Table header */}
</h3> <div className="px-5 py-3.5 border-b border-outline flex items-center justify-between">
<span className="text-sm text-gray-500"> <span className="text-sm font-semibold text-on-surface">Search Results</span>
{total} airport{total !== 1 ? 's' : ''} found <span className="text-sm text-on-surface-variant">
{total} airport{total !== 1 ? 's' : ''}
</span> </span>
</div> </div>
<div className="divide-y divide-gray-200"> {/* Desktop table (hidden on mobile) */}
{airports.map((airport) => ( <div className="hidden md:block overflow-x-auto">
<div key={airport.iata} className="px-6 py-4 hover:bg-gray-50"> <table className="w-full">
<div className="flex items-center justify-between"> <thead className="bg-surface-2 border-b border-outline">
<div className="flex-1"> <tr>
<div className="flex items-center space-x-3"> <th className="w-20 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">IATA</th>
<span className="font-bold text-lg text-blue-600"> <th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Airport Name</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">City</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Country</th>
<th className="w-16 px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-outline">
{airports.map((airport) => (
<tr key={airport.iata} className="hover:bg-surface-2 transition-colors duration-150">
<td className="px-4 py-3.5">
<span className="font-mono text-sm font-medium text-primary">
{airport.iata} {airport.iata}
</span> </span>
<span className="font-medium text-gray-900"> </td>
{airport.name} <td className="px-4 py-3.5 text-sm text-on-surface">{airport.name}</td>
</span> <td className="px-4 py-3.5 text-sm text-on-surface-variant">{airport.city}</td>
</div> <td className="px-4 py-3.5 text-sm text-on-surface-variant">{airport.country}</td>
<div className="mt-1 text-sm text-gray-500"> <td className="px-4 py-3.5">
{airport.city}, {airport.country} <button
</div> onClick={() => copyCode(airport.iata)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors hover:bg-surface-2"
aria-label={`Copy ${airport.iata}`}
>
{copied === airport.iata ? (
<>
<Check size={14} className="text-secondary" />
<span className="text-secondary">Copied!</span>
</>
) : (
<>
<Copy size={14} className="text-on-surface-variant" />
<span className="text-on-surface-variant">Copy</span>
</>
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile list (hidden on desktop) */}
<div className="md:hidden divide-y divide-outline">
{airports.map((airport) => (
<div key={airport.iata} className="px-4 py-3.5 flex items-center justify-between hover:bg-surface-2 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-medium text-primary shrink-0">
{airport.iata}
</span>
<span className="text-sm text-on-surface truncate">{airport.name}</span>
</div> </div>
<button <p className="mt-0.5 text-xs text-on-surface-variant">
onClick={() => { {airport.city}, {airport.country}
navigator.clipboard.writeText(airport.iata); </p>
}}
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded"
>
Copy Code
</button>
</div> </div>
<button
onClick={() => copyCode(airport.iata)}
className="shrink-0 ml-3 w-8 h-8 flex items-center justify-center rounded-full hover:bg-surface-2 transition-colors"
aria-label={`Copy ${airport.iata}`}
>
{copied === airport.iata
? <Check size={15} className="text-secondary" />
: <Copy size={15} className="text-on-surface-variant" />
}
</button>
</div> </div>
))} ))}
</div> </div>
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 flex justify-between items-center"> <div className="px-5 py-3 border-t border-outline flex items-center justify-between">
<div className="text-sm text-gray-500"> <span className="text-sm text-on-surface-variant">
Page {page} of {totalPages} Page {page} of {totalPages}
</div> </span>
<div className="flex space-x-2"> <div className="flex gap-2">
<button <button
onClick={() => handleSearch(query, page - 1)} onClick={() => doSearch(query, page - 1)}
disabled={page === 1 || loading} disabled={page === 1 || loading}
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50" className="px-3 py-1.5 border border-outline rounded-xs text-sm text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
Previous Previous
</button> </button>
<button <button
onClick={() => handleSearch(query, page + 1)} onClick={() => doSearch(query, page + 1)}
disabled={page === totalPages || loading} disabled={page === totalPages || loading}
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50" className="px-3 py-1.5 border border-outline rounded-xs text-sm text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
Next Next
</button> </button>
@@ -131,14 +192,20 @@ export default function Airports() {
</div> </div>
)} )}
</div> </div>
) : searched ? (
<EmptyState
icon={MapPin}
title={`No airports found for "${query}"`}
description="Try a different IATA code or city name."
/>
) : (
<EmptyState
icon={Search}
title="Search airports"
description="Enter an IATA code, city name, or airport name to search."
/>
)} )}
{/* Empty State */}
{!loading && airports.length === 0 && query.length >= 2 && (
<div className="bg-white rounded-lg shadow p-12 text-center">
<p className="text-gray-500">No airports found for "{query}"</p>
</div>
)}
</div> </div>
); );
} }