diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index b2873aa..7c2eb39 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -14,7 +14,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useCVMClient } from '@/hooks/useCVMClient'; +import { useCVMClient, type SpotResult } from '@/hooks/useCVMClient'; import { usePersonalContext } from '@/hooks/usePersonalContext'; import { useSasquatchPro } from '@/hooks/useSasquatchPro'; import { useSasquatchAiStatus } from '@/hooks/useSasquatchAiStatus'; @@ -29,6 +29,9 @@ import { TripItineraryCard } from '@/components/TripItineraryCard'; import { buildItinerary, type Itinerary, type PlannerSpot, type PlannerEvent, type PlannerCoach, type PlannerCrew, type WeatherLite } from '@/lib/tripPlanner'; import { cn } from '@/lib/utils'; import { fetchWeather, calculateSkateability } from '@/lib/weather'; +import { getCurrentPosition } from '@/lib/geolocation'; +import { getLastLocation, saveLastLocation } from '@/lib/lastLocation'; +import { calculateDistance } from '@/lib/sortOptions'; import ngeohash from 'ngeohash'; // ─── Weather helper — fetch current conditions for a city/spot via Open-Meteo ── @@ -271,6 +274,7 @@ Use when the user is LOOKING FOR gear ("I need…", "wanted: …", "anyone selli Example: {"tool":"draft_action","args":{"action":"wtb_gear","title":"8.25 deck","price":6000,"category":"deck"}} Rule: for "my/mine" questions ALWAYS use a personal tool — never guess about the user's data. +Rule: you CANNOT see the user's location or GPS. For "closest / near me / nearby", NEVER guess a spot — say you can't see their location and point them to the map's 🎯 **Near Me** button, or ask for a city ("spots in Bangkok"). Rule: for "post/sell/list/want/set my status" requests use draft_action — never claim you posted something.`; // ─── Detect if a question needs live CVM data ───────────────────────────────── @@ -344,7 +348,7 @@ function parseLocalIntent(question: string): LocalIntent | null { if (/\bshops?\b|skate ?shops?|stores?/.test(q)) tool = 'find_shops'; else if (/\btours?\b|guides?\b/.test(q)) tool = 'find_tours'; else if (/\bevents?\b|comps?|competitions?|jams?|meet ?ups?/.test(q)) tool = 'find_events'; - else if (/\bcrews?\b/.test(q)) tool = 'find_crews'; + else if (/\bcrews?\b/.test(q) && !/\bspots?\b|skate ?parks?/.test(q)) tool = 'find_crews'; else if (/\bgear\b|decks?|trucks?|wheels?|for sale|marketplace/.test(q)) tool = 'find_gear'; else if (/\bcoach(es)?\b|filmers?|photographers?|editors?|instructors?|builders?|artists?|graffiti|tattoo|trainers?|physios?|musicians?|bands?|judges?/.test(q)) tool = 'find_coaches'; else if (/\bspots?\b|skateparks?|skate parks?|places? to skate/.test(q)) tool = 'find_spots'; @@ -838,6 +842,74 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { [cvm], ); + // ── Proximity — "closest spot to me". The fix is read ON DEVICE and used only + // to sort by distance; nothing is transmitted (not to relays, not to the AI). + const answerNearestSpots = useCallback(async (): Promise => { + let loc = getLastLocation(); + try { + setStreamingText('📍 Getting your location…'); + const fix = await getCurrentPosition(); + loc = { lat: fix.lat, lng: fix.lng, at: Date.now() }; + saveLastLocation(fix.lat, fix.lng); + } catch { + /* denied/unavailable — fall back to the cached fix if we have one */ + } + setStreamingText(''); + + if (!loc) { + appendMessage({ + role: 'sasquatch', + text: "I can't see your location — nothing ever leaves your device; I read it locally only when you ask, and the browser just declined. Tap 🎯 **Near Me** on the map, or tell me a city: \"spots in Bangkok\". 🛹", + timestamp: Date.now(), + }); + return true; + } + + try { + setStreamingText('🔍 Finding the closest spots…'); + const res = await cvm.findSpots({ limit: 500 }); + const nearest = (res.spots ?? []) + .map((s) => { + if (!s.geohash) return null; + try { + const { latitude, longitude } = ngeohash.decode(s.geohash); + return { s, km: calculateDistance(loc!.lat, loc!.lng, latitude, longitude) }; + } catch { + return null; + } + }) + .filter((x): x is { s: SpotResult; km: number } => !!x && Number.isFinite(x.km)) + .sort((a, b) => a.km - b.km) + .slice(0, 3); + setStreamingText(''); + setCvmConnected(true); + + if (!nearest.length) { + appendMessage({ + role: 'sasquatch', + text: 'No mapped spots near you yet 🦧 — [be the first to drop one](https://skate-spots.org/create-spot). 🛹', + timestamp: Date.now(), + isLive: true, + }); + return true; + } + const fmt = (km: number) => (km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`); + const list = nearest + .map(({ s, km }) => `• [${s.name}](${s.url}) — **${fmt(km)}**${s.city ? ` · ${s.city}` : ''}`) + .join('\n'); + appendMessage({ + role: 'sasquatch', + text: `Closest to you right now:\n\n${list}\n\n_Computed on your device — your location never left it._`, + timestamp: Date.now(), + isLive: true, + }); + return true; + } catch { + setStreamingText(''); + return false; + } + }, [cvm, appendMessage]); + // ── Core: handle a user question ─────────────────────────────────────────── const handleSendMessage = useCallback(async (questionOverride?: string) => { @@ -888,7 +960,20 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { return; } - // 1d. LOCAL FAST-PATH — the big speed win. + // 1d. PROXIMITY — "closest spot to me / near me". Handled HERE, on device: + // the fix is read locally and used ONLY to sort distances, so no location + // is sent anywhere (not to relays, not to the AI). Guarded against other + // subjects so "spots near my crew" isn't mistaken for "near me". + { + const wantsNearby = /\b(near ?me|nearby|close(?:st)?(?: to me| by me)?|around me|nearest|by me)\b/i.test(question); + const wantsSpots = /\b(spots?|skate ?parks?|parks?|places? to skate)\b/i.test(question); + const otherSubject = /\b(crews?|shops?|stores?|events?|comps?|jams?|gear|decks?|trucks?|wheels?|bearings?|shoes?|coach(?:es)?|filmers?|photographers?|editors?|builders?|tours?|guides?)\b/i.test(question); + if (wantsNearby && wantsSpots && !otherSubject) { + if (await answerNearestSpots()) return; + } + } + + // 1e. LOCAL FAST-PATH — the big speed win. // For clear finder questions ("spots in bangkok", "events in lisbon", // "coaches near berlin", "community stats", "weather in X") we can parse // the intent + location ourselves and go STRAIGHT to the relays — skipping @@ -1396,7 +1481,7 @@ Instructions: setIsThinking(false); setStreamingText(''); } - }, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner]); + }, [userInput, isThinking, chatMessages, cvm, personal, isPro, proof, user, appendMessage, runTripPlanner, answerNearestSpots]); // Listen for external events from FAQ/About pages useEffect(() => {