From 4583c937307b266d1aebfde3d8e8bff67aa14a55 Mon Sep 17 00:00:00 2001 From: zaidmukaddam Date: Wed, 6 Nov 2024 20:22:26 +0530 Subject: [PATCH] Refactor MapComponent and complete find place tool with Mapbox API --- app/api/chat/route.ts | 55 +- app/search/page.tsx | 1284 ++++++++++++++------------------- components/map-components.tsx | 192 +++-- 3 files changed, 618 insertions(+), 913 deletions(-) diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 0f8735a..ebe05de 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -31,14 +31,6 @@ function sanitizeUrl(url: string): string { return url.replace(/\s+/g, '%20') } -type SearchResultImage = - | string - | { - url: string - description: string - number_of_results?: number - } - // Helper function to geocode an address const geocodeAddress = async (address: string) => { const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN; @@ -61,7 +53,7 @@ export async function POST(req: Request) { topP: 0.5, frequencyPenalty: 0, presencePenalty: 0, - experimental_activeTools: ["get_weather_data", "programming", "web_search", "text_translate"], + experimental_activeTools: ["get_weather_data", "programming", "web_search", "text_translate", "find_place"], system: ` You are an expert AI web search engine called MiniPerplx, that helps users find information on the internet with no bullshit talks. Always start with running the tool(s) and then and then only write your response AT ALL COSTS!! @@ -444,47 +436,28 @@ When asked a "What is" question, maintain the same format as the question and an }, }), find_place: tool({ - description: "Find a specific place using Mapbox API.", + description: "Find a place using Mapbox v6 reverse geocoding API.", parameters: z.object({ - input: z.string().describe("The place to search for (e.g., 'Museum of Contemporary Art Australia')."), - inputtype: z.enum(["textquery", "phonenumber"]).describe("The type of input (textquery or phonenumber)."), + latitude: z.number().describe("The latitude of the location."), + longitude: z.number().describe("The longitude of the location."), }), - execute: async ({ input, inputtype }: { - input: string; - inputtype: "textquery" | "phonenumber"; - }) => { + execute: async ({ latitude, longitude }: { latitude: number; longitude: number }) => { const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN; - - let searchEndpoint = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(input)}.json`; - if (inputtype === "phonenumber") { - // Note: Mapbox doesn't support phone number search directly - // We'll just search the number as text - searchEndpoint = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(input)}.json`; - } - - const response = await fetch(`${searchEndpoint}?types=poi&access_token=${mapboxToken}`); + const response = await fetch( + `https://api.mapbox.com/search/geocode/v6/reverse?longitude=${longitude}&latitude=${latitude}&access_token=${mapboxToken}` + ); const data = await response.json(); if (!data.features || data.features.length === 0) { - return { candidates: [] }; + return { features: [] }; } - const place = data.features[0]; - return { - candidates: [{ - name: place.text, - formatted_address: place.place_name, - geometry: { - location: { - lat: place.center[1], - lng: place.center[0] - } - }, - // Note: Mapbox doesn't provide these fields - rating: null, - opening_hours: null - }] + features: data.features.map((feature: any) => ({ + name: feature.properties.name_preferred || feature.properties.name, + formatted_address: feature.properties.full_address, + geometry: feature.geometry, + })), }; }, }), diff --git a/app/search/page.tsx b/app/search/page.tsx index b3a1445..86466ca 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -10,7 +10,6 @@ React, useState, useEffect, useMemo, - memo, Suspense } from 'react'; import ReactMarkdown from 'react-markdown'; @@ -95,7 +94,6 @@ import { CardTitle, } from "@/components/ui/card"; import { GitHubLogoIcon, PlusCircledIcon } from '@radix-ui/react-icons'; -import { Skeleton } from '@/components/ui/skeleton'; import Link from 'next/link'; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"; @@ -109,18 +107,11 @@ import { import Autoplay from 'embla-carousel-autoplay'; import FormComponent from '@/components/ui/form-component'; import WeatherChart from '@/components/weather-chart'; -import { MapContainer } from '@/components/map-components'; +import { MapComponent, MapContainer, MapSkeleton, PlaceDetails } from '@/components/map-components'; import InteractiveChart from '@/components/interactive-charts'; export const maxDuration = 60; -declare global { - interface Window { - google: any; - initMap: () => void; - } -} - interface Attachment { name: string; contentType: string; @@ -128,6 +119,147 @@ interface Attachment { size: number; } +interface SearchImage { + url: string; + description: string; +} + +const ImageCarousel = ({ images, onClose }: { images: SearchImage[], onClose: () => void }) => { + return ( + + + + + + {images.map((image, index) => ( + + {image.description} +

