SkateSpots/src/components/CrewAgentPanel.tsx
shakespeare.diy c9b0571de9 feat(ux): graceful image fallbacks for broken content images (1.2.18)
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>
2026-09-07 19:46:40 +07:00

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