Skip to content

Commit e3921d9

Browse files
fix(search): resolve 404 on search endpoint and implement dropdown UI
1 parent bb0da31 commit e3921d9

File tree

5 files changed

+262
-18
lines changed

5 files changed

+262
-18
lines changed

api/search.js

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// api/search.js - Geocoding Search Endpoint
2+
export const config = { runtime: 'edge' };
3+
4+
export default async function handler(request) {
5+
// CORS headers (critical for browser requests)
6+
const headers = {
7+
'Content-Type': 'application/json',
8+
'Access-Control-Allow-Origin': '*',
9+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
10+
'Access-Control-Allow-Headers': 'Content-Type',
11+
'Cache-Control': 'public, max-age=3600', // Cache search results 1 hour
12+
};
13+
14+
// Handle CORS preflight
15+
if (request.method === 'OPTIONS') {
16+
return new Response(null, { headers, status: 204 });
17+
}
18+
19+
try {
20+
const { searchParams } = new URL(request.url);
21+
const query = searchParams.get('q');
22+
const limit = Math.min(parseInt(searchParams.get('limit') || '5'), 10);
23+
24+
// Validate query
25+
if (!query || query.length < 2) {
26+
return new Response(
27+
JSON.stringify({
28+
error: 'Query must be at least 2 characters',
29+
results: _getMockResults(query, limit),
30+
}),
31+
{ status: 400, headers }
32+
);
33+
}
34+
35+
// Get API key
36+
const API_KEY = process.env.WEATHER_API_KEY;
37+
if (!API_KEY) {
38+
console.error('[Search] WEATHER_API_KEY not configured in Vercel');
39+
return new Response(
40+
JSON.stringify({
41+
error: 'Search service unavailable',
42+
fix: 'Set WEATHER_API_KEY in Vercel Dashboard',
43+
results: _getMockResults(query, limit),
44+
}),
45+
{ status: 500, headers }
46+
);
47+
}
48+
49+
// Fetch from OpenWeatherMap Geocoding API
50+
const geoUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent(query)}&limit=${limit}&appid=${API_KEY}`;
51+
52+
const geoResponse = await fetch(geoUrl, {
53+
headers: { 'User-Agent': 'WeatherApp/1.0 (Vercel Edge)' },
54+
});
55+
56+
if (!geoResponse.ok) {
57+
const errorData = await geoResponse.json().catch(() => ({}));
58+
console.error('[Search] Geocoding API error:', errorData);
59+
60+
// Return mock results on API failure (prevents broken UI)
61+
return new Response(
62+
JSON.stringify({
63+
error: `Geocoding service error ${geoResponse.status}`,
64+
results: _getMockResults(query, limit),
65+
}),
66+
{ status: 200, headers }
67+
); // Return 200 with mock data
68+
}
69+
70+
// Process successful response
71+
const locations = await geoResponse.json();
72+
const results = locations.map((loc) => ({
73+
name: loc.name,
74+
state: loc.state || '',
75+
country: loc.country,
76+
lat: loc.lat,
77+
lon: loc.lon,
78+
}));
79+
80+
console.log(`[Search] Found ${results.length} results for "${query}"`);
81+
return new Response(JSON.stringify({ results }), { status: 200, headers });
82+
} catch (error) {
83+
console.error('[Search] Edge Function error:', error);
84+
85+
// ALWAYS return mock results to prevent UI breakage
86+
return new Response(
87+
JSON.stringify({
88+
error: 'Search service temporarily unavailable',
89+
results: _getMockResults('', 5), // Return sample cities
90+
}),
91+
{ status: 200, headers }
92+
);
93+
}
94+
}
95+
96+
// ===== MOCK SEARCH RESULTS (Fallback for API failures) =====
97+
function _getMockResults(query, limit) {
98+
const allLocations = [
99+
{ name: 'Tehran', country: 'IR', lat: 35.6892, lon: 51.389 },
100+
{ name: 'New York', state: 'NY', country: 'US', lat: 40.7128, lon: -74.006 },
101+
{ name: 'London', country: 'GB', lat: 51.5074, lon: -0.1278 },
102+
{ name: 'Tokyo', country: 'JP', lat: 35.6762, lon: 139.6503 },
103+
{ name: 'Berlin', country: 'DE', lat: 52.52, lon: 13.405 },
104+
{ name: 'Paris', country: 'FR', lat: 48.8566, lon: 2.3522 },
105+
{ name: 'Cairo', country: 'EG', lat: 30.0444, lon: 31.2357 },
106+
{ name: 'Sydney', country: 'AU', lat: -33.8688, lon: 151.2093 },
107+
{ name: 'Rio de Janeiro', country: 'BR', lat: -22.9068, lon: -43.1729 },
108+
{ name: 'Mumbai', country: 'IN', lat: 19.076, lon: 72.8777 },
109+
];
110+
111+
if (!query) return allLocations.slice(0, limit);
112+
113+
const lowerQuery = query.toLowerCase();
114+
return allLocations
115+
.filter(
116+
(loc) =>
117+
loc.name.toLowerCase().includes(lowerQuery) ||
118+
loc.country.toLowerCase().includes(lowerQuery) ||
119+
(loc.state && loc.state.toLowerCase().includes(lowerQuery))
120+
)
121+
.slice(0, limit);
122+
}

public/index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ <h1 class="app-title">🌤️ WeatherFlow</h1>
130130
</svg>
131131
</button>
132132

133-
<!-- Search Input -->
133+
<!-- Search Input (in header) -->
134134
<div class="search-container">
135135
<label for="location-search" class="sr-only">Search for a location</label>
136136
<input
@@ -141,6 +141,12 @@ <h1 class="app-title">🌤️ WeatherFlow</h1>
141141
aria-label="Search for a location"
142142
autocomplete="off"
143143
/>
144+
<div
145+
id="search-autocomplete"
146+
class="search-autocomplete"
147+
role="region"
148+
aria-live="polite"
149+
></div>
144150
<button
145151
id="search-clear"
146152
class="btn btn-icon search-clear-btn"

src/css/_components.css

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,3 +637,73 @@
637637
font-weight: bold;
638638
font-size: 2rem;
639639
}
640+
.search-autocomplete {
641+
position: absolute;
642+
top: 100%;
643+
left: 0;
644+
right: 0;
645+
background: var(--color-background);
646+
border: 1px solid var(--color-border);
647+
border-radius: 8px;
648+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
649+
margin-top: 4px;
650+
z-index: 1000;
651+
display: none;
652+
max-height: 300px;
653+
overflow-y: auto;
654+
}
655+
656+
.search-results {
657+
padding: 4px 0;
658+
}
659+
660+
.search-result-item {
661+
display: flex;
662+
align-items: center;
663+
width: 100%;
664+
padding: 10px 16px;
665+
background: none;
666+
border: none;
667+
text-align: left;
668+
cursor: pointer;
669+
color: var(--color-text);
670+
font-family: inherit;
671+
font-size: 1rem;
672+
transition: background-color 0.2s;
673+
}
674+
675+
.search-result-item:hover,
676+
.search-result-item:focus {
677+
background-color: var(--color-border);
678+
outline: none;
679+
}
680+
681+
.search-result-item:focus {
682+
box-shadow: 0 0 0 2px var(--color-primary);
683+
}
684+
685+
.result-icon {
686+
flex-shrink: 0;
687+
width: 20px;
688+
height: 20px;
689+
margin-right: 12px;
690+
color: var(--color-primary);
691+
}
692+
693+
.result-text {
694+
flex: 1;
695+
min-width: 0;
696+
}
697+
698+
.result-name {
699+
font-weight: 500;
700+
margin-bottom: 2px;
701+
}
702+
703+
.result-subtitle {
704+
font-size: 0.85rem;
705+
color: var(--color-text-secondary);
706+
white-space: nowrap;
707+
overflow: hidden;
708+
text-overflow: ellipsis;
709+
}

src/js/core/WeatherService.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,8 @@ ${window.location.origin}/api/weather?lat=35.6892&lon=51.3890&units=metric&type=
696696
limit: '5', // Limit results for performance
697697
});
698698

699-
return `${this.apiBase}/search?${params.toString()}`;
699+
// ✅ CRITICAL FIX: Point to dedicated /api/search endpoint (NOT /api/weather/search)
700+
return `/api/search?${params.toString()}`;
700701
}
701702

702703
/**

src/js/ui/SearchManager.js

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -123,36 +123,81 @@ export class SearchManager {
123123
this.searchInput.classList.remove('search-loading');
124124
}
125125
}
126-
/**
127-
* Show autocomplete dropdown
128-
* @private
129-
* @param {Array} results - Search results
130-
*/
126+
// In SearchManager class, update _showAutocomplete method
131127
_showAutocomplete(results) {
132-
if (!this.autocompleteContainer) return;
128+
if (!this.autocompleteContainer) {
129+
console.warn('[SearchManager] Autocomplete container not found');
130+
return;
131+
}
133132

134-
// Clear previous results
133+
// Clear existing results
135134
this.autocompleteContainer.innerHTML = '';
136135

137-
if (results.length === 0) {
136+
// Hide if no results or empty query
137+
if (!results || results.length === 0) {
138138
this._hideAutocomplete();
139139
return;
140140
}
141141

142-
// Limit results
143-
const limitedResults = results.slice(0, this.MAX_RESULTS);
142+
// Create results list
143+
const resultsList = document.createElement('div');
144+
resultsList.className = 'search-results';
145+
resultsList.setAttribute('role', 'listbox');
146+
resultsList.setAttribute('aria-label', 'Search results');
147+
148+
// Add results
149+
results.forEach((result, index) => {
150+
const item = document.createElement('button');
151+
item.className = 'search-result-item';
152+
item.setAttribute('role', 'option');
153+
item.setAttribute('aria-selected', 'false');
154+
item.setAttribute('data-index', index);
155+
156+
// Build location text (handle missing state)
157+
const locationText = result.state
158+
? `${result.name}, ${result.state}, ${result.country}`
159+
: `${result.name}, ${result.country}`;
160+
161+
item.innerHTML = `
162+
<div class="result-icon">
163+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
164+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
165+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
166+
</svg>
167+
</div>
168+
<div class="result-text">
169+
<div class="result-name">${this._escapeHtml(result.name)}</div>
170+
<div class="result-subtitle">${this._escapeHtml(locationText)}</div>
171+
</div>
172+
`;
173+
174+
item.addEventListener('click', (e) => {
175+
e.preventDefault();
176+
this._selectResult(result);
177+
});
144178

145-
// Create result items
146-
limitedResults.forEach((result, index) => {
147-
const item = this._createAutocompleteItem(result, index);
148-
this.autocompleteContainer.appendChild(item);
179+
resultsList.appendChild(item);
149180
});
150181

151-
// Show container
182+
// Add to container
183+
this.autocompleteContainer.appendChild(resultsList);
152184
this.autocompleteContainer.style.display = 'block';
153-
this.autocompleteContainer.setAttribute('aria-expanded', 'true');
185+
186+
// Add keyboard navigation
187+
this._setupKeyboardNav(results);
188+
189+
console.log(`[SearchManager] Showing ${results.length} autocomplete results`);
154190
}
155191

192+
_escapeHtml(str) {
193+
if (typeof str !== 'string') return '';
194+
return str
195+
.replace(/&/g, '&amp;')
196+
.replace(/</g, '&lt;')
197+
.replace(/>/g, '&gt;')
198+
.replace(/"/g, '&quot;')
199+
.replace(/'/g, '&#039;');
200+
}
156201
/**
157202
* Create autocomplete item element
158203
* @private

0 commit comments

Comments
 (0)