Inquiries: completed and rejected inquiries move to an "Archiv" tab; resetting the status brings them back. Reviews: new "Ablehnen" action (Review.rejectedAt) instead of only deleting; the archive shows decided reviews in two columns, accepted and rejected, and either decision can be reversed. Pending counters in the sidebar and dashboard exclude rejected reviews, and the product page tells the author their review was not published instead of "still being checked". Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
247 lines
9.0 KiB
TypeScript
247 lines
9.0 KiB
TypeScript
import Link from "next/link";
|
|
import { prisma } from "@/lib/prisma";
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
import { BackLink } from "@/components/back-link";
|
|
import { ConfirmForm } from "@/components/confirm-form";
|
|
import { formatDate } from "@/lib/format";
|
|
import { StarRating } from "@/components/star-rating";
|
|
import {
|
|
AdminActionButton,
|
|
AdminBadge,
|
|
AdminEmptyState,
|
|
AdminPageHeader,
|
|
AdminRowActions,
|
|
AdminSearchForm,
|
|
} from "@/components/admin-list";
|
|
import { AdminArchiveTabs } from "@/components/admin-archive";
|
|
import { insensitiveContains } from "@/lib/prisma-search";
|
|
import { isArchiveView } from "@/lib/archive";
|
|
import { approvedReviewWhere, pendingReviewWhere, rejectedReviewWhere } from "@/lib/review-status";
|
|
import { approveReviewAction, deleteReviewAction, rejectReviewAction } from "../actions";
|
|
import { getLocale } from "@/i18n/locale";
|
|
import { getDictionary } from "@/i18n/get-dictionary";
|
|
import type { Dictionary } from "@/i18n/dictionary-type";
|
|
|
|
const MAX_ROWS = 100;
|
|
const BASE_PATH = "/admin/bewertungen";
|
|
|
|
const REVIEW_INCLUDE = {
|
|
product: { select: { name: true, slug: true } },
|
|
customer: { select: { firstName: true, lastName: true } },
|
|
} satisfies Prisma.ReviewInclude;
|
|
|
|
type AdminReview = Prisma.ReviewGetPayload<{ include: typeof REVIEW_INCLUDE }>;
|
|
type ReviewTexts = Dictionary["adminReviewsPage"];
|
|
|
|
function searchWhere(query: string): Prisma.ReviewWhereInput {
|
|
if (!query) return {};
|
|
return {
|
|
OR: [
|
|
{ product: { name: insensitiveContains(query) } },
|
|
{ customer: { firstName: insensitiveContains(query) } },
|
|
{ customer: { lastName: insensitiveContains(query) } },
|
|
],
|
|
};
|
|
}
|
|
|
|
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
|
|
const t = getDictionary(await getLocale());
|
|
const s = t.adminReviewsPage;
|
|
const { q, ansicht } = await props.searchParams;
|
|
const query = typeof q === "string" ? q.trim() : "";
|
|
const isArchive = isArchiveView(ansicht);
|
|
const filter = searchWhere(query);
|
|
|
|
const findReviews = (where: Prisma.ReviewWhereInput, orderBy: Prisma.ReviewOrderByWithRelationInput) =>
|
|
prisma.review.findMany({ where: { ...filter, ...where }, include: REVIEW_INCLUDE, orderBy, take: MAX_ROWS });
|
|
|
|
// Offene: aelteste zuerst, sie warten am laengsten. Archiv: zuletzt entschiedene oben.
|
|
const [pending, approved, rejected, pendingCount, decidedCount] = await Promise.all([
|
|
isArchive ? Promise.resolve([]) : findReviews(pendingReviewWhere(), { createdAt: "asc" }),
|
|
isArchive ? findReviews(approvedReviewWhere(), { updatedAt: "desc" }) : Promise.resolve([]),
|
|
isArchive ? findReviews(rejectedReviewWhere(), { rejectedAt: "desc" }) : Promise.resolve([]),
|
|
prisma.review.count({ where: { ...filter, ...pendingReviewWhere() } }),
|
|
prisma.review.count({ where: { ...filter, NOT: pendingReviewWhere() } }),
|
|
]);
|
|
|
|
return (
|
|
<div>
|
|
<BackLink href="/admin" label={s.backToDashboard} />
|
|
<AdminPageHeader title={s.title} />
|
|
|
|
<AdminArchiveTabs
|
|
basePath={BASE_PATH}
|
|
isArchive={isArchive}
|
|
counts={{ active: pendingCount, archived: decidedCount }}
|
|
keepParams={{ q: query }}
|
|
labels={{
|
|
ariaLabel: t.adminArchive.tabsAriaLabel,
|
|
active: t.adminArchive.tabActive,
|
|
archive: t.adminArchive.tabArchive,
|
|
}}
|
|
/>
|
|
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleReviews}</p> : null}
|
|
|
|
<AdminSearchForm
|
|
query={query}
|
|
label={s.searchLabel}
|
|
placeholder={s.searchPlaceholder}
|
|
submitLabel={s.filterButton}
|
|
resetHref={isArchive ? `${BASE_PATH}?ansicht=archiv` : BASE_PATH}
|
|
resetLabel={s.resetFilter}
|
|
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
|
|
/>
|
|
|
|
{isArchive ? (
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<DecidedColumn title={s.columnApproved} reviews={approved} decision="approved" s={s} />
|
|
<DecidedColumn title={s.columnRejected} reviews={rejected} decision="rejected" s={s} />
|
|
</div>
|
|
) : (
|
|
<PendingTable reviews={pending} query={query} s={s} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PendingTable({ reviews, query, s }: { reviews: AdminReview[]; query: string; s: ReviewTexts }) {
|
|
return (
|
|
<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">{s.colProduct}</th>
|
|
<th className="p-4">{s.colCustomer}</th>
|
|
<th className="p-4">{s.colRating}</th>
|
|
<th className="p-4">{s.colComment}</th>
|
|
<th className="p-4">{s.colDate}</th>
|
|
<th className="p-4">{s.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">
|
|
<ProductLink review={review} />
|
|
</td>
|
|
<td className="p-4">
|
|
{review.customer.firstName} {review.customer.lastName}
|
|
</td>
|
|
<td className="p-4 whitespace-nowrap">
|
|
<Stars rating={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">
|
|
<AdminBadge tone="pending" pulse>
|
|
{s.statusPending}
|
|
</AdminBadge>
|
|
</td>
|
|
<td className="p-4">
|
|
<ReviewActions review={review} decision="pending" s={s} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{reviews.length === 0 ? (
|
|
<AdminEmptyState message={query ? s.noResultsFiltered : s.noPendingReviews} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DecidedColumn({
|
|
title,
|
|
reviews,
|
|
decision,
|
|
s,
|
|
}: {
|
|
title: string;
|
|
reviews: AdminReview[];
|
|
decision: "approved" | "rejected";
|
|
s: ReviewTexts;
|
|
}) {
|
|
return (
|
|
<section aria-label={title} className="bg-background border border-ink/10">
|
|
<h2 className="flex items-center justify-between gap-3 border-b border-ink/10 px-5 py-3 text-heading">
|
|
{title}
|
|
<AdminBadge tone={decision === "approved" ? "positive" : "neutral"}>{reviews.length}</AdminBadge>
|
|
</h2>
|
|
{reviews.length === 0 ? (
|
|
<p className="px-5 py-6 text-sm text-ink/60">{s.emptyColumn}</p>
|
|
) : (
|
|
<ul className="divide-y divide-ink/10">
|
|
{reviews.map((review) => (
|
|
<li key={review.id} className="space-y-2 px-5 py-4 text-sm">
|
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
<ProductLink review={review} />
|
|
<Stars rating={review.rating} />
|
|
</div>
|
|
<p className="text-ink/70">
|
|
{review.customer.firstName} {review.customer.lastName} ·{" "}
|
|
<span className="tabular-nums">{formatDate(review.createdAt)}</span>
|
|
</p>
|
|
{review.comment ? <p className="text-ink/80">{review.comment}</p> : null}
|
|
{decision === "rejected" && review.rejectedAt ? (
|
|
<p className="text-xs text-ink/55">{s.rejectedOn(formatDate(review.rejectedAt))}</p>
|
|
) : null}
|
|
<ReviewActions review={review} decision={decision} s={s} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ProductLink({ review }: { review: AdminReview }) {
|
|
return (
|
|
<Link href={`/honig/${review.product.slug}`} className="link-draw text-heading">
|
|
{review.product.name}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function Stars({ rating }: { rating: number }) {
|
|
return (
|
|
<span className="whitespace-nowrap">
|
|
<StarRating value={rating} />
|
|
<span className="sr-only">{rating} / 5</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Freischalten und Ablehnen jeweils nur dort, wo es den Stand aendert; Loeschen immer. */
|
|
function ReviewActions({
|
|
review,
|
|
decision,
|
|
s,
|
|
}: {
|
|
review: AdminReview;
|
|
decision: "pending" | "approved" | "rejected";
|
|
s: ReviewTexts;
|
|
}) {
|
|
return (
|
|
<AdminRowActions>
|
|
{decision !== "approved" ? (
|
|
<form action={approveReviewAction}>
|
|
<input type="hidden" name="id" value={review.id} />
|
|
<AdminActionButton>{s.approve}</AdminActionButton>
|
|
</form>
|
|
) : null}
|
|
{decision !== "rejected" ? (
|
|
<ConfirmForm action={rejectReviewAction} confirmMessage={s.rejectConfirm}>
|
|
<input type="hidden" name="id" value={review.id} />
|
|
<AdminActionButton>{s.reject}</AdminActionButton>
|
|
</ConfirmForm>
|
|
) : null}
|
|
<ConfirmForm action={deleteReviewAction} confirmMessage={s.deleteConfirm}>
|
|
<input type="hidden" name="id" value={review.id} />
|
|
<AdminActionButton tone="danger">{s.delete}</AdminActionButton>
|
|
</ConfirmForm>
|
|
</AdminRowActions>
|
|
);
|
|
}
|