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 type { Airport } from '../api';
import EmptyState from '../components/EmptyState';
export default function Airports() {
const [query, setQuery] = useState('');
const [query, setQuery] = useState('');
const [airports, setAirports] = useState<Airport[]>([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
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) => {
if (searchQuery.length < 2) {
const doSearch = async (q: string, p = 1) => {
if (q.length < 2) {
setAirports([]);
setSearched(false);
return;
}
try {
setLoading(true);
const response = await airportApi.search(searchQuery, searchPage, 20);
const response = await airportApi.search(q, p, 20);
setAirports(response.data.data);
setTotalPages(response.data.pagination.pages);
setTotal(response.data.pagination.total);
setPage(searchPage);
} catch (error) {
console.error('Failed to search airports:', error);
setPage(p);
setSearched(true);
} catch (err) {
console.error('Failed to search airports:', err);
} finally {
setLoading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
handleSearch(query, 1);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
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 (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Airport Search</h2>
<div className="space-y-5">
{/* Search Form */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<form onSubmit={handleSubmit}>
<div className="flex space-x-4">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by IATA code, city, or airport name..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={loading || query.length < 2}
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"
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
<p className="mt-2 text-sm text-gray-500">
Enter at least 2 characters to search
</p>
</form>
{/* ── Search bar ────────────────────────────────────────────── */}
<div className="relative">
<Search
size={20}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-on-surface-variant pointer-events-none"
aria-hidden="true"
/>
<input
type="text"
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Search by IATA code, city, or airport name…"
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"
aria-label="Search airports"
/>
</div>
{/* Results */}
{airports.length > 0 && (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900">
Search Results
</h3>
<span className="text-sm text-gray-500">
{total} airport{total !== 1 ? 's' : ''} found
{/* ── Results ───────────────────────────────────────────────── */}
{loading ? (
<div className="text-sm text-on-surface-variant text-center py-8">Searching</div>
) : airports.length > 0 ? (
<div className="bg-surface rounded-lg shadow-level-1 overflow-hidden">
{/* Table header */}
<div className="px-5 py-3.5 border-b border-outline flex items-center justify-between">
<span className="text-sm font-semibold text-on-surface">Search Results</span>
<span className="text-sm text-on-surface-variant">
{total} airport{total !== 1 ? 's' : ''}
</span>
</div>
<div className="divide-y divide-gray-200">
{airports.map((airport) => (
<div key={airport.iata} className="px-6 py-4 hover:bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<span className="font-bold text-lg text-blue-600">
{/* Desktop table (hidden on mobile) */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-2 border-b border-outline">
<tr>
<th className="w-20 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">IATA</th>
<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}
</span>
<span className="font-medium text-gray-900">
{airport.name}
</span>
</div>
<div className="mt-1 text-sm text-gray-500">
{airport.city}, {airport.country}
</div>
</td>
<td className="px-4 py-3.5 text-sm text-on-surface">{airport.name}</td>
<td className="px-4 py-3.5 text-sm text-on-surface-variant">{airport.city}</td>
<td className="px-4 py-3.5 text-sm text-on-surface-variant">{airport.country}</td>
<td className="px-4 py-3.5">
<button
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>
<button
onClick={() => {
navigator.clipboard.writeText(airport.iata);
}}
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded"
>
Copy Code
</button>
<p className="mt-0.5 text-xs text-on-surface-variant">
{airport.city}, {airport.country}
</p>
</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>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-gray-200 flex justify-between items-center">
<div className="text-sm text-gray-500">
<div className="px-5 py-3 border-t border-outline flex items-center justify-between">
<span className="text-sm text-on-surface-variant">
Page {page} of {totalPages}
</div>
<div className="flex space-x-2">
</span>
<div className="flex gap-2">
<button
onClick={() => handleSearch(query, page - 1)}
onClick={() => doSearch(query, page - 1)}
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
</button>
<button
onClick={() => handleSearch(query, page + 1)}
onClick={() => doSearch(query, page + 1)}
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
</button>
@@ -131,14 +192,20 @@ export default function Airports() {
</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>
);
}