SkateSpots/src/components/CrewAgentPanel.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
2026-10-02 10:11:06 +07:00

265 lines
9.6 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 { useTranslation } from '@/contexts/language';
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(t: (key: string) => string, 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 || t('socialUi.agentPanel.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 || t('socialUi.agentPanel.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 || t('socialUi.agentPanel.labPro'),
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 || t('socialUi.agentPanel.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 || t('socialUi.agentPanel.crew'),
subtitle: c.location,
image: c.image,
to: toAppPath(c.url),
}));
}
return [];
}
function ReceiptCards({ tool, data, onNavigate }: { tool: string; data: unknown; onNavigate: () => void }) {
const { t } = useTranslation();
const items = toReceiptItems(t, 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>
);
}
export function CrewAgentPanel({ open, onOpenChange, ctx }: CrewAgentPanelProps) {
const { t } = useTranslation();
const { messages, isThinking, send } = useCrewAgentChat(ctx);
const [draft, setDraft] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
const suggestions = [
t('socialUi.agentPanel.suggestion1'),
t('socialUi.agentPanel.suggestion2'),
t('socialUi.agentPanel.suggestion3'),
t('socialUi.agentPanel.suggestion4'),
t('socialUi.agentPanel.suggestion5'),
];
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>
{t('socialUi.crew.askSasquatch')}
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
{t('socialUi.crew.agent')}
</span>
</DialogTitle>
<DialogDescription className="text-xs">
{t('socialUi.agentPanel.description')}
</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]">
{t('socialUi.agentPanel.greeting', { crew: ctx.crewName })}
</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" />
{t('socialUi.agentPanel.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={t('socialUi.agentPanel.placeholder')}
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" /> {t('socialUi.agentPanel.disclaimer')}
</p>
</div>
</DialogContent>
</Dialog>
);
}