| 1 |
'use client'; |
| 2 |
|
| 3 |
import { useState, useEffect } from 'react'; |
| 4 |
import dynamic from 'next/dynamic'; |
| 5 |
import { MapPin, Plus, Loader2 } from 'lucide-react'; |
| 6 |
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; |
| 7 |
import { Providers } from './providers'; |
| 8 |
import { Loading } from '@/components/Loading'; |
| 9 |
import ReviewModal from '@/components/ReviewModal'; |
| 10 |
import SearchPanel from '@/components/SearchPanel'; |
| 11 |
import { restaurantApi, Restaurant, CreateRatingData, PlaceSearchResult } from '@/lib/api'; |
| 12 |
|
| 13 |
// Dynamic import for Map to avoid SSR issues |
| 14 |
const Map = dynamic(() => import('@/components/Map'), { |
| 15 |
ssr: false, |
| 16 |
loading: () => <div className="h-full w-full bg-gray-100 animate-pulse" />, |
| 17 |
}); |
| 18 |
|
| 19 |
function HomePage() { |
| 20 |
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null); |
| 21 |
const [selectedRestaurant, setSelectedRestaurant] = useState<Restaurant | null>(null); |
| 22 |
const [showSearchResults, setShowSearchResults] = useState(false); |
| 23 |
const [searchResultsMinimized, setSearchResultsMinimized] = useState(false); |
| 24 |
const [searchResults, setSearchResults] = useState<PlaceSearchResult[]>([]); |
| 25 |
const [restaurants, setRestaurants] = useState<Restaurant[]>([]); |
| 26 |
const queryClient = useQueryClient(); |
| 27 |
|
| 28 |
// Get user's location |
| 29 |
useEffect(() => { |
| 30 |
if (navigator.geolocation) { |
| 31 |
navigator.geolocation.getCurrentPosition( |
| 32 |
(position) => { |
| 33 |
setUserLocation({ |
| 34 |
lat: position.coords.latitude, |
| 35 |
lng: position.coords.longitude |
| 36 |
}); |
| 37 |
}, |
| 38 |
(error) => { |
| 39 |
console.error('Error getting location:', error); |
| 40 |
// Default to NYC |
| 41 |
setUserLocation({ lat: 40.7128, lng: -74.0060 }); |
| 42 |
} |
| 43 |
); |
| 44 |
} else { |
| 45 |
setUserLocation({ lat: 40.7128, lng: -74.0060 }); |
| 46 |
} |
| 47 |
}, []); |
| 48 |
|
| 49 |
// Add rating mutation |
| 50 |
const addRatingMutation = useMutation({ |
| 51 |
mutationFn: ({ restaurantId, data }: { restaurantId: number; data: CreateRatingData }) => |
| 52 |
restaurantApi.addRating(restaurantId, data), |
| 53 |
onSuccess: async () => { |
| 54 |
// Refresh restaurants list |
| 55 |
if (userLocation) { |
| 56 |
const updatedRestaurants = await restaurantApi.getNearby(userLocation.lat, userLocation.lng); |
| 57 |
setRestaurants(updatedRestaurants); |
| 58 |
} |
| 59 |
alert('Toast rating added! 🍞'); |
| 60 |
}, |
| 61 |
onError: (error: any) => { |
| 62 |
alert(error.response?.data?.error || 'Failed to add rating'); |
| 63 |
}, |
| 64 |
}); |
| 65 |
|
| 66 |
// Search places mutation |
| 67 |
const searchMutation = useMutation({ |
| 68 |
mutationFn: async () => { |
| 69 |
const searchPlaces = await restaurantApi.searchPlaces(userLocation!.lat, userLocation!.lng, 2000); |
| 70 |
|
| 71 |
// All places are now auto-created as restaurants, so fetch the updated list |
| 72 |
const updatedRestaurants = await restaurantApi.getNearby(userLocation!.lat, userLocation!.lng); |
| 73 |
setRestaurants(updatedRestaurants); |
| 74 |
|
| 75 |
return searchPlaces; |
| 76 |
}, |
| 77 |
onSuccess: (data) => { |
| 78 |
setSearchResults(data); |
| 79 |
setShowSearchResults(true); |
| 80 |
setSelectedRestaurant(null); // Close any open restaurant panel |
| 81 |
}, |
| 82 |
onError: () => { |
| 83 |
alert('Failed to search for places'); |
| 84 |
}, |
| 85 |
}); |
| 86 |
|
| 87 |
// Update toast status mutation |
| 88 |
const updateToastStatusMutation = useMutation({ |
| 89 |
mutationFn: ({ restaurantId, hasToast }: { restaurantId: number; hasToast: boolean }) => |
| 90 |
restaurantApi.updateToastStatus(restaurantId, hasToast), |
| 91 |
onSuccess: async () => { |
| 92 |
// Refresh restaurants |
| 93 |
if (userLocation) { |
| 94 |
const updatedRestaurants = await restaurantApi.getNearby(userLocation.lat, userLocation.lng); |
| 95 |
setRestaurants(updatedRestaurants); |
| 96 |
} |
| 97 |
}, |
| 98 |
onError: () => { |
| 99 |
alert('Failed to update toast status'); |
| 100 |
}, |
| 101 |
}); |
| 102 |
|
| 103 |
const handleAddRating = async (data: CreateRatingData) => { |
| 104 |
if (!selectedRestaurant) return; |
| 105 |
await addRatingMutation.mutateAsync({ restaurantId: selectedRestaurant.id, data }); |
| 106 |
}; |
| 107 |
|
| 108 |
if (!userLocation) { |
| 109 |
return <Loading />; |
| 110 |
} |
| 111 |
|
| 112 |
return ( |
| 113 |
<div className="h-screen flex flex-col"> |
| 114 |
{/* Header */} |
| 115 |
<header className="bg-amber-600 text-white p-4 shadow-lg relative z-10"> |
| 116 |
<div className="max-w-7xl mx-auto flex items-center justify-between"> |
| 117 |
<div className="flex items-center space-x-2"> |
| 118 |
<span className="text-2xl">🍞</span> |
| 119 |
<h1 className="text-2xl font-bold">LocalToast</h1> |
| 120 |
</div> |
| 121 |
<div className="flex items-center space-x-4"> |
| 122 |
<p className="text-sm hidden sm:block">Find and rate the best toast in town!</p> |
| 123 |
<button |
| 124 |
onClick={() => searchMutation.mutate()} |
| 125 |
disabled={searchMutation.isPending} |
| 126 |
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" |
| 127 |
> |
| 128 |
{searchMutation.isPending ? ( |
| 129 |
<Loader2 className="w-4 h-4 animate-spin" /> |
| 130 |
) : ( |
| 131 |
<Plus className="w-4 h-4" /> |
| 132 |
)} |
| 133 |
<span>Find Toast</span> |
| 134 |
</button> |
| 135 |
</div> |
| 136 |
</div> |
| 137 |
</header> |
| 138 |
|
| 139 |
{/* Main Content */} |
| 140 |
<div className="flex-1 relative"> |
| 141 |
<Map |
| 142 |
center={userLocation} |
| 143 |
restaurants={restaurants} |
| 144 |
onRestaurantClick={setSelectedRestaurant} |
| 145 |
onToastStatusUpdate={(restaurantId, hasToast) => |
| 146 |
updateToastStatusMutation.mutate({ restaurantId, hasToast }) |
| 147 |
} |
| 148 |
/> |
| 149 |
|
| 150 |
{/* Loading indicator */} |
| 151 |
{searchMutation.isPending && ( |
| 152 |
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 bg-white rounded-lg shadow-md px-4 py-2 flex items-center space-x-2"> |
| 153 |
<Loader2 className="w-4 h-4 animate-spin text-amber-600" /> |
| 154 |
<span>Searching for toast spots...</span> |
| 155 |
</div> |
| 156 |
)} |
| 157 |
|
| 158 |
{/* Restaurant count - moved to top right */} |
| 159 |
{restaurants.length > 0 && !searchMutation.isPending && ( |
| 160 |
<div className="absolute top-4 right-4 bg-white rounded-lg shadow-md px-4 py-2"> |
| 161 |
<p className="text-sm font-medium"> |
| 162 |
{restaurants.length} toast spot{restaurants.length !== 1 ? 's' : ''} nearby 🍞 |
| 163 |
</p> |
| 164 |
</div> |
| 165 |
)} |
| 166 |
|
| 167 |
{/* Review Modal */} |
| 168 |
{selectedRestaurant && ( |
| 169 |
<ReviewModal |
| 170 |
restaurant={selectedRestaurant} |
| 171 |
onClose={() => setSelectedRestaurant(null)} |
| 172 |
onSubmit={handleAddRating} |
| 173 |
/> |
| 174 |
)} |
| 175 |
|
| 176 |
{/* Search results panel */} |
| 177 |
{showSearchResults && ( |
| 178 |
<> |
| 179 |
{searchResultsMinimized ? ( |
| 180 |
// Minimized state - just a bar at the bottom |
| 181 |
<div className="absolute bottom-0 left-0 right-0 bg-white shadow-xl rounded-t-xl p-3 z-[1000] cursor-pointer" |
| 182 |
onClick={() => setSearchResultsMinimized(false)}> |
| 183 |
<div className="flex justify-between items-center"> |
| 184 |
<p className="text-sm font-medium"> |
| 185 |
{searchResults.length} search results (click to expand) |
| 186 |
</p> |
| 187 |
<button className="text-gray-500 hover:text-gray-700"> |
| 188 |
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 189 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" /> |
| 190 |
</svg> |
| 191 |
</button> |
| 192 |
</div> |
| 193 |
</div> |
| 194 |
) : ( |
| 195 |
// Full search panel |
| 196 |
<SearchPanel |
| 197 |
searchResults={searchResults} |
| 198 |
onClose={() => setSearchResultsMinimized(true)} |
| 199 |
onToastStatusUpdate={(restaurantId, hasToast) => |
| 200 |
updateToastStatusMutation.mutate({ restaurantId, hasToast }) |
| 201 |
} |
| 202 |
onRestaurantClick={(restaurantId) => { |
| 203 |
const restaurant = restaurants.find(r => r.id === restaurantId); |
| 204 |
if (restaurant) { |
| 205 |
setSelectedRestaurant(restaurant); |
| 206 |
// Don't minimize - let user continue browsing |
| 207 |
} |
| 208 |
}} |
| 209 |
/> |
| 210 |
)} |
| 211 |
</> |
| 212 |
)} |
| 213 |
</div> |
| 214 |
</div> |
| 215 |
); |
| 216 |
} |
| 217 |
|
| 218 |
export default function Page() { |
| 219 |
return ( |
| 220 |
<Providers> |
| 221 |
<HomePage /> |
| 222 |
</Providers> |
| 223 |
); |
| 224 |
} |