From 1efeae096003ae86ec5a911af3c3fc65a2aa03be Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 30 Jul 2026 00:04:28 +0700 Subject: [PATCH] feat(sasquatch): add Trip Planner + tougher free-tier fallback + new joke book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trip Planner: `plan_trip` tool orchestrates spots+events+coaches+crews+weather into a weather-aware, day-by-day itinerary card (TripItineraryCard). Actions: save all spots to a NIP-51 Collection, book/zap a coach via their profile, and deep-link out to flights/stays (assist, never fake-book, no KYC). - Free-tier resilience: local intent parser answers "spots/events/coaches/ crews/gear/weather in " and full trip plans directly via CVM even when both AI providers are rate-limited — no more dead-end fallback. - New shared joke book (sasquatchJokes.ts): skate humor + gentle roasts of the KYC-your-audience Bitcoin-influencer crowd. Idle joke on the empty chat state, ethos jokes in fallbacks. Dedupes the old inline joke arrays. - UX polish: taller chat scroll area, trip-planner suggested questions, FAQ entries for the planner. Co-authored-by: shakespeare.diy --- src/components/SasquatchGuide.tsx | 337 ++++++++++++++++++++++++++- src/components/TripItineraryCard.tsx | 203 ++++++++++++++++ src/hooks/useSasquatchGuide.ts | 52 +---- src/lib/sasquatchJokes.ts | 93 ++++++++ src/lib/tripPlanner.ts | 216 +++++++++++++++++ 5 files changed, 850 insertions(+), 51 deletions(-) create mode 100644 src/components/TripItineraryCard.tsx create mode 100644 src/lib/sasquatchJokes.ts create mode 100644 src/lib/tripPlanner.ts diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index ccf9396..db83ff0 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -22,6 +22,9 @@ import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchAc import { SasquatchActionCard } from '@/components/SasquatchActionCard'; import { callSasquatchAI } from '@/lib/sasquatchBrain'; import { answerFromGlossary } from '@/lib/glossary'; +import { ethosJoke } from '@/lib/sasquatchJokes'; +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 ngeohash from 'ngeohash'; @@ -61,6 +64,25 @@ async function fetchWeatherForCity(city: string): Promise { }); } +// Parse the JSON weather blob (from fetchWeatherForCity) into the lite shape +// the itinerary card uses. Returns undefined if weather is unavailable. +function parseWeatherLite(json: string): WeatherLite | undefined { + try { + const w = JSON.parse(json) as Record; + if (w.error) return undefined; + return { + temperature_c: typeof w.temperature_c === 'number' ? w.temperature_c : undefined, + condition: typeof w.condition === 'string' ? w.condition : undefined, + weatherIcon: typeof w.icon === 'string' ? w.icon : undefined, + precipitation_mm: typeof w.precipitation_mm === 'number' ? w.precipitation_mm : undefined, + skateability_label: typeof w.skateability_label === 'string' ? w.skateability_label : undefined, + skateability_score: typeof w.skateability_score === 'number' ? w.skateability_score : undefined, + }; + } catch { + return undefined; + } +} + // AI calls route through the shared Sasquatch brain (sasquatchBrain.ts), which // handles the free-model fallback list + rate-limit backoff/retry in one place. const callAI = callSasquatchAI; @@ -100,6 +122,8 @@ interface ChatMessage { draftStatus?: 'pending' | 'publishing' | 'done' | 'cancelled'; /** Success label after publishing the draft. */ draftResult?: string; + /** A generated trip itinerary (Trip Planner). */ + itinerary?: Itinerary; } // ─── System prompt for the AI ───────────────────────────────────────────────── @@ -156,6 +180,12 @@ Args: city(string) — city name or location Example: {"tool":"get_weather","args":{"city":"Bangkok"}} Example: {"tool":"get_weather","args":{"city":"Barcelona"}} +### plan_trip +Plan a day-by-day skate trip. Orchestrates spots + events + coaches + crews + weather for a destination into an itinerary card. Use this for "plan me N days in X", "skate trip to X", "what should I skate in X for a weekend". +Args: destination(string, city name), days?(number, default 3) +Example: {"tool":"plan_trip","args":{"destination":"Lisbon","days":3}} +Note: this returns a rich itinerary CARD — after calling it, add a short one-line hype intro, DON'T re-list everything. + ## Rules 1. Data questions (spots/events/coaches/gear/stats) → ALWAYS use a tool, never guess 2. Tool call = ONE JSON object on its own line, nothing else before or after @@ -243,6 +273,139 @@ function needsLiveData(question: string): boolean { return liveKeywords.some((kw) => q.includes(kw)); } +// ─── Local intent parser — a "no-AI" fallback for live-data questions ───────── +// When the AI brain is rate-limited/down, we can still serve the most common +// query ("spots in barcelona", "events in lisbon", "coaches in berlin", …) by +// parsing the intent + location ourselves and calling CVM directly. This makes +// the free tier feel solid even when OpenRouter/Pollinations are both busy. + +type LocalIntent = + | { tool: 'find_spots'; location: string } + | { tool: 'find_events'; location: string } + | { tool: 'find_coaches'; location: string } + | { tool: 'find_crews'; location: string } + | { tool: 'find_gear'; search: string } + | { tool: 'get_community_stats' } + | { tool: 'get_weather'; city: string } + | { tool: 'plan_trip'; destination: string; days: number }; + +function parseLocalIntent(question: string): LocalIntent | null { + const q = question.toLowerCase().trim(); + + // trip planning: "plan me 3 days in lisbon", "skate trip to berlin", "weekend in nyc" + const tripMatch = + q.match(/(?:plan|planning|make|build).*?(?:trip|days?|weekend|itinerary).*?(?:in|to|for)\s+(.+)/) || + q.match(/skate\s+trip\s+(?:to|in)\s+(.+)/) || + q.match(/(?:trip|itinerary|weekend)\s+(?:to|in)\s+(.+)/); + if (tripMatch) { + const daysMatch = q.match(/(\d+)\s*days?/) || (/(weekend)/.test(q) ? ['', '2'] : null); + const days = daysMatch ? parseInt(daysMatch[1], 10) || 3 : 3; + return { tool: 'plan_trip', destination: cleanLocation(tripMatch[1]), days }; + } + + // community stats + if (/\b(community )?stats\b|how many (spots|events|users|skaters)/.test(q)) { + return { tool: 'get_community_stats' }; + } + + // weather: "weather in X" / "how's the weather in X" + const weatherMatch = q.match(/weather (?:in|at|for) (.+)/) || q.match(/how'?s? the weather (?:in|at) (.+)/); + if (weatherMatch) { + return { tool: 'get_weather', city: cleanLocation(weatherMatch[1]) }; + } + + // Determine the subject + let tool: LocalIntent['tool'] | null = null; + if (/\bspots?\b|skateparks?|skate parks?|places? to skate/.test(q)) tool = 'find_spots'; + else if (/\bevents?\b|comps?|competitions?|jams?|meet ?ups?/.test(q)) tool = 'find_events'; + else if (/\bcoach(es)?\b|filmers?|photographers?|editors?|instructors?/.test(q)) tool = 'find_coaches'; + else if (/\bcrews?\b/.test(q)) tool = 'find_crews'; + else if (/\bgear\b|decks?|trucks?|wheels?|for sale|marketplace/.test(q)) tool = 'find_gear'; + + if (!tool) return null; + + if (tool === 'find_gear') { + // Strip common filler to leave a rough search term + const search = q + .replace(/\b(find|show me|search|any|for sale|gear|listings?|marketplace|buy|sell|used)\b/g, '') + .trim(); + return { tool: 'find_gear', search }; + } + + // Try to extract a location after "in/near/around/at" + const locMatch = q.match(/\b(?:in|near|around|at|from)\s+(.+)/); + const location = locMatch ? cleanLocation(locMatch[1]) : ''; + if (!location) return null; // no location → let the AI handle nuance, don't guess + + if (tool === 'find_spots') return { tool: 'find_spots', location }; + if (tool === 'find_events') return { tool: 'find_events', location }; + if (tool === 'find_coaches') return { tool: 'find_coaches', location }; + return { tool: 'find_crews', location }; +} + +function cleanLocation(raw: string): string { + return raw + .replace(/[?.!]+$/g, '') + .replace(/\b(please|thanks?|now|today|right now)\b/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +// Format a CVM result set into a friendly, no-AI bullet list. +function formatLocalResults(intent: LocalIntent, result: unknown): string { + const r = result as Record; + + if (intent.tool === 'get_community_stats') { + const s = r as Record; + const bits = Object.entries(s) + .filter(([, v]) => typeof v === 'number') + .map(([k, v]) => `• ${k.replace(/_/g, ' ')}: **${v}**`) + .join('\n'); + return `📊 **SkateSpots by the numbers**\n${bits}`; + } + + if (intent.tool === 'get_weather') { + try { + const text = (r.content as Array<{ text: string }>)?.[0]?.text; + const w = JSON.parse(text) as Record; + return `🌤️ **${w.city}** — ${w.condition} ${w.weatherIcon ?? ''}\n• ${w.temperature_c}°C (feels ${w.feels_like_c}°C)\n• Skateability: **${w.skateability_label}** (${w.skateability_score}/100)`; + } catch { + return `Couldn't read the weather right now. Try again in a sec. 🌧️`; + } + } + + const listKey = + intent.tool === 'find_spots' ? 'spots' : + intent.tool === 'find_events' ? 'events' : + intent.tool === 'find_coaches' ? 'profiles' : + intent.tool === 'find_crews' ? 'crews' : 'listings'; + const items = (r[listKey] as Array>) ?? []; + const label = + intent.tool === 'find_spots' ? 'spots' : + intent.tool === 'find_events' ? 'events' : + intent.tool === 'find_coaches' ? 'Lab pros' : + intent.tool === 'find_crews' ? 'crews' : 'gear listings'; + + if (!items.length) { + return `No ${label} listed there yet 🦧 — be the first to add some at [skate-spots.org](https://skate-spots.org). ${ethosJoke()}`; + } + + const lines = items.slice(0, 6).map((it) => { + const name = (it.name ?? it.title ?? it.role ?? 'Unnamed') as string; + const url = it.url as string | undefined; + const sub = (it.location ?? it.city ?? it.start ?? '') as string; + const linked = url ? `[${name}](${url})` : name; + return `• ${linked}${sub ? ` — ${String(sub).slice(0, 40)}` : ''}`; + }).join('\n'); + + const emoji = + intent.tool === 'find_spots' ? '🛹' : + intent.tool === 'find_events' ? '📅' : + intent.tool === 'find_coaches' ? '🧪' : + intent.tool === 'find_crews' ? '👥' : '🛍️'; + return `${emoji} Found **${items.length}** ${label}:\n${lines}`; +} + // ─── Static FAQ fallback (for protocol/app questions without AI) ────────────── const STATIC_FAQ: Record = { @@ -274,6 +437,8 @@ const STATIC_FAQ: Record = { 'supporters': "SkateSpots is community-funded via Geyser (Bitcoin Lightning crowdfunding) — no ads, no investors, no data selling. Check the Supporters page to chip in and keep the streets free. ❤️⚡", 'trust score': "Trust scores (0-100) powered by Relatr. Analyzes your Nostr network: followers, activity, engagement, zaps. Higher = more trusted. Helps filter bots and spam. Check any profile to see their score. 🛡️", 'what relay': "SkateSpots uses: 🛹 goskatespots.nostr1.com (primary, allowlisted for quality) + relay.ditto.pub (global) + purplepag.es (profile aggregator). Your data is replicated across all of them for redundancy.", + 'trip planner': "Tell me a city and I'll build a day-by-day skate trip 🗺️ — spots, events, coaches and local crews, weather-aware (rainy day = crew tip for covered spots). Try 'plan me 3 days in Lisbon'. I can save it all to a Collection, and hand you off to flights/stays on the real sites (no KYC, no middleman). 🛹", + 'plan a trip': "Just say 'plan me 3 days in Barcelona' (or any city) and I'll map out spots, sessions, coaches and local crews for each day. Save it as a Collection and go shred. 🗺️🛹", }; function getStaticAnswer(question: string): string | null { @@ -505,6 +670,35 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { [publishDraft, setDraftStatus, appendMessage], ); + // ── Trip Planner — orchestrate several CVM tools + weather into an itinerary ─ + const runTripPlanner = useCallback( + async (destination: string, days: number): Promise => { + // Fire the data queries together to keep it snappy. + const [spotsRes, eventsRes, coachesRes, crewsRes, weatherJson] = await Promise.allSettled([ + cvm.findSpots({ city: destination, limit: 20 }), + cvm.findEvents({ city: destination, limit: 20 }), + cvm.findCoaches({ location: destination, limit: 20 }), + cvm.findCrews({ city: destination, limit: 20 }), + fetchWeatherForCity(destination), + ]); + + const spots: PlannerSpot[] = + spotsRes.status === 'fulfilled' ? (spotsRes.value.spots as PlannerSpot[]) ?? [] : []; + const events: PlannerEvent[] = + eventsRes.status === 'fulfilled' ? (eventsRes.value.events as PlannerEvent[]) ?? [] : []; + const coaches: PlannerCoach[] = + coachesRes.status === 'fulfilled' ? (coachesRes.value.profiles as PlannerCoach[]) ?? [] : []; + const crews: PlannerCrew[] = + crewsRes.status === 'fulfilled' ? (crewsRes.value.crews as PlannerCrew[]) ?? [] : []; + const weather = + weatherJson.status === 'fulfilled' ? parseWeatherLite(weatherJson.value) : undefined; + + setCvmConnected(true); + return buildItinerary({ destination, days, spots, events, coaches, crews, weather }); + }, + [cvm], + ); + // ── Core: handle a user question ─────────────────────────────────────────── const handleSendMessage = useCallback(async (questionOverride?: string) => { @@ -642,6 +836,51 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { return; } + // ── plan_trip — build a day-by-day itinerary card ────────────────── + if (toolCall && toolCall.tool === 'plan_trip') { + const a = toolCall.args as { destination?: string; days?: number }; + const destination = String(a.destination ?? '').trim(); + if (!destination) { + appendMessage({ + role: 'sasquatch', + text: "Where to? Tell me a city (e.g. \"plan me 3 days in Lisbon\") and I'll build a trip. 🗺️", + timestamp: Date.now(), + }); + } else { + setStreamingText(`🗺️ Planning your ${destination} trip...`); + try { + const itinerary = await runTripPlanner(destination, typeof a.days === 'number' ? a.days : 3); + setStreamingText(''); + if (itinerary.empty) { + appendMessage({ + role: 'sasquatch', + text: `Nothing on the map for **${destination}** yet 🦧 — be the first to drop spots at [skate-spots.org](https://skate-spots.org). Once locals add spots, crews & events, I'll build you a full trip. ${ethosJoke()}`, + timestamp: Date.now(), + }); + } else { + appendMessage({ + role: 'sasquatch', + text: `Locked in a **${destination}** mission for you 🛹 — spots, sessions and locals below. Tweak it or save it.`, + timestamp: Date.now(), + isLive: true, + itinerary, + }); + } + } catch (planErr) { + console.warn('[Sasquatch] trip planner failed:', planErr); + setStreamingText(''); + appendMessage({ + role: 'sasquatch', + text: "Couldn't reach the live data to plan that right now. Try again in a moment! 🛹", + timestamp: Date.now(), + }); + } + } + setIsThinking(false); + setStreamingText(''); + return; + } + if (toolCall) { const { tool: toolName, args: toolArgs } = toolCall; setStreamingText(`🔍 Querying CVM for ${toolName.replace(/_/g, ' ')}...`); @@ -750,12 +989,87 @@ Instructions: } if (!aiSucceeded) { - // Last-resort local answers so the user still gets something useful - // even when the AI is unreachable: static FAQ, then glossary. + // ── No-AI fallback #1: try to serve live data locally ────────────── + // Parse the intent + location ourselves and call CVM directly. This is + // the big free-tier win: "spots in barcelona" still works even when + // both AI providers are rate-limited. + const intent = parseLocalIntent(question); + + // Trip planning gets its own rich-card path (not a text blob). + if (intent && intent.tool === 'plan_trip' && intent.destination) { + try { + setStreamingText(`🗺️ Planning your ${intent.destination} trip...`); + const itinerary = await runTripPlanner(intent.destination, intent.days); + setStreamingText(''); + if (itinerary.empty) { + appendMessage({ + role: 'sasquatch', + text: `Nothing on the map for **${intent.destination}** yet 🦧 — be the first at [skate-spots.org](https://skate-spots.org). ${ethosJoke()}`, + timestamp: Date.now(), + }); + } else { + appendMessage({ + role: 'sasquatch', + text: `Here's a **${intent.destination}** mission 🛹 — spots, sessions & locals below.`, + timestamp: Date.now(), + isLive: true, + itinerary, + }); + } + return; + } catch (planErr) { + console.warn('[Sasquatch] local trip planner failed:', planErr); + setStreamingText(''); + } + } + + if (intent && intent.tool !== 'plan_trip') { + try { + setStreamingText('🔍 Checking live data...'); + let localResult: unknown; + switch (intent.tool) { + case 'find_spots': + localResult = await cvm.findSpots({ city: intent.location, limit: 20 }); + break; + case 'find_events': + localResult = await cvm.findEvents({ city: intent.location, limit: 20 }); + break; + case 'find_coaches': + localResult = await cvm.findCoaches({ location: intent.location, limit: 20 }); + break; + case 'find_crews': + localResult = await cvm.findCrews({ city: intent.location, limit: 20 }); + break; + case 'find_gear': + localResult = await cvm.findGear({ search: intent.search, limit: 20 }); + break; + case 'get_community_stats': + localResult = await cvm.getCommunityStats(); + break; + case 'get_weather': + localResult = { content: [{ type: 'text', text: await fetchWeatherForCity(intent.city) }] }; + break; + } + setCvmConnected(true); + setStreamingText(''); + appendMessage({ + role: 'sasquatch', + text: formatLocalResults(intent, localResult), + timestamp: Date.now(), + isLive: true, + }); + return; + } catch (localErr) { + console.warn('[Sasquatch] local CVM fallback failed:', localErr); + setStreamingText(''); + } + } + + // ── No-AI fallback #2: static FAQ / glossary / friendly menu ──────── const fallback = staticAnswer ?? answerFromGlossary(question) ?? - "I couldn't reach my AI brain just now 🦧 — but I've still got you. Try:\n\n• 🗺️ [Map](/) — spots, shops, events near you\n• 🧪 [The Lab](/lab) — coaches, filmers, editors\n• 📖 [Glossary](/glossary) — 150+ tricks & terms\n• 👥 [Crews](/crews) — find your people\n\n…or ask me about a specific trick (e.g. \"what is a nollie backside flip\") and I'll answer straight from the glossary. 🛹"; + `My AI brain's catching some air right now 🦧 — but I've still got you:\n\n• 🗺️ [Map](/) — spots, shops, events near you\n• 🧪 [The Lab](/lab) — coaches, filmers, editors\n• 📖 [Glossary](/glossary) — 150+ tricks & terms\n• 👥 [Crews](/crews) — find your people\n\nOr just tell me a **city** ("spots in Barcelona") or a **trick** ("what's a nollie") and I'll answer straight up. 🛹\n\n_${ethosJoke()}_`; appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() }); } } catch (err) { @@ -769,7 +1083,7 @@ Instructions: setIsThinking(false); setStreamingText(''); } - }, [userInput, isThinking, chatMessages, user, cvm, personal, isPro, appendMessage, cvmConnected]); + }, [userInput, isThinking, chatMessages, user, cvm, personal, isPro, appendMessage, cvmConnected, runTripPlanner]); // Listen for external events from FAQ/About pages useEffect(() => { @@ -819,6 +1133,10 @@ Instructions: const inputDisabled = isThinking; + // A random "idle" joke so Sasquatch feels alive on the empty chat state. + // Recomputed only when the panel is (re)opened fresh, not on every render. + const idleJoke = useMemo(() => ethosJoke(), [showChat]); + // Build a fallback generic message for pages with no specific context const activeMessage = message ?? { id: 'generic', @@ -826,6 +1144,7 @@ Instructions: message: "Yo! I'm The Sasquatch. Ask me anything about spots, events, coaches, gear, or how the app works. I've got live data from the SkateSpots relay!", context: undefined, suggestedQuestions: [ + 'Plan me 3 days in Lisbon', 'Find spots near me', 'Any upcoming events?', 'What are crews?', @@ -992,6 +1311,13 @@ Instructions: ) : (
+ {/* Idle joke — a little personality on the empty state */} + {chatMessages.length === 0 && ( +
+ 🦧{idleJoke} +
+ )} + {/* Suggested questions */} {chatMessages.length === 0 && activeMessage.suggestedQuestions && (
@@ -1013,7 +1339,7 @@ Instructions: {/* Chat history */} {(chatMessages.length > 0 || streamingText) && ( - +
{chatMessages.map((msg, idx) => (
+ {msg.itinerary && } {msg.draft && (
0.5 || (w?.skateability_score ?? 100) < 40; + + const handleSave = async () => { + if (!user) { + toast({ title: 'Log in to save', description: 'Sign in to keep this trip as a Collection.' }); + return; + } + try { + await createCollection({ + title: `${itinerary.destination} skate trip 🛹`, + description: `Trip plan by The Sasquatch — ${itinerary.days.length} days in ${itinerary.destination}.`, + spotRefs: itinerary.allSpotRefs, + }); + setSaved(true); + toast({ title: 'Saved to Collections 🦧', description: `Your ${itinerary.destination} trip is bookmarked.` }); + } catch (err) { + toast({ + title: "Couldn't save", + description: err instanceof Error ? err.message : 'Try again in a sec.', + variant: 'destructive', + }); + } + }; + + return ( +
+ {/* Header */} +
+
+ 🛹 + + {itinerary.days.length} days in {itinerary.destination} + +
+ {w && ( +
+ {w.weatherIcon} {w.condition} + {typeof w.temperature_c === 'number' && · {Math.round(w.temperature_c)}°C} + {w.skateability_label && ( + = 60 ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'bg-orange-500/15 text-orange-300')}> + {w.skateability_label} + + )} +
+ )} + {rainy && ( +
+ + Wet forecast — hit a local crew for covered/indoor options. +
+ )} +
+ + {/* Days */} +
+ {itinerary.days.map((day) => ( +
+
+ 📅 {day.label} +
+ {day.stops.length === 0 ? ( +
Free skate — explore & film. 🎥
+ ) : ( +
+ {day.stops.map((stop, i) => { + const Icon = KIND_ICON[stop.kind]; + const inner = ( +
+ +
+
{stop.title}
+ {stop.subtitle && ( +
{stop.subtitle}
+ )} + {stop.kind === 'coach' && ( +
tap to book / zap ⚡
+ )} +
+
+ ); + return stop.url ? ( + + {inner} + + ) : ( +
{inner}
+ ); + })} +
+ )} +
+ ))} +
+ + {/* Local crews */} + {itinerary.crews.length > 0 && ( +
+
+ Local crews +
+
+ {itinerary.crews.map((c, i) => ( + + 👥 {c.name} + + ))} +
+
+ )} + + {/* Actions */} +
+ {itinerary.allSpotRefs.length > 0 && ( + + )} + + {/* External travel — assist, don't book */} +
+ {travelLinks(itinerary.destination).map((link) => ( + + {link.emoji} + {link.label} + + ))} +
+

+ I plan the skating — flights & stays open on the real sites (no KYC, no middleman). +

+
+
+ ); +} diff --git a/src/hooks/useSasquatchGuide.ts b/src/hooks/useSasquatchGuide.ts index 391655f..4b64f95 100644 --- a/src/hooks/useSasquatchGuide.ts +++ b/src/hooks/useSasquatchGuide.ts @@ -2,51 +2,11 @@ import { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import type { GuideMessage } from '@/components/SasquatchGuide'; - -// Sasquatch's skate jokes - randomly displayed in The Lab -const LAB_JOKES = [ - "Why did the skateboarder bring a ladder to The Lab? Because they heard the kickflips were on another level! 🛹😂", - "What do you call a sasquatch who can tre flip? A myth! But seriously, these coaches can teach you anything. 🦧", - "How do skaters stay cool in summer? They hang out in The Lab where the tricks are always fresh! ❄️", - "Why don't sasquatches need coaches? Because we're naturally board! But you humans should check out these pros. 🎓", - "What's a skateboarder's favorite type of music? BOARD rock! Get it? ...I'll see myself out. 🎸", - "Why did the skateboard go to therapy? It had too many issues with commitment... to grinds! 😅", - "I tried to teach a tree to skateboard once. It was great until it got board. Now it just stands there. 🌲", - "What's the difference between a bad skater and a pizza? A pizza can feed a family of four. But these coaches can fix that! 🍕", - "Why did the skater bring a camera to The Lab? To capture all the moments before they bail! 📸", - "How many skaters does it take to change a lightbulb? None - they're too busy trying to ollie over it! 💡" -]; - -// Map and spots jokes - mixed with skateboarding humor -const MAP_JOKES = [ - "Why did the skate spot go to the doctor? It had too many cracks! 😂 But seriously, report those conditions!", - "What do you call a sasquatch who knows every spot? A legend! And now you can be one too by adding spots to the map! 🗺️", - "Why did the skater bring a map to the skatepark? They heard the lines were off the charts! 📍", - "How do skate shops stay in business? They keep their inventory wheel-y organized! 🛒", - "What's a spot's favorite season? FALL... get it? Because that's when most people... nevermind. 🍂", - "Why don't spots ever get lonely? Because skaters always drop in! 😎", - "I once found a spot so perfect, I thought I was dreaming. Turned out I was just board. 💤", - "What do skate shops and sasquatches have in common? We both know where the best grip is! 🦧", - "Why did the ledge break up with the handrail? Too much grinding in the relationship! 💔", - "How do you make a skate spot laugh? Tell it a crack joke! The spot will split up! 🤣", - "What's a skater's favorite type of store? One with good boards, of course! And you can find them all here. 🛹", - "Why do sasquatches love maps? Because X marks the spot... and we ALWAYS find it! ❌" -]; - -// Crew jokes — for the Crews page -const CREW_JOKES = [ - "Why did the crew skate together? Because a board shared is a board doubled! 🤘", - "What do you call a sasquatch's skate crew? The Bigfoot Brigade — we leave big tracks! 🦧", - "A crew is like grip tape — sticks together and keeps everyone from slipping. 🛹", - "Why did the crew claim a home spot? So the locals always know where to drop in! 📍", - "What's better than landing a trick? Landing it with your crew watching. 🎥", - "Crews don't gatekeep spots — they open gates. Come roll with one! 🤘", -]; - -// Get a random joke from a specific category -function getRandomJoke(jokes: string[]): string { - return jokes[Math.floor(Math.random() * jokes.length)]; -} +import { + LAB_JOKES, + CREW_JOKES, + randomJoke as getRandomJoke, +} from '@/lib/sasquatchJokes'; /** * Sasquatch Guide - Context-aware helper for onboarding and tips @@ -68,13 +28,13 @@ export function useSasquatchGuide() { message: "Yo, I'm The Sasquatch. Keeper of boards, trails, and wild rides. Welcome to the untamed skate world on Nostr—no suits, no ads, just skaters carving their own path. Here, you own your tricks, your data, and even earn Bitcoin for leaving your mark. Connect with skaters in your hood or across the globe. No rules but shredding, no footprints but your own.", context: 'home', suggestedQuestions: [ + 'Plan me 3 days in Barcelona', 'Find beginner spots', 'Any upcoming events?', 'Community stats', 'Find coaches near me', 'Gear for sale?', 'What is Nostr?', - 'What are zaps?', 'Is this free?', ], action: { diff --git a/src/lib/sasquatchJokes.ts b/src/lib/sasquatchJokes.ts new file mode 100644 index 0000000..14da4ad --- /dev/null +++ b/src/lib/sasquatchJokes.ts @@ -0,0 +1,93 @@ +/** + * Sasquatch's joke book 🦧 + * ───────────────────────────────────────────────────────────────────────────── + * A shared, framework-agnostic library of skate jokes + gentle roasts of the + * "buy-my-course, KYC-your-sats" Bitcoin-influencer crowd. Used by the Sasquatch + * guide for idle personality and by the free-tier fallback so the bot always has + * something fun to say even when the AI brain is unreachable. + * + * Keep it playful, never mean-spirited. The running gag: SkateSpots is free, + * self-custodial and no-KYC — the opposite of the grifters. + */ + +// ─── Classic skate jokes ────────────────────────────────────────────────────── +export const SKATE_JOKES = [ + "Why did the skateboarder bring a ladder to the session? The kickflips were on another level. 🛹", + "What do you call a sasquatch who can tre flip? A myth. 🦧", + "Why did the skateboard go to therapy? Too many commitment issues... with grinds. 😅", + "I tried teaching a tree to skate. Went great till it got board. 🌲", + "Why did the ledge break up with the handrail? Too much grinding in the relationship. 💔", + "How many skaters to change a lightbulb? None — they're too busy trying to ollie over it. 💡", + "What's a skater's favorite music? BOARD rock. ...I'll show myself out. 🎸", + "Why don't spots get lonely? Skaters always drop in. 😎", + "Rain check literally means 'go check the mini-ramp under the bridge.' ☔🛹", + "Bail hard, tuck early, credit the spot. That's the whole philosophy. 🤙", +]; + +// ─── Nostr / self-custody / no-KYC jokes ───────────────────────────────────── +// The heart of the request: mock the influencer-industrial complex that tells +// you to be your own bank... on their KYC exchange, with their referral link. +export const BITCOIN_KYC_JOKES = [ + "A Bitcoin influencer told me to 'be my own bank' — right after making me upload my passport to *his* exchange. 🤡", + "Guy sells a $999 'sovereignty' course, then asks for your ID, your selfie, and your firstborn's SSN. Real free, that. 🪪", + "Not your keys, not your coins. Not your face-scan either — but the KYC bro forgot that part. 📸❌", + "Influencer: 'Bitcoin is freedom!' Also influencer: 'Sign up with my referral code and KYC in 3 easy steps.' 🙃", + "Some dude on a podcast: 'I'm early, I'm a whale.' Bro your cold wallet is a Coinbase screenshot. 🐋💀", + "'DYOR,' says the guy whose research was a paid tweet thread. Here on SkateSpots you keep your own keys. 🔑", + "KYC = Know Your Customer. Self-custody = Nobody's Business But Yours. Skate free. 🛹", + "They'll sell you a hardware wallet, a seed-phrase steel plate, AND a course on how to use them. Meanwhile you just... don't KYC. 😌", + "Orange-pill grifter voice: 'Have fun staying poor!' Says the man with 40 open exchange accounts. 🍊", + "A laser-eyes profile pic does not, in fact, secure your funds. Your keys do. And nobody scanned your face for those. 👀⚡", + "The most decentralized thing that influencer owns is his three burner Twitter accounts. 🐦", + "Zap a skater directly, no middleman, no 'creator monetization program,' no KYC. That's the whole flex. ⚡🤙", + "'Number go up' hits different when you didn't hand your ID to a hack-waiting-to-happen exchange. 📈🔒", + "The KYC bros want your data. The Sasquatch wants your best line. Only one of us respects your privacy. 🦧", + "Buy the dip, they said. Then leaked your dip AND your driver's license. Self-custody, kids. 🤦", +]; + +// ─── Location / map jokes ───────────────────────────────────────────────────── +export const MAP_JOKES = [ + "Why did the skate spot go to the doctor? Too many cracks. Report those conditions! 🩹", + "X marks the spot — and a sasquatch ALWAYS finds it. 🗺️", + "Found a spot so perfect I thought I was dreaming. Turns out I was just board. 💤", + "A map without your local spots is just... geography homework. Add yours. 📍", +]; + +// ─── The Lab jokes ──────────────────────────────────────────────────────────── +export const LAB_JOKES = [ + "Why did the skater bring a camera to The Lab? To capture the moment before they bail. 📸", + "Coaches here can teach anything from kickflips to darkslides. No influencer upsell, promise. 🎓", + "A good filmer makes your sketchy landing look like a Thrasher cover. 🎥", + "What's the difference between a bad skater and a pizza? A coach can fix one of them. 🍕", +]; + +// ─── Crew jokes ─────────────────────────────────────────────────────────────── +export const CREW_JOKES = [ + "A board shared is a board doubled. Roll with a crew. 🤘", + "A crew is like grip tape — sticks together, keeps everyone from slipping. 🛹", + "Crews don't gatekeep spots — they open gates. 🚪", + "What do you call a sasquatch's crew? The Bigfoot Brigade — big tracks, bigger sessions. 🦧", +]; + +/** All the jokes, for random idle personality across the app. */ +export const ALL_JOKES = [ + ...SKATE_JOKES, + ...BITCOIN_KYC_JOKES, + ...MAP_JOKES, + ...CREW_JOKES, +]; + +/** Pick a random joke from a category (or all). */ +export function randomJoke(pool: readonly string[] = ALL_JOKES): string { + return pool[Math.floor(Math.random() * pool.length)]; +} + +/** + * A joke pool that leans into the SkateSpots ethos — half skate humor, half + * gentle roasting of the KYC-your-audience Bitcoin-influencer crowd. Good for + * the free-tier "AI is napping" fallback where we want to stay charming. + */ +export function ethosJoke(): string { + const pool = Math.random() < 0.5 ? BITCOIN_KYC_JOKES : SKATE_JOKES; + return randomJoke(pool); +} diff --git a/src/lib/tripPlanner.ts b/src/lib/tripPlanner.ts new file mode 100644 index 0000000..95c852f --- /dev/null +++ b/src/lib/tripPlanner.ts @@ -0,0 +1,216 @@ +/** + * Trip Planner — types + itinerary builder for Sasquatch. + * ───────────────────────────────────────────────────────────────────────────── + * Sasquatch orchestrates the tools it already has (spots, events, coaches, + * crews, weather) into a day-by-day, weather-aware skate itinerary. No new + * infrastructure, no fake flight/hotel bookings — everything either lives on + * Nostr (spots/events/coaches/crews you can zap or RSVP) or deep-links out to + * the real travel sites for the parts we can't (and shouldn't) do ourselves. + */ + +import { SKATE_SPOT_KIND } from '@/lib/types'; + +// ─── Shared result shapes (mirror useCVMClient return items) ───────────────── + +export interface PlannerSpot { + name: string; + description?: string; + city?: string; + difficulty?: string; + features?: string[]; + url?: string; + author_pubkey?: string; + naddr?: string; +} + +export interface PlannerEvent { + title: string; + location?: string; + start?: string; // ISO + url?: string; + author_pubkey?: string; +} + +export interface PlannerCoach { + role: string; + pubkey: string; + bio?: string; + location?: string; + skills?: string[]; + lightning_address?: string; + url?: string; +} + +export interface PlannerCrew { + name: string; + location?: string; + vibes?: string[]; + url?: string; +} + +export interface WeatherLite { + temperature_c?: number; + condition?: string; + weatherIcon?: string; + precipitation_mm?: number; + skateability_label?: string; + skateability_score?: number; +} + +// ─── Itinerary model ───────────────────────────────────────────────────────── + +export interface ItineraryStop { + kind: 'spot' | 'event' | 'coach'; + title: string; + subtitle?: string; + url?: string; + /** For coaches: enables the "Book / Zap" action. */ + coachPubkey?: string; + lightningAddress?: string; + /** For spots: the NIP-51 collection coordinate ("30818:pubkey:d"). */ + spotRef?: string; +} + +export interface ItineraryDay { + dayNumber: number; + label: string; // e.g. "Day 1" + stops: ItineraryStop[]; +} + +export interface Itinerary { + destination: string; + days: ItineraryDay[]; + weather?: WeatherLite; + crews: PlannerCrew[]; + /** Everything we found, for the "save all spots to a collection" action. */ + allSpotRefs: string[]; + /** True when we found literally nothing to plan. */ + empty: boolean; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Derive a NIP-51 collection coordinate ("30818:pubkey:d") from a CVM spot. */ +export function spotRefFromSpot(spot: PlannerSpot): string | null { + if (!spot.url || !spot.author_pubkey) return null; + // url looks like https://skate-spots.org/spot/{pubkey}/{d} + const m = spot.url.match(/\/spot\/([0-9a-f]{64})\/(.+)$/); + if (!m) return null; + return `${SKATE_SPOT_KIND}:${m[1]}:${m[2]}`; +} + +/** + * Build a day-by-day itinerary from the raw tool results. Weather-aware: + * if it's rainy / low skateability, we surface a note and lead with a crew + * tip (locals know the covered/indoor options). Pure, no side effects. + */ +export function buildItinerary(opts: { + destination: string; + days: number; + spots: PlannerSpot[]; + events: PlannerEvent[]; + coaches: PlannerCoach[]; + crews: PlannerCrew[]; + weather?: WeatherLite; +}): Itinerary { + const dayCount = Math.max(1, Math.min(opts.days || 3, 7)); + + // Build a flat, prioritized pool of stops. + const spotStops: ItineraryStop[] = opts.spots.map((s) => ({ + kind: 'spot' as const, + title: s.name, + subtitle: [s.difficulty, s.city].filter(Boolean).join(' · ') || undefined, + url: s.url, + spotRef: spotRefFromSpot(s) ?? undefined, + })); + + const eventStops: ItineraryStop[] = opts.events.map((e) => ({ + kind: 'event' as const, + title: e.title, + subtitle: e.start ? new Date(e.start).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : e.location, + url: e.url, + })); + + const coachStops: ItineraryStop[] = opts.coaches.slice(0, 3).map((c) => ({ + kind: 'coach' as const, + title: c.role.charAt(0).toUpperCase() + c.role.slice(1), + subtitle: c.skills?.slice(0, 3).join(', ') || c.location, + url: c.url, + coachPubkey: c.pubkey, + lightningAddress: c.lightning_address, + })); + + // Interleave: spread spots across days, sprinkle events + one coach. + const days: ItineraryDay[] = []; + const spotsPerDay = Math.max(1, Math.ceil(spotStops.length / dayCount)); + + for (let d = 0; d < dayCount; d++) { + const stops: ItineraryStop[] = []; + // spots for this day + stops.push(...spotStops.slice(d * spotsPerDay, d * spotsPerDay + spotsPerDay).slice(0, 2)); + // an event on this day if available + if (eventStops[d]) stops.push(eventStops[d]); + // a coach on day 2 (a good "level up" day) + if (d === 1 && coachStops[0]) stops.push(coachStops[0]); + days.push({ dayNumber: d + 1, label: `Day ${d + 1}`, stops }); + } + + // If we had more events than days, append leftovers to the last day. + if (eventStops.length > dayCount) { + days[days.length - 1].stops.push(...eventStops.slice(dayCount)); + } + // Add any remaining coaches to day 1 as options. + if (coachStops.length > 1) { + days[0].stops.push(...coachStops.slice(1, 2)); + } + + const allSpotRefs = spotStops + .map((s) => s.spotRef) + .filter((r): r is string => !!r); + + const empty = + spotStops.length === 0 && + eventStops.length === 0 && + coachStops.length === 0 && + opts.crews.length === 0; + + return { + destination: opts.destination, + days, + weather: opts.weather, + crews: opts.crews.slice(0, 3), + allSpotRefs, + empty, + }; +} + +// ─── External travel deep-links (assist, never book) ───────────────────────── +// Sasquatch does NOT book flights/hotels. It hands you off to the real sites +// with your destination pre-filled. No KYC, no middleman, no liability. + +export interface TravelLink { + label: string; + url: string; + emoji: string; +} + +export function travelLinks(destination: string): TravelLink[] { + const q = encodeURIComponent(destination); + return [ + { + label: 'Flights', + emoji: '✈️', + url: `https://www.google.com/travel/flights?q=${q}`, + }, + { + label: 'Stays', + emoji: '🏨', + url: `https://www.google.com/travel/search?q=${q}%20hotels`, + }, + { + label: 'Getting around', + emoji: '🗺️', + url: `https://www.openstreetmap.org/search?query=${q}`, + }, + ]; +}