diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 25ae903..c42b2af 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -517,7 +517,7 @@ When asked a "What is" question, maintain the same format as the question and an if (geocoding.results?.[0]?.geometry?.location) { let trimmedLat = geocoding.results[0].geometry.location.lat.toString().split('.'); - finalLat = parseFloat(trimmedLat[0] + '.' + trimmedLat[1].slice(0, 6)); + finalLat = parseFloat(trimmedLat[0] + '.' + trimmedLat[1].slice(0, 6)); let trimmedLng = geocoding.results[0].geometry.location.lng.toString().split('.'); finalLng = parseFloat(trimmedLng[0] + '.' + trimmedLng[1].slice(0, 6)); console.log('Using geocoded coordinates:', finalLat, finalLng); @@ -581,6 +581,8 @@ When asked a "What is" question, maintain the same format as the question and an const details = await detailsResponse.json(); + console.log(`Place details for "${place.name}":`, details); + // Fetch place photos let photos = []; try { @@ -611,24 +613,72 @@ When asked a "What is" question, maintain the same format as the question and an 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(); + + + // Get timezone for the location + const tzResponse = await fetch( + `https://maps.googleapis.com/maps/api/timezone/json?location=${details.latitude},${details.longitude}×tamp=${Math.floor(Date.now() / 1000)}&key=${process.env.GOOGLE_MAPS_API_KEY}` + ); + const tzData = await tzResponse.json(); + const timezone = tzData.timeZoneId || 'UTC'; + + // Process hours and status with timezone + const localTime = new Date(new Date().toLocaleString('en-US', { timeZone: timezone })); + const currentDay = localTime.getDay(); + const currentHour = localTime.getHours(); + const currentMinute = localTime.getMinutes(); + const currentTime = currentHour * 100 + currentMinute; let is_closed = true; - let next_open_close = ''; + let next_open_close = null; + let next_day = currentDay; if (details.hours?.periods) { - const todayPeriod = details.hours.periods.find((period: any) => - period.open?.day === currentDay - ); + // Sort periods by day and time for proper handling of overnight hours + const sortedPeriods = [...details.hours.periods].sort((a, b) => { + if (a.open.day !== b.open.day) return a.open.day - b.open.day; + return parseInt(a.open.time) - parseInt(b.open.time); + }); - 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; + // Find current or next opening period + for (let i = 0; i < sortedPeriods.length; i++) { + const period = sortedPeriods[i]; + const openTime = parseInt(period.open.time); + const closeTime = period.close ? parseInt(period.close.time) : 2359; + const periodDay = period.open.day; + + // Handle overnight hours + if (closeTime < openTime) { + // Place is open from previous day + if (currentDay === periodDay && currentTime < closeTime) { + is_closed = false; + next_open_close = period.close.time; + break; + } + // Place is open today and extends to tomorrow + if (currentDay === periodDay && currentTime >= openTime) { + is_closed = false; + next_open_close = period.close.time; + next_day = (periodDay + 1) % 7; + break; + } + } else { + // Normal hours within same day + if (currentDay === periodDay && currentTime >= openTime && currentTime < closeTime) { + is_closed = false; + next_open_close = period.close.time; + break; + } + } + + // Find next opening time if currently closed + if (is_closed) { + if ((periodDay > currentDay) || (periodDay === currentDay && openTime > currentTime)) { + next_open_close = period.open.time; + next_day = periodDay; + break; + } + } } } @@ -639,6 +689,7 @@ When asked a "What is" question, maintain the same format as the question and an lat: parseFloat(details.latitude || place.latitude || finalLat), lng: parseFloat(details.longitude || place.longitude || finalLng) }, + timezone, place_id: place.location_id, vicinity: place.address_obj?.address_string || '', distance: parseFloat(place.distance || '0'), @@ -654,6 +705,8 @@ When asked a "What is" question, maintain the same format as the question and an is_closed, hours: details.hours?.weekday_text || [], next_open_close, + next_day, + periods: details.hours?.periods || [], photos, source: details.source?.name || 'TripAdvisor' }; diff --git a/components/interactive-maps.tsx b/components/interactive-maps.tsx index 9962662..df10a7d 100644 --- a/components/interactive-maps.tsx +++ b/components/interactive-maps.tsx @@ -35,8 +35,7 @@ interface Place { phone?: string; website?: string; hours?: string[]; - distance?: string; - bearing?: string; + timezone?: string; } mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ''; @@ -45,8 +44,9 @@ interface InteractiveMapProps { center: Location; places: Place[]; selectedPlace: Place | null; - onPlaceSelect: (place: Place) => void; + onPlaceSelect: (place: Place | null) => void; className?: string; + viewMode?: 'map' | 'list'; } const InteractiveMap: React.FC = ({ @@ -54,7 +54,8 @@ const InteractiveMap: React.FC = ({ places, selectedPlace, onPlaceSelect, - className + className, + viewMode = 'map' }) => { const mapContainerRef = useRef(null); const mapRef = useRef(null); @@ -71,9 +72,9 @@ const InteractiveMap: React.FC = ({ mapRef.current = new mapboxgl.Map({ container: mapContainerRef.current, - style: 'mapbox://styles/mapbox/standard', + style: 'mapbox://styles/mapbox/light-v11', center: [center.lng, center.lat], - zoom: 13, + zoom: 14, attributionControl: false, }); @@ -81,14 +82,14 @@ const InteractiveMap: React.FC = ({ // Add minimal controls map.addControl( - new mapboxgl.NavigationControl({ showCompass: false }), - 'bottom-right', + new mapboxgl.NavigationControl({ showCompass: false, showZoom: true }), + 'bottom-right' ); // Compact attribution map.addControl( new mapboxgl.AttributionControl({ compact: true }), - 'bottom-right' + 'bottom-left' ); return () => { @@ -105,14 +106,14 @@ const InteractiveMap: React.FC = ({ Object.values(markersRef.current).forEach(marker => marker.remove()); markersRef.current = {}; - // Add new markers + // Create markers with click handlers places.forEach((place, index) => { - const isSelected = selectedPlace?.name === place.name; + const isSelected = selectedPlace?.place_id === place.place_id; // 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', + 'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-300 cursor-pointer shadow-md', isSelected ? 'bg-black text-white scale-110' : 'bg-white text-black hover:scale-105' @@ -135,7 +136,7 @@ const InteractiveMap: React.FC = ({ }); // Store marker reference - markersRef.current[place.name] = marker; + markersRef.current[place.place_id] = marker; }); }, [places, selectedPlace, handleMarkerClick]); @@ -153,7 +154,7 @@ const InteractiveMap: React.FC = ({ // If click wasn't on a marker, deselect if (!clickedMarker) { - onPlaceSelect(null as any); // Type cast to satisfy TS + onPlaceSelect(null); } }; @@ -164,17 +165,65 @@ const InteractiveMap: React.FC = ({ }; }, [onPlaceSelect]); - // Fly to selected place + // Fly to selected place with proper padding for list view useEffect(() => { if (!mapRef.current || !selectedPlace) return; - mapRef.current.flyTo({ - center: [selectedPlace.location.lng, selectedPlace.location.lat], - zoom: 15, + const map = mapRef.current; + const { clientWidth, clientHeight } = document.documentElement; + + // Calculate the actual width of list view (60% of viewport height in list mode) + const listHeight = viewMode === 'list' ? clientHeight * 0.6 : 0; + + // Set padding based on view mode + const padding = { + top: viewMode === 'list' ? listHeight : 50, + bottom: 50, + left: 50, + right: 50 + }; + + // Get coordinates of the target location + const coordinates: [number, number] = [selectedPlace.location.lng, selectedPlace.location.lat]; + + // Calculate the optimal zoom level + const currentZoom = map.getZoom(); + const targetZoom = currentZoom < 15 ? 15 : currentZoom; + + // Fly to location with padding + map.flyTo({ + center: coordinates, + zoom: targetZoom, + padding: padding, duration: 1500, - essential: true, + essential: true }); - }, [selectedPlace]); + + // Ensure padding is maintained after animation + setTimeout(() => { + if (mapRef.current) { + mapRef.current.setPadding(padding); + } + }, 1600); + + }, [selectedPlace, viewMode]); + + // Update map padding when view mode changes + useEffect(() => { + if (!mapRef.current) return; + + const { clientHeight } = document.documentElement; + const listHeight = viewMode === 'list' ? clientHeight * 0.6 : 0; + + const padding = { + top: viewMode === 'list' ? listHeight : 50, + bottom: 50, + left: 50, + right: 50 + }; + + mapRef.current.setPadding(padding); + }, [viewMode]); return (
diff --git a/components/nearby-search-map-view.tsx b/components/nearby-search-map-view.tsx index 017d0a6..e784bad 100644 --- a/components/nearby-search-map-view.tsx +++ b/components/nearby-search-map-view.tsx @@ -6,6 +6,7 @@ import PlaceCard from './place-card'; import { Badge } from './ui/badge'; + interface Location { lat: number; lng: number; @@ -38,8 +39,9 @@ interface Place { phone?: string; website?: string; hours?: string[]; - distance?: string; + distance?: number; bearing?: string; + timezone?: string; } // Dynamic import for the map component diff --git a/components/place-card.tsx b/components/place-card.tsx index 68fa32b..14cfa3e 100644 --- a/components/place-card.tsx +++ b/components/place-card.tsx @@ -1,9 +1,14 @@ /* eslint-disable @next/next/no-img-element */ -import React from 'react'; +import React, { useState } from 'react'; +import { DateTime } from 'luxon'; import { cn } from "@/lib/utils"; import { Button } from '@/components/ui/button'; -import PlaceholderImage from './placeholder-image'; - +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import { + MapPin, Star, ExternalLink, Navigation, Globe, Phone, ChevronDown, ChevronUp, + Clock +} from 'lucide-react'; interface Location { lat: number; @@ -37,8 +42,9 @@ interface Place { phone?: string; website?: string; hours?: string[]; - distance?: string; + distance?: number; bearing?: string; + timezone?: string; } interface PlaceCardProps { @@ -48,133 +54,191 @@ interface PlaceCardProps { variant?: 'overlay' | 'list'; } + +const HoursSection: React.FC<{ hours: string[]; timezone?: string }> = ({ hours, timezone }) => { + const [isOpen, setIsOpen] = useState(false); + const now = timezone ? + DateTime.now().setZone(timezone) : + DateTime.now(); + const currentDay = now.weekdayLong; + + if (!hours?.length) return null; + + // Find today's hours + const todayHours = hours.find(h => h.startsWith(currentDay!))?.split(': ')[1] || 'Closed'; + + return ( +
+
{ + e.stopPropagation(); + setIsOpen(!isOpen); + }} + className={cn( + "mt-4 flex items-center gap-2 cursor-pointer transition-colors", + "text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-200" + )} + > +
+ + Today: {todayHours} +
+ +
+ +
+
+
+ {hours.map((timeSlot, idx) => { + const [day, hours] = timeSlot.split(': '); + const isToday = day === currentDay; + + return ( +
+ + {day} + + + {hours} + +
+ ); + })} +
+
+
+
+ ); +}; + + const PlaceCard: React.FC = ({ place, onClick, isSelected = false, variant = 'list' }) => { + const [showHours, setShowHours] = useState(false); 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 formatTime = (timeStr: string | undefined, timezone: string | undefined): string => { + if (!timeStr || !timezone) return ''; + const hours = Math.floor(parseInt(timeStr) / 100); + const minutes = parseInt(timeStr) % 100; + return DateTime.now() + .setZone(timezone) + .set({ hour: hours, minute: minutes }) + .toFormat('h:mm a'); }; - const isValidNumber = (num: any): boolean => { - if (num === undefined || num === null) return false; - const parsed = Number(num); - return !isNaN(parsed) && isFinite(parsed) && parsed !== 0; + const getStatusDisplay = (): { text: string; color: string } | null => { + if (!place.timezone || place.is_closed === undefined || !place.next_open_close) { + return null; + } + + const timeStr = formatTime(place.next_open_close, place.timezone); + if (place.is_closed) { + return { + text: `Closed · Opens ${timeStr}`, + color: 'red-600 dark:text-red-400' + }; + } + return { + text: `Open · Closes ${timeStr}`, + color: 'green-600 dark:text-green-400' + }; }; - const formatRating = (rating: any): string => { - if (!isValidNumber(rating)) return ''; - const parsed = Number(rating); - return parsed.toFixed(1); - }; + const statusDisplay = getStatusDisplay(); - return ( -
-
- {/* Image Container */} -
- {place.photos?.[0]?.medium ? ( + const cardContent = ( + <> +
+ {/* Image with Price Badge */} + {place.photos?.[0]?.medium && ( +
{place.name} - ) : ( - - )} -
- -
- {/* Title Section */} -
-

- {place.name} -

- - {isValidNumber(place.rating) && ( -
- - {formatRating(place.rating)} - - {isValidNumber(place.reviews_count) && ( - - ({place.reviews_count} reviews) - - )} + {place.price_level && ( +
+ {place.price_level}
)}
+ )} - {/* Status & Info */} -
- {place.is_closed !== undefined && ( - - {place.is_closed ? "Closed" : "Open now"} - - )} - {isValidString(place.next_open_close) && ( - <> - · - - until {place.next_open_close} - - - )} - {isValidString(place.type) && ( - <> - · - - {place.type} - - - )} - {isValidString(place.price_level) && ( - <> - · - - {place.price_level} - - - )} +
+
+
+

+ {place.name} +

+ + {/* Rating & Reviews */} + {place.rating && ( +
+ + {place.rating.toFixed(1)} + {place.reviews_count && ( + ({place.reviews_count}) + )} +
+ )} + + {/* Status */} + {statusDisplay && ( +
+ {statusDisplay.text} +
+ )} + + {/* Address */} + {place.vicinity && ( +
+ + {place.vicinity} +
+ )} +
- {/* Description */} - {isValidString(place.description) && ( -

- {place.description} -

- )} - {/* Action Buttons */} -
+
- {isValidString(place.website) && ( + + {place.phone && ( - )} - {isValidString(place.phone) && ( - )} - {isValidString(place.place_id) && ( + + {place.website && ( + )} + + {place.place_id && !isOverlay && ( + )}
-
+ + {/* Hours Section - Only show if has hours */} + {place.hours && place.hours.length > 0 && ( + + )} + + ); + + if (isOverlay) { + return ( +
+ {cardContent} +
+ ); + } + + return ( + + {cardContent} + ); }; diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 0000000..9fa4894 --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/package.json b/package.json index faaa034..a57f969 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@foobar404/wave": "^2.0.5", "@mendable/firecrawl-js": "^1.4.3", "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.1", @@ -56,6 +57,7 @@ "highlight.js": "^11.10.0", "katex": "^0.16.11", "lucide-react": "^0.424.0", + "luxon": "^3.5.0", "mapbox-gl": "^3.7.0", "marked-react": "^2.0.0", "next": "^14.2.17", @@ -80,6 +82,7 @@ }, "devDependencies": { "@types/google.maps": "^3.55.12", + "@types/luxon": "^3.4.2", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9fd673..16a327d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ dependencies: version: 0.0.51(zod@3.23.8) '@ai-sdk/cohere': specifier: latest - version: 0.0.28(zod@3.23.8) + version: 1.0.0(zod@3.23.8) '@ai-sdk/google': specifier: ^0.0.55 version: 0.0.55(zod@3.23.8) @@ -38,6 +38,9 @@ dependencies: '@radix-ui/react-accordion': specifier: ^1.2.0 version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.1 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) @@ -146,6 +149,9 @@ dependencies: lucide-react: specifier: ^0.424.0 version: 0.424.0(react@18.3.1) + luxon: + specifier: ^3.5.0 + version: 3.5.0 mapbox-gl: specifier: ^3.7.0 version: 3.7.0 @@ -214,6 +220,9 @@ devDependencies: '@types/google.maps': specifier: ^3.55.12 version: 3.58.1 + '@types/luxon': + specifier: ^3.4.2 + version: 3.4.2 '@types/node': specifier: ^20 version: 20.16.11 @@ -270,14 +279,14 @@ packages: zod: 3.23.8 dev: false - /@ai-sdk/cohere@0.0.28(zod@3.23.8): - resolution: {integrity: sha512-aWavC7PuC5AcDcwTBcvx2ZemudtjPaZvLDmcVjiIhzvvW2+zoHoWmERD8CtSWIMlQp/vjdTcwNhSXTOqfxm27A==} + /@ai-sdk/cohere@1.0.0(zod@3.23.8): + resolution: {integrity: sha512-iN2Ww2VeRnprQBJ7dCp65DtdzCY/53+CA3UmM7Rhn8IZCTqRHkVoZebzv3ZOTb9pikO4CUWqV9yh1oUhQDgyow==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 dependencies: - '@ai-sdk/provider': 0.0.26 - '@ai-sdk/provider-utils': 1.0.22(zod@3.23.8) + '@ai-sdk/provider': 1.0.0 + '@ai-sdk/provider-utils': 2.0.0(zod@3.23.8) zod: 3.23.8 dev: false @@ -416,6 +425,22 @@ packages: zod: 3.23.8 dev: false + /@ai-sdk/provider-utils@2.0.0(zod@3.23.8): + resolution: {integrity: sha512-uITgVJByhtzuQU2ZW+2CidWRmQqTUTp6KADevy+4aRnmILZxY2LCt+UZ/ZtjJqq0MffwkuQPPY21ExmFAQ6kKA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.0.0 + peerDependenciesMeta: + zod: + optional: true + dependencies: + '@ai-sdk/provider': 1.0.0 + eventsource-parser: 3.0.0 + nanoid: 5.0.8 + secure-json-parse: 2.7.0 + zod: 3.23.8 + dev: false + /@ai-sdk/provider@0.0.22: resolution: {integrity: sha512-smZ1/2jL/JSKnbhC6ama/PxI2D/psj+YAe0c0qpd5ComQCNFltg72VFf0rpUSFMmFuj1pCCNoBOCrvyl8HTZHQ==} engines: {node: '>=18'} @@ -444,6 +469,13 @@ packages: json-schema: 0.4.0 dev: false + /@ai-sdk/provider@1.0.0: + resolution: {integrity: sha512-Sj29AzooJ7SYvhPd+AAWt/E7j63E9+AzRnoMHUaJPRYzOd/WDrVNxxv85prF9gDcQ7XPVlSk9j6oAZV9/DXYpA==} + engines: {node: '>=18'} + dependencies: + json-schema: 0.4.0 + dev: false + /@ai-sdk/react@0.0.70(react@18.3.1)(zod@3.23.8): resolution: {integrity: sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==} engines: {node: '>=18'} @@ -1843,6 +1875,10 @@ packages: resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} dev: false + /@types/luxon@3.4.2: + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + dev: true + /@types/mapbox-gl@3.4.0: resolution: {integrity: sha512-tbn++Mm94H1kE7W6FF0oVC9rMXHVzDDNUbS7KfBMRF8NV/8csFi+67ytKcZJ4LsrpsJ+8MC6Os6ZinEDCsrunw==} dependencies: @@ -3528,6 +3564,11 @@ packages: engines: {node: '>=14.18'} dev: false + /eventsource-parser@3.0.0: + resolution: {integrity: sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==} + engines: {node: '>=18.0.0'} + dev: false + /extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} dev: false @@ -4613,6 +4654,11 @@ packages: react: 18.3.1 dev: false + /luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + dev: false + /magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} dependencies: @@ -5205,6 +5251,12 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + /nanoid@5.0.8: + resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==} + engines: {node: ^18 || >=20} + hasBin: true + dev: false + /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true