import { useState, useEffect } from "react"; import { useRoute, useLocation } from "wouter"; import { useGetTool, getGetToolQueryKey, useListToolRatings, getListToolRatingsQueryKey, useGetRatingDistribution, getGetRatingDistributionQueryKey, useGetToolRatingHistory, getGetToolRatingHistoryQueryKey, useCreateRating, getGetTopToolsQueryKey, getGetAnalyticsSummaryQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { format } from "date-fns"; import { Layout } from "@/components/layout"; import { RatingStars } from "@/components/rating-stars"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, Legend } from "recharts"; import { ExternalLink, Star, ArrowLeft, Plus, Pencil, Trash2, Link as LinkIcon, DollarSign, Euro, Bookmark } from "lucide-react"; import { Link } from "wouter"; import { useAuth } from "@/hooks/use-auth"; import { useWatchlist } from "@/hooks/use-watchlist"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { customFetch } from "@workspace/api-client-react"; const ratingSchema = z.object({ usefulness: z.number().min(1).max(5), usability: z.number().min(1).max(5), comment: z.string().optional(), reviewerName: z.string().optional(), }); type RatingFormValues = z.infer; export default function ToolDetail() { const [match, params] = useRoute("/tools/:id"); const [, setLocation] = useLocation(); const id = parseInt(params?.id || "0", 10); const queryClient = useQueryClient(); const { toast } = useToast(); const [isReviewFormOpen, setIsReviewFormOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const { user, isAdmin, hasFeature } = useAuth(); const { t } = useTranslation(); const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist(); const canManageCosts = hasFeature("costs"); const hasTrash = hasFeature("trash"); const deleteTool = useDeleteTool(); const [similarData, setSimilarData] = useState<{ manual: any[]; auto: any[] } | null>(null); const [similarLoading, setSimilarLoading] = useState(false); const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [linkToolId, setLinkToolId] = useState(""); const [linkType, setLinkType] = useState("similar"); const [linkNotes, setLinkNotes] = useState(""); const [costs, setCosts] = useState([]); const [costsLoading, setCostsLoading] = useState(false); const [costDialogOpen, setCostDialogOpen] = useState(false); const [editCost, setEditCost] = useState(null); const [costLicenseType, setCostLicenseType] = useState("subscription"); const [costBillingPeriod, setCostBillingPeriod] = useState("monthly"); const [costAmount, setCostAmount] = useState(""); const [costCurrency, setCostCurrency] = useState("EUR"); const [costNotes, setCostNotes] = useState(""); useEffect(() => { if (!id) return; setSimilarLoading(true); customFetch(`/api/tools/${id}/similar`) .then((data) => setSimilarData(data)) .catch(() => {}) .finally(() => setSimilarLoading(false)); }, [id]); async function handleCreateRelation() { if (!linkToolId) return; try { await customFetch(`/api/tools/${id}/relations`, { method: "POST", body: JSON.stringify({ relatedToolId: Number(linkToolId), relationType: linkType, notes: linkNotes || undefined }), }); toast({ title: "Relation created" }); setLinkDialogOpen(false); setLinkToolId(""); setLinkNotes(""); setLinkType("similar"); setSimilarData(await customFetch(`/api/tools/${id}/similar`)); } catch (err: any) { toast({ title: "Failed to create relation", description: err.data?.error ?? err.message, variant: "destructive" }); } } async function handleDeleteRelation(relationId: number) { try { await customFetch(`/api/tools/relations/${relationId}`, { method: "DELETE" }); toast({ title: "Relation deleted" }); setSimilarData(await customFetch(`/api/tools/${id}/similar`)); } catch { toast({ title: "Failed to delete relation", variant: "destructive" }); } } useEffect(() => { if (!id) return; setCostsLoading(true); customFetch(`/api/tools/${id}/costs`) .then((data) => setCosts(data ?? [])) .catch(() => {}) .finally(() => setCostsLoading(false)); }, [id]); async function handleSaveCost() { const url = editCost ? `/api/costs/${editCost.id}` : `/api/tools/${id}/costs`; const method = editCost ? "PATCH" : "POST"; const body: Record = { licenseType: costLicenseType, billingPeriod: costLicenseType === "subscription" ? costBillingPeriod : null, currency: costCurrency, notes: costNotes || null, }; if (costAmount) body.cost = costAmount; try { await customFetch(url, { method, body: JSON.stringify(body) }); toast({ title: editCost ? "Cost updated" : "Cost added" }); setCostDialogOpen(false); resetCostForm(); setCosts(await customFetch(`/api/tools/${id}/costs`)); } catch (err: any) { toast({ title: "Failed to save cost", description: err.data?.error ?? err.message, variant: "destructive" }); } } function resetCostForm() { setEditCost(null); setCostLicenseType("subscription"); setCostBillingPeriod("monthly"); setCostAmount(""); setCostCurrency("EUR"); setCostNotes(""); } function openEditCost(c: any) { setEditCost(c); setCostLicenseType(c.licenseType); setCostBillingPeriod(c.billingPeriod ?? "monthly"); setCostAmount(c.cost ?? ""); setCostCurrency(c.currency ?? "EUR"); setCostNotes(c.notes ?? ""); setCostDialogOpen(true); } async function handleDeleteCost(costId: number) { try { await customFetch(`/api/costs/${costId}`, { method: "DELETE" }); toast({ title: "Cost deleted" }); setCosts(await customFetch(`/api/tools/${id}/costs`)); } catch { toast({ title: "Failed to delete cost", variant: "destructive" }); } } const { data: tool, isLoading: loadingTool } = useGetTool(id, { query: { enabled: !!id, queryKey: getGetToolQueryKey(id) } }); const { data: ratings, isLoading: loadingRatings } = useListToolRatings(id, { query: { enabled: !!id, queryKey: getListToolRatingsQueryKey(id) } }); const { data: distribution, isLoading: loadingDistribution } = useGetRatingDistribution({ toolId: id }, { query: { enabled: !!id, queryKey: getGetRatingDistributionQueryKey({ toolId: id }) } }); const { data: ratingHistory, isLoading: loadingRatingHistory } = useGetToolRatingHistory(id, { query: { enabled: !!id, queryKey: getGetToolRatingHistoryQueryKey(id) } }); const createRating = useCreateRating(); const form = useForm({ resolver: zodResolver(ratingSchema), defaultValues: { usefulness: 0, usability: 0, comment: "", reviewerName: "", }, }); const onSubmit = (data: RatingFormValues) => { createRating.mutate({ id, data }, { onSuccess: () => { toast({ title: "Rating submitted", description: "Thank you for your feedback!", }); setIsReviewFormOpen(false); form.reset(); // Invalidate queries queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) }); queryClient.invalidateQueries({ queryKey: getListToolRatingsQueryKey(id) }); queryClient.invalidateQueries({ queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }); queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() }); }, onError: (error) => { toast({ title: "Failed to submit rating", description: error.data?.error || error.message || "An unexpected error occurred.", variant: "destructive" }); } }); }; if (!match || isNaN(id)) { return (

Invalid Tool ID

); } const usefulnessData = distribution?.usefulness.map(b => ({ score: b.score, count: b.count })).reverse() || []; const usabilityData = distribution?.usability.map(b => ({ score: b.score, count: b.count })).reverse() || []; function canEdit(toolData: { createdBy?: string | null }): boolean { if (!user) return false; if (isAdmin) return true; return toolData.createdBy === user.sub || toolData.createdBy === user.preferredUsername; } function handleDelete() { deleteTool.mutate( { id }, { onSuccess: () => { toast({ title: "Tool deleted" }); queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() }); queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() }); queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() }); setLocation("/tools"); }, onError: (err) => { toast({ title: "Failed to delete", description: err.data?.error || err.message, variant: "destructive" }); setDeleteOpen(false); }, }, ); } return ( <>
{/* Header Section */} {loadingTool ? (
) : tool ? (
{tool.iconUrl ? ( {tool.name} { (e.target as HTMLImageElement).style.display = "none"; }} /> ) : ( {tool.name.charAt(0).toUpperCase()} )}

{tool.name}

{tool.category}

{tool.description}

{tool.tags?.map(tag => ( {tag} ))}
{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"} / 5
{t("detail.basedOnReview", { count: tool.ratingCount })}
{canWatchlist && ( )} {tool.websiteUrl && ( )} {canEdit(tool) && (
)}
{tool.features && tool.features.length > 0 && (

Key Features

    {tool.features.map((feature, i) => (
  • {feature}
  • ))}
)}
) : (
Tool not found.
)} {/* Similar Tools Section */} {tool && (

Similar Tools

{isAdmin && ( )}
{similarLoading ? (
{Array.from({ length: 3 }).map((_, i) => )}
) : similarData && (similarData.manual.length > 0 || similarData.auto.length > 0) ? (
{similarData.manual.map((item: any) => (
{item.name} {item.relationType === "superseded_by" ? "replaces" : item.relationType}
{item.category} {item.avgCombined != null && ( <> · {item.avgCombined.toFixed(1)} )}
{item.notes &&

{item.notes}

}
{isAdmin && ( )}
))} {similarData.auto.map((item: any) => (
{item.name}
{item.category} {item.avgCombined != null && ( <> · {item.avgCombined.toFixed(1)} )} · Score: {item.score}
))}
) : similarData && similarData.manual.length === 0 && similarData.auto.length === 0 ? (

No similar tools found.

) : null}
)} {/* Link Tool Dialog */} Link Similar Tool Manually link this tool to another tool.
setLinkToolId(e.target.value)} />