feat: watchlist (premium), mobile bottom nav
Build & Push Docker Image / build (push) Successful in 2m35s

This commit is contained in:
opencode
2026-08-02 13:00:10 +02:00
parent c77610786b
commit d033b20dfb
14 changed files with 383 additions and 12 deletions
+15 -1
View File
@@ -41,9 +41,11 @@ import {
} 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 } from "lucide-react";
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 { useDeleteTool, getListToolsQueryKey } from "@workspace/api-client-react";
import {
AlertDialog,
@@ -77,6 +79,7 @@ export default function ToolDetail() {
const [deleteOpen, setDeleteOpen] = useState(false);
const { user, isAdmin, hasFeature } = useAuth();
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
const canManageCosts = hasFeature("costs");
const hasTrash = hasFeature("trash");
const deleteTool = useDeleteTool();
@@ -355,6 +358,17 @@ export default function ToolDetail() {
Based on {tool.ratingCount} reviews
</div>
{canWatchlist && (
<Button
variant={isWatched(id) ? "default" : "outline"}
className="w-full gap-2"
onClick={() => toggleWatchlist(Number(id))}
>
<Bookmark className={cn("w-4 h-4", isWatched(id) && "fill-current")} />
{isWatched(id) ? "Saved to watchlist" : "Save to watchlist"}
</Button>
)}
{tool.websiteUrl && (
<Button asChild className="w-full mt-2" variant="outline">
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
@@ -17,6 +17,7 @@ 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 { useWatchlist } from "@/hooks/use-watchlist";
import { CompareBar } from "@/components/compare-bar";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -87,6 +88,7 @@ export default function ToolsBrowse() {
const { serverView, serverDensity, localView, localDensity, persist } = useBrowsePreferences();
const { hasFeature } = useAuth();
const { isWatched, toggle: toggleWatchlist, canWatchlist } = useWatchlist();
const [compareIds, setCompareIds] = useState<number[]>([]);
const [compareUpsellOpen, setCompareUpsellOpen] = useState(false);
@@ -402,6 +404,7 @@ export default function ToolsBrowse() {
key={tool.id}
tool={tool}
compare={{ selected: compareIds.includes(tool.id), onToggle: () => toggleCompare(tool.id) }}
watchlist={canWatchlist ? { watched: isWatched(tool.id), onToggle: () => toggleWatchlist(tool.id) } : undefined}
/>
))}
</div>
@@ -0,0 +1,76 @@
import {
useGetMeWatchlist,
getGetMeWatchlistQueryKey,
} from "@workspace/api-client-react";
import { useAuth } from "@/hooks/use-auth";
import { useWatchlist } from "@/hooks/use-watchlist";
import { Layout } from "@/components/layout";
import { ToolCard } from "@/components/tool-card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ShieldAlert, Bookmark } from "lucide-react";
export default function Watchlist() {
const { hasFeature, isLoading: authLoading } = useAuth();
const { toggle } = useWatchlist();
const canWatchlist = hasFeature("watchlist");
const { data: tools, isLoading: loading } = useGetMeWatchlist({
query: { queryKey: getGetMeWatchlistQueryKey(), enabled: canWatchlist },
});
if (!authLoading && !canWatchlist) {
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">Watchlist requires a higher tier</h2>
<p className="text-muted-foreground">Saving tools to your watchlist is available to Premium and Enterprise users.</p>
<Button variant="outline" asChild>
<a href="/tools">Back to tools</a>
</Button>
</div>
</Layout>
);
}
const list = tools ?? [];
return (
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Watchlist</h1>
<p className="text-muted-foreground">Tools you saved for later, with live scores.</p>
</div>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => <Skeleton key={i} className="h-[200px] w-full rounded-xl" />)}
</div>
) : list.length === 0 ? (
<div className="bg-muted/30 border border-dashed rounded-xl py-24 flex flex-col items-center justify-center text-center gap-3">
<Bookmark className="w-8 h-8 text-muted-foreground" />
<h3 className="text-xl font-medium">Your watchlist is empty</h3>
<p className="text-muted-foreground max-w-md mx-auto">
Browse tools and click the bookmark to save them here.
</p>
<Button variant="outline" asChild>
<a href="/tools">Browse tools</a>
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{list.map((tool) => (
<ToolCard
key={tool.id}
tool={tool}
watchlist={{ watched: true, onToggle: () => toggle(tool.id) }}
/>
))}
</div>
)}
</div>
</Layout>
);
}