TypeScript · 17270 bytes Raw Blame History
1 'use client';
2
3 import { useState, useEffect } from 'react';
4 import dynamic from 'next/dynamic';
5 import { MapPin, Star, Plus, Loader2 } from 'lucide-react';
6 import { QueryClient, QueryClientProvider, useQuery, useMutation } from '@tanstack/react-query';
7 import axios from 'axios';
8
9 // Dynamic import for Leaflet to avoid SSR issues
10 const Map = dynamic(() => import('@/components/Map'), {
11 ssr: false,
12 loading: () => <div className="h-full w-full bg-gray-100 animate-pulse" />
13 });
14
15 const queryClient = new QueryClient();
16 const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
17
18 interface Restaurant {
19 id: number;
20 place_id: string;
21 name: string;
22 address: string;
23 latitude: number;
24 longitude: number;
25 average_rating: number | null;
26 total_ratings: number;
27 }
28
29 interface UserLocation {
30 lat: number;
31 lng: number;
32 }
33
34 function HomePage() {
35 const [userLocation, setUserLocation] = useState<UserLocation | null>(null);
36 const [selectedRestaurant, setSelectedRestaurant] = useState<Restaurant | null>(null);
37 const [showAddRating, setShowAddRating] = useState(false);
38 const [rating, setRating] = useState(5);
39 const [review, setReview] = useState('');
40 const [showSearch, setShowSearch] = useState(false);
41 const [searchResults, setSearchResults] = useState<any[]>([]);
42 const [isSearching, setIsSearching] = useState(false);
43 const [addingPlaceId, setAddingPlaceId] = useState<string | null>(null);
44
45 // Get user's location
46 useEffect(() => {
47 if (navigator.geolocation) {
48 navigator.geolocation.getCurrentPosition(
49 (position) => {
50 setUserLocation({
51 lat: position.coords.latitude,
52 lng: position.coords.longitude
53 });
54 },
55 (error) => {
56 console.error('Error getting location:', error.message || 'Location access denied');
57 // Default to a location (NYC)
58 setUserLocation({ lat: 40.7128, lng: -74.0060 });
59 }
60 );
61 }
62 }, []);
63
64 // Fetch nearby restaurants
65 const { data: restaurants, isLoading, refetch } = useQuery({
66 queryKey: ['restaurants', userLocation],
67 queryFn: async () => {
68 if (!userLocation) return [];
69 const response = await axios.get(`${API_URL}/api/restaurants/nearby`, {
70 params: {
71 lat: userLocation.lat,
72 lng: userLocation.lng
73 }
74 });
75 return response.data;
76 },
77 enabled: !!userLocation
78 });
79
80 // Add rating mutation
81 const addRatingMutation = useMutation({
82 mutationFn: async ({ restaurantId, rating, review }: { restaurantId: number, rating: number, review: string }) => {
83 const response = await axios.post(`${API_URL}/api/restaurants/${restaurantId}/ratings`, {
84 rating,
85 review
86 });
87 return response.data;
88 },
89 onSuccess: () => {
90 refetch();
91 setShowAddRating(false);
92 setRating(5);
93 setReview('');
94 alert('Toast rating added! 🍞');
95 },
96 onError: (error: any) => {
97 alert(error.response?.data?.error || 'Failed to add rating');
98 }
99 });
100
101 // Handle rating from map popup
102 const handleMapRating = async (restaurantId: number, rating: number, review: string) => {
103 try {
104 await axios.post(`${API_URL}/api/restaurants/${restaurantId}/ratings`, {
105 rating,
106 review
107 });
108
109 // Refresh the restaurants list to update ratings
110 await refetch();
111
112 // Close any open panels
113 setSelectedRestaurant(null);
114
115 // Show success (could use a toast notification library here)
116 console.log('Toast rating added! 🍞');
117 } catch (error: any) {
118 alert(error.response?.data?.error || 'Failed to add rating');
119 throw error; // Re-throw so popup knows it failed
120 }
121 };
122
123 // Search for nearby places
124 const searchNearbyPlaces = async () => {
125 if (!userLocation) return;
126
127 setIsSearching(true);
128 try {
129 const response = await axios.get(`${API_URL}/api/search/places`, {
130 params: {
131 lat: userLocation.lat,
132 lng: userLocation.lng,
133 radius: 2000 // 2km radius
134 }
135 });
136
137 // Calculate distances and sort by closest
138 const placesWithDistance = response.data.map((place: any) => {
139 const distance = calculateDistance(
140 userLocation.lat,
141 userLocation.lng,
142 place.latitude,
143 place.longitude
144 );
145 return { ...place, distance };
146 });
147
148 // Sort by distance (closest first)
149 placesWithDistance.sort((a: any, b: any) => a.distance - b.distance);
150
151 setSearchResults(placesWithDistance);
152 setShowSearch(true);
153 } catch (error) {
154 console.error('Error searching places:', error);
155 alert('Failed to search for nearby places');
156 } finally {
157 setIsSearching(false);
158 }
159 };
160
161 // Calculate distance between two points in kilometers
162 const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
163 const R = 6371; // Radius of the Earth in km
164 const dLat = (lat2 - lat1) * Math.PI / 180;
165 const dLon = (lon2 - lon1) * Math.PI / 180;
166 const a =
167 Math.sin(dLat/2) * Math.sin(dLat/2) +
168 Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
169 Math.sin(dLon/2) * Math.sin(dLon/2);
170 const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
171 return R * c;
172 };
173
174 // Add a place from search results
175 const addPlace = async (place: any) => {
176 setAddingPlaceId(place.place_id);
177 try {
178 const response = await axios.post(`${API_URL}/api/restaurants`, {
179 place_id: place.place_id,
180 name: place.name,
181 address: place.address,
182 latitude: place.latitude,
183 longitude: place.longitude
184 });
185
186 // Refresh the restaurants list
187 await refetch();
188
189 // Remove the added place from search results
190 setSearchResults(prev => prev.filter(p => p.place_id !== place.place_id));
191
192 // If no more results, close the panel
193 if (searchResults.length <= 1) {
194 setShowSearch(false);
195 }
196 } catch (error) {
197 console.error('Error adding place:', error);
198 alert('Failed to add place');
199 } finally {
200 setAddingPlaceId(null);
201 }
202 };
203
204 return (
205 <div className="h-screen flex flex-col">
206 {/* Header */}
207 <header className="bg-amber-600 text-white p-4 shadow-lg">
208 <div className="max-w-7xl mx-auto flex items-center justify-between">
209 <div className="flex items-center space-x-2">
210 <span className="text-2xl">🍞</span>
211 <h1 className="text-2xl font-bold">LocalToast</h1>
212 </div>
213 <div className="flex items-center space-x-4">
214 <p className="text-sm hidden sm:block">Find and rate the best toast in town!</p>
215 <button
216 onClick={searchNearbyPlaces}
217 disabled={!userLocation || isSearching}
218 className="bg-amber-700 hover:bg-amber-800 px-4 py-2 rounded-lg text-sm font-medium transition disabled:opacity-50 flex items-center space-x-2"
219 >
220 {isSearching ? (
221 <Loader2 className="w-4 h-4 animate-spin" />
222 ) : (
223 <Plus className="w-4 h-4" />
224 )}
225 <span>find toast</span>
226 </button>
227 </div>
228 </div>
229 </header>
230
231 {/* Main Content */}
232 <div className="flex-1 relative">
233 {userLocation ? (
234 <>
235 <Map
236 center={userLocation}
237 restaurants={restaurants || []}
238 searchResults={showSearch ? searchResults : []}
239 onRestaurantClick={setSelectedRestaurant}
240 onAddPlace={addPlace}
241 onRateRestaurant={handleMapRating}
242 />
243
244 {/* Restaurant Details Panel */}
245 {selectedRestaurant && !showSearch && (
246 <div className="absolute bottom-0 left-0 right-0 bg-white p-6 shadow-xl rounded-t-xl max-h-96 overflow-y-auto z-[1000]">
247 <div className="flex justify-between items-start mb-4">
248 <div>
249 <h2 className="text-xl font-bold text-gray-900">{selectedRestaurant.name}</h2>
250 <p className="text-gray-700">{selectedRestaurant.address}</p>
251 </div>
252 <button
253 onClick={() => setSelectedRestaurant(null)}
254 className="text-gray-700 hover:text-gray-900"
255 >
256
257 </button>
258 </div>
259
260 <div className="flex items-center space-x-4 mb-4">
261 <div className="flex items-center">
262 {selectedRestaurant.average_rating ? (
263 <>
264 <Star className="w-5 h-5 text-yellow-500 fill-current" />
265 <span className="ml-1 font-semibold text-gray-900">
266 {selectedRestaurant.average_rating.toFixed(1)}
267 </span>
268 </>
269 ) : (
270 <span className="text-gray-700">No ratings yet</span>
271 )}
272 </div>
273 <span className="text-gray-700">
274 {selectedRestaurant.total_ratings} {selectedRestaurant.total_ratings === 1 ? 'rating' : 'ratings'}
275 </span>
276 </div>
277
278 {!showAddRating ? (
279 <button
280 onClick={() => setShowAddRating(true)}
281 className="w-full bg-amber-600 text-white py-2 px-4 rounded-lg hover:bg-amber-700 transition"
282 >
283 Rate the Toast! 🍞
284 </button>
285 ) : (
286 <div className="space-y-4">
287 <div>
288 <label className="block text-sm font-medium mb-2">Rating</label>
289 <div className="flex space-x-2">
290 {[1, 2, 3, 4, 5].map((value) => (
291 <button
292 key={value}
293 onClick={() => setRating(value)}
294 className={`p-2 ${rating >= value ? 'text-yellow-500' : 'text-gray-300'}`}
295 >
296 <Star className="w-8 h-8 fill-current" />
297 </button>
298 ))}
299 </div>
300 </div>
301
302 <div>
303 <label className="block text-sm font-medium mb-2">
304 Review (must be about toast!)
305 </label>
306 <textarea
307 value={review}
308 onChange={(e) => setReview(e.target.value)}
309 className="w-full p-2 border rounded-lg"
310 rows={3}
311 placeholder="How was the toast? Crispy? Buttery? Perfect golden brown?"
312 />
313 </div>
314
315 <div className="flex space-x-2">
316 <button
317 onClick={handleAddRating}
318 disabled={addRatingMutation.isPending}
319 className="flex-1 bg-amber-600 text-white py-2 px-4 rounded-lg hover:bg-amber-700 transition disabled:opacity-50"
320 >
321 {addRatingMutation.isPending ? 'Submitting...' : 'Submit Rating'}
322 </button>
323 <button
324 onClick={() => {
325 setShowAddRating(false);
326 setRating(5);
327 setReview('');
328 }}
329 className="flex-1 bg-gray-200 text-gray-700 py-2 px-4 rounded-lg hover:bg-gray-300 transition"
330 >
331 Cancel
332 </button>
333 </div>
334 </div>
335 )}
336 </div>
337 )}
338
339 {/* Search Results Panel */}
340 {showSearch && (
341 <div className="absolute bottom-0 left-0 right-0 bg-white p-6 shadow-xl rounded-t-xl max-h-96 overflow-y-auto z-[1000]">
342 <div className="flex justify-between items-center mb-4">
343 <h2 className="text-2xl font-bold text-black">Nearby Places That Might Serve Toast 🍞</h2>
344 <button
345 onClick={() => setShowSearch(false)}
346 className="text-black hover:bg-gray-100 p-2 rounded-lg transition"
347 aria-label="Close search results"
348 >
349
350 </button>
351 </div>
352
353 <p className="text-base font-semibold text-black mb-4">
354 Found {searchResults.length} places nearby. Cafes, bakeries, and breakfast spots are shown first!
355 </p>
356
357 {searchResults.length === 0 ? (
358 <p className="text-center text-black font-medium py-8">
359 No places found nearby. Try moving the map to a different area.
360 </p>
361 ) : (
362 <div className="space-y-3">
363 {searchResults.map((place) => (
364 <div key={place.place_id} className="border-2 border-gray-300 rounded-lg p-4 hover:bg-amber-50 transition-colors">
365 <div className="flex justify-between items-start gap-4">
366 <div className="flex-1">
367 <h3 className="text-lg font-black text-black">{place.name}</h3>
368 <p className="text-base font-normal text-black mt-1">{place.address}</p>
369 <div className="flex flex-wrap items-center gap-2 mt-2">
370 <span className="text-sm font-bold text-black uppercase bg-gray-100 px-2 py-1 rounded">
371 {place.category}
372 </span>
373 {place.cuisine && (
374 <span className="text-sm font-bold text-black bg-gray-100 px-2 py-1 rounded">
375 {place.cuisine}
376 </span>
377 )}
378 {place.distance && (
379 <span className="text-sm font-black text-black bg-amber-100 px-2 py-1 rounded">
380 📍 {place.distance < 1
381 ? `${Math.round(place.distance * 1000)}m`
382 : `${place.distance.toFixed(1)}km`
383 }
384 </span>
385 )}
386 </div>
387 {place.confidence && (
388 <span className={`inline-block mt-2 px-3 py-1.5 rounded text-sm font-black ${
389 place.confidence === 'high' ? 'bg-green-600 text-white' :
390 place.confidence === 'medium' ? 'bg-yellow-500 text-black' :
391 'bg-gray-600 text-white'
392 }`}>
393 {place.confidence === 'high' ? '🍞 LIKELY HAS TOAST!' :
394 place.confidence === 'medium' ? '🤔 MIGHT HAVE TOAST' :
395 '❓ CHECK MENU'}
396 </span>
397 )}
398 </div>
399 <button
400 onClick={() => addPlace(place)}
401 disabled={addingPlaceId === place.place_id}
402 className={`${
403 addingPlaceId === place.place_id
404 ? 'bg-green-600 text-white'
405 : 'bg-black hover:bg-gray-800 text-white'
406 } px-5 py-2.5 rounded-md text-base font-bold transition shadow-md hover:shadow-lg min-w-[80px] disabled:cursor-not-allowed`}
407 aria-label={`Add ${place.name} to LocalToast`}
408 >
409 {addingPlaceId === place.place_id ? '✓' : 'Add'}
410 </button>
411 </div>
412 </div>
413 ))}
414 </div>
415 )}
416 </div>
417 )}
418 </>
419 ) : (
420 <div className="flex items-center justify-center h-full">
421 <div className="text-center">
422 <Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-amber-600" />
423 <p>Getting your location...</p>
424 </div>
425 </div>
426 )}
427 </div>
428 </div>
429 );
430 }
431
432 export default function Page() {
433 return (
434 <QueryClientProvider client={queryClient}>
435 <HomePage />
436 </QueryClientProvider>
437 );
438 }