feat: compare (premium), rating-history trend, hover previews
Build & Push Docker Image / build (push) Successful in 2m47s

This commit is contained in:
opencode
2026-08-02 12:48:02 +02:00
parent 0c27982098
commit c77610786b
18 changed files with 911 additions and 13 deletions
+2
View File
@@ -13,6 +13,7 @@ import Analytics from "@/pages/analytics";
import Admin from "@/pages/admin";
import Redundancy from "@/pages/redundancy";
import Trash from "@/pages/trash";
import Compare from "@/pages/compare";
import Login from "@/pages/login";
import NotFound from "@/pages/not-found";
@@ -34,6 +35,7 @@ function Router() {
<Route path="/tools/new" component={ToolNew} />
<Route path="/tools/:id/edit" component={ToolEdit} />
<Route path="/tools/:id" component={ToolDetail} />
<Route path="/compare" component={Compare} />
<Route path="/analytics" component={Analytics} />
<Route path="/admin" component={Admin} />
<Route path="/admin/redundancy" component={Redundancy} />
@@ -0,0 +1,69 @@
import { X, Scale, Lock } from "lucide-react";
import type { ToolWithStats } from "@workspace/api-client-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
export function CompareBar({
tools,
onRemove,
onClear,
canCompare,
onCompare,
onUpgrade,
}: {
tools: ToolWithStats[];
onRemove: (id: number) => void;
onClear: () => void;
canCompare: boolean;
onCompare: () => void;
onUpgrade: () => void;
}) {
if (tools.length === 0) return null;
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 w-[min(96vw,42rem)]">
<div className="rounded-xl border bg-popover/95 backdrop-blur shadow-lg p-3">
<div className="flex items-center gap-2">
<Scale className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
{tools.map((t) => (
<span
key={t.id}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
"bg-primary/5 border-primary/20",
)}
>
<span className="max-w-[8rem] truncate">{t.name}</span>
<button
type="button"
onClick={() => onRemove(t.id)}
className="text-muted-foreground hover:text-foreground"
aria-label={`Remove ${t.name}`}
>
<X className="w-3 h-3" />
</button>
</span>
))}
<button
type="button"
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground whitespace-nowrap px-1"
>
Clear
</button>
</div>
{canCompare ? (
<Button size="sm" onClick={onCompare} disabled={tools.length < 2}>
Compare ({tools.length})
</Button>
) : (
<Button size="sm" variant="outline" onClick={onUpgrade} title="Compare is a Premium feature">
<Lock className="w-3.5 h-3.5 mr-1.5" />
Compare
</Button>
)}
</div>
</div>
</div>
);
}
@@ -1,11 +1,20 @@
import { Link } from "wouter";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Star, MessageSquare, Wrench, ChevronRight } from "lucide-react";
import { Star, MessageSquare, Wrench, ChevronRight, Scale } from "lucide-react";
import { ToolWithStats } from "@workspace/api-client-react";
import { MiniBarStack } from "@/components/mini-bars";
import { cn } from "@/lib/utils";
export function ToolCardWide({ tool, density }: { tool: ToolWithStats; density: "cozy" | "compact" }) {
export function ToolCardWide({
tool,
density,
compare,
}: {
tool: ToolWithStats;
density: "cozy" | "compact";
compare?: { selected: boolean; onToggle: () => void };
}) {
const compact = density === "compact";
return (
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50">
@@ -62,6 +71,25 @@ export function ToolCardWide({ tool, density }: { tool: ToolWithStats; density:
<ChevronRight className="w-4 h-4" />
</div>
{!compact && <MiniBarStack usefulness={tool.avgUsefulness} usability={tool.avgUsability} />}
{compare && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
compare.onToggle();
}}
title={compare.selected ? "Remove from compare" : "Add to compare"}
className={cn(
"shrink-0 w-7 h-7 rounded-md border flex items-center justify-center transition-colors",
compare.selected
? "bg-primary text-primary-foreground border-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Scale className="w-4 h-4" />
</button>
)}
</div>
</Link>
</Card>
@@ -1,11 +1,18 @@
import { ToolWithStats } from "@workspace/api-client-react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Star, MessageSquare, Wrench } from "lucide-react";
import { Star, MessageSquare, Wrench, Scale } from "lucide-react";
import { Link } from "wouter";
import { MiniBars } from "@/components/mini-bars";
import { cn } from "@/lib/utils";
export function ToolCard({ tool }: { tool: ToolWithStats }) {
export function ToolCard({
tool,
compare,
}: {
tool: ToolWithStats;
compare?: { selected: boolean; onToggle: () => void };
}) {
return (
<Card className="hover-elevate transition-all flex flex-col h-full cursor-pointer hover:border-primary/50">
<Link href={`/tools/${tool.id}`} className="flex flex-col h-full p-0 m-0">
@@ -63,7 +70,28 @@ export function ToolCard({ tool }: { tool: ToolWithStats }) {
</div>
</div>
</div>
<MiniBars usefulness={tool.avgUsefulness} usability={tool.avgUsability} />
<div className={cn("items-center gap-3", compare ? "flex" : "")}>
<MiniBars usefulness={tool.avgUsefulness} usability={tool.avgUsability} className={compare ? "flex-1" : undefined} />
{compare && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
compare.onToggle();
}}
title={compare.selected ? "Remove from compare" : "Add to compare"}
className={cn(
"shrink-0 w-8 h-8 rounded-md border flex items-center justify-center transition-colors",
compare.selected
? "bg-primary text-primary-foreground border-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Scale className="w-4 h-4" />
</button>
)}
</div>
</CardFooter>
</Link>
</Card>
@@ -0,0 +1,54 @@
import { Link } from "wouter";
import type { ToolWithStats } from "@workspace/api-client-react";
import { Badge } from "@/components/ui/badge";
import { RatingStars } from "@/components/rating-stars";
import { MiniBars } from "@/components/mini-bars";
export function ToolPreviewCard({ tool }: { tool: ToolWithStats }) {
return (
<div className="space-y-3">
<div>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold leading-tight truncate">{tool.name}</div>
<div className="text-xs text-muted-foreground">{tool.category}</div>
</div>
<RatingStars value={tool.avgCombined ?? 0} size="sm" />
</div>
</div>
<p className="text-xs text-muted-foreground line-clamp-3">{tool.description}</p>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{tool.ratingCount} review{tool.ratingCount === 1 ? "" : "s"}</span>
<span className="font-semibold tabular-nums">
{tool.avgCombined != null ? tool.avgCombined.toFixed(1) : ""}/5
</span>
</div>
<MiniBars usefulness={tool.avgUsefulness} usability={tool.avgUsability} />
</div>
{tool.tags && tool.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tool.tags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px]">
{tag}
</Badge>
))}
{tool.tags.length > 4 && (
<span className="text-[10px] text-muted-foreground">+{tool.tags.length - 4}</span>
)}
</div>
)}
<Link
href={`/tools/${tool.id}`}
className="block w-full rounded-md border border-primary/40 bg-primary/5 px-3 py-1.5 text-center text-xs font-medium text-primary hover:bg-primary/10"
>
View details
</Link>
</div>
);
}
+22 -1
View File
@@ -1,6 +1,6 @@
import { Link } from "wouter";
import { Badge } from "@/components/ui/badge";
import { Star, MessageSquare, Wrench } from "lucide-react";
import { Star, MessageSquare, Wrench, Scale } from "lucide-react";
import { ToolWithStats } from "@workspace/api-client-react";
import { cn } from "@/lib/utils";
import { MiniBarStack } from "@/components/mini-bars";
@@ -12,10 +12,12 @@ export function ToolRow({
tool,
density,
className,
compare,
}: {
tool: ToolWithStats;
density: "cozy" | "compact";
className?: string;
compare?: { selected: boolean; onToggle: () => void };
}) {
const compact = density === "compact";
return (
@@ -28,6 +30,25 @@ export function ToolRow({
)}
>
<div className="flex items-center gap-3 min-w-0">
{compare && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
compare.onToggle();
}}
title={compare.selected ? "Remove from compare" : "Add to compare"}
className={cn(
"shrink-0 w-6 h-6 rounded border flex items-center justify-center transition-colors",
compare.selected
? "bg-primary text-primary-foreground border-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Scale className="w-3.5 h-3.5" />
</button>
)}
<div className="shrink-0 w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden">
{tool.iconUrl ? (
<img
+218
View File
@@ -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>
);
}
+35 -1
View File
@@ -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 */}
+63 -3
View File
@@ -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>
);
}