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