underrate's picture
Initial commit
34ed5b1 verified
Raw
History Blame Contribute Delete
18.2 kB
"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">
&ldquo;{item.content}&rdquo;
</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 &quot;{deleteName}&quot;?</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>
)
}