- 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>
203 lines
8.4 KiB
TypeScript
203 lines
8.4 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|