feat: update Next.js version, add placeholder image component, and implement nearby search map view
This commit is contained in:
parent
9f8b8a856e
commit
0943747be3
@ -8,13 +8,13 @@ import {
|
|||||||
tool,
|
tool,
|
||||||
experimental_createProviderRegistry,
|
experimental_createProviderRegistry,
|
||||||
} from "ai";
|
} from "ai";
|
||||||
import { BlobRequestAbortedError, put } from '@vercel/blob';
|
import { BlobRequestAbortedError, put, list } from '@vercel/blob';
|
||||||
import CodeInterpreter from "@e2b/code-interpreter";
|
import CodeInterpreter from "@e2b/code-interpreter";
|
||||||
import FirecrawlApp from '@mendable/firecrawl-js';
|
import FirecrawlApp from '@mendable/firecrawl-js';
|
||||||
import { tavily } from '@tavily/core'
|
import { tavily } from '@tavily/core'
|
||||||
|
|
||||||
// Allow streaming responses up to 60 seconds
|
// Allow streaming responses up to 60 seconds
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 120;
|
||||||
|
|
||||||
// Azure setup
|
// Azure setup
|
||||||
const azure = createAzure({
|
const azure = createAzure({
|
||||||
@ -108,7 +108,7 @@ DON'Ts and IMPORTANT GUIDELINES:
|
|||||||
- Show plots from the programming tool using plt.show() function. The tool will automatically capture the plot and display it in the response.
|
- Show plots from the programming tool using plt.show() function. The tool will automatically capture the plot and display it in the response.
|
||||||
- If asked for multiple plots, make it happen in one run of the tool. The tool will automatically capture the plots and display them in the response.
|
- If asked for multiple plots, make it happen in one run of the tool. The tool will automatically capture the plots and display them in the response.
|
||||||
- the web search may return an incorrect latex format, please correct it before using it in the response. Check the Latex in Markdown rules for more information.
|
- the web search may return an incorrect latex format, please correct it before using it in the response. Check the Latex in Markdown rules for more information.
|
||||||
- The location search tools return images in the response, please do not include them in the response at all costs.
|
- The location search tools return images in the response, please DO NOT include them in the response at all costs!!!!!!!! This is extremely important to follow!!
|
||||||
- Do not use the $ symbol in the stock chart queries at all costs. Use the word USD instead of the $ symbol in the stock chart queries.
|
- Do not use the $ symbol in the stock chart queries at all costs. Use the word USD instead of the $ symbol in the stock chart queries.
|
||||||
- Never run web_search tool for stock chart queries at all costs.
|
- Never run web_search tool for stock chart queries at all costs.
|
||||||
|
|
||||||
@ -122,6 +122,7 @@ Follow the format and guidelines for each tool and provide the response accordin
|
|||||||
- For queries related to trips, always use the find_place tool for map location and then run the web_search tool to find information about places, directions, or reviews.
|
- For queries related to trips, always use the find_place tool for map location and then run the web_search tool to find information about places, directions, or reviews.
|
||||||
- Calling web and find place tools in the same response is allowed, but do not call the same tool in a response at all costs!!
|
- Calling web and find place tools in the same response is allowed, but do not call the same tool in a response at all costs!!
|
||||||
- For nearby search queries, use the nearby_search tool to find places around a location. Provide the location and radius in the parameters, then compose your response based on the information gathered.
|
- For nearby search queries, use the nearby_search tool to find places around a location. Provide the location and radius in the parameters, then compose your response based on the information gathered.
|
||||||
|
- Never call find_place tool before or after the nearby_search tool in the same response at all costs!! THIS IS NOT ALLOWED AT ALL COSTS!!!
|
||||||
|
|
||||||
## Programming Tool Guidelines:
|
## Programming Tool Guidelines:
|
||||||
The programming tool is actually a Python Code interpreter, so you can run any Python code in it.
|
The programming tool is actually a Python Code interpreter, so you can run any Python code in it.
|
||||||
@ -487,58 +488,204 @@ When asked a "What is" question, maintain the same format as the question and an
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
nearby_search: tool({
|
nearby_search: tool({
|
||||||
description: "Search for nearby places, such as restaurants or hotels.",
|
description: "Search for nearby places, such as restaurants or hotels based on the details given.",
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
|
location: z.string().describe("The location name given by user."),
|
||||||
latitude: z.number().describe("The latitude of the location."),
|
latitude: z.number().describe("The latitude of the location."),
|
||||||
longitude: z.number().describe("The longitude of the location."),
|
longitude: z.number().describe("The longitude of the location."),
|
||||||
type: z.string().describe("The type of place to search for (e.g., restaurant, hotel, attraction)."),
|
type: z.string().describe("The type of place to search for (restaurants, hotels, attractions, geos)."),
|
||||||
radius: z.number().default(3000).describe("The radius of the search area in meters (max 50000, default 3000)."),
|
radius: z.number().default(6000).describe("The radius in meters (max 50000, default 6000)."),
|
||||||
}),
|
}),
|
||||||
execute: async ({ latitude, longitude, type, radius }: {
|
execute: async ({ location, latitude, longitude, type, radius }: {
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
|
location: string;
|
||||||
type: string;
|
type: string;
|
||||||
radius: number;
|
radius: number;
|
||||||
}) => {
|
}) => {
|
||||||
const apiKey = process.env.TRIPADVISOR_API_KEY;
|
const apiKey = process.env.TRIPADVISOR_API_KEY;
|
||||||
console.log("Latitude:", latitude);
|
let finalLat = latitude;
|
||||||
console.log("Longitude:", longitude);
|
let finalLng = longitude;
|
||||||
console.log("Type:", type);
|
|
||||||
console.log("Radius:", radius);
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.content.tripadvisor.com/api/v1/location/nearby_search?latLong=${latitude},${longitude}&category=${type}&radius=${radius}&language=en&key=${apiKey}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// check for error
|
try {
|
||||||
if (!response.ok) {
|
// Try geocoding first
|
||||||
console.log(response);
|
const geocodingData = await fetch(
|
||||||
throw new Error(`HTTP error! status: ${response.status} ${response}`);
|
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(location)}&key=${process.env.GOOGLE_MAPS_API_KEY}`
|
||||||
}
|
);
|
||||||
|
|
||||||
const data = await response.json();
|
const geocoding = await geocodingData.json();
|
||||||
|
|
||||||
return {
|
if (geocoding.results?.[0]?.geometry?.location) {
|
||||||
results: data.data.map((place: any) => ({
|
let trimmedLat = geocoding.results[0].geometry.location.lat.toString().split('.');
|
||||||
name: place.name,
|
finalLat = parseFloat(trimmedLat[0] + '.' + trimmedLat[1].slice(0, 6));
|
||||||
location: {
|
let trimmedLng = geocoding.results[0].geometry.location.lng.toString().split('.');
|
||||||
lat: parseFloat(place.latitude),
|
finalLng = parseFloat(trimmedLng[0] + '.' + trimmedLng[1].slice(0, 6));
|
||||||
lng: parseFloat(place.longitude)
|
console.log('Using geocoded coordinates:', finalLat, finalLng);
|
||||||
},
|
} else {
|
||||||
place_id: place.location_id,
|
console.log('Using provided coordinates:', finalLat, finalLng);
|
||||||
vicinity: place.address_obj.address_string,
|
}
|
||||||
distance: place.distance,
|
|
||||||
bearing: place.bearing,
|
// Get nearby places
|
||||||
type: type
|
const nearbyResponse = await fetch(
|
||||||
})),
|
`https://api.content.tripadvisor.com/api/v1/location/nearby_search?latLong=${finalLat},${finalLng}&category=${type}&radius=${radius}&language=en&key=${apiKey}`,
|
||||||
center: { lat: latitude, lng: longitude }
|
{
|
||||||
};
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'origin': 'https://mplx.local',
|
||||||
|
'referer': 'https://mplx.local',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nearbyResponse.ok) {
|
||||||
|
throw new Error(`Nearby search failed: ${nearbyResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearbyData = await nearbyResponse.json();
|
||||||
|
|
||||||
|
if (!nearbyData.data || nearbyData.data.length === 0) {
|
||||||
|
console.log('No nearby places found');
|
||||||
|
return {
|
||||||
|
results: [],
|
||||||
|
center: { lat: finalLat, lng: finalLng }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each place
|
||||||
|
const detailedPlaces = await Promise.all(
|
||||||
|
nearbyData.data.map(async (place: any) => {
|
||||||
|
try {
|
||||||
|
if (!place.location_id) {
|
||||||
|
console.log(`Skipping place "${place.name}": No location_id`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch place details
|
||||||
|
const detailsResponse = await fetch(
|
||||||
|
`https://api.content.tripadvisor.com/api/v1/location/${place.location_id}/details?language=en¤cy=USD&key=${apiKey}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'origin': 'https://mplx.local',
|
||||||
|
'referer': 'https://mplx.local',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!detailsResponse.ok) {
|
||||||
|
console.log(`Failed to fetch details for "${place.name}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = await detailsResponse.json();
|
||||||
|
|
||||||
|
// Fetch place photos
|
||||||
|
let photos = [];
|
||||||
|
try {
|
||||||
|
const photosResponse = await fetch(
|
||||||
|
`https://api.content.tripadvisor.com/api/v1/location/${place.location_id}/photos?language=en&key=${apiKey}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'origin': 'https://mplx.local',
|
||||||
|
'referer': 'https://mplx.local',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (photosResponse.ok) {
|
||||||
|
const photosData = await photosResponse.json();
|
||||||
|
photos = photosData.data?.map((photo: any) => ({
|
||||||
|
thumbnail: photo.images?.thumbnail?.url,
|
||||||
|
small: photo.images?.small?.url,
|
||||||
|
medium: photo.images?.medium?.url,
|
||||||
|
large: photo.images?.large?.url,
|
||||||
|
original: photo.images?.original?.url,
|
||||||
|
caption: photo.caption
|
||||||
|
})).filter((photo: any) => photo.medium) || [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Photo fetch failed for "${place.name}":`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process hours and status
|
||||||
|
const now = new Date();
|
||||||
|
const currentDay = now.getDay();
|
||||||
|
const currentTime = now.getHours() * 100 + now.getMinutes();
|
||||||
|
|
||||||
|
let is_closed = true;
|
||||||
|
let next_open_close = '';
|
||||||
|
|
||||||
|
if (details.hours?.periods) {
|
||||||
|
const todayPeriod = details.hours.periods.find((period: any) =>
|
||||||
|
period.open?.day === currentDay
|
||||||
|
);
|
||||||
|
|
||||||
|
if (todayPeriod) {
|
||||||
|
const openTime = parseInt(todayPeriod.open.time);
|
||||||
|
const closeTime = todayPeriod.close ? parseInt(todayPeriod.close.time) : 2359;
|
||||||
|
is_closed = currentTime < openTime || currentTime > closeTime;
|
||||||
|
next_open_close = is_closed ? todayPeriod.open.time : todayPeriod.close?.time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return processed place data
|
||||||
|
return {
|
||||||
|
name: place.name || 'Unnamed Place',
|
||||||
|
location: {
|
||||||
|
lat: parseFloat(details.latitude || place.latitude || finalLat),
|
||||||
|
lng: parseFloat(details.longitude || place.longitude || finalLng)
|
||||||
|
},
|
||||||
|
place_id: place.location_id,
|
||||||
|
vicinity: place.address_obj?.address_string || '',
|
||||||
|
distance: parseFloat(place.distance || '0'),
|
||||||
|
bearing: place.bearing || '',
|
||||||
|
type: type,
|
||||||
|
rating: parseFloat(details.rating || '0'),
|
||||||
|
price_level: details.price_level || '',
|
||||||
|
cuisine: details.cuisine?.[0]?.name || '',
|
||||||
|
description: details.description || '',
|
||||||
|
phone: details.phone || '',
|
||||||
|
website: details.website || '',
|
||||||
|
reviews_count: parseInt(details.num_reviews || '0'),
|
||||||
|
is_closed,
|
||||||
|
hours: details.hours?.weekday_text || [],
|
||||||
|
next_open_close,
|
||||||
|
photos,
|
||||||
|
source: details.source?.name || 'TripAdvisor'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Failed to process place "${place.name}":`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter and sort results
|
||||||
|
const validPlaces = detailedPlaces
|
||||||
|
.filter(place => place !== null)
|
||||||
|
.sort((a, b) => (a?.distance || 0) - (b?.distance || 0));
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: validPlaces,
|
||||||
|
center: { lat: finalLat, lng: finalLng }
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Nearby search error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
|
|
||||||
},
|
},
|
||||||
toolChoice: "auto",
|
toolChoice: "auto",
|
||||||
onChunk(event) {
|
onChunk(event) {
|
||||||
console.log("Call Type: ", event.chunk.type);
|
if (event.chunk.type === "tool-call") {
|
||||||
|
console.log("Called Tool: ", event.chunk.toolName);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -32,16 +32,16 @@ body {
|
|||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .katex-display > .katex {
|
.markdown-body .katex-display>.katex {
|
||||||
font-size: 1.21em;
|
font-size: 1.21em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .katex-display > .katex > .katex-html {
|
.markdown-body .katex-display>.katex>.katex-html {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-body .katex-display > .katex > .katex-html > .tag {
|
.markdown-body .katex-display>.katex>.katex-html>.tag {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
@ -67,7 +67,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tweet-container > div {
|
.tweet-container>div {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,6 +80,7 @@ h1 {
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
@ -141,6 +142,7 @@ h1 {
|
|||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,8 +103,9 @@ import {
|
|||||||
import Autoplay from 'embla-carousel-autoplay';
|
import Autoplay from 'embla-carousel-autoplay';
|
||||||
import FormComponent from '@/components/ui/form-component';
|
import FormComponent from '@/components/ui/form-component';
|
||||||
import WeatherChart from '@/components/weather-chart';
|
import WeatherChart from '@/components/weather-chart';
|
||||||
import { MapComponent, MapContainer, MapSkeleton, MapView, Place, PlaceDetails } from '@/components/map-components';
|
|
||||||
import InteractiveChart from '@/components/interactive-charts';
|
import InteractiveChart from '@/components/interactive-charts';
|
||||||
|
import NearbySearchMapView from '@/components/nearby-search-map-view';
|
||||||
|
import { MapComponent, MapContainer, MapSkeleton } from '@/components/map-components';
|
||||||
|
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 60;
|
||||||
|
|
||||||
@ -271,7 +272,6 @@ const HomeContent = () => {
|
|||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [viewMode, setViewMode] = useState<'map' | 'list'>('map');
|
|
||||||
|
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
@ -701,54 +701,15 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="my-4">
|
<div className="my-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<NearbySearchMapView
|
||||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
|
||||||
Nearby {args.type}s
|
|
||||||
</h2>
|
|
||||||
<Badge variant="secondary" className="bg-neutral-200 dark:bg-neutral-700">
|
|
||||||
Found {result.results.length} places
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MapView
|
|
||||||
center={result.center}
|
center={result.center}
|
||||||
places={result.results}
|
places={result.results}
|
||||||
zoom={14}
|
type={args.type}
|
||||||
view={viewMode}
|
|
||||||
onViewChange={(newView) => setViewMode(newView)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
{result.results.map((place: Place, placeIndex: number) => (
|
|
||||||
<PlaceDetails
|
|
||||||
key={placeIndex}
|
|
||||||
{...place}
|
|
||||||
onDirectionsClick={() => {
|
|
||||||
const url = `https://www.google.com/maps/dir/?api=1&destination=${place.location.lat},${place.location.lng}`;
|
|
||||||
window.open(url, '_blank');
|
|
||||||
}}
|
|
||||||
onWebsiteClick={() => {
|
|
||||||
if (place.place_id) {
|
|
||||||
window.open(`https://www.tripadvisor.com/Attraction_Review-g-d${place.place_id}`, '_blank');
|
|
||||||
} else {
|
|
||||||
toast.info("Website not available");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCallClick={() => {
|
|
||||||
if (place.phone) {
|
|
||||||
window.open(`tel:${place.phone}`);
|
|
||||||
} else {
|
|
||||||
toast.info("Phone number not available");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1388,7 +1349,7 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
|
|||||||
|
|
||||||
const Navbar: React.FC<NavbarProps> = () => {
|
const Navbar: React.FC<NavbarProps> = () => {
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 right-0 z-50 flex justify-between items-center p-4 bg-white dark:bg-neutral-950">
|
<div className="fixed top-0 left-0 right-0 z-[60] flex justify-between items-center p-4 bg-white dark:bg-neutral-950">
|
||||||
<Link href="/new">
|
<Link href="/new">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -1514,6 +1475,7 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
|
|||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
setSelectedModel={handleModelChange}
|
setSelectedModel={handleModelChange}
|
||||||
resetSuggestedQuestions={resetSuggestedQuestions}
|
resetSuggestedQuestions={resetSuggestedQuestions}
|
||||||
|
lastSubmittedQueryRef={lastSubmittedQueryRef}
|
||||||
/>
|
/>
|
||||||
<SuggestionCards selectedModel={selectedModel} />
|
<SuggestionCards selectedModel={selectedModel} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -1675,6 +1637,7 @@ GPT-4o has been re-enabled! You can use it by selecting the model from the dropd
|
|||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
setSelectedModel={handleModelChange}
|
setSelectedModel={handleModelChange}
|
||||||
resetSuggestedQuestions={resetSuggestedQuestions}
|
resetSuggestedQuestions={resetSuggestedQuestions}
|
||||||
|
lastSubmittedQueryRef={lastSubmittedQueryRef}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
186
components/interactive-maps.tsx
Normal file
186
components/interactive-maps.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import React, { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
thumbnail: string;
|
||||||
|
small: string;
|
||||||
|
medium: string;
|
||||||
|
large: string;
|
||||||
|
original: string;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Place {
|
||||||
|
name: string;
|
||||||
|
location: Location;
|
||||||
|
place_id: string;
|
||||||
|
vicinity: string;
|
||||||
|
rating?: number;
|
||||||
|
reviews_count?: number;
|
||||||
|
price_level?: string;
|
||||||
|
description?: string;
|
||||||
|
photos?: Photo[];
|
||||||
|
is_closed?: boolean;
|
||||||
|
next_open_close?: string;
|
||||||
|
type?: string;
|
||||||
|
cuisine?: string;
|
||||||
|
source?: string;
|
||||||
|
phone?: string;
|
||||||
|
website?: string;
|
||||||
|
hours?: string[];
|
||||||
|
distance?: string;
|
||||||
|
bearing?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || '';
|
||||||
|
|
||||||
|
interface InteractiveMapProps {
|
||||||
|
center: Location;
|
||||||
|
places: Place[];
|
||||||
|
selectedPlace: Place | null;
|
||||||
|
onPlaceSelect: (place: Place) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InteractiveMap: React.FC<InteractiveMapProps> = ({
|
||||||
|
center,
|
||||||
|
places,
|
||||||
|
selectedPlace,
|
||||||
|
onPlaceSelect,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<mapboxgl.Map | null>(null);
|
||||||
|
const markersRef = useRef<{ [key: string]: mapboxgl.Marker }>({});
|
||||||
|
|
||||||
|
// Handler for marker clicks
|
||||||
|
const handleMarkerClick = useCallback((place: Place) => {
|
||||||
|
onPlaceSelect(place);
|
||||||
|
}, [onPlaceSelect]);
|
||||||
|
|
||||||
|
// Initialize map
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainerRef.current) return;
|
||||||
|
|
||||||
|
mapRef.current = new mapboxgl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: 'mapbox://styles/mapbox/standard',
|
||||||
|
center: [center.lng, center.lat],
|
||||||
|
zoom: 13,
|
||||||
|
attributionControl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
|
||||||
|
// Add minimal controls
|
||||||
|
map.addControl(
|
||||||
|
new mapboxgl.NavigationControl({ showCompass: false }),
|
||||||
|
'bottom-right',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compact attribution
|
||||||
|
map.addControl(
|
||||||
|
new mapboxgl.AttributionControl({ compact: true }),
|
||||||
|
'bottom-right'
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Object.values(markersRef.current).forEach(marker => marker.remove());
|
||||||
|
map.remove();
|
||||||
|
};
|
||||||
|
}, [center.lat, center.lng]);
|
||||||
|
|
||||||
|
// Update markers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return;
|
||||||
|
|
||||||
|
// Clear existing markers
|
||||||
|
Object.values(markersRef.current).forEach(marker => marker.remove());
|
||||||
|
markersRef.current = {};
|
||||||
|
|
||||||
|
// Add new markers
|
||||||
|
places.forEach((place, index) => {
|
||||||
|
const isSelected = selectedPlace?.name === place.name;
|
||||||
|
|
||||||
|
// Create marker element
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = cn(
|
||||||
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-300 cursor-pointer',
|
||||||
|
isSelected
|
||||||
|
? 'bg-black text-white scale-110'
|
||||||
|
: 'bg-white text-black hover:scale-105'
|
||||||
|
);
|
||||||
|
el.style.border = '2px solid currentColor';
|
||||||
|
el.innerHTML = `${index + 1}`;
|
||||||
|
|
||||||
|
// Create and add marker
|
||||||
|
const marker = new mapboxgl.Marker({
|
||||||
|
element: el,
|
||||||
|
anchor: 'center',
|
||||||
|
})
|
||||||
|
.setLngLat([place.location.lng, place.location.lat])
|
||||||
|
.addTo(mapRef.current!);
|
||||||
|
|
||||||
|
// Add click handler
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); // Prevent map click
|
||||||
|
handleMarkerClick(place);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store marker reference
|
||||||
|
markersRef.current[place.name] = marker;
|
||||||
|
});
|
||||||
|
}, [places, selectedPlace, handleMarkerClick]);
|
||||||
|
|
||||||
|
// Handle map click to deselect
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const handleMapClick = (e: mapboxgl.MapMouseEvent) => {
|
||||||
|
// Check if click was on a marker
|
||||||
|
const clickedMarker = Object.values(markersRef.current).some(marker => {
|
||||||
|
const markerEl = marker.getElement();
|
||||||
|
return e.originalEvent.target === markerEl || markerEl.contains(e.originalEvent.target as Node);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If click wasn't on a marker, deselect
|
||||||
|
if (!clickedMarker) {
|
||||||
|
onPlaceSelect(null as any); // Type cast to satisfy TS
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('click', handleMapClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('click', handleMapClick);
|
||||||
|
};
|
||||||
|
}, [onPlaceSelect]);
|
||||||
|
|
||||||
|
// Fly to selected place
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !selectedPlace) return;
|
||||||
|
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: [selectedPlace.location.lng, selectedPlace.location.lat],
|
||||||
|
zoom: 15,
|
||||||
|
duration: 1500,
|
||||||
|
essential: true,
|
||||||
|
});
|
||||||
|
}, [selectedPlace]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full h-full relative z-0", className)}>
|
||||||
|
<div ref={mapContainerRef} className="w-full h-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InteractiveMap;
|
||||||
185
components/list-view.tsx
Normal file
185
components/list-view.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import PlaceholderImage from './placeholder-image';
|
||||||
|
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
thumbnail: string;
|
||||||
|
small: string;
|
||||||
|
medium: string;
|
||||||
|
large: string;
|
||||||
|
original: string;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Place {
|
||||||
|
name: string;
|
||||||
|
location: Location;
|
||||||
|
place_id: string;
|
||||||
|
vicinity: string;
|
||||||
|
rating?: number;
|
||||||
|
reviews_count?: number;
|
||||||
|
price_level?: string;
|
||||||
|
description?: string;
|
||||||
|
photos?: Photo[];
|
||||||
|
is_closed?: boolean;
|
||||||
|
next_open_close?: string;
|
||||||
|
type?: string;
|
||||||
|
cuisine?: string;
|
||||||
|
source?: string;
|
||||||
|
phone?: string;
|
||||||
|
website?: string;
|
||||||
|
hours?: string[];
|
||||||
|
distance?: string;
|
||||||
|
bearing?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaceCardProps {
|
||||||
|
place: Place;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: 'overlay' | 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaceCard: React.FC<PlaceCardProps> = ({
|
||||||
|
place,
|
||||||
|
onClick,
|
||||||
|
variant = 'list'
|
||||||
|
}) => {
|
||||||
|
const isOverlay = variant === 'overlay';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"bg-black text-white rounded-lg transition-transform",
|
||||||
|
isOverlay ? 'bg-opacity-90 backdrop-blur-sm' : 'hover:bg-opacity-80',
|
||||||
|
'cursor-pointer p-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-24 h-24 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
|
{place.photos?.[0]?.medium ? (
|
||||||
|
<img
|
||||||
|
src={place.photos[0].medium}
|
||||||
|
alt={place.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PlaceholderImage />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-xl font-medium mb-1">{place.name}</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
place.is_closed ? "text-red-500" : "text-green-500"
|
||||||
|
)}>
|
||||||
|
{place.is_closed ? "Closed" : "Open now"}
|
||||||
|
</span>
|
||||||
|
{place.next_open_close && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-400">·</span>
|
||||||
|
<span className="text-sm text-neutral-400">until {place.next_open_close}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{place.type && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-400">·</span>
|
||||||
|
<span className="text-sm text-neutral-400 capitalize">{place.type}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm mb-2">
|
||||||
|
{place.rating && (
|
||||||
|
<span>{place.rating.toFixed(1)}</span>
|
||||||
|
)}
|
||||||
|
{place.reviews_count && (
|
||||||
|
<span className="text-neutral-400">({place.reviews_count} reviews)</span>
|
||||||
|
)}
|
||||||
|
{place.price_level && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-400">·</span>
|
||||||
|
<span>{place.price_level}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{place.description && (
|
||||||
|
<p className="text-sm text-neutral-400 line-clamp-2 mb-3">
|
||||||
|
{place.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="bg-neutral-800 hover:bg-neutral-700 text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(
|
||||||
|
`https://www.google.com/maps/dir/?api=1&destination=${place.location.lat},${place.location.lng}`,
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Directions
|
||||||
|
</Button>
|
||||||
|
{place.website && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="bg-neutral-800 hover:bg-neutral-700 text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(place.website, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Website
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{place.phone && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="bg-neutral-800 hover:bg-neutral-700 text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(`tel:${place.phone}`, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Call
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{place.place_id && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="bg-neutral-800 hover:bg-neutral-700 text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(`https://www.tripadvisor.com/${place.place_id}`, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
TripAdvisor
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaceCard;
|
||||||
@ -1,12 +1,8 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
// /app/components/map-components.tsx
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Star, MapPin, Globe, Phone } from 'lucide-react';
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
|
|
||||||
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || '';
|
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || '';
|
||||||
|
|
||||||
@ -19,15 +15,6 @@ export interface Place {
|
|||||||
name: string;
|
name: string;
|
||||||
location: Location;
|
location: Location;
|
||||||
vicinity?: string;
|
vicinity?: string;
|
||||||
rating?: number;
|
|
||||||
user_ratings_total?: number;
|
|
||||||
place_id?: string; // TripAdvisor location_id
|
|
||||||
distance?: string; // Distance from search center
|
|
||||||
bearing?: string; // Direction from search center (e.g., "north", "southeast")
|
|
||||||
type?: string; // Type of place (e.g., "restaurant", "hotel")
|
|
||||||
phone?: string; // Phone number if available
|
|
||||||
website?: string; // Website URL if available
|
|
||||||
photos?: string[]; // Array of photo URLs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MapProps {
|
interface MapProps {
|
||||||
@ -129,63 +116,6 @@ interface PlaceDetailsProps extends Place {
|
|||||||
onCallClick?: () => void;
|
onCallClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaceDetails = ({
|
|
||||||
name,
|
|
||||||
vicinity,
|
|
||||||
rating,
|
|
||||||
user_ratings_total,
|
|
||||||
onDirectionsClick,
|
|
||||||
onWebsiteClick,
|
|
||||||
onCallClick
|
|
||||||
}: PlaceDetailsProps) => (
|
|
||||||
<Card className="w-full bg-white dark:bg-neutral-800 shadow-lg">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{name}</h3>
|
|
||||||
{vicinity && <p className="text-sm text-neutral-500 dark:text-neutral-400">{vicinity}</p>}
|
|
||||||
</div>
|
|
||||||
{rating && (
|
|
||||||
<Badge variant="secondary" className="flex items-center gap-1">
|
|
||||||
<Star className="h-3 w-3 text-yellow-400" />
|
|
||||||
<span>{rating}</span>
|
|
||||||
{user_ratings_total && <span className="text-xs">({user_ratings_total})</span>}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onDirectionsClick}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<MapPin className="h-4 w-4 mr-2" />
|
|
||||||
Directions
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onWebsiteClick}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<Globe className="h-4 w-4 mr-2" />
|
|
||||||
Website
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onCallClick}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<Phone className="h-4 w-4 mr-2" />
|
|
||||||
Call
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface MapContainerProps {
|
interface MapContainerProps {
|
||||||
title: string;
|
title: string;
|
||||||
center: Location;
|
center: Location;
|
||||||
@ -212,64 +142,8 @@ const MapContainer: React.FC<MapContainerProps> = ({
|
|||||||
<div className="my-4">
|
<div className="my-4">
|
||||||
<h2 className="text-xl font-semibold mb-2">{title}</h2>
|
<h2 className="text-xl font-semibold mb-2">{title}</h2>
|
||||||
<MapComponent center={center} places={places} />
|
<MapComponent center={center} places={places} />
|
||||||
{places.map((place, index) => (
|
|
||||||
<PlaceDetails key={index} {...place} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MapViewProps extends MapProps {
|
export { MapComponent, MapSkeleton, MapContainer };
|
||||||
view: 'map' | 'list';
|
|
||||||
onViewChange: (view: 'map' | 'list') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MapView: React.FC<MapViewProps> = ({
|
|
||||||
center,
|
|
||||||
places = [],
|
|
||||||
zoom = 14,
|
|
||||||
view,
|
|
||||||
onViewChange
|
|
||||||
}) => {
|
|
||||||
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
|
||||||
Nearby Places
|
|
||||||
</h2>
|
|
||||||
<Tabs value={view} onValueChange={(v) => onViewChange(v as 'map' | 'list')}>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="map">Map</TabsTrigger>
|
|
||||||
<TabsTrigger value="list">List</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{view === 'map' ? (
|
|
||||||
<div className="relative">
|
|
||||||
<MapComponent
|
|
||||||
center={center}
|
|
||||||
places={places}
|
|
||||||
zoom={zoom}
|
|
||||||
onMarkerClick={setSelectedPlace}
|
|
||||||
/>
|
|
||||||
{selectedPlace && (
|
|
||||||
<div className="absolute bottom-4 left-4 right-4">
|
|
||||||
<PlaceDetails {...selectedPlace} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{places.map((place, index) => (
|
|
||||||
<PlaceDetails key={index} {...place} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { MapComponent, MapSkeleton, MapContainer, PlaceDetails, MapView };
|
|
||||||
146
components/nearby-search-map-view.tsx
Normal file
146
components/nearby-search-map-view.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import PlaceCard from './place-card';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
thumbnail: string;
|
||||||
|
small: string;
|
||||||
|
medium: string;
|
||||||
|
large: string;
|
||||||
|
original: string;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Place {
|
||||||
|
name: string;
|
||||||
|
location: Location;
|
||||||
|
place_id: string;
|
||||||
|
vicinity: string;
|
||||||
|
rating?: number;
|
||||||
|
reviews_count?: number;
|
||||||
|
price_level?: string;
|
||||||
|
description?: string;
|
||||||
|
photos?: Photo[];
|
||||||
|
is_closed?: boolean;
|
||||||
|
next_open_close?: string;
|
||||||
|
type?: string;
|
||||||
|
cuisine?: string;
|
||||||
|
source?: string;
|
||||||
|
phone?: string;
|
||||||
|
website?: string;
|
||||||
|
hours?: string[];
|
||||||
|
distance?: string;
|
||||||
|
bearing?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import for the map component
|
||||||
|
const InteractiveMap = dynamic(() => import('./interactive-maps'), { ssr: false });
|
||||||
|
|
||||||
|
interface NearbySearchMapViewProps {
|
||||||
|
center: {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
};
|
||||||
|
places: Place[];
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NearbySearchMapView: React.FC<NearbySearchMapViewProps> = ({
|
||||||
|
center,
|
||||||
|
places,
|
||||||
|
type,
|
||||||
|
}) => {
|
||||||
|
const [viewMode, setViewMode] = useState<'map' | 'list'>('map');
|
||||||
|
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-[70vh] bg-white dark:bg-neutral-900 rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-800">
|
||||||
|
<Badge variant={"secondary"} className="absolute top-4 left-4 z-10">Beta</Badge>
|
||||||
|
{/* View Toggle */}
|
||||||
|
<div className="absolute top-4 right-4 z-10 flex rounded-full bg-white dark:bg-black border border-neutral-200 dark:border-neutral-700 p-0.5 shadow-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-1 rounded-full text-sm font-medium transition-colors",
|
||||||
|
viewMode === 'list'
|
||||||
|
? "bg-black dark:bg-white text-white dark:text-black"
|
||||||
|
: "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('map')}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-1 rounded-full text-sm font-medium transition-colors",
|
||||||
|
viewMode === 'map'
|
||||||
|
? "bg-black dark:bg-white text-white dark:text-black"
|
||||||
|
: "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(
|
||||||
|
"w-full h-full flex flex-col",
|
||||||
|
viewMode === 'list' ? 'divide-y divide-neutral-200 dark:divide-neutral-800' : ''
|
||||||
|
)}>
|
||||||
|
{/* Map Container */}
|
||||||
|
<div className={cn(
|
||||||
|
"w-full transition-all duration-300",
|
||||||
|
viewMode === 'map' ? 'h-full' : 'h-[40%]'
|
||||||
|
)}>
|
||||||
|
<InteractiveMap
|
||||||
|
center={center}
|
||||||
|
places={places}
|
||||||
|
selectedPlace={selectedPlace}
|
||||||
|
onPlaceSelect={setSelectedPlace}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Selected Place Overlay - Only show in map view */}
|
||||||
|
{selectedPlace && viewMode === 'map' && (
|
||||||
|
<div className="absolute left-4 right-4 bottom-4 z-10">
|
||||||
|
<PlaceCard
|
||||||
|
place={selectedPlace}
|
||||||
|
onClick={() => {}}
|
||||||
|
isSelected={true}
|
||||||
|
variant="overlay"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Container */}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<div className="h-[60%] bg-white dark:bg-neutral-900">
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="max-w-3xl mx-auto p-4 space-y-4">
|
||||||
|
{places.map((place, index) => (
|
||||||
|
<PlaceCard
|
||||||
|
key={index}
|
||||||
|
place={place}
|
||||||
|
onClick={() => setSelectedPlace(place)}
|
||||||
|
isSelected={selectedPlace?.place_id === place.place_id}
|
||||||
|
variant="list"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NearbySearchMapView;
|
||||||
234
components/place-card.tsx
Normal file
234
components/place-card.tsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import React from 'react';
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import PlaceholderImage from './placeholder-image';
|
||||||
|
|
||||||
|
|
||||||
|
interface Location {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
thumbnail: string;
|
||||||
|
small: string;
|
||||||
|
medium: string;
|
||||||
|
large: string;
|
||||||
|
original: string;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Place {
|
||||||
|
name: string;
|
||||||
|
location: Location;
|
||||||
|
place_id: string;
|
||||||
|
vicinity: string;
|
||||||
|
rating?: number;
|
||||||
|
reviews_count?: number;
|
||||||
|
price_level?: string;
|
||||||
|
description?: string;
|
||||||
|
photos?: Photo[];
|
||||||
|
is_closed?: boolean;
|
||||||
|
next_open_close?: string;
|
||||||
|
type?: string;
|
||||||
|
cuisine?: string;
|
||||||
|
source?: string;
|
||||||
|
phone?: string;
|
||||||
|
website?: string;
|
||||||
|
hours?: string[];
|
||||||
|
distance?: string;
|
||||||
|
bearing?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaceCardProps {
|
||||||
|
place: Place;
|
||||||
|
onClick: () => void;
|
||||||
|
isSelected?: boolean;
|
||||||
|
variant?: 'overlay' | 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaceCard: React.FC<PlaceCardProps> = ({
|
||||||
|
place,
|
||||||
|
onClick,
|
||||||
|
isSelected = false,
|
||||||
|
variant = 'list'
|
||||||
|
}) => {
|
||||||
|
const isOverlay = variant === 'overlay';
|
||||||
|
|
||||||
|
// Validation helpers from before...
|
||||||
|
const isValidString = (str: any): boolean => {
|
||||||
|
return str !== undefined &&
|
||||||
|
str !== null &&
|
||||||
|
String(str).trim() !== '' &&
|
||||||
|
String(str).toLowerCase() !== 'undefined' &&
|
||||||
|
String(str).toLowerCase() !== 'null';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidNumber = (num: any): boolean => {
|
||||||
|
if (num === undefined || num === null) return false;
|
||||||
|
const parsed = Number(num);
|
||||||
|
return !isNaN(parsed) && isFinite(parsed) && parsed !== 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRating = (rating: any): string => {
|
||||||
|
if (!isValidNumber(rating)) return '';
|
||||||
|
const parsed = Number(rating);
|
||||||
|
return parsed.toFixed(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-200 cursor-pointer rounded-lg",
|
||||||
|
variant === 'overlay'
|
||||||
|
? 'bg-white/90 dark:bg-black/90 backdrop-blur-sm'
|
||||||
|
: 'bg-white dark:bg-neutral-900 hover:bg-neutral-50 dark:hover:bg-neutral-800',
|
||||||
|
isSelected && variant !== 'overlay' && 'ring-2 ring-primary dark:ring-primary',
|
||||||
|
'border border-neutral-200 dark:border-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 p-4">
|
||||||
|
{/* Image Container */}
|
||||||
|
<div className="w-full sm:w-24 h-40 sm:h-24 rounded-lg overflow-hidden flex-shrink-0">
|
||||||
|
{place.photos?.[0]?.medium ? (
|
||||||
|
<img
|
||||||
|
src={place.photos[0].medium}
|
||||||
|
alt={place.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PlaceholderImage />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Title Section */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-2 mb-2">
|
||||||
|
<h3 className="text-xl font-medium text-neutral-900 dark:text-white truncate">
|
||||||
|
{place.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{isValidNumber(place.rating) && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium text-neutral-900 dark:text-white">
|
||||||
|
{formatRating(place.rating)}
|
||||||
|
</span>
|
||||||
|
{isValidNumber(place.reviews_count) && (
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400">
|
||||||
|
({place.reviews_count} reviews)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status & Info */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mb-2">
|
||||||
|
{place.is_closed !== undefined && (
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
place.is_closed
|
||||||
|
? "text-red-600 dark:text-red-400"
|
||||||
|
: "text-green-600 dark:text-green-400"
|
||||||
|
)}>
|
||||||
|
{place.is_closed ? "Closed" : "Open now"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isValidString(place.next_open_close) && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400">·</span>
|
||||||
|
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
until {place.next_open_close}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isValidString(place.type) && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400">·</span>
|
||||||
|
<span className="text-sm text-neutral-500 dark:text-neutral-400 capitalize">
|
||||||
|
{place.type}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isValidString(place.price_level) && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400">·</span>
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400">
|
||||||
|
{place.price_level}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{isValidString(place.description) && (
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 line-clamp-2 mb-3">
|
||||||
|
{place.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="grid grid-cols-2 sm:flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-700 text-neutral-900 dark:text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(
|
||||||
|
`https://www.google.com/maps/dir/?api=1&destination=${place.location.lat},${place.location.lng}`,
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Directions
|
||||||
|
</Button>
|
||||||
|
{isValidString(place.website) && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-700 text-neutral-900 dark:text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(place.website, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Website
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isValidString(place.phone) && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-700 text-neutral-900 dark:text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(`tel:${place.phone}`, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Call
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isValidString(place.place_id) && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-700 text-neutral-900 dark:text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(`https://www.tripadvisor.com/${place.place_id}`, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
TripAdvisor
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaceCard;
|
||||||
22
components/placeholder-image.tsx
Normal file
22
components/placeholder-image.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ImageIcon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PlaceholderImageProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaceholderImage: React.FC<PlaceholderImageProps> = ({ className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full h-full flex items-center justify-center bg-neutral-100 dark:bg-neutral-800",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-8 h-8 text-neutral-400 dark:text-neutral-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaceholderImage;
|
||||||
@ -194,6 +194,7 @@ interface FormComponentProps {
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
setSelectedModel: (value: string) => void;
|
setSelectedModel: (value: string) => void;
|
||||||
resetSuggestedQuestions: () => void;
|
resetSuggestedQuestions: () => void;
|
||||||
|
lastSubmittedQueryRef: React.MutableRefObject<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AttachmentPreview: React.FC<{ attachment: Attachment | UploadingAttachment, onRemove: () => void, isUploading: boolean }> = ({ attachment, onRemove, isUploading }) => {
|
const AttachmentPreview: React.FC<{ attachment: Attachment | UploadingAttachment, onRemove: () => void, isUploading: boolean }> = ({ attachment, onRemove, isUploading }) => {
|
||||||
@ -214,7 +215,7 @@ const AttachmentPreview: React.FC<{ attachment: Attachment | UploadingAttachment
|
|||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.8 }}
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="relative flex items-center bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-2 pr-8 gap-2 shadow-sm flex-shrink-0"
|
className="relative flex items-center bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-2 pr-8 gap-2 shadow-sm flex-shrink-0 z-0"
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<div className="w-10 h-10 flex items-center justify-center">
|
<div className="w-10 h-10 flex items-center justify-center">
|
||||||
@ -305,6 +306,7 @@ const FormComponent: React.FC<FormComponentProps> = ({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
setSelectedModel,
|
setSelectedModel,
|
||||||
resetSuggestedQuestions,
|
resetSuggestedQuestions,
|
||||||
|
lastSubmittedQueryRef,
|
||||||
}) => {
|
}) => {
|
||||||
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);
|
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
@ -388,6 +390,7 @@ const FormComponent: React.FC<FormComponentProps> = ({
|
|||||||
|
|
||||||
if (input.trim() || attachments.length > 0) {
|
if (input.trim() || attachments.length > 0) {
|
||||||
setHasSubmitted(true);
|
setHasSubmitted(true);
|
||||||
|
lastSubmittedQueryRef.current = input.trim();
|
||||||
track("search input", {query: input.trim()})
|
track("search input", {query: input.trim()})
|
||||||
|
|
||||||
handleSubmit(event, {
|
handleSubmit(event, {
|
||||||
@ -401,7 +404,7 @@ const FormComponent: React.FC<FormComponentProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
toast.error("Please enter a search query or attach an image.");
|
toast.error("Please enter a search query or attach an image.");
|
||||||
}
|
}
|
||||||
}, [input, attachments, setHasSubmitted, handleSubmit, setAttachments, fileInputRef]);
|
}, [input, attachments, setHasSubmitted, handleSubmit, setAttachments, fileInputRef, lastSubmittedQueryRef]);
|
||||||
|
|
||||||
const submitForm = useCallback(() => {
|
const submitForm = useCallback(() => {
|
||||||
onSubmit({ preventDefault: () => { }, stopPropagation: () => { } } as React.FormEvent<HTMLFormElement>);
|
onSubmit({ preventDefault: () => { }, stopPropagation: () => { } } as React.FormEvent<HTMLFormElement>);
|
||||||
@ -428,7 +431,7 @@ const FormComponent: React.FC<FormComponentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"relative w-full flex flex-col gap-2 rounded-lg transition-all duration-300",
|
"relative w-full flex flex-col gap-2 rounded-lg transition-all duration-300 z-[9999]",
|
||||||
attachments.length > 0 || uploadQueue.length > 0
|
attachments.length > 0 || uploadQueue.length > 0
|
||||||
? "bg-gray-100/70 dark:bg-neutral-800 p-1"
|
? "bg-gray-100/70 dark:bg-neutral-800 p-1"
|
||||||
: "bg-transparent"
|
: "bg-transparent"
|
||||||
|
|||||||
@ -58,7 +58,7 @@
|
|||||||
"lucide-react": "^0.424.0",
|
"lucide-react": "^0.424.0",
|
||||||
"mapbox-gl": "^3.7.0",
|
"mapbox-gl": "^3.7.0",
|
||||||
"marked-react": "^2.0.0",
|
"marked-react": "^2.0.0",
|
||||||
"next": "^14.2.10",
|
"next": "^14.2.17",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"openai": "^4.56.0",
|
"openai": "^4.56.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
|||||||
@ -94,7 +94,7 @@ dependencies:
|
|||||||
version: 1.34.2
|
version: 1.34.2
|
||||||
'@vercel/analytics':
|
'@vercel/analytics':
|
||||||
specifier: ^1.3.1
|
specifier: ^1.3.1
|
||||||
version: 1.3.1(next@14.2.14)(react@18.3.1)
|
version: 1.3.1(next@14.2.17)(react@18.3.1)
|
||||||
'@vercel/blob':
|
'@vercel/blob':
|
||||||
specifier: ^0.23.4
|
specifier: ^0.23.4
|
||||||
version: 0.23.4
|
version: 0.23.4
|
||||||
@ -153,8 +153,8 @@ dependencies:
|
|||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0(react@18.3.1)
|
version: 2.0.0(react@18.3.1)
|
||||||
next:
|
next:
|
||||||
specifier: ^14.2.10
|
specifier: ^14.2.17
|
||||||
version: 14.2.14(react-dom@18.3.1)(react@18.3.1)
|
version: 14.2.17(react-dom@18.3.1)(react@18.3.1)
|
||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.3.0
|
specifier: ^0.3.0
|
||||||
version: 0.3.0(react-dom@18.3.1)(react@18.3.1)
|
version: 0.3.0(react-dom@18.3.1)(react@18.3.1)
|
||||||
@ -782,8 +782,8 @@ packages:
|
|||||||
- ws
|
- ws
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@next/env@14.2.14:
|
/@next/env@14.2.17:
|
||||||
resolution: {integrity: sha512-/0hWQfiaD5//LvGNgc8PjvyqV50vGK0cADYzaoOOGN8fxzBn3iAiaq3S0tCRnFBldq0LVveLcxCTi41ZoYgAgg==}
|
resolution: {integrity: sha512-MCgO7VHxXo8sYR/0z+sk9fGyJJU636JyRmkjc7ZJY8Hurl8df35qG5hoAh5KMs75FLjhlEo9bb2LGe89Y/scDA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@next/eslint-plugin-next@14.2.5:
|
/@next/eslint-plugin-next@14.2.5:
|
||||||
@ -792,8 +792,8 @@ packages:
|
|||||||
glob: 10.3.10
|
glob: 10.3.10
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@next/swc-darwin-arm64@14.2.14:
|
/@next/swc-darwin-arm64@14.2.17:
|
||||||
resolution: {integrity: sha512-bsxbSAUodM1cjYeA4o6y7sp9wslvwjSkWw57t8DtC8Zig8aG8V6r+Yc05/9mDzLKcybb6EN85k1rJDnMKBd9Gw==}
|
resolution: {integrity: sha512-WiOf5nElPknrhRMTipXYTJcUz7+8IAjOYw3vXzj3BYRcVY0hRHKWgTgQ5439EvzQyHEko77XK+yN9x9OJ0oOog==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
@ -801,8 +801,8 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@next/swc-darwin-x64@14.2.14:
|
/@next/swc-darwin-x64@14.2.17:
|
||||||
resolution: {integrity: sha512-cC9/I+0+SK5L1k9J8CInahduTVWGMXhQoXFeNvF0uNs3Bt1Ub0Azb8JzTU9vNCr0hnaMqiWu/Z0S1hfKc3+dww==}
|
resolution: {integrity: sha512-29y425wYnL17cvtxrDQWC3CkXe/oRrdt8ie61S03VrpwpPRI0XsnTvtKO06XCisK4alaMnZlf8riwZIbJTaSHQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
@ -810,8 +810,8 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@next/swc-linux-arm64-gnu@14.2.14:
|
/@next/swc-linux-arm64-gnu@14.2.17:
|
||||||
resolution: {integrity: sha512-RMLOdA2NU4O7w1PQ3Z9ft3PxD6Htl4uB2TJpocm+4jcllHySPkFaUIFacQ3Jekcg6w+LBaFvjSPthZHiPmiAUg==}
|
resolution: {integrity: sha512-SSHLZls3ZwNEHsc+d0ynKS+7Af0Nr8+KTUBAy9pm6xz9SHkJ/TeuEg6W3cbbcMSh6j4ITvrjv3Oi8n27VR+IPw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@ -819,8 +819,8 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@next/swc-linux-arm64-musl@14.2.14:
|
/@next/swc-linux-arm64-musl@14.2.17:
|
||||||
resolution: {integrity: sha512-WgLOA4hT9EIP7jhlkPnvz49iSOMdZgDJVvbpb8WWzJv5wBD07M2wdJXLkDYIpZmCFfo/wPqFsFR4JS4V9KkQ2A==}
|
resolution: {integrity: sha512-VFge37us5LNPatB4F7iYeuGs9Dprqe4ZkW7lOEJM91r+Wf8EIdViWHLpIwfdDXinvCdLl6b4VyLpEBwpkctJHA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@ -828,8 +828,8 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@next/swc-linux-x64-gnu@14.2.14:
|
/@next/swc-linux-x64-gnu@14.2.17:
|
||||||
resolution: {integrity: sha512-lbn7svjUps1kmCettV/R9oAvEW+eUI0lo0LJNFOXoQM5NGNxloAyFRNByYeZKL3+1bF5YE0h0irIJfzXBq9Y6w==}
|
resolution: {integrity: sha512-aaQlpxUVb9RZ41adlTYVQ3xvYEfBPUC8+6rDgmQ/0l7SvK8S1YNJzPmDPX6a4t0jLtIoNk7j+nroS/pB4nx7vQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@ -837,8 +837,8 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@next/swc-linux-x64-musl@14.2.14:
|
/@next/swc-linux-x64-musl@14.2.17:
|
||||||
resolution: {integrity: sha512-7TcQCvLQ/hKfQRgjxMN4TZ2BRB0P7HwrGAYL+p+m3u3XcKTraUFerVbV3jkNZNwDeQDa8zdxkKkw2els/S5onQ==}
|
resolution: {integrity: sha512-HSyEiFaEY3ay5iATDqEup5WAfrhMATNJm8dYx3ZxL+e9eKv10XKZCwtZByDoLST7CyBmyDz+OFJL1wigyXeaoA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@ -846,8 +846,8 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@next/swc-win32-arm64-msvc@14.2.14:
|
/@next/swc-win32-arm64-msvc@14.2.17:
|
||||||
resolution: {integrity: sha512-8i0Ou5XjTLEje0oj0JiI0Xo9L/93ghFtAUYZ24jARSeTMXLUx8yFIdhS55mTExq5Tj4/dC2fJuaT4e3ySvXU1A==}
|
resolution: {integrity: sha512-h5qM9Btqv87eYH8ArrnLoAHLyi79oPTP2vlGNSg4CDvUiXgi7l0+5KuEGp5pJoMhjuv9ChRdm7mRlUUACeBt4w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@ -855,8 +855,8 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@next/swc-win32-ia32-msvc@14.2.14:
|
/@next/swc-win32-ia32-msvc@14.2.17:
|
||||||
resolution: {integrity: sha512-2u2XcSaDEOj+96eXpyjHjtVPLhkAFw2nlaz83EPeuK4obF+HmtDJHqgR1dZB7Gb6V/d55FL26/lYVd0TwMgcOQ==}
|
resolution: {integrity: sha512-BD/G++GKSLexQjdyoEUgyo5nClU7er5rK0sE+HlEqnldJSm96CIr/+YOTT063LVTT/dUOeQsNgp5DXr86/K7/A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@ -864,8 +864,8 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@next/swc-win32-x64-msvc@14.2.14:
|
/@next/swc-win32-x64-msvc@14.2.17:
|
||||||
resolution: {integrity: sha512-MZom+OvZ1NZxuRovKt1ApevjiUJTcU2PmdJKL66xUPaJeRywnbGGRWUlaAOwunD6dX+pm83vj979NTC8QXjGWg==}
|
resolution: {integrity: sha512-vkQfN1+4V4KqDibkW2q0sJ6CxQuXq5l2ma3z0BRcfIqkAMZiiW67T9yCpwqJKP68QghBtPEFjPAlaqe38O6frw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@ -2013,7 +2013,7 @@ packages:
|
|||||||
crypto-js: 4.2.0
|
crypto-js: 4.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@vercel/analytics@1.3.1(next@14.2.14)(react@18.3.1):
|
/@vercel/analytics@1.3.1(next@14.2.17)(react@18.3.1):
|
||||||
resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==}
|
resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
next: '>= 13'
|
next: '>= 13'
|
||||||
@ -2024,7 +2024,7 @@ packages:
|
|||||||
react:
|
react:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
next: 14.2.14(react-dom@18.3.1)(react@18.3.1)
|
next: 14.2.17(react-dom@18.3.1)(react@18.3.1)
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
server-only: 0.0.1
|
server-only: 0.0.1
|
||||||
dev: false
|
dev: false
|
||||||
@ -5219,8 +5219,8 @@ packages:
|
|||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/next@14.2.14(react-dom@18.3.1)(react@18.3.1):
|
/next@14.2.17(react-dom@18.3.1)(react@18.3.1):
|
||||||
resolution: {integrity: sha512-Q1coZG17MW0Ly5x76shJ4dkC23woLAhhnDnw+DfTc7EpZSGuWrlsZ3bZaO8t6u1Yu8FVfhkqJE+U8GC7E0GLPQ==}
|
resolution: {integrity: sha512-hNo/Zy701DDO3nzKkPmsLRlDfNCtb1OJxFUvjGEl04u7SFa3zwC6hqsOUzMajcaEOEV8ey1GjvByvrg0Qr5AiQ==}
|
||||||
engines: {node: '>=18.17.0'}
|
engines: {node: '>=18.17.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -5237,7 +5237,7 @@ packages:
|
|||||||
sass:
|
sass:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 14.2.14
|
'@next/env': 14.2.17
|
||||||
'@swc/helpers': 0.5.5
|
'@swc/helpers': 0.5.5
|
||||||
busboy: 1.6.0
|
busboy: 1.6.0
|
||||||
caniuse-lite: 1.0.30001667
|
caniuse-lite: 1.0.30001667
|
||||||
@ -5247,15 +5247,15 @@ packages:
|
|||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
styled-jsx: 5.1.1(react@18.3.1)
|
styled-jsx: 5.1.1(react@18.3.1)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 14.2.14
|
'@next/swc-darwin-arm64': 14.2.17
|
||||||
'@next/swc-darwin-x64': 14.2.14
|
'@next/swc-darwin-x64': 14.2.17
|
||||||
'@next/swc-linux-arm64-gnu': 14.2.14
|
'@next/swc-linux-arm64-gnu': 14.2.17
|
||||||
'@next/swc-linux-arm64-musl': 14.2.14
|
'@next/swc-linux-arm64-musl': 14.2.17
|
||||||
'@next/swc-linux-x64-gnu': 14.2.14
|
'@next/swc-linux-x64-gnu': 14.2.17
|
||||||
'@next/swc-linux-x64-musl': 14.2.14
|
'@next/swc-linux-x64-musl': 14.2.17
|
||||||
'@next/swc-win32-arm64-msvc': 14.2.14
|
'@next/swc-win32-arm64-msvc': 14.2.17
|
||||||
'@next/swc-win32-ia32-msvc': 14.2.14
|
'@next/swc-win32-ia32-msvc': 14.2.17
|
||||||
'@next/swc-win32-x64-msvc': 14.2.14
|
'@next/swc-win32-x64-msvc': 14.2.17
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|||||||
@ -18,6 +18,10 @@ const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
|
height: {
|
||||||
|
screen: '100vh',
|
||||||
|
'screen-small': '100svh',
|
||||||
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'sans-serif'],
|
sans: ['Inter', 'sans-serif'],
|
||||||
serif: ['var(--font-serif)', 'serif'],
|
serif: ['var(--font-serif)', 'serif'],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user