feat(sasquatch): add Trip Planner + tougher free-tier fallback + new joke book

- 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 <city>" 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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-30 00:04:28 +07:00
parent 5655fbc976
commit 1efeae0960
5 changed files with 850 additions and 51 deletions

View file

@ -22,6 +22,9 @@ import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchAc
import { SasquatchActionCard } from '@/components/SasquatchActionCard'; import { SasquatchActionCard } from '@/components/SasquatchActionCard';
import { callSasquatchAI } from '@/lib/sasquatchBrain'; import { callSasquatchAI } from '@/lib/sasquatchBrain';
import { answerFromGlossary } from '@/lib/glossary'; 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 { cn } from '@/lib/utils';
import { fetchWeather, calculateSkateability } from '@/lib/weather'; import { fetchWeather, calculateSkateability } from '@/lib/weather';
import ngeohash from 'ngeohash'; import ngeohash from 'ngeohash';
@ -61,6 +64,25 @@ async function fetchWeatherForCity(city: string): Promise<string> {
}); });
} }
// 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<string, unknown>;
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 // AI calls route through the shared Sasquatch brain (sasquatchBrain.ts), which
// handles the free-model fallback list + rate-limit backoff/retry in one place. // handles the free-model fallback list + rate-limit backoff/retry in one place.
const callAI = callSasquatchAI; const callAI = callSasquatchAI;
@ -100,6 +122,8 @@ interface ChatMessage {
draftStatus?: 'pending' | 'publishing' | 'done' | 'cancelled'; draftStatus?: 'pending' | 'publishing' | 'done' | 'cancelled';
/** Success label after publishing the draft. */ /** Success label after publishing the draft. */
draftResult?: string; draftResult?: string;
/** A generated trip itinerary (Trip Planner). */
itinerary?: Itinerary;
} }
// ─── System prompt for the AI ───────────────────────────────────────────────── // ─── 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":"Bangkok"}}
Example: {"tool":"get_weather","args":{"city":"Barcelona"}} 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 ## Rules
1. Data questions (spots/events/coaches/gear/stats) → ALWAYS use a tool, never guess 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 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)); 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<string, unknown>;
if (intent.tool === 'get_community_stats') {
const s = r as Record<string, number | string>;
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<string, unknown>;
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<Record<string, unknown>>) ?? [];
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) ────────────── // ─── Static FAQ fallback (for protocol/app questions without AI) ──────────────
const STATIC_FAQ: Record<string, string> = { const STATIC_FAQ: Record<string, string> = {
@ -274,6 +437,8 @@ const STATIC_FAQ: Record<string, string> = {
'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. ❤️⚡", '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. 🛡️", '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.", '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 { function getStaticAnswer(question: string): string | null {
@ -505,6 +670,35 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
[publishDraft, setDraftStatus, appendMessage], [publishDraft, setDraftStatus, appendMessage],
); );
// ── Trip Planner — orchestrate several CVM tools + weather into an itinerary ─
const runTripPlanner = useCallback(
async (destination: string, days: number): Promise<Itinerary> => {
// 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 ─────────────────────────────────────────── // ── Core: handle a user question ───────────────────────────────────────────
const handleSendMessage = useCallback(async (questionOverride?: string) => { const handleSendMessage = useCallback(async (questionOverride?: string) => {
@ -642,6 +836,51 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
return; 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) { if (toolCall) {
const { tool: toolName, args: toolArgs } = toolCall; const { tool: toolName, args: toolArgs } = toolCall;
setStreamingText(`🔍 Querying CVM for ${toolName.replace(/_/g, ' ')}...`); setStreamingText(`🔍 Querying CVM for ${toolName.replace(/_/g, ' ')}...`);
@ -750,12 +989,87 @@ Instructions:
} }
if (!aiSucceeded) { if (!aiSucceeded) {
// Last-resort local answers so the user still gets something useful // ── No-AI fallback #1: try to serve live data locally ──────────────
// even when the AI is unreachable: static FAQ, then glossary. // 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 = const fallback =
staticAnswer ?? staticAnswer ??
answerFromGlossary(question) ?? 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() }); appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() });
} }
} catch (err) { } catch (err) {
@ -769,7 +1083,7 @@ Instructions:
setIsThinking(false); setIsThinking(false);
setStreamingText(''); 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 // Listen for external events from FAQ/About pages
useEffect(() => { useEffect(() => {
@ -819,6 +1133,10 @@ Instructions:
const inputDisabled = isThinking; 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 // Build a fallback generic message for pages with no specific context
const activeMessage = message ?? { const activeMessage = message ?? {
id: 'generic', 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!", 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, context: undefined,
suggestedQuestions: [ suggestedQuestions: [
'Plan me 3 days in Lisbon',
'Find spots near me', 'Find spots near me',
'Any upcoming events?', 'Any upcoming events?',
'What are crews?', 'What are crews?',
@ -992,6 +1311,13 @@ Instructions:
) : ( ) : (
<div className="space-y-2.5"> <div className="space-y-2.5">
{/* Idle joke — a little personality on the empty state */}
{chatMessages.length === 0 && (
<div className="rounded-lg border border-[#ccff00]/10 bg-black/30 px-2.5 py-2 text-[10px] italic text-gray-400 leading-snug">
<span className="not-italic mr-1">🦧</span>{idleJoke}
</div>
)}
{/* Suggested questions */} {/* Suggested questions */}
{chatMessages.length === 0 && activeMessage.suggestedQuestions && ( {chatMessages.length === 0 && activeMessage.suggestedQuestions && (
<div className="space-y-1"> <div className="space-y-1">
@ -1013,7 +1339,7 @@ Instructions:
{/* Chat history */} {/* Chat history */}
{(chatMessages.length > 0 || streamingText) && ( {(chatMessages.length > 0 || streamingText) && (
<ScrollArea className="h-48 md:h-52 rounded-lg border border-white/5 bg-black/40 p-2"> <ScrollArea className="h-56 md:h-64 rounded-lg border border-white/5 bg-black/40 p-2">
<div className="space-y-2" ref={scrollRef}> <div className="space-y-2" ref={scrollRef}>
{chatMessages.map((msg, idx) => ( {chatMessages.map((msg, idx) => (
<div <div
@ -1034,6 +1360,7 @@ Instructions:
)} )}
</div> </div>
<div className="whitespace-pre-wrap"><ChatText text={msg.text} /></div> <div className="whitespace-pre-wrap"><ChatText text={msg.text} /></div>
{msg.itinerary && <TripItineraryCard itinerary={msg.itinerary} />}
{msg.draft && ( {msg.draft && (
<div className="mt-2"> <div className="mt-2">
<SasquatchActionCard <SasquatchActionCard

View file

@ -0,0 +1,203 @@
/**
* TripItineraryCard — Sasquatch's day-by-day skate trip plan.
* ─────────────────────────────────────────────────────────────────────────────
* Renders a structured itinerary (not a wall of text): weather header, days
* with spots/events/coaches, local crews, and honest actions —
* • Save all spots to a Collection (Nostr-native, logged-in only)
* • Book / message a coach (links to their profile → zap/DM there)
* • Deep-link out to flights/stays (we assist, we don't book)
*/
import { useState } from 'react';
import { Loader2, Check, MapPin, Calendar, GraduationCap, Users, CloudRain, Bookmark, Plane } from 'lucide-react';
import type { Itinerary } from '@/lib/tripPlanner';
import { travelLinks } from '@/lib/tripPlanner';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCreateSpotCollection } from '@/hooks/useSpotCollections';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
const KIND_ICON = {
spot: MapPin,
event: Calendar,
coach: GraduationCap,
} as const;
export function TripItineraryCard({ itinerary }: { itinerary: Itinerary }) {
const { user } = useCurrentUser();
const { createCollection, isPending } = useCreateSpotCollection();
const { toast } = useToast();
const [saved, setSaved] = useState(false);
const w = itinerary.weather;
const rainy = (w?.precipitation_mm ?? 0) > 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 (
<div
className="mt-2 rounded-lg border border-[#ccff00]/25 overflow-hidden"
style={{ background: 'linear-gradient(160deg, rgba(20,40,10,0.6), rgba(6,13,3,0.6))' }}
>
{/* Header */}
<div className="px-2.5 py-2 border-b border-[#ccff00]/15">
<div className="flex items-center gap-1.5">
<span className="text-[13px]">🛹</span>
<span className="font-black text-[11px] text-[#ccff00] uppercase tracking-wider truncate">
{itinerary.days.length} days in {itinerary.destination}
</span>
</div>
{w && (
<div className="mt-1 flex items-center gap-1.5 text-[9px] text-gray-300">
<span>{w.weatherIcon} {w.condition}</span>
{typeof w.temperature_c === 'number' && <span>· {Math.round(w.temperature_c)}°C</span>}
{w.skateability_label && (
<span className={cn('px-1 rounded font-bold',
(w.skateability_score ?? 0) >= 60 ? 'bg-[#ccff00]/15 text-[#ccff00]' : 'bg-orange-500/15 text-orange-300')}>
{w.skateability_label}
</span>
)}
</div>
)}
{rainy && (
<div className="mt-1 flex items-start gap-1 text-[9px] text-orange-300/90">
<CloudRain className="h-2.5 w-2.5 mt-0.5 flex-shrink-0" />
<span>Wet forecast — hit a local crew for covered/indoor options.</span>
</div>
)}
</div>
{/* Days */}
<div className="divide-y divide-white/5">
{itinerary.days.map((day) => (
<div key={day.dayNumber} className="px-2.5 py-2">
<div className="text-[9px] font-black text-[#ccff00]/70 uppercase tracking-widest mb-1">
📅 {day.label}
</div>
{day.stops.length === 0 ? (
<div className="text-[9px] text-gray-500 italic">Free skate — explore & film. 🎥</div>
) : (
<div className="space-y-1">
{day.stops.map((stop, i) => {
const Icon = KIND_ICON[stop.kind];
const inner = (
<div className="flex items-start gap-1.5">
<Icon className="h-2.5 w-2.5 mt-0.5 flex-shrink-0 text-[#ccff00]/70" />
<div className="min-w-0">
<div className="text-[10px] text-gray-100 truncate">{stop.title}</div>
{stop.subtitle && (
<div className="text-[8px] text-gray-500 truncate">{stop.subtitle}</div>
)}
{stop.kind === 'coach' && (
<div className="text-[8px] text-[#ccff00]/70">tap to book / zap ⚡</div>
)}
</div>
</div>
);
return stop.url ? (
<a
key={i}
href={stop.url}
target="_blank"
rel="noopener noreferrer"
className="block rounded px-1.5 py-1 hover:bg-[#ccff00]/5 transition-colors"
>
{inner}
</a>
) : (
<div key={i} className="px-1.5 py-1">{inner}</div>
);
})}
</div>
)}
</div>
))}
</div>
{/* Local crews */}
{itinerary.crews.length > 0 && (
<div className="px-2.5 py-2 border-t border-white/5">
<div className="text-[9px] font-black text-[#ccff00]/70 uppercase tracking-widest mb-1 flex items-center gap-1">
<Users className="h-2.5 w-2.5" /> Local crews
</div>
<div className="flex flex-wrap gap-1">
{itinerary.crews.map((c, i) => (
<a
key={i}
href={c.url}
target="_blank"
rel="noopener noreferrer"
className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-gray-200 hover:bg-[#ccff00]/10 hover:text-[#ccff00] transition-colors"
>
👥 {c.name}
</a>
))}
</div>
</div>
)}
{/* Actions */}
<div className="px-2.5 py-2 border-t border-[#ccff00]/15 space-y-1.5">
{itinerary.allSpotRefs.length > 0 && (
<button
onClick={handleSave}
disabled={isPending || saved}
className={cn(
'w-full flex items-center justify-center gap-1.5 rounded-md py-1.5 text-[10px] font-bold transition-colors',
saved
? 'bg-[#ccff00]/20 text-[#ccff00] cursor-default'
: 'bg-[#ccff00] text-black hover:bg-[#d4ff1a] disabled:opacity-50',
)}
>
{isPending ? (
<><Loader2 className="h-3 w-3 animate-spin" /> Saving…</>
) : saved ? (
<><Check className="h-3 w-3" /> Saved to Collections</>
) : (
<><Bookmark className="h-3 w-3" /> Save {itinerary.allSpotRefs.length} spots to a Collection</>
)}
</button>
)}
{/* External travel — assist, don't book */}
<div className="flex gap-1">
{travelLinks(itinerary.destination).map((link) => (
<a
key={link.label}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-white/10 py-1 text-[9px] text-gray-300 hover:border-[#ccff00]/40 hover:text-[#ccff00] transition-colors"
>
<span>{link.emoji}</span>
<span className="truncate">{link.label}</span>
</a>
))}
</div>
<p className="text-[8px] text-gray-600 flex items-center gap-1 justify-center">
<Plane className="h-2 w-2" /> I plan the skating — flights & stays open on the real sites (no KYC, no middleman).
</p>
</div>
</div>
);
}

View file

@ -2,51 +2,11 @@ import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { GuideMessage } from '@/components/SasquatchGuide'; import type { GuideMessage } from '@/components/SasquatchGuide';
import {
// Sasquatch's skate jokes - randomly displayed in The Lab LAB_JOKES,
const LAB_JOKES = [ CREW_JOKES,
"Why did the skateboarder bring a ladder to The Lab? Because they heard the kickflips were on another level! 🛹😂", randomJoke as getRandomJoke,
"What do you call a sasquatch who can tre flip? A myth! But seriously, these coaches can teach you anything. 🦧", } from '@/lib/sasquatchJokes';
"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)];
}
/** /**
* Sasquatch Guide - Context-aware helper for onboarding and tips * 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.", 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', context: 'home',
suggestedQuestions: [ suggestedQuestions: [
'Plan me 3 days in Barcelona',
'Find beginner spots', 'Find beginner spots',
'Any upcoming events?', 'Any upcoming events?',
'Community stats', 'Community stats',
'Find coaches near me', 'Find coaches near me',
'Gear for sale?', 'Gear for sale?',
'What is Nostr?', 'What is Nostr?',
'What are zaps?',
'Is this free?', 'Is this free?',
], ],
action: { action: {

93
src/lib/sasquatchJokes.ts Normal file
View file

@ -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);
}

216
src/lib/tripPlanner.ts Normal file
View file

@ -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}`,
},
];
}