~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).
265 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
}
|