TypeScript · 7369 bytes Raw Blame History
1 'use client';
2
3 import { useEffect, useState } from 'react';
4 import L from 'leaflet';
5 import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
6 import 'leaflet/dist/leaflet.css';
7 import { Star, ThumbsUp, ThumbsDown, MessageSquare } from 'lucide-react';
8 import { Restaurant } from '@/lib/api';
9
10 // Fix for default markers in React-Leaflet
11 if (typeof window !== 'undefined') {
12 delete (L.Icon.Default.prototype as any)._getIconUrl;
13 L.Icon.Default.mergeOptions({
14 iconRetinaUrl: '/leaflet/marker-icon-2x.png',
15 iconUrl: '/leaflet/marker-icon.png',
16 shadowUrl: '/leaflet/marker-shadow.png',
17 });
18 }
19
20 interface MapProps {
21 center: { lat: number; lng: number };
22 restaurants: Restaurant[];
23 onRestaurantClick: (restaurant: Restaurant) => void;
24 onToastStatusUpdate: (restaurantId: number, hasToast: boolean) => void;
25 }
26
27 // Component to recenter map when location changes
28 function RecenterMap({ center }: { center: { lat: number; lng: number } }) {
29 const map = useMap();
30 useEffect(() => {
31 map.setView([center.lat, center.lng], 13);
32 }, [center, map]);
33 return null;
34 }
35
36 export default function Map({ center, restaurants, onRestaurantClick, onToastStatusUpdate }: MapProps) {
37 const [mounted, setMounted] = useState(false);
38
39 useEffect(() => {
40 setMounted(true);
41 }, []);
42
43 if (!mounted) {
44 return <div className="h-full w-full bg-gray-100 animate-pulse" />;
45 }
46
47 // Create icons based on toast status
48 const createRestaurantIcon = (hasToast: boolean | null) => {
49 let iconContent = '';
50 let bgColor = '';
51
52 if (hasToast === null) {
53 // Unknown status - gray question mark
54 iconContent = '❓';
55 bgColor = '#9CA3AF'; // gray-400
56 } else if (hasToast) {
57 // Has toast - green toast
58 iconContent = '🍞';
59 bgColor = '#10B981'; // green-500
60 } else {
61 // No toast - red X
62 iconContent = '❌';
63 bgColor = '#EF4444'; // red-500
64 }
65
66 return new L.Icon({
67 iconUrl: 'data:image/svg+xml,' +
68 encodeURIComponent(`
69 <svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
70 <circle cx="20" cy="20" r="18" fill="${bgColor}" stroke="#374151" stroke-width="2"/>
71 <text x="20" y="27" text-anchor="middle" font-size="20">${iconContent}</text>
72 </svg>
73 `),
74 iconSize: [40, 40],
75 iconAnchor: [20, 40],
76 popupAnchor: [0, -40],
77 });
78 };
79
80 const userIcon = new L.Icon({
81 iconUrl: 'data:image/svg+xml,' +
82 encodeURIComponent(`
83 <svg width="30" height="30" viewBox="0 0 30 30" xmlns="http://www.w3.org/2000/svg">
84 <circle cx="15" cy="15" r="10" fill="#3B82F6" stroke="#1E40AF" stroke-width="2"/>
85 <circle cx="15" cy="15" r="4" fill="white"/>
86 </svg>
87 `),
88 iconSize: [30, 30],
89 iconAnchor: [15, 15],
90 });
91
92 return (
93 <div className="h-full w-full relative">
94 <MapContainer
95 center={[center.lat, center.lng]}
96 zoom={13}
97 className="h-full w-full"
98 style={{ height: '100%', width: '100%' }}
99 scrollWheelZoom={true}
100 >
101 <TileLayer
102 attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
103 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
104 />
105 <RecenterMap center={center} />
106
107 {/* User location marker */}
108 <Marker position={[center.lat, center.lng]} icon={userIcon}>
109 <Popup>
110 <div className="text-center">
111 <p className="font-semibold">You are here!</p>
112 <p className="text-sm text-gray-600">Ready to find some toast? 🍞</p>
113 </div>
114 </Popup>
115 </Marker>
116
117 {/* Restaurant markers */}
118 {restaurants.map((restaurant) => (
119 <Marker
120 key={restaurant.id}
121 position={[restaurant.latitude, restaurant.longitude]}
122 icon={createRestaurantIcon(restaurant.has_toast)}
123 >
124 <Popup>
125 <div className="p-2 min-w-[250px]">
126 <h3 className="font-bold text-base mb-1">{restaurant.name}</h3>
127 <p className="text-sm text-gray-700 mb-2">{restaurant.address}</p>
128
129 {/* Toast status */}
130 <div className="mb-3 p-2 bg-gray-50 rounded">
131 <p className="text-sm font-medium mb-2">
132 {restaurant.has_toast === null && "🤔 Does this place have toast?"}
133 {restaurant.has_toast === true && "✅ This place serves toast!"}
134 {restaurant.has_toast === false && "❌ No toast here"}
135 </p>
136
137 {/* Toast voting buttons */}
138 <div className="flex gap-2">
139 <button
140 onClick={(e) => {
141 e.stopPropagation();
142 onToastStatusUpdate(restaurant.id, true);
143 }}
144 className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition ${
145 restaurant.has_toast === true
146 ? 'bg-green-500 text-white'
147 : 'bg-gray-200 hover:bg-green-100 text-gray-700'
148 }`}
149 >
150 <ThumbsUp className="w-4 h-4" />
151 <span>Has Toast</span>
152 </button>
153 <button
154 onClick={(e) => {
155 e.stopPropagation();
156 onToastStatusUpdate(restaurant.id, false);
157 }}
158 className={`flex-1 flex items-center justify-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition ${
159 restaurant.has_toast === false
160 ? 'bg-red-500 text-white'
161 : 'bg-gray-200 hover:bg-red-100 text-gray-700'
162 }`}
163 >
164 <ThumbsDown className="w-4 h-4" />
165 <span>No Toast</span>
166 </button>
167 </div>
168 </div>
169
170 {/* Rating info */}
171 {restaurant.average_rating ? (
172 <p className="text-sm font-semibold flex items-center mb-2">
173 <Star className="w-4 h-4 text-yellow-500 fill-current mr-1" />
174 {restaurant.average_rating.toFixed(1)} ({restaurant.total_ratings} ratings)
175 </p>
176 ) : (
177 <p className="text-sm text-gray-600 mb-2">No ratings yet</p>
178 )}
179
180 {/* Review button */}
181 <button
182 onClick={(e) => {
183 e.stopPropagation();
184 onRestaurantClick(restaurant);
185 }}
186 className="w-full bg-amber-600 hover:bg-amber-700 text-white px-3 py-2 rounded text-sm font-medium transition flex items-center justify-center gap-2"
187 >
188 <MessageSquare className="w-4 h-4" />
189 <span>Write Toast Review</span>
190 </button>
191 </div>
192 </Popup>
193 </Marker>
194 ))}
195 </MapContainer>
196 </div>
197 );
198 }