Seed: product-management template (23 files)

This commit is contained in:
2026-07-23 22:14:59 +00:00
parent fe54c60807
commit 1f97dda691
+281
View File
@@ -0,0 +1,281 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter
} from "@/components/ui/dialog";
import {
Plus, ThumbsUp, MessageSquare, TrendingUp, Clock, Bug, Lightbulb, Wrench, ArrowUpDown, LogIn, LogOut
} from "lucide-react";
import { toast } from "sonner";
import { useAuth, useSaasMutation } from "@/hooks/use-tygarun";
import Sidebar from "@/components/Sidebar";
import Header from "@/components/Header";
interface FeedbackItem {
id: number;
user: string;
userInitials: string;
userColor: string;
text: string;
category: string;
categoryColor: string;
categoryIcon: string;
votes: number;
status: string;
statusColor: string;
date: string;
comments: number;
}
const feedbackData: FeedbackItem[] = [
{ id: 1, user: "Elena Martinez", userInitials: "EM", userColor: "bg-blue-500", text: "The roadmap board needs a timeline view option. It would be so much easier to visualize dependencies and deadlines in a Gantt-style layout.", category: "Feature", categoryColor: "bg-blue-100 text-blue-700", categoryIcon: "lightbulb", votes: 34, status: "Under Review", statusColor: "bg-amber-100 text-amber-700", date: "Apr 10, 2026", comments: 8 },
{ id: 2, user: "James Chen", userInitials: "JC", userColor: "bg-emerald-500", text: "Exporting reports to PDF cuts off long feature descriptions. The page margins seem too wide and text wraps incorrectly in the generated document.", category: "Bug", categoryColor: "bg-red-100 text-red-700", categoryIcon: "bug", votes: 28, status: "In Progress", statusColor: "bg-blue-100 text-blue-700", date: "Apr 9, 2026", comments: 5 },
{ id: 3, user: "Taylor Pham", userInitials: "TP", userColor: "bg-violet-500", text: "Would love the ability to link related feedback items together. Sometimes multiple users report the same issue in different ways.", category: "Feature", categoryColor: "bg-blue-100 text-blue-700", categoryIcon: "lightbulb", votes: 45, status: "Planned", statusColor: "bg-slate-100 text-slate-700", date: "Apr 8, 2026", comments: 12 },
{ id: 4, user: "Lisa Wang", userInitials: "LW", userColor: "bg-sky-500", text: "The notification emails could include more context. Right now they just say a feature was updated but not what changed specifically.", category: "Improvement", categoryColor: "bg-amber-100 text-amber-700", categoryIcon: "wrench", votes: 19, status: "Under Review", statusColor: "bg-amber-100 text-amber-700", date: "Apr 7, 2026", comments: 3 },
{ id: 5, user: "David Park", userInitials: "DP", userColor: "bg-indigo-500", text: "Dashboard widgets sometimes show stale data after switching between views. A manual refresh button or auto-refresh interval would fix this.", category: "Bug", categoryColor: "bg-red-100 text-red-700", categoryIcon: "bug", votes: 22, status: "In Progress", statusColor: "bg-blue-100 text-blue-700", date: "Apr 6, 2026", comments: 6 },
{ id: 6, user: "Amy Foster", userInitials: "AF", userColor: "bg-pink-500", text: "It would be great if the feature voting page had a search and filter option. With hundreds of features, scrolling through all of them is tedious.", category: "Improvement", categoryColor: "bg-amber-100 text-amber-700", categoryIcon: "wrench", votes: 37, status: "Planned", statusColor: "bg-slate-100 text-slate-700", date: "Apr 5, 2026", comments: 9 },
];
const categoryIcons: Record<string, React.ElementType> = {
bug: Bug,
lightbulb: Lightbulb,
wrench: Wrench,
};
export default function FeedbackPage() {
const [sortBy, setSortBy] = useState<"votes" | "newest" | "trending">("votes");
const [dialogOpen, setDialogOpen] = useState(false);
const [items, setItems] = useState(feedbackData);
const [fbTitle, setFbTitle] = useState("");
const [fbDesc, setFbDesc] = useState("");
const [fbCategory, setFbCategory] = useState("feature");
// Login is LIVE via tyga.run auth; voting + feedback submission use surveys (seed until live)
const { user, isSignedIn, signin, signout } = useAuth();
const { mutate: submitFeedback } = useSaasMutation("surveys", "/responses");
const { mutate: castVote } = useSaasMutation("surveys", "/responses");
const [authOpen, setAuthOpen] = useState(false);
const [authEmail, setAuthEmail] = useState("");
const [authPassword, setAuthPassword] = useState("");
const [authBusy, setAuthBusy] = useState(false);
const handleSignin = async (e: React.FormEvent) => {
e.preventDefault();
setAuthBusy(true);
try {
await signin(authEmail, authPassword);
toast.success("Signed in! Your votes now count.");
setAuthOpen(false);
setAuthEmail("");
setAuthPassword("");
} catch (err) {
toast.error("Sign in failed. Check your credentials.");
} finally {
setAuthBusy(false);
}
};
const handleVote = async (id: number) => {
if (!isSignedIn) { setAuthOpen(true); toast("Sign in to vote"); return; }
setItems(prev => prev.map(item => item.id === id ? { ...item, votes: item.votes + 1 } : item));
try {
await castVote({ feedbackId: id, type: "upvote" });
toast("Vote recorded!");
} catch (err) {
toast.error("Could not record vote.");
}
};
const handleSubmit = async () => {
if (!isSignedIn) { setAuthOpen(true); toast("Sign in to submit feedback"); return; }
try {
await submitFeedback({ title: fbTitle, description: fbDesc, category: fbCategory });
toast.success("Feedback submitted successfully!");
setDialogOpen(false);
setFbTitle("");
setFbDesc("");
setFbCategory("feature");
} catch (err) {
toast.error("Could not submit feedback.");
}
};
const sorted = [...items].sort((a, b) => {
if (sortBy === "votes") return b.votes - a.votes;
if (sortBy === "newest") return new Date(b.date).getTime() - new Date(a.date).getTime();
return (b.votes + b.comments * 2) - (a.votes + a.comments * 2);
});
return (
<div className="flex min-h-screen bg-slate-50">
<Sidebar activePath="/feedback" />
<div className="flex-1 lg:ml-64">
<Header />
<main className="p-6 space-y-6">
{/* ── Page Header ── */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 animate-fade-up">Feedback</h1>
<p className="text-slate-500 mt-1">Collect and prioritize user feedback</p>
</div>
<div className="flex items-center gap-3">
{/* Auth control — login is LIVE via tyga.run */}
{isSignedIn ? (
<Button variant="outline" size="sm" onClick={() => { signout(); toast("Signed out"); }}>
<LogOut className="mr-2 h-4 w-4" /> {user?.email || "Sign out"}
</Button>
) : (
<Dialog open={authOpen} onOpenChange={setAuthOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<LogIn className="mr-2 h-4 w-4" /> Sign in
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Sign in to vote</DialogTitle>
</DialogHeader>
<form onSubmit={handleSignin} className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="auth-email">Email</Label>
<Input id="auth-email" type="email" value={authEmail} onChange={(e) => setAuthEmail(e.target.value)} placeholder="you@company.com" required />
</div>
<div className="space-y-2">
<Label htmlFor="auth-password">Password</Label>
<Input id="auth-password" type="password" value={authPassword} onChange={(e) => setAuthPassword(e.target.value)} placeholder="Password" required />
</div>
<Button type="submit" disabled={authBusy} className="w-full bg-blue-600 hover:bg-blue-700 text-white">
{authBusy ? "Signing in..." : "Sign in"}
</Button>
</form>
</DialogContent>
</Dialog>
)}
{/* Sort Options */}
<div className="flex items-center border rounded-lg overflow-hidden bg-white">
{(["votes", "newest", "trending"] as const).map((option) => (
<Button
key={option}
variant={sortBy === option ? "default" : "ghost"}
size="sm"
onClick={() => setSortBy(option)}
className={`rounded-none text-xs capitalize \${sortBy === option ? "bg-blue-600 hover:bg-blue-700 text-white" : ""}`}
>
{option === "votes" && <ThumbsUp className="mr-1 h-3 w-3" />}
{option === "newest" && <Clock className="mr-1 h-3 w-3" />}
{option === "trending" && <TrendingUp className="mr-1 h-3 w-3" />}
{option}
</Button>
))}
</div>
{/* Submit Feedback Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="mr-2 h-4 w-4" /> Submit Feedback
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Submit Feedback</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="fb-title">Title</Label>
<Input id="fb-title" value={fbTitle} onChange={(e) => setFbTitle(e.target.value)} placeholder="Brief summary of your feedback" />
</div>
<div className="space-y-2">
<Label htmlFor="fb-desc">Description</Label>
<Textarea id="fb-desc" value={fbDesc} onChange={(e) => setFbDesc(e.target.value)} placeholder="Describe your feedback in detail..." className="min-h-[120px]" />
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={fbCategory} onValueChange={setFbCategory}>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="feature">Feature Request</SelectItem>
<SelectItem value="bug">Bug Report</SelectItem>
<SelectItem value="improvement">Improvement</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleSubmit}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
{/* ── Feedback Cards ── */}
<div className="space-y-4 stagger">
{sorted.map((item) => {
const IconComponent = categoryIcons[item.categoryIcon];
return (
<Card key={item.id} className="border-0 shadow-sm backdrop-blur-sm bg-card/80 border-border/50 hover:-translate-y-1 hover:shadow-lg transition-all duration-300">
<CardContent className="p-6">
<div className="flex gap-4">
{/* Vote Column */}
<div className="flex flex-col items-center gap-1 shrink-0">
<button
onClick={() => handleVote(item.id)}
className="flex flex-col items-center gap-1 p-2 rounded-lg hover:bg-blue-50 transition-colors group"
>
<ThumbsUp className="h-5 w-5 text-slate-400 group-hover:text-blue-600 transition-colors" />
<span className="text-sm font-bold text-slate-700">{item.votes}</span>
</button>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className={`text-xs text-white \${item.userColor}`}>{item.userInitials}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium text-slate-900">{item.user}</p>
<p className="text-xs text-slate-400">{item.date}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className={`\${item.categoryColor} text-xs font-medium border-0 flex items-center gap-1`}>
{IconComponent && <IconComponent className="h-3 w-3" />}
{item.category}
</Badge>
<Badge className={`\${item.statusColor} text-xs font-medium border-0`}>{item.status}</Badge>
</div>
</div>
<p className="text-sm text-slate-600 leading-relaxed">{item.text}</p>
<div className="flex items-center gap-4 mt-3">
<button className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-blue-600 transition-colors">
<MessageSquare className="h-3.5 w-3.5" />
{item.comments} comments
</button>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</main>
</div>
</div>
);
}