Add API endpoints and frontend components for tool management and analytics
Implement CRUD operations for tools and ratings, introduce analytics endpoints, and develop frontend components for displaying tools, ratings, and analytics data. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 776963d0-f75d-42e2-a57b-cc36bdff8495 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: feaa4ce1-5aed-4cc0-bcea-47855b615b48 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/0683fb79-a27c-485c-9333-5f4b288c4567/776963d0-f75d-42e2-a57b-cc36bdff8495/rx9K7bW Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
import { useState } from "react";
|
||||
import { useRoute, useLocation } from "wouter";
|
||||
import {
|
||||
useGetTool,
|
||||
getGetToolQueryKey,
|
||||
useListToolRatings,
|
||||
getListToolRatingsQueryKey,
|
||||
useGetRatingDistribution,
|
||||
getGetRatingDistributionQueryKey,
|
||||
useCreateRating
|
||||
} 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 { useToast } from "@/hooks/use-toast";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import { ExternalLink, Star, ArrowLeft, Plus } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
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<typeof ratingSchema>;
|
||||
|
||||
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 { 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 createRating = useCreateRating();
|
||||
|
||||
const form = useForm<RatingFormValues>({
|
||||
resolver: zodResolver(ratingSchema),
|
||||
defaultValues: {
|
||||
usefulness: 0,
|
||||
usability: 0,
|
||||
comment: "",
|
||||
reviewerName: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: RatingFormValues) => {
|
||||
if (data.usefulness === 0 || data.usability === 0) {
|
||||
toast({
|
||||
title: "Missing ratings",
|
||||
description: "Please rate both usefulness and usability.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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 }) });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Failed to submit rating",
|
||||
description: error.error || "An unexpected error occurred.",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!match || isNaN(id)) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<h2 className="text-2xl font-bold">Invalid Tool ID</h2>
|
||||
<Button variant="link" asChild className="mt-4">
|
||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to tools</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
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() || [];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6 max-w-5xl mx-auto pb-10">
|
||||
<Button variant="ghost" asChild className="mb-2 -ml-4 text-muted-foreground">
|
||||
<Link href="/tools"><ArrowLeft className="w-4 h-4 mr-2" /> Back to browse</Link>
|
||||
</Button>
|
||||
|
||||
{/* Header Section */}
|
||||
{loadingTool ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-1/3" />
|
||||
<Skeleton className="h-6 w-1/4" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : tool ? (
|
||||
<div className="bg-card border rounded-xl p-6 md:p-8 space-y-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-bl-full -z-10" />
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between gap-6 items-start">
|
||||
<div className="space-y-4 flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-3xl font-bold">{tool.name}</h1>
|
||||
<Badge variant="outline" className="text-sm bg-background">{tool.category}</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-lg text-muted-foreground max-w-3xl">
|
||||
{tool.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{tool.tags?.map(tag => (
|
||||
<Badge key={tag} variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 shrink-0 md:items-end w-full md:w-auto bg-muted/30 p-4 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="w-6 h-6 fill-primary text-primary" />
|
||||
<span className="text-3xl font-bold">{tool.avgCombined ? tool.avgCombined.toFixed(1) : "N/A"}</span>
|
||||
<span className="text-muted-foreground self-end mb-1">/ 5</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Based on {tool.ratingCount} reviews
|
||||
</div>
|
||||
|
||||
{tool.websiteUrl && (
|
||||
<Button asChild className="w-full mt-2" variant="outline">
|
||||
<a href={tool.websiteUrl} target="_blank" rel="noopener noreferrer">
|
||||
Visit Website <ExternalLink className="w-4 h-4 ml-2" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tool.features && tool.features.length > 0 && (
|
||||
<div className="pt-6 border-t">
|
||||
<h3 className="text-lg font-semibold mb-3">Key Features</h3>
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{tool.features.map((feature, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<div className="mt-1 bg-primary/20 p-0.5 rounded text-primary">
|
||||
<Plus className="w-3 h-3" />
|
||||
</div>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-10">Tool not found.</div>
|
||||
)}
|
||||
|
||||
{/* Ratings & Reviews Section */}
|
||||
{tool && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Left Column: Stats */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rating Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">Usefulness</span>
|
||||
<span className="font-bold">{tool.avgUsefulness ? tool.avgUsefulness.toFixed(1) : "0.0"}</span>
|
||||
</div>
|
||||
<Progress value={((tool.avgUsefulness || 0) / 5) * 100} className="h-2" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">Usability</span>
|
||||
<span className="font-bold">{tool.avgUsability ? tool.avgUsability.toFixed(1) : "0.0"}</span>
|
||||
</div>
|
||||
<Progress value={((tool.avgUsability || 0) / 5) * 100} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingDistribution ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={usefulnessData} layout="vertical" margin={{ top: 0, right: 0, bottom: 0, left: -20 }}>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis dataKey="score" type="category" axisLine={false} tickLine={false} tickFormatter={(val) => `${val} ★`} />
|
||||
<Tooltip cursor={{ fill: 'transparent' }} />
|
||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} barSize={12} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Reviews */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-2xl font-bold">Reviews</h3>
|
||||
{!isReviewFormOpen && (
|
||||
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReviewFormOpen && (
|
||||
<Card className="border-primary shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Write a Review</CardTitle>
|
||||
<CardDescription>Share your experience with {tool.name}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="usefulness"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usefulness</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
interactive
|
||||
onChange={field.onChange}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="usability"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Usability</FormLabel>
|
||||
<div className="py-2">
|
||||
<RatingStars
|
||||
value={field.value}
|
||||
interactive
|
||||
onChange={field.onChange}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="comment"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Comment (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What do you think about this tool?"
|
||||
className="resize-none min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reviewerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Anonymous" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsReviewFormOpen(false)}
|
||||
disabled={createRating.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createRating.isPending}>
|
||||
{createRating.isPending ? "Submitting..." : "Submit Review"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{loadingRatings ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}><CardContent className="p-6"><Skeleton className="h-24 w-full" /></CardContent></Card>
|
||||
))
|
||||
) : ratings && ratings.length > 0 ? (
|
||||
ratings.map((rating) => (
|
||||
<Card key={rating.id} className="bg-card">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">{rating.reviewerName || "Anonymous Engineer"}</span>
|
||||
<span className="text-muted-foreground text-sm ml-2">
|
||||
{format(new Date(rating.createdAt), "MMM d, yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 mb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usefulness</span>
|
||||
<RatingStars value={rating.usefulness} size="sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Usability</span>
|
||||
<RatingStars value={rating.usability} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rating.comment && (
|
||||
<p className="text-foreground leading-relaxed">
|
||||
{rating.comment}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 bg-muted/30 border border-dashed rounded-xl">
|
||||
<Star className="w-12 h-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<h4 className="text-lg font-medium">No reviews yet</h4>
|
||||
<p className="text-muted-foreground mt-1">Be the first to share your thoughts on this tool.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user