- 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>
216 lines
6.7 KiB
TypeScript
216 lines
6.7 KiB
TypeScript
/**
|
|
* 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}`,
|
|
},
|
|
];
|
|
}
|