Added <ImageWithFallback> — a drop-in <img> that shows a subtle placeholder (skate emoji or a caller-supplied icon) instead of the browser's broken-image glyph when a user-supplied URL is missing or 404s. Fills the same layout box so nothing shifts. Swapped it into the content-image surfaces that display user-uploaded URLs: - Marketplace item cards (grid + purchase dialog) - Spot detail (hero + gallery thumbs) and map list/popup cards - Shop detail (hero + gallery) - Crew & Lab cross-link cards (icon fallback) - Crew agent panel, newsletter issue view + notification bell newsletter thumb - Stash gear cards (list + grid), profile banner (NIP19), newsletter spot cards Left untouched: avatars (shadcn Avatar already has fallbacks), QR codes and rider-card/print images (data URLs, never 404), and in-form upload previews (local object URLs). Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
264 lines
9.4 KiB
TypeScript
264 lines
9.4 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { ImageWithFallback } from '@/components/ImageWithFallback';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Send, Loader2, MapPin, ChevronRight, Sparkles } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { CREW_AGENT_AVATAR } from '@/lib/crewAgent';
|
|
import { useCrewAgentChat, type CrewContext, type CrewAgentMessage } from '@/hooks/useCrewAgentChat';
|
|
|
|
interface CrewAgentPanelProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
ctx: CrewContext;
|
|
}
|
|
|
|
/** Turn a skate-spots.org URL into an in-app path so links stay inside the app. */
|
|
function toAppPath(url: string): string {
|
|
try {
|
|
return new URL(url).pathname;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
interface ReceiptItem {
|
|
key: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
image?: string;
|
|
to: string;
|
|
}
|
|
|
|
/** Normalise a CVM tool result into flat receipt cards. */
|
|
function toReceiptItems(tool: string, data: unknown): ReceiptItem[] {
|
|
const d = data as Record<string, unknown>;
|
|
const take = <T,>(arr: unknown): T[] => (Array.isArray(arr) ? (arr as T[]).slice(0, 6) : []);
|
|
|
|
if (tool === 'find_spots') {
|
|
return take<Record<string, string>>(d.spots).map((s, i) => ({
|
|
key: `${s.url}-${i}`,
|
|
title: s.name || 'Spot',
|
|
subtitle: [s.difficulty, s.location].filter(Boolean).join(' · ') || undefined,
|
|
image: s.image,
|
|
to: toAppPath(s.url),
|
|
}));
|
|
}
|
|
if (tool === 'find_events') {
|
|
return take<Record<string, string>>(d.events).map((e, i) => ({
|
|
key: `${e.url}-${i}`,
|
|
title: e.title || 'Event',
|
|
subtitle: e.location,
|
|
image: e.image,
|
|
to: toAppPath(e.url),
|
|
}));
|
|
}
|
|
if (tool === 'find_coaches') {
|
|
return take<Record<string, string>>(d.profiles).map((p, i) => ({
|
|
key: `${p.url}-${i}`,
|
|
title: p.title || p.role || 'Lab pro',
|
|
subtitle: [p.role, p.location].filter(Boolean).join(' · ') || undefined,
|
|
image: p.image,
|
|
to: toAppPath(p.url),
|
|
}));
|
|
}
|
|
if (tool === 'find_gear') {
|
|
return take<Record<string, string>>(d.listings).map((g, i) => ({
|
|
key: `${g.url}-${i}`,
|
|
title: g.title || 'Gear',
|
|
subtitle: g.condition,
|
|
image: g.image,
|
|
to: toAppPath(g.url),
|
|
}));
|
|
}
|
|
if (tool === 'find_crews') {
|
|
return take<Record<string, string>>(d.crews).map((c, i) => ({
|
|
key: `${c.url}-${i}`,
|
|
title: c.name || 'Crew',
|
|
subtitle: c.location,
|
|
image: c.image,
|
|
to: toAppPath(c.url),
|
|
}));
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function ReceiptCards({ tool, data, onNavigate }: { tool: string; data: unknown; onNavigate: () => void }) {
|
|
const items = toReceiptItems(tool, data);
|
|
if (items.length === 0) return null;
|
|
return (
|
|
<div className="mt-2 space-y-1.5">
|
|
{items.map((item) => (
|
|
<Link
|
|
key={item.key}
|
|
to={item.to}
|
|
onClick={onNavigate}
|
|
className="group flex items-center gap-2.5 rounded-xl border border-teal-500/20 bg-white/[0.03] p-2 transition-all hover:border-teal-500/50 hover:bg-white/[0.06]"
|
|
>
|
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-teal-500/15">
|
|
<ImageWithFallback
|
|
src={item.image}
|
|
alt=""
|
|
className="h-full w-full object-cover"
|
|
fallback={<MapPin className="h-4 w-4 text-teal-400" />}
|
|
/>
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-bold text-white">{item.title}</p>
|
|
{item.subtitle && <p className="truncate text-xs text-gray-400">{item.subtitle}</p>}
|
|
</div>
|
|
<ChevronRight className="h-4 w-4 flex-shrink-0 text-gray-600 group-hover:text-teal-400" />
|
|
</Link>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MessageRow({ m, onNavigate }: { m: CrewAgentMessage; onNavigate: () => void }) {
|
|
const isAgent = m.role === 'agent';
|
|
return (
|
|
<div className={cn('flex gap-2.5', !isAgent && 'flex-row-reverse')}>
|
|
{isAgent && (
|
|
<Avatar className="h-8 w-8 flex-shrink-0">
|
|
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
|
|
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-[10px] font-black">SQ</AvatarFallback>
|
|
</Avatar>
|
|
)}
|
|
<div className={cn('flex max-w-[82%] flex-col', !isAgent && 'items-end')}>
|
|
<div
|
|
className={cn(
|
|
'rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words',
|
|
isAgent ? 'bg-muted rounded-tl-sm' : 'bg-teal-500 text-black rounded-tr-sm',
|
|
)}
|
|
>
|
|
{m.text}
|
|
</div>
|
|
{isAgent && m.receipts && (
|
|
<ReceiptCards tool={m.receipts.tool} data={m.receipts.data} onNavigate={onNavigate} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const SUGGESTIONS = [
|
|
"What's our next jam?",
|
|
'Who runs this crew?',
|
|
'What are we looking for?',
|
|
'Best spots near us?',
|
|
"What's the weather for skating?",
|
|
];
|
|
|
|
export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps) {
|
|
const { messages, isThinking, send } = useCrewAgentChat(ctx);
|
|
const [draft, setDraft] = useState('');
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
|
}, [messages, isThinking]);
|
|
|
|
const submit = (text: string) => {
|
|
const t = text.trim();
|
|
if (!t || isThinking) return;
|
|
setDraft('');
|
|
send(t);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="w-[calc(100%-1rem)] sm:max-w-md p-0 overflow-hidden bg-black border-teal-500/30 flex flex-col max-h-[85vh]">
|
|
<DialogHeader className="p-4 pb-3 border-b border-white/5">
|
|
<DialogTitle className="flex items-center gap-2 text-white">
|
|
<Avatar className="h-6 w-6">
|
|
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
|
|
<AvatarFallback className="bg-teal-900/40 text-teal-300 text-[9px] font-black">SQ</AvatarFallback>
|
|
</Avatar>
|
|
Ask Sasquatch
|
|
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
|
|
Crew Agent
|
|
</span>
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
Knows this crew — its jams, roster & open call — plus the wider scene with live data.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-3 min-h-[240px]">
|
|
{messages.length === 0 && !isThinking && (
|
|
<div className="flex flex-col items-center justify-center gap-3 py-6 text-center">
|
|
<Avatar className="h-12 w-12">
|
|
<AvatarImage src={CREW_AGENT_AVATAR} alt="Sasquatch" />
|
|
<AvatarFallback className="bg-teal-900/40 text-teal-300 font-black">SQ</AvatarFallback>
|
|
</Avatar>
|
|
<p className="text-sm text-muted-foreground max-w-[16rem]">
|
|
Yo! I'm Sasquatch, {ctx.crewName}'s guide. Ask me about the crew — our next jam,
|
|
who runs it, what we're after — or the wider scene.
|
|
</p>
|
|
<div className="flex flex-wrap justify-center gap-1.5">
|
|
{SUGGESTIONS.map((s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => submit(s)}
|
|
className="rounded-full border border-teal-500/30 bg-teal-500/10 px-3 py-1.5 text-xs font-semibold text-teal-300 transition-colors hover:bg-teal-500/20"
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((m) => (
|
|
<MessageRow key={m.id} m={m} onNavigate={() => onOpenChange(false)} />
|
|
))}
|
|
|
|
{isThinking && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-teal-400" />
|
|
Sasquatch is thinking…
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-white/5 p-3">
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
submit(draft);
|
|
}}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Input
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
placeholder="Ask about your local scene…"
|
|
maxLength={300}
|
|
className="flex-1"
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="icon"
|
|
disabled={isThinking || !draft.trim()}
|
|
className="bg-teal-500 hover:bg-teal-600 text-black shrink-0"
|
|
>
|
|
{isThinking ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
|
</Button>
|
|
</form>
|
|
<p className="mt-1.5 flex items-center justify-center gap-1 text-[10px] text-muted-foreground">
|
|
<Sparkles className="h-2.5 w-2.5" /> AI can be wrong — links go to real listings.
|
|
</p>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|