TypeScript · 8631 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 apiError = error as ApiError;
71 alert(apiError.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 apiError = error as ApiError;
93 alert(apiError.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: unknown) => {
109 const apiError = error as ApiError;
110 alert(apiError.response?.data?.error || 'Failed to update toast status');
111 },
112 });
113
114 const handleAddRating = async (data: CreateRatingData) => {
115 if (!selectedRestaurant) return;
116 await addRatingMutation.mutateAsync({ restaurantId: selectedRestaurant.id, data });
117 };
118
119 if (!userLocation) {
120 return <Loading />;
121 }
122
123 return (
124 <div className="h-screen flex flex-col">
125 {/* Header */}
126 <header className="bg-amber-600 text-white p-4 shadow-lg relative z-10">
127 <div className="max-w-7xl mx-auto flex items-center justify-between">
128 <div className="flex items-center space-x-2">
129 <span className="text-2xl">🍞</span>
130 <h1 className="text-2xl font-bold">LocalToast</h1>
131 </div>
132 <div className="flex items-center space-x-4">
133 <p className="text-sm hidden sm:block">Find and rate the best toast in town!</p>
134 <button
135 onClick={() => searchMutation.mutate()}
136 disabled={searchMutation.isPending}
137 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"
138 >
139 {searchMutation.isPending ? (
140 <Loader2 className="w-4 h-4 animate-spin" />
141 ) : (
142 <Plus className="w-4 h-4" />
143 )}
144 <span>Find Toast</span>
145 </button>
146 </div>
147 </div>
148 </header>
149
150 {/* Main Content */}
151 <div className="flex-1 relative">
152 <Map
153 center={userLocation}
154 restaurants={restaurants}
155 onRestaurantClick={setSelectedRestaurant}
156 onToastStatusUpdate={(restaurantId, hasToast) =>
157 updateToastStatusMutation.mutate({ restaurantId, hasToast })
158 }
159 />
160
161 {/* Loading indicator */}
162 {searchMutation.isPending && (
163 <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">
164 <Loader2 className="w-4 h-4 animate-spin text-amber-600" />
165 <span>Searching for toast spots...</span>
166 </div>
167 )}
168
169 {/* Restaurant count - moved to top right */}
170 {restaurants.length > 0 && !searchMutation.isPending && (
171 <div className="absolute top-4 right-4 bg-white rounded-lg shadow-md px-4 py-2">
172 <p className="text-sm font-medium">
173 {restaurants.length} toast spot{restaurants.length !== 1 ? 's' : ''} nearby 🍞
174 </p>
175 </div>
176 )}
177
178 {/* Review Modal */}
179 {selectedRestaurant && (
180 <ReviewModal
181 restaurant={selectedRestaurant}
182 onClose={() => setSelectedRestaurant(null)}
183 onSubmit={handleAddRating}
184 />
185 )}
186
187 {/* Search results panel */}
188 {showSearchResults && (
189 <>
190 {searchResultsMinimized ? (
191 // Minimized state - just a bar at the bottom
192 <div className="absolute bottom-0 left-0 right-0 bg-white shadow-xl rounded-t-xl p-3 z-[1000] cursor-pointer"
193 onClick={() => setSearchResultsMinimized(false)}>
194 <div className="flex justify-between items-center">
195 <p className="text-sm font-medium">
196 {searchResults.length} search results (click to expand)
197 </p>
198 <button className="text-gray-500 hover:text-gray-700">
199 <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
200 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
201 </svg>
202 </button>
203 </div>
204 </div>
205 ) : (
206 // Full search panel
207 <SearchPanel
208 searchResults={searchResults}
209 onClose={() => setSearchResultsMinimized(true)}
210 onToastStatusUpdate={(restaurantId, hasToast) =>
211 updateToastStatusMutation.mutate({ restaurantId, hasToast })
212 }
213 onRestaurantClick={(restaurantId) => {
214 const restaurant = restaurants.find(r => r.id === restaurantId);
215 if (restaurant) {
216 setSelectedRestaurant(restaurant);
217 // Don't minimize - let user continue browsing
218 }
219 }}
220 />
221 )}
222 </>
223 )}
224 </div>
225 </div>
226 );
227 }
228
229 export default function Page() {
230 return (
231 <Providers>
232 <HomePage />
233 </Providers>
234 );
235 }