Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei: Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich (Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung, PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma- Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
131 lines
5.4 KiB
TypeScript
131 lines
5.4 KiB
TypeScript
import Link from "next/link";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { BackLink } from "@/components/back-link";
|
|
import { ConfirmForm } from "@/components/confirm-form";
|
|
import { formatDate } from "@/lib/format";
|
|
import { insensitiveContains } from "@/lib/prisma-search";
|
|
import { approveReviewAction, deleteReviewAction } from "../actions";
|
|
import { getLocale } from "@/i18n/locale";
|
|
import { getDictionary } from "@/i18n/get-dictionary";
|
|
|
|
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
|
|
const t = getDictionary(await getLocale());
|
|
const { q } = await props.searchParams;
|
|
const query = typeof q === "string" ? q.trim() : "";
|
|
|
|
const reviews = await prisma.review.findMany({
|
|
where: query
|
|
? {
|
|
OR: [
|
|
{ product: { name: insensitiveContains(query) } },
|
|
{ customer: { firstName: insensitiveContains(query) } },
|
|
{ customer: { lastName: insensitiveContains(query) } },
|
|
],
|
|
}
|
|
: undefined,
|
|
include: {
|
|
product: { select: { name: true, slug: true } },
|
|
customer: { select: { firstName: true, lastName: true } },
|
|
},
|
|
orderBy: [{ isApproved: "asc" }, { createdAt: "desc" }],
|
|
take: 100,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} />
|
|
<h1 className="text-2xl text-forest mb-8">{t.adminReviewsPage.title}</h1>
|
|
|
|
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
|
|
<label className="text-sm">
|
|
<span className="block text-ink/65 text-xs mb-1">{t.adminReviewsPage.searchLabel}</span>
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
defaultValue={query}
|
|
placeholder={t.adminReviewsPage.searchPlaceholder}
|
|
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
|
/>
|
|
</label>
|
|
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
|
{t.adminReviewsPage.filterButton}
|
|
</button>
|
|
{query ? (
|
|
<Link href="/admin/bewertungen" className="text-sm text-ink/65 underline">
|
|
{t.adminReviewsPage.resetFilter}
|
|
</Link>
|
|
) : null}
|
|
</form>
|
|
|
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
|
<table className="w-full text-sm">
|
|
<thead className="text-left text-ink/65 border-b border-ink/10">
|
|
<tr>
|
|
<th className="p-4">{t.adminReviewsPage.colProduct}</th>
|
|
<th className="p-4">{t.adminReviewsPage.colCustomer}</th>
|
|
<th className="p-4">{t.adminReviewsPage.colRating}</th>
|
|
<th className="p-4">{t.adminReviewsPage.colComment}</th>
|
|
<th className="p-4">{t.adminReviewsPage.colDate}</th>
|
|
<th className="p-4">{t.adminReviewsPage.colStatus}</th>
|
|
<th className="p-4" />
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-ink/10">
|
|
{reviews.map((review) => (
|
|
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
|
|
<td className="p-4">
|
|
<Link href={`/honig/${review.product.slug}`} className="text-forest underline">
|
|
{review.product.name}
|
|
</Link>
|
|
</td>
|
|
<td className="p-4">
|
|
{review.customer.firstName} {review.customer.lastName}
|
|
</td>
|
|
<td className="p-4 text-honey-dark whitespace-nowrap">
|
|
{"★".repeat(review.rating)}
|
|
{"☆".repeat(5 - review.rating)}
|
|
</td>
|
|
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
|
|
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
|
|
<td className="p-4">
|
|
{review.isApproved ? (
|
|
<span className="text-forest">{t.adminReviewsPage.statusPublished}</span>
|
|
) : (
|
|
<span className="text-honey-dark">{t.adminReviewsPage.statusPending}</span>
|
|
)}
|
|
</td>
|
|
<td className="p-4">
|
|
<div className="flex items-center gap-3 whitespace-nowrap">
|
|
{!review.isApproved ? (
|
|
<form action={approveReviewAction}>
|
|
<input type="hidden" name="id" value={review.id} />
|
|
<button type="submit" className="text-forest underline">
|
|
{t.adminReviewsPage.approve}
|
|
</button>
|
|
</form>
|
|
) : null}
|
|
<ConfirmForm
|
|
action={deleteReviewAction}
|
|
confirmMessage={t.adminReviewsPage.deleteConfirm}
|
|
>
|
|
<input type="hidden" name="id" value={review.id} />
|
|
<button type="submit" className="text-red-700 underline">
|
|
{t.adminReviewsPage.delete}
|
|
</button>
|
|
</ConfirmForm>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{reviews.length === 0 ? (
|
|
<p className="p-6 text-ink/65">
|
|
{query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|