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:
@@ -1,6 +1,8 @@
|
|||||||
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('');
|
||||||
@@ -9,121 +11,180 @@ export default function Airports() {
|
|||||||
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}
|
||||||
|
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-on-surface-variant pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={handleInputChange}
|
||||||
placeholder="Search by IATA code, city, or airport name..."
|
onKeyDown={handleKeyDown}
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
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"
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
</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) */}
|
||||||
|
<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) => (
|
{airports.map((airport) => (
|
||||||
<div key={airport.iata} className="px-6 py-4 hover:bg-gray-50">
|
<tr key={airport.iata} className="hover:bg-surface-2 transition-colors duration-150">
|
||||||
<div className="flex items-center justify-between">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex-1">
|
<span className="font-mono text-sm font-medium text-primary">
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<span className="font-bold text-lg text-blue-600">
|
|
||||||
{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>
|
||||||
|
<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>
|
||||||
|
<span className="text-sm text-on-surface truncate">{airport.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm text-gray-500">
|
<p className="mt-0.5 text-xs text-on-surface-variant">
|
||||||
{airport.city}, {airport.country}
|
{airport.city}, {airport.country}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => copyCode(airport.iata)}
|
||||||
navigator.clipboard.writeText(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}`}
|
||||||
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded"
|
|
||||||
>
|
>
|
||||||
Copy Code
|
{copied === airport.iata
|
||||||
|
? <Check size={15} className="text-secondary" />
|
||||||
|
: <Copy size={15} className="text-on-surface-variant" />
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user