SkateSpots/src/components/TripItineraryCard.tsx
shakespeare.diy 1efeae0960 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>
2026-07-30 00:04:28 +07:00

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