Seed: product-management template (23 files)
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user