feat: Wabenhain Honig-Shop Anwendung

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.
This commit is contained in:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/app/admin/actions";
import { getStripeClient } from "@/lib/stripe";
import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail";
import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@/lib/email-templates";
import { restockOrderItems } from "@/lib/inventory";
import { isLocale, defaultLocale } from "@/i18n/config";
function str(formData: FormData, key: string): string {
return String(formData.get(key) ?? "").trim();
}
function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
export async function updateReturnRequestStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const status = str(formData, "status");
if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return;
const returnRequest = await prisma.returnRequest.findUnique({
where: { id },
select: { status: true },
});
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
// erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird.
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") return;
const updated = await prisma.returnRequest.update({
where: { id },
data: { status: status as "ANGENOMMEN" | "ABGELEHNT" },
include: { order: { select: { orderNumber: true, email: true, firstName: true, locale: true } } },
});
await logAdminAction(admin, {
action: "return_request.status_change",
entityType: "ReturnRequest",
entityId: id,
detail: `${updated.order.orderNumber}: ${status}`,
});
const locale = isLocale(updated.order.locale) ? updated.order.locale : defaultLocale;
const { subject, html, text } =
status === "ANGENOMMEN"
? await returnAcceptedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName })
: await returnRejectedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName });
await sendMail({ to: updated.order.email, subject, html, text });
revalidatePath("/admin/retouren");
}
export async function refundReturnRequestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const returnRequest = await prisma.returnRequest.findUnique({
where: { id },
include: {
order: {
include: { items: true, payment: { select: { stripePaymentIntentId: true } } },
},
},
});
// Nur eine angenommene Retoure kann erstattet werden — verhindert
// Doppel-Erstattung, falls dieselbe Aktion mehrfach ausgeloest wird.
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
if (!paymentIntentId) {
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
}
const stripe = getStripeClient();
await stripe.refunds.create({ payment_intent: paymentIntentId });
await prisma.$transaction(async (tx) => {
await tx.returnRequest.update({ where: { id }, data: { status: "ERSTATTET" } });
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
// zur Stornierung im Checkout-Webhook.
await restockOrderItems(tx, returnRequest.order.items);
});
await logAdminAction(admin, {
action: "return_request.refund",
entityType: "ReturnRequest",
entityId: id,
detail: returnRequest.order.orderNumber,
});
const { subject, html, text } = await returnRefundedEmail({
locale: isLocale(returnRequest.order.locale) ? returnRequest.order.locale : defaultLocale,
orderNumber: returnRequest.order.orderNumber,
firstName: returnRequest.order.firstName,
});
await sendMail({ to: returnRequest.order.email, subject, html, text });
revalidatePath("/admin/retouren");
}
+128
View File
@@ -0,0 +1,128 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatDate, formatPrice } from "@/lib/format";
import { insensitiveContains } from "@/lib/prisma-search";
import { updateReturnRequestStatusAction, refundReturnRequestAction } from "./actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) {
const t = getDictionary(await getLocale());
const statusLabels: Record<string, string> = {
ANGEFRAGT: t.adminReturnsPage.statusRequested,
ANGENOMMEN: t.adminReturnsPage.statusAccepted,
ABGELEHNT: t.adminReturnsPage.statusRejected,
ERSTATTET: t.adminReturnsPage.statusRefunded,
};
const { q } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const returnRequests = await prisma.returnRequest.findMany({
where: query
? {
order: {
OR: [
{ orderNumber: insensitiveContains(query) },
{ firstName: insensitiveContains(query) },
{ lastName: insensitiveContains(query) },
{ email: insensitiveContains(query) },
],
},
}
: undefined,
orderBy: { createdAt: "desc" },
take: 100,
include: {
order: { include: { items: true } },
},
});
return (
<div>
<BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminReturnsPage.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.adminReturnsPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminReturnsPage.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.adminReturnsPage.filterButton}
</button>
{query ? (
<Link href="/admin/retouren" className="text-sm text-ink/65 underline">
{t.adminReturnsPage.resetFilter}
</Link>
) : null}
</form>
<div className="space-y-4">
{returnRequests.map((request) => (
<div key={request.id} className="bg-background border border-ink/10 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-forest">{request.order.orderNumber}</p>
<p className="text-sm text-ink/70">
{request.order.firstName} {request.order.lastName} · {request.order.email}
</p>
<p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p>
</div>
<p className="text-sm">{statusLabels[request.status] ?? request.status}</p>
</div>
<ul className="mt-4 text-sm text-ink/70 space-y-1">
{request.order.items.map((item) => (
<li key={item.id}>
{item.quantity} × {item.productName}
</li>
))}
</ul>
<p className="mt-2 text-forest">{formatPrice(request.order.totalCents, request.order.currency)}</p>
<p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{request.reason}</p>
{request.status === "ANGEFRAGT" ? (
<div className="mt-4 flex gap-4">
<form action={updateReturnRequestStatusAction}>
<input type="hidden" name="id" value={request.id} />
<input type="hidden" name="status" value="ANGENOMMEN" />
<button type="submit" className="text-xs text-forest underline">
{t.adminReturnsPage.accept}
</button>
</form>
<form action={updateReturnRequestStatusAction}>
<input type="hidden" name="id" value={request.id} />
<input type="hidden" name="status" value="ABGELEHNT" />
<button type="submit" className="text-xs text-red-700 underline">
{t.adminReturnsPage.reject}
</button>
</form>
</div>
) : null}
{request.status === "ANGENOMMEN" ? (
<form action={refundReturnRequestAction} className="mt-4">
<input type="hidden" name="id" value={request.id} />
<button type="submit" className="text-xs text-forest underline">
{t.adminReturnsPage.refundNow}
</button>
</form>
) : null}
</div>
))}
{returnRequests.length === 0 ? (
<p className="text-ink/65">
{query ? t.adminReturnsPage.noResultsFiltered : t.adminReturnsPage.noRequestsYet}
</p>
) : null}
</div>
</div>
);
}