feat: compare (premium), rating-history trend, hover previews
Build & Push Docker Image / build (push) Successful in 2m47s
Build & Push Docker Image / build (push) Successful in 2m47s
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useListCompareTools,
|
||||
getListCompareToolsQueryKey,
|
||||
type ToolWithStats,
|
||||
} from "@workspace/api-client-react";
|
||||
import { Link, useSearch } from "wouter";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { RatingStars } from "@/components/rating-stars";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ShieldAlert, Trophy, Scale } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmt(v: number | null | undefined): string {
|
||||
return v != null ? v.toFixed(1) : "–";
|
||||
}
|
||||
|
||||
export default function Compare() {
|
||||
const { hasFeature, isLoading: authLoading } = useAuth();
|
||||
const search = useSearch();
|
||||
|
||||
const params = useMemo(() => {
|
||||
const ids = new URLSearchParams(search)
|
||||
.get("ids")
|
||||
?.split(",")
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
return ids && ids.length > 0 ? { ids: ids.join(",") } : undefined;
|
||||
}, [search]);
|
||||
|
||||
const canCompare = hasFeature("compare");
|
||||
|
||||
const { data: tools, isLoading: loading } = useListCompareTools(
|
||||
params ?? { ids: "" },
|
||||
{
|
||||
query: {
|
||||
queryKey: getListCompareToolsQueryKey(params ?? { ids: "" }),
|
||||
enabled: !!params && canCompare,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!authLoading && !canCompare) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Compare requires a higher tier</h2>
|
||||
<p className="text-muted-foreground">Compare tools side-by-side is available to Premium and Enterprise users.</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Back to tools</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const list = tools ?? [];
|
||||
if (list.length === 0) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Scale className="w-12 h-12 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">Nothing to compare</h2>
|
||||
<p className="text-muted-foreground">Select at least one tool to compare and come back here.</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a href="/tools">Browse tools</a>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const bestRating = Math.max(...list.map((t) => t.avgCombined ?? 0));
|
||||
const bestUsefulness = Math.max(...list.map((t) => t.avgUsefulness ?? 0));
|
||||
const bestUsability = Math.max(...list.map((t) => t.avgUsability ?? 0));
|
||||
const bestReviews = Math.max(...list.map((t) => t.ratingCount));
|
||||
|
||||
const isBest = (value: number | null, best: number) =>
|
||||
value != null && value > 0 && value >= best && value === best;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6 pb-10">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Compare Tools</h1>
|
||||
<p className="text-muted-foreground">Comparing {list.length} tool(s) side-by-side.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Side-by-side comparison</CardTitle>
|
||||
<CardDescription>Best value in each row is highlighted.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-44">Attribute</TableHead>
|
||||
{list.map((t) => (
|
||||
<TableHead key={t.id} className="min-w-[12rem]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link to={`/tools/${t.id}`} className="font-semibold hover:text-primary hover:underline">
|
||||
{t.name}
|
||||
</Link>
|
||||
<Badge variant="outline" className="w-fit text-[10px]">{t.category}</Badge>
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Rating</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.avgCombined, bestRating) && "bg-primary/5")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<RatingStars value={t.avgCombined ?? 0} size="sm" />
|
||||
<span className="font-semibold tabular-nums">{fmt(t.avgCombined)}/5</span>
|
||||
{isBest(t.avgCombined, bestRating) && <Trophy className="w-3.5 h-3.5 text-primary" />}
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Usefulness</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.avgUsefulness, bestUsefulness) && "bg-primary/5")}>
|
||||
<span className="tabular-nums">{fmt(t.avgUsefulness)}/5</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Usability</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.avgUsability, bestUsability) && "bg-primary/5")}>
|
||||
<span className="tabular-nums">{fmt(t.avgUsability)}/5</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Reviews</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className={cn(isBest(t.ratingCount, bestReviews) && "bg-primary/5")}>
|
||||
<span className="tabular-nums">{t.ratingCount}</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Description</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="text-sm text-muted-foreground align-top">
|
||||
{t.description}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Features</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="align-top">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(t.features ?? []).map((f) => (
|
||||
<Badge key={f} variant="secondary" className="text-[10px]">{f}</Badge>
|
||||
))}
|
||||
{(t.features?.length ?? 0) === 0 && <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Tags</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="align-top">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(t.tags ?? []).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-[10px]">{tag}</Badge>
|
||||
))}
|
||||
{(t.tags?.length ?? 0) === 0 && <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">Last updated</TableCell>
|
||||
{list.map((t) => (
|
||||
<TableCell key={t.id} className="text-sm text-muted-foreground">
|
||||
{new Date(t.updatedAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/tools">Back to browse</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
getListToolRatingsQueryKey,
|
||||
useGetRatingDistribution,
|
||||
getGetRatingDistributionQueryKey,
|
||||
useGetToolRatingHistory,
|
||||
getGetToolRatingHistoryQueryKey,
|
||||
useCreateRating,
|
||||
getGetTopToolsQueryKey,
|
||||
getGetAnalyticsSummaryQueryKey,
|
||||
@@ -38,7 +40,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
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 } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -203,6 +205,10 @@ export default function ToolDetail() {
|
||||
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<RatingFormValues>({
|
||||
@@ -695,6 +701,34 @@ export default function ToolDetail() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Trend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingRatingHistory ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : ratingHistory && ratingHistory.length > 1 ? (
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={ratingHistory} margin={{ top: 5, right: 10, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
|
||||
<XAxis dataKey="date" hide />
|
||||
<YAxis domain={[0, 5]} tick={{ fill: 'hsl(var(--foreground))', fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip labelFormatter={(_, payload) => (payload?.[0] ? format(new Date(payload[0].payload.date), "dd.MM.yyyy HH:mm") : "")} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="combined" name="Combined" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="usefulness" name="Usefulness" stroke="hsl(var(--success))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||
<Line type="monotone" dataKey="usability" name="Usability" stroke="hsl(var(--warning))" strokeWidth={1.5} dot={false} strokeDasharray="4 3" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Not enough ratings yet to show a trend.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Reviews */}
|
||||
|
||||
@@ -9,10 +9,15 @@ import { Layout } from "@/components/layout";
|
||||
import { ToolCard } from "@/components/tool-card";
|
||||
import { ToolCardWide } from "@/components/tool-card-wide";
|
||||
import { ToolRow, TABLE_GRID } from "@/components/tool-row";
|
||||
import { ToolPreviewCard } from "@/components/tool-preview-card";
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { ViewToggle, type ViewMode } from "@/components/view-toggle";
|
||||
import { DensityToggle, type Density } from "@/components/density-toggle";
|
||||
import { FilterPopover } from "@/components/filter-popover";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { useBrowsePreferences, isViewMode, isDensity } from "@/hooks/use-browse-preferences";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { CompareBar } from "@/components/compare-bar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -81,6 +86,14 @@ export default function ToolsBrowse() {
|
||||
: ListToolsSort.newest;
|
||||
|
||||
const { serverView, serverDensity, localView, localDensity, persist } = useBrowsePreferences();
|
||||
const { hasFeature } = useAuth();
|
||||
|
||||
const [compareIds, setCompareIds] = useState<number[]>([]);
|
||||
const [compareUpsellOpen, setCompareUpsellOpen] = useState(false);
|
||||
|
||||
function toggleCompare(id: number) {
|
||||
setCompareIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
}
|
||||
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const [searchInput, setSearchInput] = useState(initialSearch);
|
||||
@@ -385,7 +398,11 @@ export default function ToolsBrowse() {
|
||||
view === "grid" ? (
|
||||
<div className={cn("grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4", gridPad)}>
|
||||
{allTools.map((tool) => (
|
||||
<ToolCard key={tool.id} tool={tool} />
|
||||
<ToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : view === "rows" ? (
|
||||
@@ -399,7 +416,11 @@ export default function ToolsBrowse() {
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${vi.start}px)` }}
|
||||
>
|
||||
<ToolCardWide tool={allTools[vi.index]} density={density} />
|
||||
<ToolCardWide
|
||||
tool={allTools[vi.index]}
|
||||
density={density}
|
||||
compare={{ selected: compareIds.includes(allTools[vi.index].id), onToggle: () => toggleCompare(allTools[vi.index].id) }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -437,7 +458,20 @@ export default function ToolsBrowse() {
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${vi.start}px)` }}
|
||||
>
|
||||
<ToolRow tool={tool} density={density} />
|
||||
<HoverCard openDelay={250} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div>
|
||||
<ToolRow
|
||||
tool={tool}
|
||||
density={density}
|
||||
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" className="w-72" collisionPadding={16}>
|
||||
<ToolPreviewCard tool={tool} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -467,6 +501,32 @@ export default function ToolsBrowse() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CompareBar
|
||||
tools={allTools.filter((t) => compareIds.includes(t.id))}
|
||||
onRemove={(id) => toggleCompare(id)}
|
||||
onClear={() => setCompareIds([])}
|
||||
canCompare={hasFeature("compare")}
|
||||
onCompare={() => navigate(`/compare?ids=${compareIds.join(",")}`)}
|
||||
onUpgrade={() => setCompareUpsellOpen(true)}
|
||||
/>
|
||||
|
||||
<AlertDialog open={compareUpsellOpen} onOpenChange={setCompareUpsellOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Compare is a Premium feature</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Comparing tools side-by-side is available to Premium and Enterprise users. Upgrade your plan to unlock it.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Not now</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<a href="/admin?tab=plan">Upgrade to Premium</a>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user