Spaces:
Sleeping
Sleeping
| "use client" | |
| import { useState, useEffect, useCallback } from "react" | |
| import { useSession } from "next-auth/react" | |
| import { useRouter } from "next/navigation" | |
| import { Button } from "@/components/ui/button" | |
| import { Card, CardContent } from "@/components/ui/card" | |
| import { Input } from "@/components/ui/input" | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| } from "@/components/ui/dialog" | |
| import { | |
| AlertDialog, | |
| AlertDialogAction, | |
| AlertDialogCancel, | |
| AlertDialogContent, | |
| AlertDialogDescription, | |
| AlertDialogFooter, | |
| AlertDialogHeader, | |
| AlertDialogTitle, | |
| } from "@/components/ui/alert-dialog" | |
| import { Plus, Pencil, Trash2, Star, CheckCircle, XCircle, Loader2 } from "lucide-react" | |
| const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:5000/api" | |
| type Testimonial = { | |
| id: string | |
| clientName: string | |
| company: string | null | |
| content: string | |
| rating: number | |
| imageUrl: string | null | |
| approved: boolean | |
| createdAt: string | |
| } | |
| type FormData = { | |
| clientName: string | |
| company: string | |
| content: string | |
| rating: number | |
| imageUrl: string | |
| } | |
| const emptyForm: FormData = { | |
| clientName: "", | |
| company: "", | |
| content: "", | |
| rating: 5, | |
| imageUrl: "", | |
| } | |
| export default function AdminTestimonialsPage() { | |
| const { data: session, status } = useSession() | |
| const router = useRouter() | |
| const [items, setItems] = useState<Testimonial[]>([]) | |
| const [loading, setLoading] = useState(true) | |
| const [saving, setSaving] = useState(false) | |
| const [formError, setFormError] = useState("") | |
| const [dialogOpen, setDialogOpen] = useState(false) | |
| const [editingId, setEditingId] = useState<string | null>(null) | |
| const [form, setForm] = useState<FormData>(emptyForm) | |
| const [deleteId, setDeleteId] = useState<string | null>(null) | |
| const [deleteName, setDeleteName] = useState("") | |
| useEffect(() => { | |
| if (status === "unauthenticated") router.push("/admin/login") | |
| }, [status, router]) | |
| const fetchItems = useCallback(async () => { | |
| try { | |
| const res = await fetch(`${API_URL}/admin/testimonials`, { | |
| headers: { Authorization: `Bearer ${(session as any)?.accessToken || "session"}` }, | |
| credentials: "include", | |
| }) | |
| if (res.ok) { | |
| const data = await res.json() | |
| setItems(data) | |
| } | |
| } catch (err) { | |
| console.error("Failed to fetch testimonials:", err) | |
| } finally { | |
| setLoading(false) | |
| } | |
| }, [session]) | |
| useEffect(() => { | |
| if (status === "authenticated") fetchItems() | |
| }, [status, fetchItems]) | |
| const openCreate = () => { | |
| setEditingId(null) | |
| setForm(emptyForm) | |
| setFormError("") | |
| setDialogOpen(true) | |
| } | |
| const openEdit = (item: Testimonial) => { | |
| setEditingId(item.id) | |
| setForm({ | |
| clientName: item.clientName, | |
| company: item.company || "", | |
| content: item.content, | |
| rating: item.rating, | |
| imageUrl: item.imageUrl || "", | |
| }) | |
| setFormError("") | |
| setDialogOpen(true) | |
| } | |
| const handleSubmit = async () => { | |
| if (!form.clientName.trim()) return setFormError("Client name is required") | |
| if (!form.content.trim()) return setFormError("Testimonial content is required") | |
| if (form.rating < 1 || form.rating > 5) return setFormError("Rating must be between 1 and 5") | |
| setSaving(true) | |
| setFormError("") | |
| try { | |
| const url = editingId | |
| ? `${API_URL}/admin/testimonials/${editingId}` | |
| : `${API_URL}/admin/testimonials` | |
| const res = await fetch(url, { | |
| method: editingId ? "PATCH" : "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| Authorization: `Bearer ${(session as any)?.accessToken || "session"}`, | |
| }, | |
| credentials: "include", | |
| body: JSON.stringify({ | |
| clientName: form.clientName.trim(), | |
| company: form.company.trim() || null, | |
| content: form.content.trim(), | |
| rating: form.rating, | |
| imageUrl: form.imageUrl.trim() || null, | |
| }), | |
| }) | |
| const data = await res.json() | |
| if (!res.ok) { | |
| setFormError(data.error || "Something went wrong") | |
| return | |
| } | |
| setDialogOpen(false) | |
| fetchItems() | |
| } catch (err) { | |
| setFormError("Network error. Please try again.") | |
| } finally { | |
| setSaving(false) | |
| } | |
| } | |
| const handleToggle = async (id: string) => { | |
| try { | |
| await fetch(`${API_URL}/admin/testimonials/${id}/toggle`, { | |
| method: "PATCH", | |
| headers: { Authorization: `Bearer ${(session as any)?.accessToken || "session"}` }, | |
| credentials: "include", | |
| }) | |
| fetchItems() | |
| } catch (err) { | |
| console.error("Toggle failed:", err) | |
| } | |
| } | |
| const handleDelete = async () => { | |
| if (!deleteId) return | |
| try { | |
| await fetch(`${API_URL}/admin/testimonials/${deleteId}`, { | |
| method: "DELETE", | |
| headers: { Authorization: `Bearer ${(session as any)?.accessToken || "session"}` }, | |
| credentials: "include", | |
| }) | |
| setDeleteId(null) | |
| fetchItems() | |
| } catch (err) { | |
| console.error("Delete failed:", err) | |
| } | |
| } | |
| if (status === "loading" || loading) { | |
| return ( | |
| <div className="flex items-center justify-center min-h-[60vh]"> | |
| <Loader2 className="h-8 w-8 animate-spin text-primary" suppressHydrationWarning /> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="p-8 space-y-8"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h1 className="text-3xl font-bold">Testimonials</h1> | |
| <p className="text-muted-foreground mt-1">Manage client testimonials and approval status</p> | |
| </div> | |
| <Button onClick={openCreate}> | |
| <Plus className="mr-2 h-4 w-4" suppressHydrationWarning /> | |
| New Testimonial | |
| </Button> | |
| </div> | |
| {/* Testimonials list */} | |
| {items.length === 0 ? ( | |
| <Card> | |
| <CardContent className="flex flex-col items-center justify-center py-16 text-center"> | |
| <p className="text-muted-foreground mb-4">No testimonials yet.</p> | |
| <Button onClick={openCreate} variant="outline"> | |
| <Plus className="mr-2 h-4 w-4" suppressHydrationWarning /> | |
| Add Your First Testimonial | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| <div className="grid gap-4"> | |
| {items.map((item) => ( | |
| <Card key={item.id} className="hover:shadow-md transition-shadow"> | |
| <CardContent className="flex items-start justify-between py-4 gap-4"> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-3 mb-1"> | |
| <h3 className="font-semibold">{item.clientName}</h3> | |
| {item.company && ( | |
| <span className="text-sm text-muted-foreground"> | |
| {item.company} | |
| </span> | |
| )} | |
| {/* Approval badge */} | |
| {item.approved ? ( | |
| <span className="inline-flex items-center gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 px-2 py-0.5 rounded-full text-xs font-medium"> | |
| <CheckCircle className="h-3 w-3" suppressHydrationWarning /> | |
| Approved | |
| </span> | |
| ) : ( | |
| <span className="inline-flex items-center gap-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 px-2 py-0.5 rounded-full text-xs font-medium"> | |
| <XCircle className="h-3 w-3" suppressHydrationWarning /> | |
| Pending | |
| </span> | |
| )} | |
| </div> | |
| <p className="text-sm text-muted-foreground line-clamp-2 mb-2"> | |
| “{item.content}” | |
| </p> | |
| <div className="flex items-center gap-1"> | |
| {[...Array(5)].map((_, j) => ( | |
| <Star | |
| key={j} | |
| className={`h-3.5 w-3.5 ${j < item.rating ? "fill-amber-400 text-amber-400" : "text-muted-foreground/30"}`} | |
| suppressHydrationWarning | |
| /> | |
| ))} | |
| <span className="text-xs text-muted-foreground ml-2"> | |
| {new Date(item.createdAt).toLocaleDateString()} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1 shrink-0"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => handleToggle(item.id)} | |
| className={item.approved ? "text-amber-600 hover:text-amber-700" : "text-green-600 hover:text-green-700"} | |
| > | |
| {item.approved ? "Unpublish" : "Approve"} | |
| </Button> | |
| <Button variant="ghost" size="icon-sm" onClick={() => openEdit(item)}> | |
| <Pencil className="h-4 w-4" suppressHydrationWarning /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon-sm" | |
| className="text-destructive hover:text-destructive" | |
| onClick={() => { setDeleteId(item.id); setDeleteName(item.clientName) }} | |
| > | |
| <Trash2 className="h-4 w-4" suppressHydrationWarning /> | |
| </Button> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ))} | |
| </div> | |
| )} | |
| {/* Create/Edit Dialog */} | |
| <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> | |
| <DialogContent className="max-w-lg"> | |
| <DialogHeader> | |
| <DialogTitle>{editingId ? "Edit Testimonial" : "New Testimonial"}</DialogTitle> | |
| <DialogDescription> | |
| {editingId ? "Update the testimonial details." : "Add a new client testimonial."} | |
| </DialogDescription> | |
| </DialogHeader> | |
| <div className="space-y-4 py-4"> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="text-sm font-medium mb-1 block">Client Name *</label> | |
| <Input | |
| value={form.clientName} | |
| onChange={(e) => setForm((p) => ({ ...p, clientName: e.target.value }))} | |
| placeholder="Jane Doe" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium mb-1 block">Company</label> | |
| <Input | |
| value={form.company} | |
| onChange={(e) => setForm((p) => ({ ...p, company: e.target.value }))} | |
| placeholder="Acme Inc." | |
| /> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium mb-1 block">Testimonial *</label> | |
| <textarea | |
| className="flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" | |
| value={form.content} | |
| onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} | |
| placeholder="What the client said about your work..." | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium mb-1 block">Rating (1-5)</label> | |
| <div className="flex items-center gap-1"> | |
| {[1, 2, 3, 4, 5].map((val) => ( | |
| <button | |
| key={val} | |
| type="button" | |
| onClick={() => setForm((p) => ({ ...p, rating: val }))} | |
| className="p-1 hover:scale-110 transition-transform" | |
| > | |
| <Star | |
| className={`h-6 w-6 ${val <= form.rating ? "fill-amber-400 text-amber-400" : "text-muted-foreground/30"}`} | |
| suppressHydrationWarning | |
| /> | |
| </button> | |
| ))} | |
| <span className="text-sm text-muted-foreground ml-2">{form.rating}/5</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label className="text-sm font-medium mb-1 block">Avatar URL</label> | |
| <Input | |
| value={form.imageUrl} | |
| onChange={(e) => setForm((p) => ({ ...p, imageUrl: e.target.value }))} | |
| placeholder="https://example.com/avatar.jpg" | |
| /> | |
| </div> | |
| {formError && ( | |
| <p className="text-sm text-destructive font-medium">{formError}</p> | |
| )} | |
| </div> | |
| <DialogFooter> | |
| <Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}> | |
| Cancel | |
| </Button> | |
| <Button onClick={handleSubmit} disabled={saving}> | |
| {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" suppressHydrationWarning />} | |
| {editingId ? "Save Changes" : "Add Testimonial"} | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| {/* Delete Confirmation */} | |
| <AlertDialog open={!!deleteId} onOpenChange={(open) => { if (!open) setDeleteId(null) }}> | |
| <AlertDialogContent> | |
| <AlertDialogHeader> | |
| <AlertDialogTitle>Delete testimonial from "{deleteName}"?</AlertDialogTitle> | |
| <AlertDialogDescription> | |
| This action cannot be undone. This will permanently remove this testimonial. | |
| </AlertDialogDescription> | |
| </AlertDialogHeader> | |
| <AlertDialogFooter> | |
| <AlertDialogCancel>Cancel</AlertDialogCancel> | |
| <AlertDialogAction | |
| onClick={handleDelete} | |
| className="bg-destructive text-destructive-foreground hover:bg-destructive/90" | |
| > | |
| Delete | |
| </AlertDialogAction> | |
| </AlertDialogFooter> | |
| </AlertDialogContent> | |
| </AlertDialog> | |
| </div> | |
| ) | |
| } | |