{image.description}

+
+ ))} +
+ + +
+
+
+ ); +}; + + +const WebSearchResults = ({ result, args }: { result: any, args: any }) => { + const [openDialog, setOpenDialog] = useState(false); + const [selectedImageIndex, setSelectedImageIndex] = useState(0); + + const handleImageClick = (index: number) => { + setSelectedImageIndex(index); + setOpenDialog(true); + }; + + const handleCloseDialog = () => { + setOpenDialog(false); + }; + + return ( +
+ + + +
+
+ +

Sources Found

+
+ {result && ( + {result.results.length} results + )} +
+
+ + {args?.query && ( + + + {args.query} + + )} + {result && ( +
+ {result.results.map((item: any, itemIndex: number) => ( +
+
+ Favicon +
+

{item.title}

+

{item.content}

+
+
+ + {item.url} + +
+ ))} +
+ )} +
+
+
+ {result && result.images && result.images.length > 0 && ( +
+
+ +

Images

+
+
+ {result.images.slice(0, 4).map((image: SearchImage, itemIndex: number) => ( +
handleImageClick(itemIndex)} + > + {image.description} + {itemIndex === 3 && result.images.length > 4 && ( +
+ +
+ )} +
+ ))} +
+
+ )} + {openDialog && result.images && ( + + )} +
+ ); +}; + const HomeContent = () => { const searchParams = useSearchParams(); const initialQuery = searchParams.get('query') || ''; @@ -312,223 +444,6 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd // Google Maps components - const isValidCoordinate = (coord: number) => { - return typeof coord === 'number' && !isNaN(coord) && isFinite(coord); - }; - - const loadGoogleMapsScript = (callback: () => void) => { - if (window.google && window.google.maps) { - callback(); - return; - } - - const existingScript = document.getElementById('googleMapsScript'); - if (existingScript) { - existingScript.remove(); - } - - window.initMap = callback; - const script = document.createElement('script'); - script.id = 'googleMapsScript'; - script.src = `https://maps.googleapis.com/maps/api/js?key=${process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY}&libraries=places,marker&callback=initMap`; - script.async = true; - script.defer = true; - document.head.appendChild(script); - }; - - const MapComponent = React.memo(({ center, places }: { center: { lat: number; lng: number }, places: any[] }) => { - const mapRef = useRef(null); - const [mapError, setMapError] = useState(null); - const googleMapRef = useRef(null); - const markersRef = useRef([]); - - const memoizedCenter = useMemo(() => center, [center]); - const memoizedPlaces = useMemo(() => places, [places]); - - const initializeMap = useCallback(async () => { - if (mapRef.current && isValidCoordinate(memoizedCenter.lat) && isValidCoordinate(memoizedCenter.lng)) { - const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary; - const { AdvancedMarkerElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary; - - if (!googleMapRef.current) { - googleMapRef.current = new Map(mapRef.current, { - center: memoizedCenter, - zoom: 14, - mapId: "347ff92e0c7225cf", - }); - } else { - googleMapRef.current.setCenter(memoizedCenter); - } - - // Clear existing markers - markersRef.current.forEach(marker => marker.map = null); - markersRef.current = []; - - memoizedPlaces.forEach((place) => { - if (isValidCoordinate(place.location.lat) && isValidCoordinate(place.location.lng)) { - const marker = new AdvancedMarkerElement({ - map: googleMapRef.current, - position: place.location, - title: place.name, - }); - markersRef.current.push(marker); - } - }); - } else { - setMapError('Invalid coordinates provided'); - } - }, [memoizedCenter, memoizedPlaces]); - - useEffect(() => { - loadGoogleMapsScript(() => { - try { - initializeMap(); - } catch (error) { - console.error('Error initializing map:', error); - setMapError('Failed to initialize Google Maps'); - } - }); - - return () => { - // Clean up markers when component unmounts - markersRef.current.forEach(marker => marker.map = null); - }; - }, [initializeMap]); - - if (mapError) { - return
{mapError}
; - } - - return
; - }); - - MapComponent.displayName = 'MapComponent'; - - const MapSkeleton = () => ( - - ); - - const PlaceDetails = ({ place }: { place: any }) => ( -
-
-

{place.name}

-

- {place.vicinity} -

-
- {place.rating && ( - - - {place.rating} ({place.user_ratings_total}) - - )} -
- ); - - const MapEmbed = memo(({ location, zoom = 15 }: { location: string, zoom?: number }) => { - const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY; - const mapUrl = `https://www.google.com/maps/embed/v1/place?key=${apiKey}&q=${encodeURIComponent(location)}&zoom=${zoom}`; - - return ( -
- -
- ); - }); - - MapEmbed.displayName = 'MapEmbed'; - - const FindPlaceResult = memo(({ result }: { result: any }) => { - const place = result.candidates[0]; - const location = `${place.geometry.location.lat},${place.geometry.location.lng}`; - - return ( - - - - - {place.name} - - - - -
-

Address: {place.formatted_address}

- {place.rating && ( -
- Rating: - - - {place.rating} - -
- )} - {place.opening_hours && ( -

Open now: {place.opening_hours.open_now ? 'Yes' : 'No'}

- )} -
-
-
- ); - }); - - FindPlaceResult.displayName = 'FindPlaceResult'; - - const TextSearchResult = memo(({ result }: { result: any }) => { - const centerLocation = result.results[0]?.geometry?.location; - const mapLocation = centerLocation ? `${centerLocation.lat},${centerLocation.lng}` : ''; - - return ( - - - - - Text Search Results - - - - {mapLocation && } - - - Place Details - -
- {result.results.map((place: any, index: number) => ( -
-
-

{place.name}

-

- {place.formatted_address} -

-
- {place.rating && ( - - - {place.rating} ({place.user_ratings_total}) - - )} -
- ))} -
-
-
-
-
-
- ); - }); - - TextSearchResult.displayName = 'TextSearchResult'; - const TranslationTool: React.FC<{ toolInvocation: ToolInvocation; result: any }> = ({ toolInvocation, result }) => { const [isPlaying, setIsPlaying] = useState(false); const [audioUrl, setAudioUrl] = useState(null); @@ -644,145 +559,8 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd ); }; - interface SearchImage { - url: string; - description: string; - } - - const ImageCarousel = ({ images, onClose }: { images: SearchImage[], onClose: () => void }) => { - return ( - - - - - - {images.map((image, index) => ( - - {image.description} -

{image.description}

-
- ))} -
- - -
-
-
- ); - }; - - const WebSearchResults = ({ result, args }: { result: any, args: any }) => { - const [openDialog, setOpenDialog] = useState(false); - const [selectedImageIndex, setSelectedImageIndex] = useState(0); - - const handleImageClick = (index: number) => { - setSelectedImageIndex(index); - setOpenDialog(true); - }; - - const handleCloseDialog = () => { - setOpenDialog(false); - }; - - return ( -
- - - -
-
- -

Sources Found

-
- {result && ( - {result.results.length} results - )} -
-
- - {args?.query && ( - - - {args.query} - - )} - {result && ( -
- {result.results.map((item: any, itemIndex: number) => ( -
-
- Favicon -
-

{item.title}

-

{item.content}

-
-
- - {item.url} - -
- ))} -
- )} -
-
-
- {result && result.images && result.images.length > 0 && ( -
-
- -

Images

-
-
- {result.images.slice(0, 4).map((image: SearchImage, itemIndex: number) => ( -
handleImageClick(itemIndex)} - > - {image.description} - {itemIndex === 3 && result.images.length > 4 && ( -
- -
- )} -
- ))} -
-
- )} - {openDialog && result.images && ( - - )} -
- ); - }; + + interface TableData { title: string; @@ -859,341 +637,118 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd ResultsOverview.displayName = 'ResultsOverview'; - const renderToolInvocation = (toolInvocation: ToolInvocation, index: number) => { - const args = JSON.parse(JSON.stringify(toolInvocation.args)); - const result = 'result' in toolInvocation ? JSON.parse(JSON.stringify(toolInvocation.result)) : null; + const renderToolInvocation = useCallback( + (toolInvocation: ToolInvocation, index: number) => { + const args = JSON.parse(JSON.stringify(toolInvocation.args)); + const result = 'result' in toolInvocation ? JSON.parse(JSON.stringify(toolInvocation.result)) : null; - if (toolInvocation.toolName === 'nearby_search') { - if (!result) { - return ( -
-
- - Searching nearby places... + if (toolInvocation.toolName === 'find_place') { + if (!result) { + return ( +
+ +

Loading place information...

- - {[0, 1, 2].map((index) => ( - - ))} - + ); + } + + const place = result.features[0]; + if (!place) return null; + + return ( +
+ +
); } - return ( - - ); - } - - if (toolInvocation.toolName === 'find_place') { - if (!result) { - return ( -
-
- - Finding place... + if (toolInvocation.toolName === 'nearby_search') { + if (!result) { + return ( +
+ +

Loading nearby places...

- - {[0, 1, 2].map((index) => ( - - ))} - + ); + } + + return ( +
+
); } - const place = result.candidates[0]; - return ( - - ); - } - - if (toolInvocation.toolName === 'text_search') { - if (!result) { - return ( -
-
- - Searching places... -
- - {[0, 1, 2].map((index) => ( - - ))} - -
- ); - } - - const centerLocation = result.results[0]?.geometry?.location; - return ( - ({ - name: place.name, - location: place.geometry.location, - vicinity: place.formatted_address - }))} - /> - ); - } - - if (toolInvocation.toolName === 'get_weather_data') { - if (!result) { - return ( -
-
- - Fetching weather data... -
-
- {[0, 1, 2].map((index) => ( - - ))} -
-
- ); - } - return ; - } - - if (toolInvocation.toolName === 'programming') { - return ( - - - -
-
- -

Programming

-
- {!result ? ( - - - Executing - - ) : ( - - - Executed - - )} -
-
- -
-
- {args.icon === 'stock' && } - {args.icon === 'default' && } - {args.icon === 'date' && } - {args.icon === 'calculation' && } - {args.title} -
- - - - Code - - - Output - - {result?.images && result.images.length > 0 && ( - - Images - - )} - {result?.chart && ( - - Visualization - - )} - - -
- - {args.code} - - -
- -
-
-
- -
- {result ? ( - <> -
-                                                        {result.message}
-                                                    
-
- -
- - ) : ( -
-
- - Executing code... -
-
- )} -
-
- {result?.images && result.images.length > 0 && ( - -
- {result.images.map((img: { format: string, url: string }, imgIndex: number) => ( -
-
-

Image {imgIndex + 1}

- {img.url && img.url.trim() !== '' && ( - - )} -
-
- {img.url && img.url.trim() !== '' ? ( - {`Generated - ) : ( -
- Image upload failed or URL is empty -
- )} -
-
- ))} -
-
- )} - {result?.chart && ( - - - - )} -
-
-
-
-
- ); - } - - if (toolInvocation.toolName === 'web_search') { - return ( -
- {!result ? ( + if (toolInvocation.toolName === 'text_search') { + if (!result) { + return (
- - Running a search... + + Searching places... +
+ + {[0, 1, 2].map((index) => ( + + ))} + +
+ ); + } + + const centerLocation = result.results[0]?.geometry?.location; + return ( + ({ + name: place.name, + location: place.geometry.location, + vicinity: place.formatted_address + }))} + /> + ); + } + + if (toolInvocation.toolName === 'get_weather_data') { + if (!result) { + return ( +
+
+ + Fetching weather data...
{[0, 1, 2].map((index) => ( @@ -1212,94 +767,297 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd ))}
- ) : ( - - )} -
- ); - } - - if (toolInvocation.toolName === 'retrieve') { - if (!result) { - return ( -
-
- - Retrieving content... -
-
- {[0, 1, 2].map((index) => ( - - ))} -
-
- ); + ); + } + return ; } - return ( -
-
- -

Retrieved Content

-
-
-

{result.results[0].title}

-

{result.results[0].description}

-
- {result.results[0].language || 'Unknown language'} - - Source - -
-
+ if (toolInvocation.toolName === 'programming') { + return ( - - View Content + + +
+
+ +

Programming

+
+ {!result ? ( + + + Executing + + ) : ( + + + Executed + + )} +
+
-
- - {result.results[0].content} - +
+
+ {args.icon === 'stock' && } + {args.icon === 'default' && } + {args.icon === 'date' && } + {args.icon === 'calculation' && } + {args.title} +
+ + + + Code + + + Output + + {result?.images && result.images.length > 0 && ( + + Images + + )} + {result?.chart && ( + + Visualization + + )} + + +
+ + {args.code} + + +
+ +
+
+
+ +
+ {result ? ( + <> +
+                                                            {result.message}
+                                                        
+
+ +
+ + ) : ( +
+
+ + Executing code... +
+
+ )} +
+
+ {result?.images && result.images.length > 0 && ( + +
+ {result.images.map((img: { format: string, url: string }, imgIndex: number) => ( +
+
+

Image {imgIndex + 1}

+ {img.url && img.url.trim() !== '' && ( + + )} +
+
+ {img.url && img.url.trim() !== '' ? ( + {`Generated + ) : ( +
+ Image upload failed or URL is empty +
+ )} +
+
+ ))} +
+
+ )} + {result?.chart && ( + + + + )} +
-
- ); - } + ); + } - if (toolInvocation.toolName === 'text_translate') { - return ; - } - - if (toolInvocation.toolName === 'results_overview') { - if (!result) { + if (toolInvocation.toolName === 'web_search') { return ( -
-
- - Generating overview... -
+
+ {!result ? ( +
+
+ + Running a search... +
+
+ {[0, 1, 2].map((index) => ( + + ))} +
+
+ ) : ( + + )}
); } - return ; - } + if (toolInvocation.toolName === 'retrieve') { + if (!result) { + return ( +
+
+ + Retrieving content... +
+
+ {[0, 1, 2].map((index) => ( + + ))} +
+
+ ); + } - return null; - }; + return ( +
+
+ +

Retrieved Content

+
+
+

{result.results[0].title}

+

{result.results[0].description}

+
+ {result.results[0].language || 'Unknown language'} + + Source + +
+
+ + + View Content + +
+ + {result.results[0].content} + +
+
+
+
+
+ ); + } + + if (toolInvocation.toolName === 'text_translate') { + return ; + } + + if (toolInvocation.toolName === 'results_overview') { + if (!result) { + return ( +
+
+ + Generating overview... +
+
+ ); + } + + return ; + } + + return null; + }, + [ResultsOverview, theme] + ); interface MarkdownRendererProps { content: string; @@ -1660,6 +1418,8 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd }, []); + const memoizedMessages = useMemo(() => messages, [messages]); + return (
@@ -1712,7 +1472,7 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
- {messages.map((message, index) => ( + {memoizedMessages.map((message, index) => (
{message.role === 'user' && ( { - const mapContainer = useRef(null); - const map = useRef(null); - const markers = useRef([]); - const [mapError, setMapError] = useState(null); +const MapComponent = ({ center, places = [], zoom = 14 }: MapProps) => { + const mapRef = useRef(null); + const mapInstance = useRef(null); + const markersRef = useRef([]); + // Initialize the map only once useEffect(() => { - if (!mapContainer.current) return; + if (!mapRef.current || mapInstance.current) return; - try { - map.current = new mapboxgl.Map({ - container: mapContainer.current, - style: 'mapbox://styles/mapbox/streets-v12', - center: [center.lng, center.lat], - zoom: zoom - }); - - // Add navigation control - map.current.addControl(new mapboxgl.NavigationControl(), 'top-right'); - - // Clean up markers when component unmounts - return () => { - markers.current.forEach(marker => marker.remove()); - map.current?.remove(); - }; - } catch (error) { - console.error('Error initializing map:', error); - setMapError('Failed to initialize map'); + if (!mapboxgl.accessToken) { + console.error('Mapbox access token is not set'); + return; } + + mapInstance.current = new mapboxgl.Map({ + container: mapRef.current, + style: 'mapbox://styles/mapbox/standard', + center: [center.lng, center.lat], + zoom, + }); + + return () => { + mapInstance.current?.remove(); + mapInstance.current = null; + }; }, [center.lat, center.lng, zoom]); + // Update map center when 'center' prop changes useEffect(() => { - if (!map.current) return; + if (mapInstance.current) { + mapInstance.current.flyTo({ + center: [center.lng, center.lat], + zoom, + essential: true, + }); + } + }, [center, zoom]); - // Update center when it changes - map.current.flyTo({ - center: [center.lng, center.lat], - essential: true - }); + // Update markers when 'places' prop changes + useEffect(() => { + if (!mapInstance.current) return; - // Clear existing markers - markers.current.forEach(marker => marker.remove()); - markers.current = []; + // Remove existing markers + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; // Add new markers - places.forEach(place => { - const el = document.createElement('div'); - el.className = 'marker'; - el.innerHTML = ''; - el.style.color = 'hsl(var(--primary))'; - el.style.width = '24px'; - el.style.height = '24px'; - el.style.cursor = 'pointer'; - - const marker = new mapboxgl.Marker(el) + places.forEach((place) => { + const marker = new mapboxgl.Marker() .setLngLat([place.location.lng, place.location.lat]) .setPopup( - new mapboxgl.Popup({ offset: 25 }) - .setHTML( - `${place.name}${place.rating ? - `
Rating: ${place.rating} ⭐ (${place.user_ratings_total} reviews)` : - ''}` - ) + new mapboxgl.Popup({ offset: 25 }).setText( + `${place.name}${place.vicinity ? `\n${place.vicinity}` : ''}` + ) ) - .addTo(map.current!); + .addTo(mapInstance.current!); - markers.current.push(marker); + markersRef.current.push(marker); }); - }, [center, places]); + }, [places]); - if (mapError) { - return ( -
- {mapError} -
- ); - } + return ( +
+
+
+ ); +}; - return
; +export default React.memo(MapComponent, (prevProps, nextProps) => { + return ( + prevProps.center.lat === nextProps.center.lat && + prevProps.center.lng === nextProps.center.lng && + prevProps.zoom === nextProps.zoom && + JSON.stringify(prevProps.places) === JSON.stringify(nextProps.places) + ); }); -MapComponent.displayName = 'MapComponent'; - const MapSkeleton = () => ( ); @@ -118,15 +110,14 @@ const MapSkeleton = () => ( const PlaceDetails = ({ place }: { place: Place }) => (
-

{place.name}

- {place.vicinity && ( -

- {place.vicinity} -

- )} +

{place.name}

+ {place.vicinity &&

{place.vicinity}

}
{place.rating && ( - + {place.rating} ({place.user_ratings_total}) @@ -141,48 +132,29 @@ interface MapContainerProps { loading?: boolean; } -const MapContainer: React.FC = ({ title, center, places = [], loading = false }) => { +const MapContainer: React.FC = ({ + title, + center, + places = [], + loading = false, +}) => { if (loading) { return ( - - - - - - - - +
+ +

Loading map...

+
); } return ( - - - - - {title} - - - - - {places.length > 0 && ( - - - - Place Details - - -
- {places.map((place, index) => ( - - ))} -
-
-
-
- )} -
-
+
+

{title}

+ + {places.map((place, index) => ( + + ))} +
); };