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:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -0,0 +1,14 @@
|
||||
// Barrel-Datei: die eigentlichen Server Actions leben domainweise unter
|
||||
// ./actions/* (waren zuvor alle in dieser einen 800+-Zeilen-Datei). Bestehende
|
||||
// Importe ("@/app/admin/actions" bzw. "../actions" aus den Admin-Seiten)
|
||||
// bleiben dadurch unveraendert funktionsfaehig.
|
||||
export { requireAdmin } from "./actions/_shared";
|
||||
export * from "./actions/orders";
|
||||
export * from "./actions/harvests-batches";
|
||||
export * from "./actions/products";
|
||||
export * from "./actions/inquiries";
|
||||
export * from "./actions/customers";
|
||||
export * from "./actions/shipping";
|
||||
export * from "./actions/reviews";
|
||||
export * from "./actions/discount-codes";
|
||||
export * from "./actions/content";
|
||||
@@ -0,0 +1,55 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import type { CustomerRole } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Autoritative Rollenpruefung fuer jede Admin-Mutation. Der Proxy
|
||||
* (src/proxy.ts) blockt fremde Requests bereits auf Netzwerkebene, aber
|
||||
* Server Actions sind eigene POST-Endpunkte und muessen laut Next.js-
|
||||
* Sicherheitsleitfaden auch fuer sich genommen autorisieren — Render-Zeit-
|
||||
* Gating (Button wird nur im Admin-UI angezeigt) ist keine Sicherheitsgrenze.
|
||||
* Gibt das Admin-Konto zurueck, damit Aufrufer es ohne Zweitabfrage fuers
|
||||
* Audit-Log verwenden koennen.
|
||||
*/
|
||||
export async function requireAdmin(): Promise<{ id: string; email: string; role: CustomerRole }> {
|
||||
const admin = await getCurrentAdmin();
|
||||
if (!admin) {
|
||||
throw new Error("Nicht autorisiert.");
|
||||
}
|
||||
// Admin-Konten mit Zugriff auf Bestellungen, Rueckerstattungen und
|
||||
// Kundendaten muessen Zwei-Faktor-Authentifizierung aktiviert haben — das
|
||||
// Admin-Layout leitet dafuer bereits weiter, diese Pruefung faengt zusaetzlich
|
||||
// eine direkt aufgerufene Server Action ab, die das Layout umgeht.
|
||||
if (!admin.twoFactorEnabled) {
|
||||
throw new Error("Zwei-Faktor-Authentifizierung ist für Admin-Konten erforderlich.");
|
||||
}
|
||||
return admin;
|
||||
}
|
||||
|
||||
export function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
|
||||
export async function isLastAdmin(customerId: string): Promise<boolean> {
|
||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer || customer.role !== "ADMIN") return false;
|
||||
const adminCount = await prisma.customer.count({ where: { role: "ADMIN" } });
|
||||
return adminCount <= 1;
|
||||
}
|
||||
|
||||
export function str(formData: FormData, key: string): string {
|
||||
return String(formData.get(key) ?? "").trim();
|
||||
}
|
||||
|
||||
export function num(formData: FormData, key: string): number {
|
||||
const value = Number(formData.get(key) ?? 0);
|
||||
// NaN/Infinity (z.B. leeres oder manipuliertes Feld) nicht an Prisma durchreichen
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
export function strOrNull(formData: FormData, key: string): string | null {
|
||||
const value = str(formData, key);
|
||||
return value === "" ? null : value;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { CONTENT_REGISTRY } from "@/lib/content-registry";
|
||||
import { requireAdmin } from "./_shared";
|
||||
|
||||
/**
|
||||
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
|
||||
* — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action
|
||||
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
|
||||
* lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" /
|
||||
* "en::{key}" (siehe admin/inhalte/page.tsx).
|
||||
*/
|
||||
export async function updateSiteContentAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const group = String(formData.get("group") ?? "");
|
||||
|
||||
const updates: { key: string; valueDe: string; valueEn: string | null }[] = [];
|
||||
|
||||
for (const def of CONTENT_REGISTRY) {
|
||||
if (def.localized) {
|
||||
const de = formData.get(`de::${def.key}`);
|
||||
const en = formData.get(`en::${def.key}`);
|
||||
if (de === null && en === null) continue;
|
||||
updates.push({
|
||||
key: def.key,
|
||||
valueDe: String(de ?? ""),
|
||||
valueEn: String(en ?? ""),
|
||||
});
|
||||
} else {
|
||||
const value = formData.get(def.key);
|
||||
if (value === null) continue;
|
||||
updates.push({ key: def.key, valueDe: String(value), valueEn: null });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
updates.map((u) =>
|
||||
prisma.siteContent.upsert({
|
||||
where: { key: u.key },
|
||||
create: { key: u.key, valueDe: u.valueDe, valueEn: u.valueEn },
|
||||
update: { valueDe: u.valueDe, valueEn: u.valueEn },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "site_content.update",
|
||||
entityType: "SiteContent",
|
||||
entityId: group || undefined,
|
||||
detail: `${updates.length} Feld(er)`,
|
||||
});
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
redirect("/admin/inhalte?saved=1");
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { createAndSendPasswordResetEmail } from "@/lib/password-reset";
|
||||
import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
|
||||
|
||||
export async function createCustomerAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
const parsed = adminCreateCustomerSchema.safeParse({
|
||||
firstName: str(formData, "firstName"),
|
||||
lastName: str(formData, "lastName"),
|
||||
email: str(formData, "email"),
|
||||
role: str(formData, "role") || "KUNDE",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
if (existing) {
|
||||
fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
|
||||
}
|
||||
|
||||
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
|
||||
// niemandem bekannten Hash. Der Nutzer vergibt sein eigenes Passwort ueber
|
||||
// den Reset-Link, der sofort per E-Mail verschickt wird.
|
||||
const unusablePassword = randomBytes(32).toString("hex");
|
||||
const customer = await prisma.customer.create({
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
email: parsed.data.email,
|
||||
role: parsed.data.role,
|
||||
passwordHash: await hashPassword(unusablePassword),
|
||||
},
|
||||
});
|
||||
|
||||
await createAndSendPasswordResetEmail(customer);
|
||||
await logAdminAction(admin, {
|
||||
action: "customer.create",
|
||||
entityType: "Customer",
|
||||
entityId: customer.id,
|
||||
detail: `${customer.email} (${parsed.data.role})`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/benutzer");
|
||||
redirect("/admin/benutzer");
|
||||
}
|
||||
|
||||
export async function updateCustomerAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
const id = str(formData, "id");
|
||||
const parsed = adminUpdateCustomerSchema.safeParse({
|
||||
firstName: str(formData, "firstName"),
|
||||
lastName: str(formData, "lastName"),
|
||||
email: str(formData, "email"),
|
||||
phone: str(formData, "phone"),
|
||||
role: str(formData, "role") || "KUNDE",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
if (id === admin.id && parsed.data.role !== admin.role) {
|
||||
fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
|
||||
}
|
||||
|
||||
const target = await prisma.customer.findUnique({ where: { id } });
|
||||
if (!target) fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
|
||||
if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) {
|
||||
fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
|
||||
}
|
||||
|
||||
const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
if (emailOwner && emailOwner.id !== id) {
|
||||
fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
|
||||
}
|
||||
|
||||
const emailChanged = parsed.data.email !== target.email;
|
||||
|
||||
const updated = await prisma.customer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
email: parsed.data.email,
|
||||
phone: parsed.data.phone || null,
|
||||
role: parsed.data.role,
|
||||
// Eine geaenderte Adresse ist nicht automatisch bestaetigt — sonst
|
||||
// koennte eine falsch eingetragene (oder von einem Admin-Account aus
|
||||
// manipulierte) Adresse als "verifiziert" gelten, ohne dass deren
|
||||
// tatsaechliche Inhaberin/Inhaber je einen Bestaetigungslink geoeffnet hat.
|
||||
...(emailChanged ? { emailVerifiedAt: null } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (emailChanged) {
|
||||
await createAndSendVerificationEmail(updated);
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "customer.update",
|
||||
entityType: "Customer",
|
||||
entityId: id,
|
||||
detail: target.role !== parsed.data.role ? `Rolle: ${target.role} → ${parsed.data.role}` : parsed.data.email,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/benutzer");
|
||||
redirect("/admin/benutzer");
|
||||
}
|
||||
|
||||
export async function blockCustomerAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
const id = str(formData, "id");
|
||||
if (id === admin.id) {
|
||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
|
||||
}
|
||||
if (await isLastAdmin(id)) {
|
||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
|
||||
}
|
||||
|
||||
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
|
||||
// gesperrten Kundschaft sofort ungueltig wird statt erst beim naechsten
|
||||
// Login (getCurrentCustomer prueft ohnehin zusaetzlich blockedAt direkt).
|
||||
const blocked = await prisma.customer.update({
|
||||
where: { id },
|
||||
data: { blockedAt: new Date(), sessionVersion: { increment: 1 } },
|
||||
});
|
||||
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
|
||||
revalidatePath("/admin/benutzer");
|
||||
}
|
||||
|
||||
export async function unblockCustomerAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
const unblocked = await prisma.customer.update({
|
||||
where: { id },
|
||||
data: { blockedAt: null, failedLoginAttempts: 0, lockedUntil: null },
|
||||
});
|
||||
await logAdminAction(admin, {
|
||||
action: "customer.unblock",
|
||||
entityType: "Customer",
|
||||
entityId: id,
|
||||
detail: unblocked.email,
|
||||
});
|
||||
revalidatePath("/admin/benutzer");
|
||||
}
|
||||
|
||||
export async function deleteCustomerAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
const id = str(formData, "id");
|
||||
if (id === admin.id) {
|
||||
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
|
||||
}
|
||||
if (await isLastAdmin(id)) {
|
||||
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
|
||||
}
|
||||
|
||||
const deleted = await prisma.customer.delete({ where: { id } });
|
||||
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
|
||||
revalidatePath("/admin/benutzer");
|
||||
redirect("/admin/benutzer");
|
||||
}
|
||||
|
||||
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
|
||||
export async function sendCustomerPasswordResetAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
const customer = await prisma.customer.findUnique({ where: { id } });
|
||||
if (!customer) fail("/admin/benutzer", "Konto nicht gefunden.");
|
||||
|
||||
await createAndSendPasswordResetEmail(customer);
|
||||
await logAdminAction(admin, {
|
||||
action: "customer.password_reset_sent",
|
||||
entityType: "Customer",
|
||||
entityId: id,
|
||||
detail: customer.email,
|
||||
});
|
||||
redirect(`/admin/benutzer/${id}?message=` + encodeURIComponent("Passwort-Reset-E-Mail wurde verschickt."));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { discountCodeSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, str } from "./_shared";
|
||||
|
||||
function parseDiscountCodeForm(formData: FormData) {
|
||||
const rawMax = str(formData, "maxUses");
|
||||
const rawMinQuantity = str(formData, "minQuantity");
|
||||
const rawFrom = str(formData, "validFrom");
|
||||
const rawUntil = str(formData, "validUntil");
|
||||
return discountCodeSchema.safeParse({
|
||||
code: str(formData, "code"),
|
||||
type: str(formData, "type"),
|
||||
value: str(formData, "value"),
|
||||
minOrderCents: str(formData, "minOrderCents") || "0",
|
||||
minQuantity: rawMinQuantity === "" ? undefined : rawMinQuantity,
|
||||
maxUses: rawMax === "" ? undefined : rawMax,
|
||||
isActive: formData.get("isActive") === "on",
|
||||
validFrom: rawFrom === "" ? undefined : rawFrom,
|
||||
validUntil: rawUntil === "" ? undefined : rawUntil,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDiscountCodeAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
const parsed = parseDiscountCodeForm(formData);
|
||||
if (!parsed.success) {
|
||||
fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
||||
if (existing) {
|
||||
fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
|
||||
}
|
||||
|
||||
const code = await prisma.discountCode.create({ data: parsed.data });
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "discount_code.create",
|
||||
entityType: "DiscountCode",
|
||||
entityId: code.id,
|
||||
detail: code.code,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/gutscheine");
|
||||
redirect("/admin/gutscheine");
|
||||
}
|
||||
|
||||
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
const parsed = parseDiscountCodeForm(formData);
|
||||
if (!parsed.success) {
|
||||
fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
|
||||
if (existing && existing.id !== id) {
|
||||
fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
|
||||
}
|
||||
|
||||
await prisma.discountCode.update({ where: { id }, data: parsed.data });
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "discount_code.update",
|
||||
entityType: "DiscountCode",
|
||||
entityId: id,
|
||||
detail: parsed.data.code,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/gutscheine");
|
||||
redirect("/admin/gutscheine");
|
||||
}
|
||||
|
||||
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
const deleted = await prisma.discountCode.delete({ where: { id } });
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "discount_code.delete",
|
||||
entityType: "DiscountCode",
|
||||
entityId: id,
|
||||
detail: deleted.code,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/gutscheine");
|
||||
redirect("/admin/gutscheine");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { requireAdmin, str, num } from "./_shared";
|
||||
|
||||
export async function createHarvestAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const year = num(formData, "year");
|
||||
const type = str(formData, "type");
|
||||
const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`;
|
||||
|
||||
const harvest = await prisma.harvest.create({
|
||||
data: {
|
||||
year,
|
||||
type,
|
||||
slug,
|
||||
harvestDate: new Date(str(formData, "harvestDate")),
|
||||
location: str(formData, "location") || "Altenmünster",
|
||||
description: str(formData, "description"),
|
||||
},
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "harvest.create",
|
||||
entityType: "Harvest",
|
||||
entityId: harvest.id,
|
||||
detail: `${type} ${year}`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
redirect("/admin/ernten");
|
||||
}
|
||||
|
||||
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
await prisma.harvest.update({
|
||||
where: { id },
|
||||
data: {
|
||||
year: num(formData, "year"),
|
||||
type: str(formData, "type"),
|
||||
harvestDate: new Date(str(formData, "harvestDate")),
|
||||
location: str(formData, "location") || "Altenmünster",
|
||||
description: str(formData, "description"),
|
||||
},
|
||||
});
|
||||
|
||||
await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id });
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
redirect("/admin/ernten");
|
||||
}
|
||||
|
||||
export async function createBatchAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const code = str(formData, "code");
|
||||
const batch = await prisma.batch.create({
|
||||
data: {
|
||||
code,
|
||||
harvestId: str(formData, "harvestId"),
|
||||
fillDate: new Date(str(formData, "fillDate")),
|
||||
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
|
||||
glassCount: num(formData, "glassCount"),
|
||||
remainingGlasses: num(formData, "remainingGlasses"),
|
||||
},
|
||||
});
|
||||
|
||||
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
|
||||
|
||||
revalidatePath("/admin/chargen");
|
||||
redirect("/admin/chargen");
|
||||
}
|
||||
|
||||
export async function updateBatchAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
await prisma.batch.update({
|
||||
where: { id },
|
||||
data: {
|
||||
fillDate: new Date(str(formData, "fillDate")),
|
||||
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
|
||||
glassCount: num(formData, "glassCount"),
|
||||
remainingGlasses: num(formData, "remainingGlasses"),
|
||||
},
|
||||
});
|
||||
|
||||
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
|
||||
|
||||
revalidatePath("/admin/chargen");
|
||||
redirect("/admin/chargen");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { requireAdmin, str } from "./_shared";
|
||||
|
||||
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const status = str(formData, "status");
|
||||
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return;
|
||||
|
||||
await prisma.businessInquiry.update({
|
||||
where: { id },
|
||||
data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" },
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "business_inquiry.status_change",
|
||||
entityType: "BusinessInquiry",
|
||||
entityId: id,
|
||||
detail: status,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/anfragen");
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
|
||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||
import { restockOrderItems } from "@/lib/inventory";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import { requireAdmin, str, strOrNull } from "./_shared";
|
||||
|
||||
const ORDER_STATUSES = [
|
||||
"NEU",
|
||||
"BEZAHLT",
|
||||
"IN_VORBEREITUNG",
|
||||
"VERSANDBEREIT",
|
||||
"VERSENDET",
|
||||
"ABHOLBEREIT",
|
||||
"ABGEHOLT",
|
||||
"STORNIERT",
|
||||
] as const;
|
||||
|
||||
export async function updateOrderStatusAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const orderId = str(formData, "orderId");
|
||||
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
|
||||
if (!ORDER_STATUSES.includes(status)) return;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
status: true,
|
||||
orderNumber: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
locale: true,
|
||||
items: { select: { productId: true, quantity: true } },
|
||||
},
|
||||
});
|
||||
if (!previous) return null;
|
||||
|
||||
await tx.order.update({ where: { id: orderId }, data: { status } });
|
||||
|
||||
// Beim Checkout reservierter Bestand wird bei Stornierung zurueckgebucht
|
||||
// (genau einmal — nur beim Wechsel VON einem nicht-stornierten Status).
|
||||
if (status === "STORNIERT" && previous.status !== "STORNIERT") {
|
||||
await restockOrderItems(tx, previous.items);
|
||||
}
|
||||
|
||||
let shipment: { carrier: string | null; trackingNumber: string | null } | null = null;
|
||||
if (status === "VERSENDET") {
|
||||
shipment = await tx.shipment.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, shippedAt: new Date() },
|
||||
update: { shippedAt: new Date() },
|
||||
select: { carrier: true, trackingNumber: true },
|
||||
});
|
||||
}
|
||||
if (status === "ABHOLBEREIT") {
|
||||
await tx.pickup.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, readyAt: new Date() },
|
||||
update: { readyAt: new Date() },
|
||||
});
|
||||
}
|
||||
if (status === "ABGEHOLT") {
|
||||
await tx.pickup.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, pickedUpAt: new Date() },
|
||||
update: { pickedUpAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
return { ...previous, shipment };
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await logAdminAction(admin, {
|
||||
action: "order.status_change",
|
||||
entityType: "Order",
|
||||
entityId: orderId,
|
||||
detail: `${result.orderNumber}: ${result.status} → ${status}`,
|
||||
});
|
||||
|
||||
// E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach
|
||||
// der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht
|
||||
// rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts).
|
||||
const locale = isLocale(result.locale) ? result.locale : defaultLocale;
|
||||
if (status === "VERSENDET" && result.status !== "VERSENDET") {
|
||||
const carrier = result.shipment?.carrier ?? null;
|
||||
const trackingNumber = result.shipment?.trackingNumber ?? null;
|
||||
const { subject, html, text } = await shipmentEmail({
|
||||
locale,
|
||||
orderNumber: result.orderNumber,
|
||||
firstName: result.firstName,
|
||||
carrier,
|
||||
trackingNumber,
|
||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||
});
|
||||
await sendMail({ to: result.email, subject, html, text });
|
||||
}
|
||||
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
|
||||
const { subject, html, text } = await pickupReadyEmail({
|
||||
locale,
|
||||
orderNumber: result.orderNumber,
|
||||
firstName: result.firstName,
|
||||
});
|
||||
await sendMail({ to: result.email, subject, html, text });
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
}
|
||||
|
||||
/**
|
||||
* Erfasst Versanddienstleister + Sendungsnummer fuer eine Bestellung —
|
||||
* unabhaengig vom Status setzbar, da die Nummer oft schon vor dem
|
||||
* eigentlichen Versandscan vom Versanddienstleister vergeben wird. Ist die
|
||||
* Bestellung bereits als VERSENDET markiert, wird die Sendungsverfolgung
|
||||
* per separater E-Mail nachgereicht statt nur in der ersten Versand-Mail zu
|
||||
* stehen (die zu diesem Zeitpunkt schon verschickt war).
|
||||
*/
|
||||
export async function updateShipmentTrackingAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const orderId = str(formData, "orderId");
|
||||
const carrier = strOrNull(formData, "carrier");
|
||||
const trackingNumber = strOrNull(formData, "trackingNumber");
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
|
||||
});
|
||||
if (!order) return;
|
||||
|
||||
await prisma.shipment.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, carrier, trackingNumber },
|
||||
update: { carrier, trackingNumber },
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "order.tracking_update",
|
||||
entityType: "Order",
|
||||
entityId: orderId,
|
||||
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
|
||||
});
|
||||
|
||||
if (order.status === "VERSENDET" && carrier && trackingNumber) {
|
||||
const { subject, html, text } = await shipmentEmail({
|
||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||
orderNumber: order.orderNumber,
|
||||
firstName: order.firstName,
|
||||
carrier,
|
||||
trackingNumber,
|
||||
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
|
||||
});
|
||||
await sendMail({ to: order.email, subject, html, text });
|
||||
}
|
||||
|
||||
revalidatePath("/admin/bestellungen");
|
||||
revalidatePath(`/admin/bestellungen/${orderId}`);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { notifyStockAvailable } from "@/lib/stock-notifications";
|
||||
import { adminProductNumbersSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, str, strOrNull } from "./_shared";
|
||||
|
||||
export async function createProductAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
||||
|
||||
const parsedNumbers = adminProductNumbersSchema.safeParse({
|
||||
intensityMild: str(formData, "intensityMild"),
|
||||
intensityFloral: str(formData, "intensityFloral"),
|
||||
intensityKraeftig: str(formData, "intensityKraeftig"),
|
||||
weightGrams: str(formData, "weightGrams"),
|
||||
priceCents: str(formData, "priceCents"),
|
||||
pricePerKgCents: str(formData, "pricePerKgCents"),
|
||||
quantity: str(formData, "quantity"),
|
||||
});
|
||||
if (!parsedNumbers.success) {
|
||||
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
const numbers = parsedNumbers.data;
|
||||
|
||||
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
|
||||
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
|
||||
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
|
||||
// ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen.
|
||||
const isHoney = category === "HONIG";
|
||||
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
slug,
|
||||
name: str(formData, "name"),
|
||||
category,
|
||||
harvestId: isHoney ? strOrNull(formData, "harvestId") : null,
|
||||
batchId: isHoney ? strOrNull(formData, "batchId") : null,
|
||||
description: str(formData, "description"),
|
||||
tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean),
|
||||
intensityMild: numbers.intensityMild,
|
||||
intensityFloral: numbers.intensityFloral,
|
||||
intensityKraeftig: numbers.intensityKraeftig,
|
||||
consistency: str(formData, "consistency") || "feincremig",
|
||||
weightGrams: numbers.weightGrams,
|
||||
priceCents: numbers.priceCents,
|
||||
pricePerKgCents: numbers.pricePerKgCents,
|
||||
isActive: formData.get("isActive") === "on",
|
||||
inventory: { create: { quantity: numbers.quantity } },
|
||||
},
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "product.create",
|
||||
entityType: "Product",
|
||||
entityId: product.id,
|
||||
detail: product.name,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/produkte");
|
||||
redirect("/admin/produkte");
|
||||
}
|
||||
|
||||
export async function updateProductAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
const parsedNumbers = adminProductNumbersSchema.safeParse({
|
||||
intensityMild: str(formData, "intensityMild"),
|
||||
intensityFloral: str(formData, "intensityFloral"),
|
||||
intensityKraeftig: str(formData, "intensityKraeftig"),
|
||||
weightGrams: str(formData, "weightGrams"),
|
||||
priceCents: str(formData, "priceCents"),
|
||||
pricePerKgCents: str(formData, "pricePerKgCents"),
|
||||
quantity: str(formData, "quantity"),
|
||||
});
|
||||
if (!parsedNumbers.success) {
|
||||
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
const numbers = parsedNumbers.data;
|
||||
|
||||
const previousInventory = await prisma.inventory.findUnique({
|
||||
where: { productId: id },
|
||||
select: { quantity: true },
|
||||
});
|
||||
|
||||
const product = await prisma.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: str(formData, "name"),
|
||||
description: str(formData, "description"),
|
||||
tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean),
|
||||
intensityMild: numbers.intensityMild,
|
||||
intensityFloral: numbers.intensityFloral,
|
||||
intensityKraeftig: numbers.intensityKraeftig,
|
||||
consistency: str(formData, "consistency") || "feincremig",
|
||||
weightGrams: numbers.weightGrams,
|
||||
priceCents: numbers.priceCents,
|
||||
pricePerKgCents: numbers.pricePerKgCents,
|
||||
isActive: formData.get("isActive") === "on",
|
||||
inventory: {
|
||||
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "product.update",
|
||||
entityType: "Product",
|
||||
entityId: id,
|
||||
detail: product.name,
|
||||
});
|
||||
|
||||
// Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und
|
||||
// ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden.
|
||||
if ((previousInventory?.quantity ?? 0) === 0 && numbers.quantity > 0) {
|
||||
await notifyStockAvailable(id);
|
||||
}
|
||||
|
||||
revalidatePath("/admin/produkte");
|
||||
redirect("/admin/produkte");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { requireAdmin, str } from "./_shared";
|
||||
|
||||
export async function approveReviewAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
const review = await prisma.review.update({
|
||||
where: { id },
|
||||
data: { isApproved: true },
|
||||
select: { product: { select: { slug: true, name: true } } },
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "review.approve",
|
||||
entityType: "Review",
|
||||
entityId: id,
|
||||
detail: review.product.name,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/bewertungen");
|
||||
revalidatePath(`/honig/${review.product.slug}`);
|
||||
}
|
||||
|
||||
export async function deleteReviewAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
const review = await prisma.review.delete({
|
||||
where: { id },
|
||||
select: { product: { select: { slug: true, name: true } } },
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "review.delete",
|
||||
entityType: "Review",
|
||||
entityId: id,
|
||||
detail: review.product.name,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/bewertungen");
|
||||
revalidatePath(`/honig/${review.product.slug}`);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { shippingTierSchema } from "@/lib/validation";
|
||||
import { requireAdmin, fail, str } from "./_shared";
|
||||
|
||||
function parseShippingTierForm(formData: FormData) {
|
||||
const rawMax = str(formData, "maxQuantity");
|
||||
return shippingTierSchema.safeParse({
|
||||
minQuantity: str(formData, "minQuantity"),
|
||||
maxQuantity: rawMax === "" ? undefined : rawMax,
|
||||
priceCents: str(formData, "priceCents"),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createShippingTierAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
const parsed = parseShippingTierForm(formData);
|
||||
if (!parsed.success) {
|
||||
fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const tier = await prisma.shippingTier.create({
|
||||
data: {
|
||||
minQuantity: parsed.data.minQuantity,
|
||||
maxQuantity: parsed.data.maxQuantity ?? null,
|
||||
priceCents: parsed.data.priceCents,
|
||||
},
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "shipping_tier.create",
|
||||
entityType: "ShippingTier",
|
||||
entityId: tier.id,
|
||||
detail: `ab ${tier.minQuantity} Stück: ${tier.priceCents} Cent`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/versand");
|
||||
revalidatePath("/warenkorb");
|
||||
revalidatePath("/versand-zahlung");
|
||||
redirect("/admin/versand");
|
||||
}
|
||||
|
||||
export async function updateShippingTierAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
const parsed = parseShippingTierForm(formData);
|
||||
if (!parsed.success) {
|
||||
fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
await prisma.shippingTier.update({
|
||||
where: { id },
|
||||
data: {
|
||||
minQuantity: parsed.data.minQuantity,
|
||||
maxQuantity: parsed.data.maxQuantity ?? null,
|
||||
priceCents: parsed.data.priceCents,
|
||||
},
|
||||
});
|
||||
|
||||
await logAdminAction(admin, { action: "shipping_tier.update", entityType: "ShippingTier", entityId: id });
|
||||
|
||||
revalidatePath("/admin/versand");
|
||||
revalidatePath("/warenkorb");
|
||||
revalidatePath("/versand-zahlung");
|
||||
redirect("/admin/versand");
|
||||
}
|
||||
|
||||
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
|
||||
await prisma.shippingTier.delete({ where: { id } });
|
||||
|
||||
await logAdminAction(admin, { action: "shipping_tier.delete", entityType: "ShippingTier", entityId: id });
|
||||
|
||||
revalidatePath("/admin/versand");
|
||||
revalidatePath("/warenkorb");
|
||||
revalidatePath("/versand-zahlung");
|
||||
redirect("/admin/versand");
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { insensitiveContains } from "@/lib/prisma-search";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { updateBusinessInquiryStatusAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels: Record<string, string> = {
|
||||
NEU: t.adminInquiriesPage.statusNew,
|
||||
IN_BEARBEITUNG: t.adminInquiriesPage.statusInProgress,
|
||||
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
|
||||
ABGELEHNT: t.adminInquiriesPage.statusRejected,
|
||||
};
|
||||
const { q } = await props.searchParams;
|
||||
const query = typeof q === "string" ? q.trim() : "";
|
||||
|
||||
const inquiries = await prisma.businessInquiry.findMany({
|
||||
where: query
|
||||
? {
|
||||
OR: [
|
||||
{ companyName: insensitiveContains(query) },
|
||||
{ contactPerson: insensitiveContains(query) },
|
||||
{ email: insensitiveContains(query) },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminInquiriesPage.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.adminInquiriesPage.searchLabel}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
placeholder={t.adminInquiriesPage.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.adminInquiriesPage.filterButton}
|
||||
</button>
|
||||
{query ? (
|
||||
<Link href="/admin/anfragen" className="text-sm text-ink/65 underline">
|
||||
{t.adminInquiriesPage.resetFilter}
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
<div className="space-y-4">
|
||||
{inquiries.map((inquiry) => (
|
||||
<div key={inquiry.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">{inquiry.companyName}</p>
|
||||
<p className="text-sm text-ink/70">
|
||||
{inquiry.contactPerson} · {inquiry.email}
|
||||
{inquiry.phone ? ` · ${inquiry.phone}` : ""}
|
||||
</p>
|
||||
<p className="text-xs text-ink/65 mt-1">{formatDate(inquiry.createdAt)}</p>
|
||||
</div>
|
||||
<form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="id" value={inquiry.id} />
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={inquiry.status}
|
||||
className="border border-ink/20 bg-background px-2 py-1 text-xs"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="text-xs text-forest underline">
|
||||
{t.adminInquiriesPage.save}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<dl className="mt-4 grid sm:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-ink/65 text-xs">{t.adminInquiriesPage.packageLabel}</dt>
|
||||
<dd>{inquiry.package}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-ink/65 text-xs">{t.adminInquiriesPage.quantityLabel}</dt>
|
||||
<dd>{t.adminInquiriesPage.unitsSuffix(inquiry.desiredQuantity)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-ink/65 text-xs">{t.adminInquiriesPage.deliveryDateLabel}</dt>
|
||||
<dd>{inquiry.desiredDeliveryDate ? formatDate(inquiry.desiredDeliveryDate) : "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{inquiry.message ? (
|
||||
<p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{inquiry.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{inquiries.length === 0 ? (
|
||||
<p className="text-ink/65">
|
||||
{query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
const RECENT_LIMIT = 200;
|
||||
|
||||
export default async function AdminAuditLogPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const entries = await prisma.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: RECENT_LIMIT,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-2">{t.adminAuditLogPage.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">{t.adminAuditLogPage.intro(RECENT_LIMIT)}</p>
|
||||
|
||||
<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.adminAuditLogPage.colTimestamp}</th>
|
||||
<th className="p-4">{t.adminAuditLogPage.colAdmin}</th>
|
||||
<th className="p-4">{t.adminAuditLogPage.colAction}</th>
|
||||
<th className="p-4">{t.adminAuditLogPage.colObject}</th>
|
||||
<th className="p-4">{t.adminAuditLogPage.colDetails}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 whitespace-nowrap text-ink/70">{formatDateTime(entry.createdAt)}</td>
|
||||
<td className="p-4 whitespace-nowrap">{entry.adminEmail}</td>
|
||||
<td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</td>
|
||||
<td className="p-4 whitespace-nowrap text-ink/70">
|
||||
{entry.entityType}
|
||||
{entry.entityId ? <span className="text-ink/65"> #{entry.entityId.slice(-6)}</span> : null}
|
||||
</td>
|
||||
<td className="p-4 max-w-sm">{entry.detail ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{entries.length === 0 ? <p className="p-6 text-ink/65">{t.adminAuditLogPage.noEntriesYet}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
updateCustomerAction,
|
||||
blockCustomerAction,
|
||||
unblockCustomerAction,
|
||||
deleteCustomerAction,
|
||||
sendCustomerPasswordResetAction,
|
||||
} from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error, message } = await props.searchParams;
|
||||
|
||||
const [admin, customer] = await Promise.all([
|
||||
getCurrentAdmin(),
|
||||
prisma.customer.findUnique({ where: { id } }),
|
||||
]);
|
||||
if (!customer) notFound();
|
||||
|
||||
const isSelf = customer.id === admin?.id;
|
||||
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/benutzer" label={t.adminUserEditPage.backToUsers} />
|
||||
<h1 className="text-2xl text-forest mb-2">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.adminUserEditPage.firstNameLabel} defaultValue={customer.firstName} required />
|
||||
<Field name="lastName" label={t.adminUserEditPage.lastNameLabel} defaultValue={customer.lastName} required />
|
||||
</div>
|
||||
<Field name="email" label={t.adminUserEditPage.emailLabel} type="email" defaultValue={customer.email} required />
|
||||
<Field name="phone" label={t.adminUserEditPage.phoneLabel} defaultValue={customer.phone ?? ""} />
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminUserEditPage.roleLabel}</span>
|
||||
<select
|
||||
name="role"
|
||||
defaultValue={customer.role}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
>
|
||||
<option value="KUNDE">{t.adminUserEditPage.roleCustomer}</option>
|
||||
<option value="ADMIN">{t.adminUserEditPage.roleAdmin}</option>
|
||||
</select>
|
||||
{isSelf ? (
|
||||
<span className="mt-1 block text-xs text-ink/65">{t.adminUserEditPage.cannotChangeOwnRole}</span>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminUserEditPage.save}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 bg-background border border-ink/10 p-6 space-y-4">
|
||||
<h2 className="text-sm text-ink/70">{t.adminUserEditPage.securityHeading}</h2>
|
||||
<dl className="text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink/65">{t.adminUserEditPage.twoFactorLabel}</dt>
|
||||
<dd>{customer.twoFactorEnabled ? t.adminUserEditPage.twoFactorActive : t.adminUserEditPage.twoFactorInactive}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink/65">{t.adminUserEditPage.statusLabel}</dt>
|
||||
<dd>
|
||||
{customer.blockedAt ? (
|
||||
<span className="text-red-700">
|
||||
{t.adminUserEditPage.blockedSince(formatDate(customer.blockedAt))}
|
||||
</span>
|
||||
) : isTemporarilyLocked ? (
|
||||
<span className="text-honey-dark">{t.adminUserEditPage.temporarilyLockedFailedAttempts}</span>
|
||||
) : (
|
||||
<span className="text-forest">{t.adminUserEditPage.statusActive}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<form action={sendCustomerPasswordResetAction}>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||
{t.adminUserEditPage.sendPasswordReset}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{!isSelf ? (
|
||||
<div className="flex items-center gap-4 pt-2 border-t border-ink/10">
|
||||
{customer.blockedAt ? (
|
||||
<form action={unblockCustomerAction}>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="text-forest underline text-sm">
|
||||
{t.adminUserEditPage.unblock}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={blockCustomerAction}>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="text-ink/70 underline text-sm">
|
||||
{t.adminUserEditPage.block}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<ConfirmForm
|
||||
action={deleteCustomerAction}
|
||||
confirmMessage={t.adminUserEditPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
|
||||
>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="text-red-700 underline text-sm">
|
||||
{t.adminUserEditPage.deleteAccount}
|
||||
</button>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { createCustomerAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benutzer/neu">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/benutzer" label={t.adminUserNewPage.backToUsers} />
|
||||
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1>
|
||||
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.adminUserNewPage.firstNameLabel} required />
|
||||
<Field name="lastName" label={t.adminUserNewPage.lastNameLabel} required />
|
||||
</div>
|
||||
<Field name="email" label={t.adminUserNewPage.emailLabel} type="email" required />
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminUserNewPage.roleLabel}</span>
|
||||
<select
|
||||
name="role"
|
||||
defaultValue="KUNDE"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
>
|
||||
<option value="KUNDE">{t.adminUserNewPage.roleCustomer}</option>
|
||||
<option value="ADMIN">{t.adminUserNewPage.roleAdmin}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminUserNewPage.createAndSend}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { insensitiveContains } from "@/lib/prisma-search";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin };
|
||||
const { error, message, q } = await props.searchParams;
|
||||
const query = typeof q === "string" ? q.trim() : "";
|
||||
|
||||
const [admin, customers] = await Promise.all([
|
||||
getCurrentAdmin(),
|
||||
prisma.customer.findMany({
|
||||
where: query
|
||||
? {
|
||||
OR: [
|
||||
{ firstName: insensitiveContains(query) },
|
||||
{ lastName: insensitiveContains(query) },
|
||||
{ email: insensitiveContains(query) },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminUsersPage.backToDashboard} />
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl text-forest">{t.adminUsersPage.title}</h1>
|
||||
<Link href="/admin/benutzer/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||
{t.adminUsersPage.newUser}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
<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.adminUsersPage.searchLabel}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
placeholder={t.adminUsersPage.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.adminUsersPage.filterButton}
|
||||
</button>
|
||||
{query ? (
|
||||
<Link href="/admin/benutzer" className="text-sm text-ink/65 underline">
|
||||
{t.adminUsersPage.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.adminUsersPage.colName}</th>
|
||||
<th className="p-4">{t.adminUsersPage.colEmail}</th>
|
||||
<th className="p-4">{t.adminUsersPage.colRole}</th>
|
||||
<th className="p-4">{t.adminUsersPage.colStatus}</th>
|
||||
<th className="p-4">{t.adminUsersPage.col2fa}</th>
|
||||
<th className="p-4">{t.adminUsersPage.colCreated}</th>
|
||||
<th className="p-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{customers.map((customer) => {
|
||||
const isSelf = customer.id === admin?.id;
|
||||
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
|
||||
|
||||
return (
|
||||
<tr key={customer.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">
|
||||
{customer.firstName} {customer.lastName}
|
||||
{isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
|
||||
</td>
|
||||
<td className="p-4">{customer.email}</td>
|
||||
<td className="p-4">{roleLabels[customer.role] ?? customer.role}</td>
|
||||
<td className="p-4">
|
||||
{customer.blockedAt ? (
|
||||
<span className="text-red-700">{t.adminUsersPage.statusBlocked}</span>
|
||||
) : isTemporarilyLocked ? (
|
||||
<span className="text-honey-dark">{t.adminUsersPage.statusTemporarilyLocked}</span>
|
||||
) : (
|
||||
<span className="text-forest">{t.adminUsersPage.statusActive}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">{customer.twoFactorEnabled ? t.adminUsersPage.statusActive : "—"}</td>
|
||||
<td className="p-4">{formatDate(customer.createdAt)}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<Link href={`/admin/benutzer/${customer.id}`} className="text-forest underline">
|
||||
{t.adminUsersPage.edit}
|
||||
</Link>
|
||||
{!isSelf ? (
|
||||
customer.blockedAt ? (
|
||||
<form action={unblockCustomerAction}>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="text-forest underline">
|
||||
{t.adminUsersPage.unblock}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={blockCustomerAction}>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="text-ink/70 underline">
|
||||
{t.adminUsersPage.block}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
) : null}
|
||||
{!isSelf ? (
|
||||
<ConfirmForm
|
||||
action={deleteCustomerAction}
|
||||
confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
|
||||
>
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<button type="submit" className="text-red-700 underline">
|
||||
{t.adminUsersPage.delete}
|
||||
</button>
|
||||
</ConfirmForm>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{customers.length === 0 ? (
|
||||
<p className="p-6 text-ink/65">
|
||||
{query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { updateOrderStatusAction, updateShipmentTrackingAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const { id } = await props.params;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } },
|
||||
payment: true,
|
||||
shipment: true,
|
||||
pickup: true,
|
||||
invoice: true,
|
||||
customer: { select: { email: true, firstName: true, lastName: true } },
|
||||
discountCode: { select: { code: true } },
|
||||
},
|
||||
});
|
||||
if (!order) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl text-forest">{order.orderNumber}</h1>
|
||||
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
<form action={updateOrderStatusAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={order.status}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||
{t.adminOrderDetailPage.saveStatus}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{order.giftMessage ? (
|
||||
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
|
||||
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
|
||||
<p className="whitespace-pre-wrap text-ink">{order.giftMessage}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.customerHeading}</h2>
|
||||
<p>{order.firstName} {order.lastName}</p>
|
||||
<p className="text-ink/70">{order.email}</p>
|
||||
{order.phone ? <p className="text-ink/70">{order.phone}</p> : null}
|
||||
{order.customer ? (
|
||||
<p className="mt-2 text-xs text-honey-dark">{t.adminOrderDetailPage.registeredAccount}</p>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-ink/65">{t.adminOrderDetailPage.guestOrder}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">
|
||||
{order.fulfillmentMethod === "VERSAND"
|
||||
? t.adminOrderDetailPage.shippingAddressHeading
|
||||
: t.adminOrderDetailPage.pickupHeading}
|
||||
</h2>
|
||||
{order.fulfillmentMethod === "VERSAND" ? (
|
||||
<>
|
||||
<p>{order.street}</p>
|
||||
<p>{order.zip} {order.city}</p>
|
||||
<p className="mt-2 text-xs text-ink/65">
|
||||
{order.shipment?.shippedAt
|
||||
? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||
: t.adminOrderDetailPage.notShippedYet}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-ink/65">
|
||||
{order.pickup?.pickedUpAt
|
||||
? t.adminOrderDetailPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||
: order.pickup?.readyAt
|
||||
? t.adminOrderDetailPage.readySince(formatDate(order.pickup.readyAt))
|
||||
: t.adminOrderDetailPage.notPickedUpYet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.fulfillmentMethod === "VERSAND" ? (
|
||||
<div className="bg-background border border-ink/10 p-6 mb-8">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.trackingHeading}</h2>
|
||||
<form action={updateShipmentTrackingAction} className="flex flex-wrap items-end gap-4">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminOrderDetailPage.carrierLabel}</span>
|
||||
<input
|
||||
name="carrier"
|
||||
defaultValue={order.shipment?.carrier ?? ""}
|
||||
placeholder={t.adminOrderDetailPage.carrierPlaceholder}
|
||||
className="mt-1 w-48 border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminOrderDetailPage.trackingNumberLabel}</span>
|
||||
<input
|
||||
name="trackingNumber"
|
||||
defaultValue={order.shipment?.trackingNumber ?? ""}
|
||||
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||
{t.adminOrderDetailPage.saveTracking}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6 mb-8">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.itemsHeading}</h2>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||
<tr>
|
||||
<th className="pb-2">{t.adminOrderDetailPage.colProduct}</th>
|
||||
<th className="pb-2">{t.adminOrderDetailPage.colBatch}</th>
|
||||
<th className="pb-2 text-right">{t.adminOrderDetailPage.colQuantity}</th>
|
||||
<th className="pb-2 text-right">{t.adminOrderDetailPage.colTotal}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{order.items.map((item) => (
|
||||
<tr key={item.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="py-2">
|
||||
<Link href={`/admin/produkte/${item.productId}`} className="text-forest underline">
|
||||
{item.productName}
|
||||
</Link>{" "}
|
||||
<span className="text-ink/65">({item.weightGrams} g)</span>
|
||||
</td>
|
||||
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2 text-right">{formatPrice(item.totalCents, order.currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<div className="w-56 space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.adminOrderDetailPage.subtotalLabel}</span>
|
||||
<span>{formatPrice(order.subtotalCents, order.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">{t.adminOrderDetailPage.shippingLabel}</span>
|
||||
<span>
|
||||
{order.shippingCents === 0
|
||||
? t.adminOrderDetailPage.freeShipping
|
||||
: formatPrice(order.shippingCents, order.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{order.discountCents > 0 ? (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ink/70">
|
||||
{t.adminOrderDetailPage.discountLabel}{order.discountCode ? ` (${order.discountCode.code})` : ""}
|
||||
</span>
|
||||
<span>−{formatPrice(order.discountCents, order.currency)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between text-forest pt-1 border-t border-ink/10">
|
||||
<span>{t.adminOrderDetailPage.totalLabel}</span>
|
||||
<span>{formatPrice(order.totalCents, order.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.paymentHeading}</h2>
|
||||
{order.payment ? (
|
||||
<>
|
||||
<p>{order.payment.status}</p>
|
||||
<p className="text-ink/70">{formatPrice(order.payment.amountCents, order.payment.currency)}</p>
|
||||
{order.payment.paidAt ? (
|
||||
<p className="text-xs text-ink/65 mt-1">
|
||||
{t.adminOrderDetailPage.paidOn(formatDate(order.payment.paidAt))}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-ink/65">{t.adminOrderDetailPage.noPaymentYet}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.invoiceHeading}</h2>
|
||||
{order.invoice ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{order.invoice.invoiceNumber}</span>
|
||||
<a
|
||||
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
|
||||
className="text-forest underline text-sm"
|
||||
>
|
||||
{t.adminOrderDetailPage.downloadPdf}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { OrderStatusBadge } from "@/components/account-ui";
|
||||
import { updateOrderStatusAction } from "../actions";
|
||||
import type { OrderStatus } from "@/generated/prisma/client";
|
||||
import { insensitiveContains } from "@/lib/prisma-search";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const { q, status } = await props.searchParams;
|
||||
const query = typeof q === "string" ? q.trim() : "";
|
||||
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
|
||||
|
||||
const orders = await prisma.order.findMany({
|
||||
where: {
|
||||
...(statusFilter ? { status: statusFilter } : {}),
|
||||
...(query
|
||||
? {
|
||||
OR: [
|
||||
{ orderNumber: insensitiveContains(query) },
|
||||
{ email: insensitiveContains(query) },
|
||||
{ lastName: insensitiveContains(query) },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.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.adminOrdersPage.searchLabel}</span>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={query}
|
||||
placeholder={t.adminOrdersPage.searchPlaceholder}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={statusFilter ?? ""}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">{t.adminOrdersPage.statusAll}</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
|
||||
{t.adminOrdersPage.filterButton}
|
||||
</button>
|
||||
{query || statusFilter ? (
|
||||
<Link href="/admin/bestellungen" className="text-sm text-ink/65 underline">
|
||||
{t.adminOrdersPage.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.adminOrdersPage.colNumber}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colCustomer}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colDate}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colItems}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colTotal}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colStatus}</th>
|
||||
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">
|
||||
<Link href={`/admin/bestellungen/${order.id}`} className="underline">
|
||||
{order.orderNumber}
|
||||
</Link>
|
||||
{order.giftMessage ? (
|
||||
<span className="ml-1.5" title={t.adminOrdersPage.giftOrderTitle}>
|
||||
🎁
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{order.firstName} {order.lastName}
|
||||
<br />
|
||||
<span className="text-ink/65 text-xs">{order.email}</span>
|
||||
</td>
|
||||
<td className="p-4">{formatDate(order.createdAt)}</td>
|
||||
<td className="p-4">{order.items.length}</td>
|
||||
<td className="p-4">{formatPrice(order.totalCents, order.currency)}</td>
|
||||
<td className="p-4">
|
||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={order.status}
|
||||
className="border border-ink/20 bg-background px-2 py-1 text-xs"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="text-xs text-forest underline">
|
||||
{t.adminOrdersPage.save}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 ? (
|
||||
<p className="p-6 text-ink/65">
|
||||
{query || statusFilter ? t.adminOrdersPage.noResultsFiltered : t.adminOrdersPage.noOrdersYet}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { updateBatchAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminBatchEditPage(props: PageProps<"/admin/chargen/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const batch = await prisma.batch.findUnique({ where: { id }, include: { harvest: true } });
|
||||
if (!batch) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/chargen" label={t.adminBatchEditPage.backToBatches} />
|
||||
<h1 className="text-2xl text-forest mb-2">{t.adminBatchEditPage.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">
|
||||
{batch.code} · {batch.harvest.type} {batch.harvest.year}
|
||||
</p>
|
||||
<form action={updateBatchAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={batch.id} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchEditPage.fillDateLabel}</span>
|
||||
<input
|
||||
name="fillDate"
|
||||
type="date"
|
||||
defaultValue={batch.fillDate.toISOString().slice(0, 10)}
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchEditPage.bestBeforeDateLabel}</span>
|
||||
<input
|
||||
name="bestBeforeDate"
|
||||
type="date"
|
||||
defaultValue={batch.bestBeforeDate.toISOString().slice(0, 10)}
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchEditPage.glassCountLabel}</span>
|
||||
<input
|
||||
name="glassCount"
|
||||
type="number"
|
||||
defaultValue={batch.glassCount}
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchEditPage.remainingGlassesLabel}</span>
|
||||
<input
|
||||
name="remainingGlasses"
|
||||
type="number"
|
||||
defaultValue={batch.remainingGlasses}
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminBatchEditPage.save}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { createBatchAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminBatchesPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const [batches, harvests] = await Promise.all([
|
||||
prisma.batch.findMany({
|
||||
orderBy: { fillDate: "desc" },
|
||||
include: { harvest: true },
|
||||
}),
|
||||
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
|
||||
<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.adminBatchesPage.colBatch}</th>
|
||||
<th className="p-4">{t.adminBatchesPage.colHarvest}</th>
|
||||
<th className="p-4">{t.adminBatchesPage.colFillDate}</th>
|
||||
<th className="p-4">{t.adminBatchesPage.colBestBefore}</th>
|
||||
<th className="p-4">{t.adminBatchesPage.colTotalGlasses}</th>
|
||||
<th className="p-4">{t.adminBatchesPage.colRemaining}</th>
|
||||
<th className="p-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{batches.map((batch) => (
|
||||
<tr key={batch.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">{batch.code}</td>
|
||||
<td className="p-4">
|
||||
{batch.harvest.type} {batch.harvest.year}
|
||||
</td>
|
||||
<td className="p-4">{formatDate(batch.fillDate)}</td>
|
||||
<td className="p-4">{formatDate(batch.bestBeforeDate)}</td>
|
||||
<td className="p-4">{batch.glassCount}</td>
|
||||
<td className="p-4">{batch.remainingGlasses}</td>
|
||||
<td className="p-4">
|
||||
<Link href={`/admin/chargen/${batch.id}`} className="text-forest underline">
|
||||
{t.adminBatchesPage.edit}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{batches.length === 0 ? <p className="p-6 text-ink/65">{t.adminBatchesPage.noBatchesYet}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
|
||||
<form action={createBatchAction} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.codeLabel}</span>
|
||||
<input
|
||||
name="code"
|
||||
required
|
||||
placeholder={t.adminBatchesPage.codePlaceholder}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.harvestLabel}</span>
|
||||
<select name="harvestId" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||
{harvests.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.type} {h.year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.fillDateLabel}</span>
|
||||
<input name="fillDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.bestBeforeDateLabel}</span>
|
||||
<input name="bestBeforeDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.glassCountLabel}</span>
|
||||
<input name="glassCount" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminBatchesPage.remainingGlassesLabel}</span>
|
||||
<input name="remainingGlasses" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminBatchesPage.create}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { updateHarvestAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const harvest = await prisma.harvest.findUnique({ where: { id } });
|
||||
if (!harvest) notFound();
|
||||
|
||||
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/ernten" label={t.adminHarvestEditPage.backToHarvests} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestEditPage.title}</h1>
|
||||
<form action={updateHarvestAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={harvest.id} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminHarvestEditPage.typeLabel}</span>
|
||||
<input
|
||||
name="type"
|
||||
defaultValue={harvest.type}
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminHarvestEditPage.yearLabel}</span>
|
||||
<input
|
||||
name="year"
|
||||
type="number"
|
||||
defaultValue={harvest.year}
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminHarvestEditPage.harvestDateLabel}</span>
|
||||
<input
|
||||
name="harvestDate"
|
||||
type="date"
|
||||
defaultValue={harvestDateValue}
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminHarvestEditPage.locationLabel}</span>
|
||||
<input
|
||||
name="location"
|
||||
defaultValue={harvest.location}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminHarvestEditPage.descriptionLabel}</span>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={harvest.description}
|
||||
required
|
||||
rows={4}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminHarvestEditPage.save}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { createHarvestAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminHarvestsPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const harvests = await prisma.harvest.findMany({
|
||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||
include: { _count: { select: { batches: true, products: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
|
||||
<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.adminHarvestsPage.colType}</th>
|
||||
<th className="p-4">{t.adminHarvestsPage.colYear}</th>
|
||||
<th className="p-4">{t.adminHarvestsPage.colHarvestDate}</th>
|
||||
<th className="p-4">{t.adminHarvestsPage.colLocation}</th>
|
||||
<th className="p-4">{t.adminHarvestsPage.colBatches}</th>
|
||||
<th className="p-4">{t.adminHarvestsPage.colProducts}</th>
|
||||
<th className="p-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{harvests.map((harvest) => (
|
||||
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">{harvest.type}</td>
|
||||
<td className="p-4">{harvest.year}</td>
|
||||
<td className="p-4">{formatDate(harvest.harvestDate)}</td>
|
||||
<td className="p-4">{harvest.location}</td>
|
||||
<td className="p-4">{harvest._count.batches}</td>
|
||||
<td className="p-4">{harvest._count.products}</td>
|
||||
<td className="p-4">
|
||||
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline">
|
||||
{t.adminHarvestsPage.edit}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
||||
<form action={createHarvestAction} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="type" label={t.adminHarvestsPage.typeLabel} required />
|
||||
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
|
||||
</div>
|
||||
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
|
||||
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
|
||||
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminHarvestsPage.create}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArea({ name, label, required = false }: { name: string; label: string; required?: boolean }) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<textarea name={name} required={required} rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
export default function AdminError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p>
|
||||
<h1 className="text-2xl text-forest">{t.errorPage.title}</h1>
|
||||
<p className="mt-3 text-ink/70">{t.errorPage.description}</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-4">
|
||||
<Button type="button" onClick={() => reset()}>
|
||||
{t.errorPage.retry}
|
||||
</Button>
|
||||
<Link href="/admin" className="text-sm text-forest underline">
|
||||
{t.adminVouchersPage.backToDashboard}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { updateDiscountCodeAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
function toDateInputValue(date: Date | null): string {
|
||||
if (!date) return "";
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const code = await prisma.discountCode.findUnique({ where: { id } });
|
||||
if (!code) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={code.id} />
|
||||
<Field name="code" label={t.adminVoucherEditPage.codeLabel} defaultValue={code.code} required />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminVoucherEditPage.typeLabel}</span>
|
||||
<select
|
||||
name="type"
|
||||
defaultValue={code.type}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
>
|
||||
<option value="PERCENT">{t.adminVoucherEditPage.typePercent}</option>
|
||||
<option value="FIXED">{t.adminVoucherEditPage.typeFixed}</option>
|
||||
</select>
|
||||
</label>
|
||||
<Field name="value" label={t.adminVoucherEditPage.valueLabel} type="number" defaultValue={String(code.value)} required />
|
||||
</div>
|
||||
|
||||
<Field
|
||||
name="minOrderCents"
|
||||
label={t.adminVoucherEditPage.minOrderLabel}
|
||||
type="number"
|
||||
defaultValue={String(code.minOrderCents)}
|
||||
/>
|
||||
<Field
|
||||
name="minQuantity"
|
||||
label={t.adminVoucherEditPage.minQuantityLabel}
|
||||
type="number"
|
||||
defaultValue={code.minQuantity !== null ? String(code.minQuantity) : ""}
|
||||
/>
|
||||
<Field
|
||||
name="maxUses"
|
||||
label={t.adminVoucherEditPage.maxUsesLabel}
|
||||
type="number"
|
||||
defaultValue={code.maxUses !== null ? String(code.maxUses) : ""}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
name="validFrom"
|
||||
label={t.adminVoucherEditPage.validFromLabel}
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(code.validFrom)}
|
||||
/>
|
||||
<Field
|
||||
name="validUntil"
|
||||
label={t.adminVoucherEditPage.validUntilLabel}
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(code.validUntil)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isActive" defaultChecked={code.isActive} />
|
||||
{t.adminVoucherEditPage.activeLabel}
|
||||
</label>
|
||||
|
||||
<p className="text-xs text-ink/65">{t.adminVoucherEditPage.redeemedSoFar(code.usedCount, code.maxUses)}</p>
|
||||
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminVoucherEditPage.save}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
min={0}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { createDiscountCodeAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminGutscheinNewPage(props: PageProps<"/admin/gutscheine/neu">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminVoucherNewPage.typeLabel}</span>
|
||||
<select name="type" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||
<option value="PERCENT">{t.adminVoucherNewPage.typePercent}</option>
|
||||
<option value="FIXED">{t.adminVoucherNewPage.typeFixed}</option>
|
||||
</select>
|
||||
</label>
|
||||
<Field name="value" label={t.adminVoucherNewPage.valueLabel} type="number" placeholder="10" required />
|
||||
</div>
|
||||
|
||||
<Field name="minOrderCents" label={t.adminVoucherNewPage.minOrderLabel} type="number" placeholder="0" />
|
||||
<Field name="minQuantity" label={t.adminVoucherNewPage.minQuantityLabel} type="number" placeholder="3" />
|
||||
<Field name="maxUses" label={t.adminVoucherNewPage.maxUsesLabel} type="number" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="validFrom" label={t.adminVoucherNewPage.validFromLabel} type="date" />
|
||||
<Field name="validUntil" label={t.adminVoucherNewPage.validUntilLabel} type="date" />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isActive" defaultChecked />
|
||||
{t.adminVoucherNewPage.activeLabel}
|
||||
</label>
|
||||
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminVoucherNewPage.create}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
placeholder,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
min={0}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { deleteDiscountCodeAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminGutscheinePage(props: PageProps<"/admin/gutscheine">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} />
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl text-forest">{t.adminVouchersPage.title}</h1>
|
||||
<Link href="/admin/gutscheine/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||
{t.adminVouchersPage.newVoucher}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<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.adminVouchersPage.colCode}</th>
|
||||
<th className="p-4">{t.adminVouchersPage.colDiscount}</th>
|
||||
<th className="p-4">{t.adminVouchersPage.colMinOrder}</th>
|
||||
<th className="p-4">{t.adminVouchersPage.colMinQuantity}</th>
|
||||
<th className="p-4">{t.adminVouchersPage.colUsage}</th>
|
||||
<th className="p-4">{t.adminVouchersPage.colValidity}</th>
|
||||
<th className="p-4">{t.adminVouchersPage.colStatus}</th>
|
||||
<th className="p-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{codes.map((code) => (
|
||||
<tr key={code.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest font-mono">{code.code}</td>
|
||||
<td className="p-4">
|
||||
{code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)}
|
||||
</td>
|
||||
<td className="p-4">{code.minOrderCents > 0 ? formatPrice(code.minOrderCents) : "—"}</td>
|
||||
<td className="p-4">{code.minQuantity !== null ? code.minQuantity : "—"}</td>
|
||||
<td className="p-4">
|
||||
{code.usedCount}
|
||||
{code.maxUses !== null ? ` / ${code.maxUses}` : ""}
|
||||
</td>
|
||||
<td className="p-4 whitespace-nowrap">
|
||||
{code.validFrom ? formatDate(code.validFrom) : t.adminVouchersPage.immediately} –{" "}
|
||||
{code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{code.isActive ? (
|
||||
<span className="text-forest">{t.adminVouchersPage.statusActive}</span>
|
||||
) : (
|
||||
<span className="text-ink/65">{t.adminVouchersPage.statusInactive}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<Link href={`/admin/gutscheine/${code.id}`} className="text-forest underline">
|
||||
{t.adminVouchersPage.edit}
|
||||
</Link>
|
||||
<ConfirmForm
|
||||
action={deleteDiscountCodeAction}
|
||||
confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)}
|
||||
>
|
||||
<input type="hidden" name="id" value={code.id} />
|
||||
<button type="submit" className="text-red-700 underline">
|
||||
{t.adminVouchersPage.delete}
|
||||
</button>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{codes.length === 0 ? <p className="p-6 text-ink/65">{t.adminVouchersPage.noVouchersYet}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
|
||||
import { updateSiteContentAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
|
||||
text: 1,
|
||||
textarea: 3,
|
||||
"list-simple": 4,
|
||||
"list-titled": 5,
|
||||
"list-numbered": 6,
|
||||
"richtext-sections": 14,
|
||||
};
|
||||
|
||||
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
|
||||
const rows = await prisma.siteContent.findMany();
|
||||
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
|
||||
}
|
||||
|
||||
export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte">) {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const { saved } = await props.searchParams;
|
||||
const current = await loadCurrentValues();
|
||||
|
||||
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
||||
{ key: "company", heading: t.adminContentPage.groupCompany },
|
||||
{ key: "home", heading: t.adminContentPage.groupHome },
|
||||
{ key: "legal", heading: t.adminContentPage.groupLegal },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminContentPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
|
||||
|
||||
{saved ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
{t.adminContentPage.saved}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{groups.map((group) => {
|
||||
const fields = CONTENT_REGISTRY.filter((f) => f.group === group.key);
|
||||
return (
|
||||
<form
|
||||
key={group.key}
|
||||
action={updateSiteContentAction}
|
||||
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
|
||||
>
|
||||
<input type="hidden" name="group" value={group.key} />
|
||||
<h2 className="text-lg text-forest">{group.heading}</h2>
|
||||
|
||||
{fields.map((def) => {
|
||||
const row = current.get(def.key);
|
||||
const rows = ROWS_BY_TYPE[def.type];
|
||||
const label = locale === "de" ? def.labelDe : def.labelEn;
|
||||
|
||||
if (!def.localized) {
|
||||
return (
|
||||
<label key={def.key} className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
{def.type === "textarea" ? (
|
||||
<textarea
|
||||
name={def.key}
|
||||
rows={rows}
|
||||
defaultValue={row?.valueDe ?? ""}
|
||||
placeholder={def.getDefault("de")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
name={def.key}
|
||||
defaultValue={row?.valueDe ?? ""}
|
||||
placeholder={def.getDefault("de")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const isTextarea = def.type !== "text";
|
||||
return (
|
||||
<div key={def.key} className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
|
||||
<p className="text-sm text-ink/70 mb-2">{label}</p>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<label className="block text-xs">
|
||||
<span className="text-ink/50">{t.adminContentPage.deLabel}</span>
|
||||
{isTextarea ? (
|
||||
<textarea
|
||||
name={`de::${def.key}`}
|
||||
rows={rows}
|
||||
defaultValue={row?.valueDe ?? ""}
|
||||
placeholder={def.getDefault("de")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
name={`de::${def.key}`}
|
||||
defaultValue={row?.valueDe ?? ""}
|
||||
placeholder={def.getDefault("de")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<span className="text-ink/50">{t.adminContentPage.enLabel}</span>
|
||||
{isTextarea ? (
|
||||
<textarea
|
||||
name={`en::${def.key}`}
|
||||
rows={rows}
|
||||
defaultValue={row?.valueEn ?? ""}
|
||||
placeholder={def.getDefault("en")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
name={`en::${def.key}`}
|
||||
defaultValue={row?.valueEn ?? ""}
|
||||
placeholder={def.getDefault("en")}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminContentPage.save}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import { logoutAction } from "@/app/konto/actions";
|
||||
import { AdminShell } from "@/components/admin-shell";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
// Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem
|
||||
// entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Einzelne Admin-Unterseiten (bestellungen, produkte, ...) haben bewusst
|
||||
// keine eigene Metadata — der Bereich ist ueber robots.ts von der Indexierung
|
||||
// ausgeschlossen. Dieses Template sorgt trotzdem fuer einen sinnvollen
|
||||
// Browser-Tab-Titel statt des generischen Startseiten-Titels ueberall im
|
||||
// Admin-Bereich, ohne jede Unterseite einzeln pflegen zu muessen.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
return {
|
||||
title: { default: t.account.navAdmin.title, template: `%s | ${t.account.navAdmin.title}` },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
||||
// Der Proxy (src/proxy.ts) blockt fremde Requests bereits vor dem Rendern;
|
||||
// diese zweite, gegen die Datenbank autoritative Pruefung faengt u.a. den
|
||||
// Fall ab, dass die ADMIN-Rolle eines eingeloggten Kontos nach dem Login
|
||||
// entzogen wurde (das JWT traegt die Rolle nur als Snapshot vom Login-Zeitpunkt).
|
||||
const admin = await getCurrentAdmin();
|
||||
if (!admin) {
|
||||
redirect("/konto?error=" + encodeURIComponent("Bitte melde dich mit einem Admin-Konto an."));
|
||||
}
|
||||
|
||||
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
|
||||
// Bestellungen, Rueckerstattungen und Kundendaten — ohne 2FA waere ein
|
||||
// gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung
|
||||
// statt Blockade, damit das Konto die Einrichtung direkt nachholen kann.
|
||||
if (!admin.twoFactorEnabled) {
|
||||
redirect(
|
||||
"/konto/zwei-faktor?error=" +
|
||||
encodeURIComponent("Für Admin-Konten ist Zwei-Faktor-Authentifizierung erforderlich. Bitte richte sie jetzt ein.")
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
adminName={`${admin.firstName} ${admin.lastName}`}
|
||||
logoutAction={logoutAction}
|
||||
>
|
||||
{children}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Generisches Suspense-Skeleton fuer den gesamten Admin-Bereich (alle
|
||||
* Unterseiten sind "force-dynamic" und laden bei jeder Navigation neu
|
||||
* gegen die DB) — orientiert an der haeufigsten Admin-Ansicht (Tabelle mit
|
||||
* Filterzeile), statt pro Unterseite ein eigenes Skeleton zu pflegen.
|
||||
*/
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-32 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-6 h-8 w-56 bg-ink/10 rounded-sm" />
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="h-9 w-32 border border-ink/10 bg-ink/5" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border border-ink/10">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="p-4 border-b border-ink/10 last:border-b-0 flex gap-6">
|
||||
<div className="h-4 w-24 bg-ink/10 rounded-sm" />
|
||||
<div className="h-4 w-40 bg-ink/10 rounded-sm" />
|
||||
<div className="h-4 w-20 bg-ink/10 rounded-sm" />
|
||||
<div className="h-4 w-16 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
const DASHBOARD_ICON_PROPS = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.5,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
|
||||
function CoinIcon() {
|
||||
return (
|
||||
<svg {...DASHBOARD_ICON_PROPS}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v10M9 9.5a2 2 0 0 1 2-1.5h1.5a2 2 0 0 1 0 4H11a2 2 0 0 0 0 4h1.5a2 2 0 0 0 2-1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function OrdersIcon() {
|
||||
return (
|
||||
<svg {...DASHBOARD_ICON_PROPS}>
|
||||
<path d="M4 8l8-4 8 4-8 4-8-4z" />
|
||||
<path d="M4 8v8l8 4 8-4V8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function JarIcon() {
|
||||
return (
|
||||
<svg {...DASHBOARD_ICON_PROPS}>
|
||||
<path d="M8 3h8M9 3v3.5L6.5 9A3 3 0 0 0 5.5 11.3V19a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-7.7A3 3 0 0 0 17.5 9L15 6.5V3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleIcon() {
|
||||
return (
|
||||
<svg {...DASHBOARD_ICON_PROPS}>
|
||||
<circle cx="9" cy="8" r="3" />
|
||||
<path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
||||
<path d="M16 4.5a3 3 0 0 1 0 6" />
|
||||
<path d="M18.5 14.3c2 .6 3.5 2.7 3.5 5.7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const STAT_KEYS = [
|
||||
"revenueToday",
|
||||
"revenueMonth",
|
||||
"openOrders",
|
||||
"inventory",
|
||||
"soldGlasses",
|
||||
"avgCart",
|
||||
"repeatCustomers",
|
||||
] as const;
|
||||
|
||||
const statIcons: Record<(typeof STAT_KEYS)[number], () => ReturnType<typeof CoinIcon>> = {
|
||||
revenueToday: CoinIcon,
|
||||
revenueMonth: CoinIcon,
|
||||
openOrders: OrdersIcon,
|
||||
inventory: JarIcon,
|
||||
soldGlasses: JarIcon,
|
||||
avgCart: CoinIcon,
|
||||
repeatCustomers: PeopleIcon,
|
||||
};
|
||||
|
||||
function startOfDay(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function startOfMonth(): Date {
|
||||
const d = new Date();
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
const REVENUE_TREND_DAYS = 14;
|
||||
|
||||
function daysAgo(n: number): Date {
|
||||
const d = startOfDay();
|
||||
d.setDate(d.getDate() - n);
|
||||
return d;
|
||||
}
|
||||
|
||||
export default async function AdminDashboardPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const [
|
||||
revenueToday,
|
||||
revenueMonth,
|
||||
openOrders,
|
||||
inventorySum,
|
||||
soldGlasses,
|
||||
orderStats,
|
||||
soldItems,
|
||||
recentOrders,
|
||||
repeatCustomerGroups,
|
||||
topDiscountCodes,
|
||||
] = await Promise.all([
|
||||
prisma.order.aggregate({
|
||||
_sum: { totalCents: true },
|
||||
where: { createdAt: { gte: startOfDay() }, status: { not: "STORNIERT" } },
|
||||
}),
|
||||
prisma.order.aggregate({
|
||||
_sum: { totalCents: true },
|
||||
where: { createdAt: { gte: startOfMonth() }, status: { not: "STORNIERT" } },
|
||||
}),
|
||||
prisma.order.count({
|
||||
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
|
||||
}),
|
||||
prisma.inventory.aggregate({ _sum: { quantity: true } }),
|
||||
prisma.orderItem.aggregate({ _sum: { quantity: true } }),
|
||||
prisma.order.aggregate({
|
||||
_avg: { totalCents: true },
|
||||
_count: true,
|
||||
where: { status: { not: "STORNIERT" } },
|
||||
}),
|
||||
// Fuer Top-Produkte/Umsatz nach Kategorie: kein Prisma-groupBy ueber
|
||||
// Relationen moeglich, deshalb Rohdaten laden und im Code aggregieren
|
||||
// (bei der Groessenordnung dieses Shops unproblematisch).
|
||||
prisma.orderItem.findMany({
|
||||
where: { order: { status: { not: "STORNIERT" } } },
|
||||
select: {
|
||||
productId: true,
|
||||
productName: true,
|
||||
quantity: true,
|
||||
totalCents: true,
|
||||
product: { select: { category: true } },
|
||||
},
|
||||
}),
|
||||
prisma.order.findMany({
|
||||
where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, status: { not: "STORNIERT" } },
|
||||
select: { createdAt: true, totalCents: true },
|
||||
}),
|
||||
// Stammkunden: Kund:innen mit mehr als einer nicht-stornierten Bestellung.
|
||||
// Kein Prisma-groupBy mit HAVING count > 1 in einer Abfrage moeglich,
|
||||
// deshalb Rohdaten laden und im Code gruppieren.
|
||||
prisma.order.findMany({
|
||||
where: { customerId: { not: null }, status: { not: "STORNIERT" } },
|
||||
select: { customerId: true },
|
||||
}),
|
||||
prisma.discountCode.findMany({
|
||||
where: { usedCount: { gt: 0 } },
|
||||
orderBy: { usedCount: "desc" },
|
||||
take: 5,
|
||||
}),
|
||||
]);
|
||||
|
||||
const ordersPerCustomer = new Map<string, number>();
|
||||
for (const order of repeatCustomerGroups) {
|
||||
if (!order.customerId) continue;
|
||||
ordersPerCustomer.set(order.customerId, (ordersPerCustomer.get(order.customerId) ?? 0) + 1);
|
||||
}
|
||||
const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length;
|
||||
|
||||
const statLabels: Record<(typeof STAT_KEYS)[number], string> = {
|
||||
revenueToday: t.adminDashboardPage.statRevenueToday,
|
||||
revenueMonth: t.adminDashboardPage.statRevenueMonth,
|
||||
openOrders: t.adminDashboardPage.statOpenOrders,
|
||||
inventory: t.adminDashboardPage.statInventory,
|
||||
soldGlasses: t.adminDashboardPage.statSoldGlasses,
|
||||
avgCart: t.adminDashboardPage.statAvgCart,
|
||||
repeatCustomers: t.adminDashboardPage.statRepeatCustomers,
|
||||
};
|
||||
|
||||
const stats: { key: (typeof STAT_KEYS)[number]; value: string }[] = [
|
||||
{ key: "revenueToday", value: formatPrice(revenueToday._sum.totalCents ?? 0) },
|
||||
{ key: "revenueMonth", value: formatPrice(revenueMonth._sum.totalCents ?? 0) },
|
||||
{ key: "openOrders", value: String(openOrders) },
|
||||
{ key: "inventory", value: String(inventorySum._sum.quantity ?? 0) },
|
||||
{ key: "soldGlasses", value: String(soldGlasses._sum.quantity ?? 0) },
|
||||
{
|
||||
key: "avgCart",
|
||||
value: formatPrice(Math.round(orderStats._avg.totalCents ?? 0)),
|
||||
},
|
||||
{ key: "repeatCustomers", value: String(repeatCustomerCount) },
|
||||
];
|
||||
|
||||
const productTotals = new Map<string, { name: string; quantity: number; revenueCents: number }>();
|
||||
const categoryTotals = new Map<string, { quantity: number; revenueCents: number }>();
|
||||
|
||||
for (const item of soldItems) {
|
||||
const product = productTotals.get(item.productId) ?? {
|
||||
name: item.productName,
|
||||
quantity: 0,
|
||||
revenueCents: 0,
|
||||
};
|
||||
product.quantity += item.quantity;
|
||||
product.revenueCents += item.totalCents;
|
||||
productTotals.set(item.productId, product);
|
||||
|
||||
const category = item.product?.category ?? "HONIG";
|
||||
const categoryTotal = categoryTotals.get(category) ?? { quantity: 0, revenueCents: 0 };
|
||||
categoryTotal.quantity += item.quantity;
|
||||
categoryTotal.revenueCents += item.totalCents;
|
||||
categoryTotals.set(category, categoryTotal);
|
||||
}
|
||||
|
||||
const topProducts = [...productTotals.values()].sort((a, b) => b.revenueCents - a.revenueCents).slice(0, 5);
|
||||
const revenueByCategory = [...categoryTotals.entries()].sort((a, b) => b[1].revenueCents - a[1].revenueCents);
|
||||
const maxCategoryRevenue = Math.max(1, ...revenueByCategory.map(([, v]) => v.revenueCents));
|
||||
|
||||
const revenueByDay = new Map<string, number>();
|
||||
for (let i = REVENUE_TREND_DAYS - 1; i >= 0; i--) {
|
||||
revenueByDay.set(daysAgo(i).toISOString().slice(0, 10), 0);
|
||||
}
|
||||
for (const order of recentOrders) {
|
||||
const key = order.createdAt.toISOString().slice(0, 10);
|
||||
revenueByDay.set(key, (revenueByDay.get(key) ?? 0) + order.totalCents);
|
||||
}
|
||||
const revenueTrend = [...revenueByDay.entries()];
|
||||
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{stats.map((stat) => {
|
||||
const Icon = statIcons[stat.key];
|
||||
const primary = stat.key === "revenueToday";
|
||||
return (
|
||||
<div
|
||||
key={stat.key}
|
||||
className={`border p-6 flex items-start gap-4 ${
|
||||
primary ? "border-honey/40 bg-honey/5" : "bg-background border-ink/10"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 border ${
|
||||
primary ? "border-honey/40 text-honey-dark" : "border-ink/15 text-forest"
|
||||
}`}
|
||||
>
|
||||
<Icon />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs text-ink/65 tracking-wide-caps">{statLabels[stat.key]}</p>
|
||||
<p className="mt-1 text-2xl text-forest">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid lg:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topProductsHeading}</h2>
|
||||
{topProducts.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
|
||||
) : (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{topProducts.map((product, i) => (
|
||||
<li key={product.name} className="flex items-center justify-between gap-4">
|
||||
<span className="text-ink/70">
|
||||
{i + 1}. {product.name}{" "}
|
||||
<span className="text-ink/65">{t.adminDashboardPage.unitsSuffix(product.quantity)}</span>
|
||||
</span>
|
||||
<span className="text-forest whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">
|
||||
{t.adminDashboardPage.revenueByCategoryHeading}
|
||||
</h2>
|
||||
{revenueByCategory.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
|
||||
) : (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{revenueByCategory.map(([category, totals]) => (
|
||||
<li key={category}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-ink/70">
|
||||
{t.common.categories[category as keyof typeof t.common.categories] ?? category}
|
||||
</span>
|
||||
<span className="text-forest">{formatPrice(totals.revenueCents)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-ink/10">
|
||||
<div
|
||||
className="h-full bg-honey"
|
||||
style={{ width: `${(totals.revenueCents / maxCategoryRevenue) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid lg:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">
|
||||
{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}
|
||||
</h2>
|
||||
<div className="flex items-end gap-1 h-24">
|
||||
{revenueTrend.map(([day, revenueCents]) => (
|
||||
<div
|
||||
key={day}
|
||||
className="flex-1 h-full flex flex-col justify-end"
|
||||
title={`${day}: ${formatPrice(revenueCents)}`}
|
||||
>
|
||||
<div
|
||||
className="bg-honey w-full"
|
||||
style={{ height: `${Math.max(2, (revenueCents / maxDailyRevenue) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-[10px] text-ink/65">
|
||||
<span>{t.adminDashboardPage.daysAgoLabel(REVENUE_TREND_DAYS)}</span>
|
||||
<span>{t.adminDashboardPage.todayLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
|
||||
{topDiscountCodes.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{t.adminDashboardPage.noVouchersYet}</p>
|
||||
) : (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{topDiscountCodes.map((code) => (
|
||||
<li key={code.id} className="flex items-center justify-between gap-4">
|
||||
<span className="text-ink/70">
|
||||
{code.code}{" "}
|
||||
<span className="text-ink/65">
|
||||
({code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)})
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-forest whitespace-nowrap">
|
||||
{t.adminDashboardPage.usedCountLabel(code.usedCount, code.maxUses)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-xs text-ink/65">
|
||||
<Link href="/admin/bestellungen" className="underline">
|
||||
{t.adminDashboardPage.viewAllOrders}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { asStringArray } from "@/lib/json-array";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { updateProductAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const rawProduct = await prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { harvest: true, batch: true, inventory: true },
|
||||
});
|
||||
if (!rawProduct) notFound();
|
||||
const product = { ...rawProduct, tasteTags: asStringArray(rawProduct.tasteTags) };
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/produkte" label={t.adminProductEditPage.backToProducts} />
|
||||
<h1 className="text-2xl text-forest mb-2">{t.adminProductEditPage.title}</h1>
|
||||
<p className="text-sm text-ink/65 mb-8">
|
||||
{t.common.categories[product.category]}
|
||||
{product.harvest && product.batch
|
||||
? t.adminProductEditPage.harvestBatchSuffix(
|
||||
product.harvest.type,
|
||||
product.harvest.year,
|
||||
product.batch.code
|
||||
)
|
||||
: null}
|
||||
</p>
|
||||
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={product.id} />
|
||||
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
|
||||
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminProductEditPage.descriptionLabel}</span>
|
||||
<textarea
|
||||
name="description"
|
||||
defaultValue={product.description}
|
||||
required
|
||||
rows={3}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
name="tasteTags"
|
||||
label={t.adminProductEditPage.tasteTagsLabel}
|
||||
defaultValue={product.tasteTags.join(", ")}
|
||||
/>
|
||||
|
||||
{product.category === "HONIG" ? (
|
||||
<>
|
||||
<Field
|
||||
name="consistency"
|
||||
label={t.adminProductEditPage.consistencyLabel}
|
||||
defaultValue={product.consistency}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field
|
||||
name="intensityMild"
|
||||
label={t.adminProductEditPage.intensityMildLabel}
|
||||
type="number"
|
||||
defaultValue={String(product.intensityMild)}
|
||||
/>
|
||||
<Field
|
||||
name="intensityFloral"
|
||||
label={t.adminProductEditPage.intensityFloralLabel}
|
||||
type="number"
|
||||
defaultValue={String(product.intensityFloral)}
|
||||
/>
|
||||
<Field
|
||||
name="intensityKraeftig"
|
||||
label={t.adminProductEditPage.intensityKraeftigLabel}
|
||||
type="number"
|
||||
defaultValue={String(product.intensityKraeftig)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
name="weightGrams"
|
||||
label={t.adminProductEditPage.weightLabel}
|
||||
type="number"
|
||||
defaultValue={String(product.weightGrams)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="quantity"
|
||||
label={t.adminProductEditPage.stockLabel}
|
||||
type="number"
|
||||
defaultValue={String(product.inventory?.quantity ?? 0)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
name="priceCents"
|
||||
label={t.adminProductEditPage.priceLabel}
|
||||
type="number"
|
||||
defaultValue={String(product.priceCents)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="pricePerKgCents"
|
||||
label={t.adminProductEditPage.pricePerKgLabel}
|
||||
type="number"
|
||||
defaultValue={String(product.pricePerKgCents)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isActive" defaultChecked={product.isActive} />
|
||||
{t.adminProductEditPage.visibleInShop}
|
||||
</label>
|
||||
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminProductEditPage.save}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { createProductAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminProductNewPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const [harvests, batches] = await Promise.all([
|
||||
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
|
||||
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
|
||||
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<Field name="name" label={t.adminProductNewPage.nameLabel} required />
|
||||
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
|
||||
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminProductNewPage.categoryLabel}</span>
|
||||
<select name="category" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||
<option value="HONIG">{t.common.categories.HONIG}</option>
|
||||
<option value="KERZEN">{t.common.categories.KERZEN}</option>
|
||||
<option value="PROPOLIS">{t.common.categories.PROPOLIS}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<p className="text-xs text-ink/65">{t.adminProductNewPage.categoryHint}</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminProductNewPage.harvestLabel}</span>
|
||||
<select name="harvestId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||
<option value="">{t.adminProductNewPage.noneOption}</option>
|
||||
{harvests.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.type} {h.year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminProductNewPage.batchLabel}</span>
|
||||
<select name="batchId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
|
||||
<option value="">{t.adminProductNewPage.noneOption}</option>
|
||||
{batches.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{t.adminProductNewPage.batchOption(b.code, b.harvest.type, b.harvest.year)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.adminProductNewPage.descriptionLabel}</span>
|
||||
<textarea name="description" required rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
|
||||
</label>
|
||||
|
||||
<Field
|
||||
name="tasteTags"
|
||||
label={t.adminProductNewPage.tasteTagsLabel}
|
||||
placeholder={t.adminProductNewPage.tasteTagsPlaceholder}
|
||||
/>
|
||||
<Field
|
||||
name="consistency"
|
||||
label={t.adminProductNewPage.consistencyLabel}
|
||||
placeholder={t.adminProductNewPage.consistencyPlaceholder}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field
|
||||
name="intensityMild"
|
||||
label={t.adminProductNewPage.intensityMildLabel}
|
||||
type="number"
|
||||
defaultValue="3"
|
||||
/>
|
||||
<Field
|
||||
name="intensityFloral"
|
||||
label={t.adminProductNewPage.intensityFloralLabel}
|
||||
type="number"
|
||||
defaultValue="3"
|
||||
/>
|
||||
<Field
|
||||
name="intensityKraeftig"
|
||||
label={t.adminProductNewPage.intensityKraeftigLabel}
|
||||
type="number"
|
||||
defaultValue="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="weightGrams" label={t.adminProductNewPage.weightLabel} type="number" required defaultValue="500" />
|
||||
<Field name="quantity" label={t.adminProductNewPage.stockLabel} type="number" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
name="priceCents"
|
||||
label={t.adminProductNewPage.priceLabel}
|
||||
type="number"
|
||||
required
|
||||
placeholder={t.adminProductNewPage.pricePlaceholder}
|
||||
/>
|
||||
<Field
|
||||
name="pricePerKgCents"
|
||||
label={t.adminProductNewPage.pricePerKgLabel}
|
||||
type="number"
|
||||
required
|
||||
placeholder={t.adminProductNewPage.pricePerKgPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isActive" defaultChecked />
|
||||
{t.adminProductNewPage.visibleInShop}
|
||||
</label>
|
||||
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminProductNewPage.create}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
placeholder,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const products = await prisma.product.findMany({
|
||||
include: { harvest: true, batch: true, inventory: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminProductsPage.backToDashboard} />
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl text-forest">{t.adminProductsPage.title}</h1>
|
||||
<Link href="/admin/produkte/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||
{t.adminProductsPage.newProduct}
|
||||
</Link>
|
||||
</div>
|
||||
<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.adminProductsPage.colProduct}</th>
|
||||
<th className="p-4">{t.adminProductsPage.colCategory}</th>
|
||||
<th className="p-4">{t.adminProductsPage.colHarvest}</th>
|
||||
<th className="p-4">{t.adminProductsPage.colBatch}</th>
|
||||
<th className="p-4">{t.adminProductsPage.colWeight}</th>
|
||||
<th className="p-4">{t.adminProductsPage.colPrice}</th>
|
||||
<th className="p-4">{t.adminProductsPage.colStock}</th>
|
||||
<th className="p-4">{t.adminProductsPage.colStatus}</th>
|
||||
<th className="p-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{products.map((product) => (
|
||||
<tr key={product.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">{product.name}</td>
|
||||
<td className="p-4">{t.common.categories[product.category]}</td>
|
||||
<td className="p-4">
|
||||
{product.harvest ? `${product.harvest.type} ${product.harvest.year}` : "—"}
|
||||
</td>
|
||||
<td className="p-4">{product.batch?.code ?? "—"}</td>
|
||||
<td className="p-4">{product.weightGrams} g</td>
|
||||
<td className="p-4">{formatPrice(product.priceCents, product.currency)}</td>
|
||||
<td className="p-4">{product.inventory?.quantity ?? 0}</td>
|
||||
<td className="p-4">
|
||||
{product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline">
|
||||
{t.adminProductsPage.edit}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{products.length === 0 ? (
|
||||
<p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { updateShippingTierAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const tier = await prisma.shippingTier.findUnique({ where: { id } });
|
||||
if (!tier) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminShippingEditPage.title}</h1>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={updateShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={tier.id} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field
|
||||
name="minQuantity"
|
||||
label={t.adminShippingEditPage.minQuantityLabel}
|
||||
type="number"
|
||||
defaultValue={String(tier.minQuantity)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
name="maxQuantity"
|
||||
label={t.adminShippingEditPage.maxQuantityLabel}
|
||||
type="number"
|
||||
defaultValue={tier.maxQuantity !== null ? String(tier.maxQuantity) : ""}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
name="priceCents"
|
||||
label={t.adminShippingEditPage.priceLabel}
|
||||
type="number"
|
||||
defaultValue={String(tier.priceCents)}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminShippingEditPage.save}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
min={0}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { createShippingTierAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminVersandNewPage(props: PageProps<"/admin/versand/neu">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminShippingNewPage.title}</h1>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={createShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="minQuantity" label={t.adminShippingNewPage.minQuantityLabel} type="number" required placeholder="0" />
|
||||
<Field name="maxQuantity" label={t.adminShippingNewPage.maxQuantityLabel} type="number" placeholder="3" />
|
||||
</div>
|
||||
<Field name="priceCents" label={t.adminShippingNewPage.priceLabel} type="number" required placeholder="700" />
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminShippingNewPage.create}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
placeholder,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
min={0}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { deleteShippingTierAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminVersandPage(props: PageProps<"/admin/versand">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminShippingPage.backToDashboard} />
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl text-forest">{t.adminShippingPage.title}</h1>
|
||||
<Link href="/admin/versand/neu" className="bg-forest text-cream px-5 py-2 text-sm">
|
||||
{t.adminShippingPage.newTier}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-ink/70 mb-8 max-w-2xl">{t.adminShippingPage.intro}</p>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<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.adminShippingPage.colQuantity}</th>
|
||||
<th className="p-4">{t.adminShippingPage.colPrice}</th>
|
||||
<th className="p-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{tiers.map((tier) => (
|
||||
<tr key={tier.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">
|
||||
{tier.maxQuantity === null
|
||||
? t.adminShippingPage.quantityFrom(tier.minQuantity)
|
||||
: t.adminShippingPage.quantityRange(tier.minQuantity, tier.maxQuantity)}
|
||||
</td>
|
||||
<td className="p-4">{formatPrice(tier.priceCents)}</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3 whitespace-nowrap">
|
||||
<Link href={`/admin/versand/${tier.id}`} className="text-forest underline">
|
||||
{t.adminShippingPage.edit}
|
||||
</Link>
|
||||
<ConfirmForm
|
||||
action={deleteShippingTierAction}
|
||||
confirmMessage={t.adminShippingPage.deleteConfirm}
|
||||
>
|
||||
<input type="hidden" name="id" value={tier.id} />
|
||||
<button type="submit" className="text-red-700 underline">
|
||||
{t.adminShippingPage.delete}
|
||||
</button>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tiers.length === 0 ? <p className="p-6 text-ink/65">{t.adminShippingPage.noTiersYet}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/legal-page";
|
||||
import { getContentValue, parseSections } from "@/lib/content";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "AGB", alternates: { canonical: "/agb" } };
|
||||
|
||||
export default async function AgbPage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const sections = parseSections(await getContentValue("legal.agb", locale));
|
||||
return (
|
||||
<LegalPage
|
||||
title={t.agbPage.title}
|
||||
noticeText={t.legalPage.reviewNotice}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { businessInquirySchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`business-inquiry:${ip}`, 5, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Anfragen. Bitte versuche es später erneut." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = businessInquirySchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bitte überprüfe deine Angaben.", issues: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const turnstileToken = typeof body?.turnstileToken === "string" ? body.turnstileToken : null;
|
||||
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||
return NextResponse.json({ error: "Die Sicherheitsprüfung ist fehlgeschlagen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { desiredDeliveryDate, ...rest } = parsed.data;
|
||||
|
||||
await prisma.businessInquiry.create({
|
||||
data: {
|
||||
...rest,
|
||||
desiredDeliveryDate: desiredDeliveryDate ? new Date(desiredDeliveryDate) : null,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { checkoutSchema } from "@/lib/validation";
|
||||
import { getProductsByIds } from "@/lib/data";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getShippingCostCents } from "@/lib/shipping";
|
||||
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
|
||||
class OutOfStockError extends Error {}
|
||||
|
||||
function generateOrderNumber(): string {
|
||||
const year = new Date().getFullYear();
|
||||
// Kryptografisch zufaellig statt Math.random(): nicht erratbar und
|
||||
// praktisch kollisionsfrei (das @unique-Constraint faengt den Rest ab).
|
||||
const random = randomBytes(4).toString("hex").toUpperCase();
|
||||
return `WH-${year}-${random}`;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`checkout:${ip}`, 10, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Bestellversuche. Bitte versuche es später erneut." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = checkoutSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ungültige Eingabe.", issues: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
if (input.fulfillmentMethod === "VERSAND") {
|
||||
if (!input.street || !input.zip || !input.city) {
|
||||
return NextResponse.json(
|
||||
{ error: "Bitte gib eine vollständige Lieferadresse an." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const productIds = input.items.map((item) => item.productId);
|
||||
const products = await getProductsByIds(productIds);
|
||||
|
||||
if (products.length !== productIds.length) {
|
||||
return NextResponse.json(
|
||||
{ error: "Ein Produkt im Warenkorb ist nicht mehr verfügbar." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const orderItemsData = input.items.map((cartItem) => {
|
||||
const product = products.find((p) => p.id === cartItem.productId)!;
|
||||
return {
|
||||
productId: product.id,
|
||||
batchId: product.batchId,
|
||||
productName: product.name,
|
||||
unitPriceCents: product.priceCents,
|
||||
quantity: cartItem.quantity,
|
||||
weightGrams: product.weightGrams,
|
||||
totalCents: product.priceCents * cartItem.quantity,
|
||||
};
|
||||
});
|
||||
|
||||
const subtotalCents = orderItemsData.reduce((sum, item) => sum + item.totalCents, 0);
|
||||
// Versandkosten richten sich nach der admin-gepflegten Mengenstaffel und
|
||||
// der Gesamtmenge ueber alle Positionen hinweg (mehrere Artikel werden zu
|
||||
// einer Menge zusammengefasst, nicht separat berechnet) — kein
|
||||
// wertbasierter Freibetrag mehr.
|
||||
const totalQuantity = orderItemsData.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const shippingCents =
|
||||
input.fulfillmentMethod === "ABHOLUNG" ? 0 : await getShippingCostCents(totalQuantity);
|
||||
|
||||
// Gutscheincode nur auf den Warenwert anwenden, nicht auf den Versand —
|
||||
// haelt die Rabattlogik einfach nachvollziehbar.
|
||||
let discountCents = 0;
|
||||
let discountCode: { id: string; code: string } | null = null;
|
||||
if (input.discountCode) {
|
||||
const now = new Date();
|
||||
const code = await prisma.discountCode.findUnique({
|
||||
where: { code: input.discountCode.trim().toUpperCase() },
|
||||
});
|
||||
|
||||
if (!code || !isDiscountCodeValid(code, subtotalCents, now, totalQuantity)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// usedCount allein reicht bei einem Nutzungslimit nicht: er wird erst bei
|
||||
// bestaetigter Zahlung erhoeht (s.u.), zaehlt also nicht bereits laufende,
|
||||
// noch unbezahlte Checkouts mit demselben Code mit. Ohne diese Pruefung
|
||||
// koennten mehrere parallele Checkout-Versuche (z.B. mehrere Tabs) einen
|
||||
// maxUses:1-Code alle gleichzeitig als gueltig durchgehen und am Ende
|
||||
// mehrfach tatsaechlich bezahlt einloesen.
|
||||
if (code.maxUses !== null) {
|
||||
const pendingUses = await prisma.order.count({
|
||||
where: { discountCodeId: code.id, status: "NEU" },
|
||||
});
|
||||
if (code.usedCount + pendingUses >= code.maxUses) {
|
||||
return NextResponse.json(
|
||||
{ error: "Der Gutscheincode ist ungültig, abgelaufen oder nicht mehr einlösbar." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
||||
discountCode = code;
|
||||
} else {
|
||||
// Kein manuell eingegebener Code — unter allen im Admin-Bereich
|
||||
// gepflegten Mengenrabatten (minQuantity gesetzt) den best passenden
|
||||
// waehlen (hoechste erreichte Schwelle). Keine Nutzungsgrenze noetig,
|
||||
// daher keine pendingUses-Pruefung wie beim manuellen Code oben.
|
||||
await ensureBulkDiscountCode();
|
||||
const candidates = await prisma.discountCode.findMany({ where: { minQuantity: { not: null } } });
|
||||
const now = new Date();
|
||||
const code = candidates
|
||||
.filter((candidate) => isDiscountCodeValid(candidate, subtotalCents, now, totalQuantity))
|
||||
.sort((a, b) => (b.minQuantity ?? 0) - (a.minQuantity ?? 0))[0];
|
||||
if (code) {
|
||||
discountCents = calculateDiscountCents(code, subtotalCents);
|
||||
discountCode = code;
|
||||
}
|
||||
}
|
||||
// usedCount wird bewusst erst im Webhook bei bestaetigter Zahlung erhoeht,
|
||||
// nicht hier — sonst wuerde ein abgebrochener Stripe-Checkout einen
|
||||
// Gutschein-Nutzungsslot verbrauchen, ohne dass je bezahlt wurde.
|
||||
|
||||
const totalCents = subtotalCents - discountCents + shippingCents;
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
// Gastbestellungen (kein Konto) sind davon unberuehrt — die Pflicht gilt
|
||||
// nur fuer registrierte, eingeloggte Kund:innen.
|
||||
if (customer && !customer.emailVerifiedAt) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Bitte bestätige zuerst deine E-Mail-Adresse (Link auf deiner Kontoseite erneut anfordern), bevor du mit deinem Konto bestellst.",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const locale = await getLocale();
|
||||
|
||||
let order;
|
||||
try {
|
||||
// Bestellanlage und Bestandsreservierung atomar: das bedingte
|
||||
// updateMany (quantity >= bestellt) verhindert Ueberverkauf auch bei
|
||||
// gleichzeitigen Bestellungen — schlaegt eine Position fehl, wird die
|
||||
// gesamte Transaktion (inkl. bereits reservierter Positionen) verworfen.
|
||||
order = await prisma.$transaction(async (tx) => {
|
||||
for (const item of orderItemsData) {
|
||||
const reserved = await tx.inventory.updateMany({
|
||||
where: { productId: item.productId, quantity: { gte: item.quantity } },
|
||||
data: { quantity: { decrement: item.quantity } },
|
||||
});
|
||||
if (reserved.count === 0) {
|
||||
throw new OutOfStockError(item.productName);
|
||||
}
|
||||
}
|
||||
|
||||
return tx.order.create({
|
||||
data: {
|
||||
orderNumber: generateOrderNumber(),
|
||||
customerId: customer?.id,
|
||||
locale,
|
||||
fulfillmentMethod: input.fulfillmentMethod,
|
||||
email: input.email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
phone: input.phone,
|
||||
street: input.street ?? "",
|
||||
zip: input.zip ?? "",
|
||||
city: input.city ?? "",
|
||||
country: input.country,
|
||||
subtotalCents,
|
||||
shippingCents,
|
||||
discountCents,
|
||||
discountCodeId: discountCode?.id,
|
||||
totalCents,
|
||||
giftMessage: input.giftMessage || null,
|
||||
items: {
|
||||
create: orderItemsData,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof OutOfStockError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `„${error.message}“ ist leider nicht mehr in der gewünschten Menge verfügbar. Bitte passe deinen Warenkorb an.`,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
// Bewusst NICHT aus dem Origin-Request-Header: der ist vom Aufrufer frei
|
||||
// waehlbar (z.B. direkter API-Aufruf statt ueber den Browser) und wuerde
|
||||
// sich sonst in success_url/cancel_url der Stripe-Session einschleusen —
|
||||
// ein offenes Redirect direkt nach einer echten Zahlung auf Stripes
|
||||
// eigener Checkout-Seite waere hochgradig phishing-tauglich.
|
||||
const origin = getBaseUrl();
|
||||
|
||||
// Stripe erlaubt keine negativen Preise in line_items — ein Rabatt wird
|
||||
// stattdessen als einmaliger Coupon auf die Session angewendet, damit
|
||||
// Produktpositionen weiterhin ihre echten Einzelpreise zeigen.
|
||||
let discounts: { coupon: string }[] | undefined;
|
||||
if (discountCents > 0) {
|
||||
const coupon = await stripe.coupons.create({
|
||||
amount_off: discountCents,
|
||||
currency: "eur",
|
||||
duration: "once",
|
||||
name: input.discountCode?.trim().toUpperCase() ?? discountCode?.code,
|
||||
});
|
||||
discounts = [{ coupon: coupon.id }];
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
customer_email: input.email,
|
||||
line_items: [
|
||||
...orderItemsData.map((item) => ({
|
||||
quantity: item.quantity,
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
unit_amount: item.unitPriceCents,
|
||||
product_data: { name: `${item.productName} (${item.weightGrams} g)` },
|
||||
},
|
||||
})),
|
||||
...(shippingCents > 0
|
||||
? [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
unit_amount: shippingCents,
|
||||
product_data: { name: "Versand" },
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
...(discounts ? { discounts } : {}),
|
||||
success_url: `${origin}/checkout/success?order=${order.orderNumber}`,
|
||||
cancel_url: `${origin}/checkout/cancel`,
|
||||
metadata: { orderId: order.id, orderNumber: order.orderNumber },
|
||||
});
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { stripeCheckoutSessionId: session.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (error: unknown) {
|
||||
// Bestellung bleibt als "NEU" erhalten, damit sie im Admin-Bereich manuell
|
||||
// nachbearbeitet werden kann, auch wenn Stripe (noch) nicht konfiguriert ist.
|
||||
// Die Stripe-Fehlermeldung nur serverseitig loggen, nicht an den Client
|
||||
// zurueckgeben — sie kann interne Konfigurations-/Kontodetails enthalten
|
||||
// und dieser Endpunkt ist ohne Authentifizierung erreichbar.
|
||||
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
|
||||
console.error(`Checkout-Session fuer Bestellung ${order.orderNumber} fehlgeschlagen:`, message);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Die Zahlungsabwicklung ist aktuell nicht verfügbar. Deine Bestellung wurde vorgemerkt, wir melden uns bei dir.",
|
||||
orderNumber: order.orderNumber,
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { getStripeClient } from "@/lib/stripe";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { orderConfirmationEmail, paymentFailedEmail } from "@/lib/email-templates";
|
||||
import { restockOrderItems } from "@/lib/inventory";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
/**
|
||||
* Verarbeitet Stripe-Webhook-Events. In Stripe muss ein Webhook-Endpoint auf
|
||||
* /api/checkout/webhook mit den Events "checkout.session.completed",
|
||||
* "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed"
|
||||
* und "checkout.session.expired" angelegt und STRIPE_WEBHOOK_SECRET gesetzt
|
||||
* werden. Die beiden async-Events sind fuer Zahlungsmethoden mit verzoegerter
|
||||
* Bestaetigung (SEPA-Lastschrift, teils Klarna) noetig — dort ist die
|
||||
* Checkout-Sitzung bereits abgeschlossen, aber die Zahlung selbst noch nicht
|
||||
* (siehe fulfillOrder/failOrder unten). "expired" faengt den Fall ab, dass
|
||||
* eine Kundschaft den Checkout einfach verlaesst, ohne zu bezahlen — ohne
|
||||
* diesen Handler bliebe der beim Checkout reservierte Lagerbestand fuer
|
||||
* diese Bestellung dauerhaft blockiert.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
return NextResponse.json(
|
||||
{ error: "STRIPE_WEBHOOK_SECRET ist nicht konfiguriert." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
const rawBody = await request.text();
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
const stripe = getStripeClient();
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature ?? "", webhookSecret);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Signaturprüfung fehlgeschlagen";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
// Bei Zahlungsmethoden mit verzoegerter Bestaetigung (z.B.
|
||||
// SEPA-Lastschrift) ist payment_status hier noch "unpaid" — die
|
||||
// eigentliche Bestaetigung kommt dann als eigenes
|
||||
// checkout.session.async_payment_succeeded-Ereignis (siehe unten).
|
||||
// Ohne diese Pruefung wuerden wir die Bestellung faelschlich als
|
||||
// bezahlt markieren, obwohl das Geld noch gar nicht eingezogen ist.
|
||||
if (session.payment_status !== "unpaid") {
|
||||
await fulfillOrder(session);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.async_payment_succeeded") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await fulfillOrder(session);
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.async_payment_failed") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await failOrder(session);
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.expired") {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await failOrder(session);
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
|
||||
async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const orderId = session.metadata?.orderId;
|
||||
if (!orderId) return;
|
||||
|
||||
// Transaktional + Status-Guard (Muster wie failOrder unten): verhindert,
|
||||
// dass zwei parallel zugestellte Webhook-Kopien denselben Gutschein
|
||||
// doppelt zaehlen, und verhindert, dass eine im Admin-Bereich bereits
|
||||
// stornierte (und zurueckgebuchte) Bestellung durch eine verspaetete
|
||||
// Zahlungsbestaetigung faelschlich wieder auf "BEZAHLT" gesetzt wird.
|
||||
const order = await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.order.updateMany({
|
||||
where: { id: orderId, status: "NEU" },
|
||||
data: { status: "BEZAHLT" },
|
||||
});
|
||||
if (result.count === 0) return null;
|
||||
|
||||
const updated = await tx.order.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
if (updated.discountCodeId) {
|
||||
await tx.discountCode.update({
|
||||
where: { id: updated.discountCodeId },
|
||||
data: { usedCount: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
// Bereits verarbeitet (Webhook-Replay) oder Bestellung war nicht mehr
|
||||
// im Status "NEU" (z.B. schon storniert) — nichts weiter zu tun.
|
||||
if (!order) return;
|
||||
|
||||
await prisma.payment.upsert({
|
||||
where: { orderId: order.id },
|
||||
create: {
|
||||
orderId: order.id,
|
||||
stripePaymentIntentId:
|
||||
typeof session.payment_intent === "string" ? session.payment_intent : null,
|
||||
status: "SUCCEEDED",
|
||||
amountCents: session.amount_total ?? order.totalCents,
|
||||
paidAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
status: "SUCCEEDED",
|
||||
paidAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.invoice.upsert({
|
||||
where: { orderId: order.id },
|
||||
create: {
|
||||
orderId: order.id,
|
||||
invoiceNumber: `RE-${new Date().getFullYear()}-${randomBytes(4).toString("hex").toUpperCase()}`,
|
||||
totalCents: order.totalCents,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
// E-Mail-Fehler duerfen die Zahlungsbestaetigung selbst nicht scheitern
|
||||
// lassen — Stripe wiederholt Webhooks bei Fehlerantworten, ein
|
||||
// Mailserver-Ausfall soll das nicht auf die Bestellabwicklung durchschlagen.
|
||||
const { subject, html, text } = await orderConfirmationEmail({
|
||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||
orderNumber: order.orderNumber,
|
||||
firstName: order.firstName,
|
||||
fulfillmentMethod: order.fulfillmentMethod,
|
||||
items: order.items.map((item) => ({
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
weightGrams: item.weightGrams,
|
||||
totalCents: item.totalCents,
|
||||
})),
|
||||
subtotalCents: order.subtotalCents,
|
||||
shippingCents: order.shippingCents,
|
||||
discountCents: order.discountCents,
|
||||
totalCents: order.totalCents,
|
||||
street: order.street,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
giftMessage: order.giftMessage,
|
||||
trackingUrl: `${getBaseUrl()}/bestellung-verfolgen?order=${encodeURIComponent(order.orderNumber)}&email=${encodeURIComponent(order.email)}`,
|
||||
});
|
||||
await sendMail({ to: order.email, subject, html, text });
|
||||
}
|
||||
|
||||
async function failOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const orderId = session.metadata?.orderId;
|
||||
if (!orderId) return;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const previous = await tx.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
status: true,
|
||||
orderNumber: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
locale: true,
|
||||
items: { select: { productId: true, quantity: true } },
|
||||
},
|
||||
});
|
||||
// Nur von "NEU" aus stornieren: verhindert doppelte Bestandsrueckbuchung,
|
||||
// falls Stripe denselben Webhook erneut zustellt, und laesst bereits
|
||||
// bezahlte/stornierte Bestellungen unangetastet.
|
||||
if (!previous || previous.status !== "NEU") return null;
|
||||
|
||||
await tx.order.update({ where: { id: orderId }, data: { status: "STORNIERT" } });
|
||||
|
||||
// Beim Checkout reservierten Bestand zurueckbuchen (gleiches Muster wie
|
||||
// bei manueller Stornierung im Admin-Bereich, siehe admin/actions.ts).
|
||||
await restockOrderItems(tx, previous.items);
|
||||
|
||||
await tx.payment.upsert({
|
||||
where: { orderId },
|
||||
create: { orderId, status: "FAILED", amountCents: session.amount_total ?? 0 },
|
||||
update: { status: "FAILED" },
|
||||
});
|
||||
|
||||
return previous;
|
||||
});
|
||||
|
||||
if (result) {
|
||||
const { subject, html, text } = await paymentFailedEmail({
|
||||
locale: isLocale(result.locale) ? result.locale : defaultLocale,
|
||||
orderNumber: result.orderNumber,
|
||||
firstName: result.firstName,
|
||||
});
|
||||
await sendMail({ to: result.email, subject, html, text });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
|
||||
const EXPORT_ATTEMPTS_PER_WINDOW = 5;
|
||||
const EXPORT_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export async function GET() {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`data-export:${ip}:${customer.id}`, EXPORT_ATTEMPTS_PER_WINDOW, EXPORT_WINDOW_MS)) {
|
||||
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
|
||||
}
|
||||
|
||||
const [addresses, orders, reviews, wishlistItems, newsletter] = await Promise.all([
|
||||
prisma.address.findMany({ where: { customerId: customer.id } }),
|
||||
prisma.order.findMany({
|
||||
where: { customerId: customer.id },
|
||||
include: { items: true, invoice: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.review.findMany({
|
||||
where: { customerId: customer.id },
|
||||
include: { product: { select: { name: true } } },
|
||||
}),
|
||||
prisma.wishlistItem.findMany({
|
||||
where: { customerId: customer.id },
|
||||
include: { product: { select: { name: true } } },
|
||||
}),
|
||||
prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } }),
|
||||
]);
|
||||
|
||||
const exportData = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
account: {
|
||||
email: customer.email,
|
||||
firstName: customer.firstName,
|
||||
lastName: customer.lastName,
|
||||
phone: customer.phone,
|
||||
createdAt: customer.createdAt,
|
||||
twoFactorEnabled: customer.twoFactorEnabled,
|
||||
},
|
||||
addresses: addresses.map((a) => ({
|
||||
label: a.label,
|
||||
firstName: a.firstName,
|
||||
lastName: a.lastName,
|
||||
street: a.street,
|
||||
zip: a.zip,
|
||||
city: a.city,
|
||||
country: a.country,
|
||||
isDefault: a.isDefault,
|
||||
})),
|
||||
orders: orders.map((o) => ({
|
||||
orderNumber: o.orderNumber,
|
||||
createdAt: o.createdAt,
|
||||
status: o.status,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
fulfillmentMethod: o.fulfillmentMethod,
|
||||
invoiceNumber: o.invoice?.invoiceNumber ?? null,
|
||||
items: o.items.map((item) => ({
|
||||
productName: item.productName,
|
||||
quantity: item.quantity,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
totalCents: item.totalCents,
|
||||
})),
|
||||
})),
|
||||
reviews: reviews.map((r) => ({
|
||||
product: r.product.name,
|
||||
rating: r.rating,
|
||||
comment: r.comment,
|
||||
createdAt: r.createdAt,
|
||||
isApproved: r.isApproved,
|
||||
})),
|
||||
wishlist: wishlistItems.map((w) => ({ product: w.product.name, addedAt: w.createdAt })),
|
||||
newsletter: newsletter
|
||||
? { subscribed: newsletter.unsubscribedAt === null, confirmed: newsletter.confirmed }
|
||||
: { subscribed: false, confirmed: false },
|
||||
};
|
||||
|
||||
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="wabenhain-meine-daten-${customer.id.slice(-8)}.json"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { hashVerificationToken } from "@/lib/email-verification";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
const baseUrl = getBaseUrl();
|
||||
const fail = (message: string) =>
|
||||
NextResponse.redirect(`${baseUrl}/konto?error=${encodeURIComponent(message)}`);
|
||||
|
||||
if (!token) {
|
||||
return fail("Dieser Bestätigungslink ist ungültig.");
|
||||
}
|
||||
|
||||
const verificationToken = await prisma.emailVerificationToken.findUnique({
|
||||
where: { tokenHash: hashVerificationToken(token) },
|
||||
});
|
||||
|
||||
const isValid =
|
||||
verificationToken && verificationToken.usedAt === null && verificationToken.expiresAt > new Date();
|
||||
if (!isValid) {
|
||||
return fail("Dieser Bestätigungslink ist ungültig oder abgelaufen. Bitte fordere über /konto einen neuen an.");
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customer.update({
|
||||
where: { id: verificationToken.customerId },
|
||||
data: { emailVerifiedAt: new Date() },
|
||||
}),
|
||||
prisma.emailVerificationToken.update({
|
||||
where: { id: verificationToken.id },
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/konto?message=${encodeURIComponent("Deine E-Mail-Adresse wurde bestätigt.")}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { ensureNewsletterDiscountCode } from "@/lib/newsletter-discount";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||
}
|
||||
|
||||
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||
if (!subscriber) {
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||
}
|
||||
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: subscriber.id },
|
||||
data: { confirmed: true, unsubscribedAt: null },
|
||||
});
|
||||
await ensureNewsletterDiscountCode();
|
||||
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=ok`);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { newsletterSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { newsletterConfirmEmail } from "@/lib/email-templates";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`newsletter:${ip}`, 5, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Anfragen. Bitte versuche es später erneut." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = newsletterSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Ungültige E-Mail-Adresse." }, { status: 400 });
|
||||
}
|
||||
|
||||
const turnstileToken = typeof body?.turnstileToken === "string" ? body.turnstileToken : null;
|
||||
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||
return NextResponse.json({ error: "Die Sicherheitsprüfung ist fehlgeschlagen." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.newsletterSubscriber.findUnique({ where: { email: parsed.data.email } });
|
||||
|
||||
if (existing?.confirmed) {
|
||||
// Bereits bestaetigter Abonnent meldet sich erneut an — keine neue Mail
|
||||
// noetig, aber auch keine Fehlermeldung (verraet sonst, dass die
|
||||
// E-Mail-Adresse bereits existiert).
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const token = randomBytes(24).toString("hex");
|
||||
const subscriber = await prisma.newsletterSubscriber.upsert({
|
||||
where: { email: parsed.data.email },
|
||||
create: { email: parsed.data.email, confirmationToken: token },
|
||||
update: { confirmationToken: token, unsubscribedAt: null },
|
||||
});
|
||||
|
||||
const confirmUrl = `${getBaseUrl()}/api/newsletter/confirm?token=${subscriber.confirmationToken}`;
|
||||
const { subject, html, text } = await newsletterConfirmEmail(await getLocale(), confirmUrl);
|
||||
await sendMail({ to: parsed.data.email, subject, html, text });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||
}
|
||||
|
||||
const subscriber = await prisma.newsletterSubscriber.findUnique({ where: { confirmationToken: token } });
|
||||
if (!subscriber) {
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=invalid`);
|
||||
}
|
||||
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: subscriber.id },
|
||||
data: { unsubscribedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`${baseUrl}/newsletter-bestaetigt?status=unsubscribed`);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getActiveProducts } from "@/lib/data";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
|
||||
const SUGGESTION_LIMIT = 5;
|
||||
const SUGGEST_ATTEMPTS_PER_WINDOW = 60;
|
||||
const SUGGEST_WINDOW_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* Liefert bis zu 5 passende Produkte fuer die Live-Suchvorschlaege im
|
||||
* Header — dieselbe Suchlogik wie die Shop-Uebersicht (getActiveProducts),
|
||||
* nur ohne vollen Seiten-Reload. Rein oeffentliche, bereits im Shop
|
||||
* sichtbare Produktdaten, keine Authentifizierung noetig, aber wie jeder
|
||||
* andere oeffentliche Endpunkt hier rate-limitiert (grosszuegig, da bei
|
||||
* jedem Tastenanschlag aufgerufen).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`search-suggest:${ip}`, SUGGEST_ATTEMPTS_PER_WINDOW, SUGGEST_WINDOW_MS)) {
|
||||
return NextResponse.json({ suggestions: [] }, { status: 429 });
|
||||
}
|
||||
|
||||
const query = new URL(request.url).searchParams.get("q")?.trim() ?? "";
|
||||
if (query.length < 2) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
const products = await getActiveProducts({ query, onlyAvailable: true });
|
||||
const suggestions = products.slice(0, SUGGESTION_LIMIT).map((product) => ({
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
price: formatPrice(product.priceCents, product.currency),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ suggestions });
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
|
||||
import { getOrderForTracking } from "@/lib/account-data";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestellung verfolgen", robots: { index: false } };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TRACK_ATTEMPTS_PER_WINDOW = 10;
|
||||
const TRACK_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
export default async function TrackOrderPage(props: PageProps<"/bestellung-verfolgen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { order: orderNumber, email } = await props.searchParams;
|
||||
const hasQuery = typeof orderNumber === "string" && typeof email === "string" && orderNumber && email;
|
||||
|
||||
let order = null;
|
||||
let rateLimited = false;
|
||||
if (hasQuery) {
|
||||
const ip = await getClientIp();
|
||||
if (rateLimit(`track-order:${ip}`, TRACK_ATTEMPTS_PER_WINDOW, TRACK_WINDOW_MS)) {
|
||||
order = await getOrderForTracking(orderNumber, email);
|
||||
} else {
|
||||
rateLimited = true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-xl">
|
||||
<SectionHeading eyebrow={t.trackOrderPage.eyebrow} title={t.trackOrderPage.title} />
|
||||
<p className="mt-4 text-ink/70 leading-relaxed">{t.trackOrderPage.intro}</p>
|
||||
|
||||
<form method="GET" className="mt-8 space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.trackOrderPage.orderNumberLabel}</span>
|
||||
<input
|
||||
name="order"
|
||||
type="text"
|
||||
defaultValue={typeof orderNumber === "string" ? orderNumber : ""}
|
||||
placeholder={t.trackOrderPage.orderNumberPlaceholder}
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.account.emailLabel}</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={typeof email === "string" ? email : ""}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">{t.trackOrderPage.submit}</Button>
|
||||
</form>
|
||||
|
||||
{rateLimited ? (
|
||||
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{t.trackOrderPage.rateLimitError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasQuery && !order && !rateLimited ? (
|
||||
<p className="mt-8 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{t.trackOrderPage.notFoundError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{order ? (
|
||||
<div className="mt-10 border border-ink/10 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-forest">{order.orderNumber}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<OrderStatusBadge
|
||||
status={order.status}
|
||||
label={t.orderStatusBadge[order.status as keyof typeof t.orderStatusBadge] ?? order.status}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-ink/65">
|
||||
{order.fulfillmentMethod === "VERSAND"
|
||||
? order.shipment?.shippedAt
|
||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||
: t.ordersPage.shippingPending
|
||||
: order.pickup?.pickedUpAt
|
||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||
: order.pickup?.readyAt
|
||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
|
||||
: t.ordersPage.pickupPending}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} × {item.productName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-5">
|
||||
<OrderStatusTimeline
|
||||
status={order.status}
|
||||
fulfillmentMethod={order.fulfillmentMethod}
|
||||
labels={{
|
||||
ordered: t.orderStatusTimeline.ordered,
|
||||
paid: t.orderStatusTimeline.paid,
|
||||
preparing: t.orderStatusTimeline.preparing,
|
||||
readyToShip: t.orderStatusTimeline.readyToShip,
|
||||
shipped: t.orderStatusTimeline.shipped,
|
||||
readyForPickup: t.orderStatusTimeline.readyForPickup,
|
||||
pickedUp: t.orderStatusTimeline.pickedUp,
|
||||
cancelled: t.orderStatusTimeline.cancelled,
|
||||
ariaLabel: t.orderStatusTimeline.ariaLabel,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{order.shipment?.carrier && order.shipment.trackingNumber ? (
|
||||
<p className="mt-3 text-xs text-ink/65">
|
||||
{t.shipmentInfo.carrierLabel}: {order.shipment.carrier} · {t.shipmentInfo.trackingNumberLabel}:{" "}
|
||||
{order.shipment.trackingNumber}
|
||||
{(() => {
|
||||
const url = getCarrierTrackingUrl(order.shipment.carrier, order.shipment.trackingNumber);
|
||||
return url ? (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-forest underline">
|
||||
{t.shipmentInfo.trackPackage}
|
||||
</a>
|
||||
</>
|
||||
) : null;
|
||||
})()}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-4">
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p>
|
||||
{order.invoice ? (
|
||||
<Link href={`/rechnung/${order.invoice.invoiceNumber}`} className="text-sm text-forest underline">
|
||||
{t.trackOrderPage.viewInvoice}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Container } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { ShieldIcon } from "@/components/account-ui";
|
||||
import { ProductTeaserLink } from "@/components/product-card";
|
||||
import { getBatchByCode } from "@/lib/data";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
// Siehe Begruendung in app/honig/[slug]/page.tsx.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps<"/charge/[code]">
|
||||
): Promise<Metadata> {
|
||||
const { code } = await props.params;
|
||||
return { title: `Charge ${code}`, alternates: { canonical: `/charge/${code}` } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargen-Seite, die ueber den QR-Code auf jedem Wabenhain-Glas erreichbar
|
||||
* ist (wabenhain.com/charge/{code}). Zeigt Herkunft, Erntezeitraum und
|
||||
* Abfuelldatum der konkreten Charge, aus der das Glas stammt — der Moment,
|
||||
* in dem jemand mit dem Glas in der Hand die Herkunftsangaben nachprueft.
|
||||
*/
|
||||
export default async function BatchPage(props: PageProps<"/charge/[code]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { code } = await props.params;
|
||||
const batch = await getBatchByCode(code);
|
||||
if (!batch) notFound();
|
||||
|
||||
const steps = [
|
||||
{ label: t.chargePage.stepHarvest, value: formatDate(batch.harvest.harvestDate) },
|
||||
{ label: t.chargePage.stepFilling, value: formatDate(batch.fillDate) },
|
||||
{ label: t.chargePage.stepBestBefore, value: formatDate(batch.bestBeforeDate) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<div className="flex items-center gap-2 text-honey-dark">
|
||||
<ShieldIcon />
|
||||
<span className="tracking-wide-caps text-xs">{t.chargePage.verifiedBatch}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-4xl md:text-5xl text-forest">
|
||||
{batch.harvest.type} {batch.harvest.year}
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-ink/60">{t.chargePage.provenanceNote(batch.code)}</p>
|
||||
|
||||
<div className="mt-10 grid sm:grid-cols-2 gap-6">
|
||||
<PlaceholderImage tone="honey" className="aspect-square" label={`${batch.harvest.type} ${batch.harvest.year}`} />
|
||||
<PlaceholderImage tone="forest" className="aspect-square" label={batch.harvest.location} />
|
||||
</div>
|
||||
|
||||
<div className="mt-14">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{steps.map((step, i) => (
|
||||
<div key={step.label} className="relative">
|
||||
<div className="flex items-center">
|
||||
<span className="flex-shrink-0 w-2 h-2 rounded-full bg-honey" />
|
||||
{i < steps.length - 1 ? (
|
||||
<span className="ml-1 h-px flex-1 bg-ink/15" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-xs tracking-wide-caps text-ink/60">{step.label}</p>
|
||||
<p className="mt-1 text-forest">{step.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border-t border-ink/10 pt-8">
|
||||
<p className="text-sm tracking-wide-caps text-ink/60 mb-2">{t.chargePage.regionLabel}</p>
|
||||
<p className="text-ink/80">{batch.harvest.location}</p>
|
||||
<p className="mt-6 text-ink/70 leading-relaxed">{batch.harvest.description}</p>
|
||||
</div>
|
||||
|
||||
<TrustBadges className="mt-10 pt-8 border-t border-ink/10" />
|
||||
|
||||
{batch.products.length > 0 ? (
|
||||
<div className="mt-14 border-t border-ink/10 pt-10">
|
||||
<p className="text-sm tracking-wide-caps text-ink/60 mb-6">{t.chargePage.fromThisBatch}</p>
|
||||
<div className="grid sm:grid-cols-2 gap-6">
|
||||
{batch.products.map((product) => (
|
||||
<ProductTeaserLink key={product.id} slug={product.slug} name={product.name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Bestellung abgebrochen" };
|
||||
|
||||
export default async function CheckoutCancelPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
return (
|
||||
<div className="py-24">
|
||||
<Container className="max-w-xl text-center">
|
||||
<SectionHeading
|
||||
eyebrow={t.checkoutCancelPage.eyebrow}
|
||||
title={t.checkoutCancelPage.title}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
<p className="mt-6 text-ink/70">
|
||||
{t.checkoutCancelPage.text}
|
||||
</p>
|
||||
<LinkButton href="/warenkorb" className="mt-10">
|
||||
{t.checkoutCancelPage.backToCart}
|
||||
</LinkButton>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, test, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CheckoutForm } from "./checkout-form";
|
||||
import { useCartStore, type CartItem } from "@/store/cart-store";
|
||||
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||
import type { ShippingTierLike } from "@/lib/shipping-calc";
|
||||
import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }];
|
||||
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
|
||||
|
||||
const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
||||
productId: "p1",
|
||||
slug: "fruehtracht-2026",
|
||||
name: "Frühtracht 2026",
|
||||
weightGrams: 500,
|
||||
priceCents: 990,
|
||||
image: null,
|
||||
quantity: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function renderCheckout(items: CartItem[]) {
|
||||
useCartStore.setState({ items, isOpen: false });
|
||||
return render(
|
||||
<I18nProvider locale="de">
|
||||
<CheckoutForm tiers={tiers} bulkDiscountTiers={bulkDiscountTiers} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCartStore.setState({ items: [], isOpen: false });
|
||||
});
|
||||
|
||||
describe("CheckoutForm", () => {
|
||||
test("shows the empty-cart message instead of the form when the cart is empty", () => {
|
||||
renderCheckout([]);
|
||||
expect(screen.queryByRole("button", { name: /kostenpflichtig bestellen/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("charges shipping by default and drops it when switching to pickup", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
renderCheckout([item()]);
|
||||
|
||||
expect(screen.getByText("7,00 €")).toBeInTheDocument();
|
||||
|
||||
await userEventInstance.click(screen.getByRole("button", { name: /abholung/i }));
|
||||
|
||||
expect(screen.queryByText("7,00 €")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("kostenlos")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
|
||||
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
type Fulfillment = "VERSAND" | "ABHOLUNG";
|
||||
|
||||
export interface CheckoutInitialValues {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
export function CheckoutForm({
|
||||
initialValues,
|
||||
tiers,
|
||||
bulkDiscountTiers,
|
||||
}: {
|
||||
initialValues?: CheckoutInitialValues;
|
||||
tiers: ShippingTierLike[];
|
||||
bulkDiscountTiers: BulkDiscountTierLike[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const items = useCartStore((s) => s.items);
|
||||
const subtotal = cartTotalCents(items);
|
||||
const totalQuantity = cartItemCount(items);
|
||||
const router = useRouter();
|
||||
|
||||
const [fulfillment, setFulfillment] = useState<Fulfillment>("VERSAND");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGift, setIsGift] = useState(false);
|
||||
|
||||
// Live sichtbar statt erst nach der Weiterleitung zu Stripe — reagiert auf
|
||||
// die Versand/Abholung-Auswahl, damit die Gesamtsumme immer stimmt.
|
||||
const shipping =
|
||||
fulfillment === "ABHOLUNG" ? 0 : resolveShippingCostCents(tiers, totalQuantity);
|
||||
const activeBulkDiscount = resolveBulkDiscount(bulkDiscountTiers, totalQuantity);
|
||||
const bulkDiscountCents = activeBulkDiscount
|
||||
? Math.round((subtotal * activeBulkDiscount.percent) / 100)
|
||||
: 0;
|
||||
const total = subtotal - bulkDiscountCents + shipping;
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = {
|
||||
email: String(formData.get("email") ?? ""),
|
||||
firstName: String(formData.get("firstName") ?? ""),
|
||||
lastName: String(formData.get("lastName") ?? ""),
|
||||
phone: String(formData.get("phone") ?? "") || undefined,
|
||||
fulfillmentMethod: fulfillment,
|
||||
street: String(formData.get("street") ?? "") || undefined,
|
||||
zip: String(formData.get("zip") ?? "") || undefined,
|
||||
city: String(formData.get("city") ?? "") || undefined,
|
||||
country: "DE",
|
||||
items: items.map((item) => ({ productId: item.productId, quantity: item.quantity })),
|
||||
discountCode: String(formData.get("discountCode") ?? "").trim() || undefined,
|
||||
giftMessage: isGift ? String(formData.get("giftMessage") ?? "").trim() || undefined : undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (data.orderNumber) {
|
||||
router.push(`/checkout/success?order=${data.orderNumber}&pending=1`);
|
||||
return;
|
||||
}
|
||||
setError(data.error ?? t.checkoutPage.genericError);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = data.url;
|
||||
} catch {
|
||||
setError(t.checkoutPage.genericError);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container>
|
||||
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.emptyCart} />
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<Container className="grid lg:grid-cols-3 gap-14">
|
||||
<form onSubmit={handleSubmit} className="lg:col-span-2 space-y-8">
|
||||
<SectionHeading eyebrow={t.checkoutPage.eyebrow} title={t.checkoutPage.yourDetails} />
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.checkoutPage.firstName} defaultValue={initialValues?.firstName} required />
|
||||
<Field name="lastName" label={t.checkoutPage.lastName} defaultValue={initialValues?.lastName} required />
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field name="email" label={t.checkoutPage.email} type="email" defaultValue={initialValues?.email} required />
|
||||
<Field
|
||||
name="phone"
|
||||
label={t.checkoutPage.phoneOptional}
|
||||
defaultValue={initialValues?.phone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-ink/70 mb-3">{t.checkoutPage.fulfillmentLabel}</p>
|
||||
<div className="flex gap-4">
|
||||
{(["VERSAND", "ABHOLUNG"] as const).map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option}
|
||||
onClick={() => setFulfillment(option)}
|
||||
className={`flex-1 border px-4 py-3 text-sm text-left transition-colors ${
|
||||
fulfillment === option
|
||||
? "border-forest bg-forest text-cream"
|
||||
: "border-ink/20 text-ink/70"
|
||||
}`}
|
||||
>
|
||||
{option === "VERSAND" ? t.checkoutPage.shippingOption : t.checkoutPage.pickupOption}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{fulfillment === "VERSAND" ? (
|
||||
<p className="mt-2 text-xs text-ink/60">
|
||||
{t.checkoutPage.deliveryTimeNote}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{fulfillment === "VERSAND" ? (
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
name="street"
|
||||
label={t.checkoutPage.streetLabel}
|
||||
defaultValue={initialValues?.street}
|
||||
required
|
||||
/>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Field name="zip" label={t.checkoutPage.zipLabel} defaultValue={initialValues?.zip} required />
|
||||
<Field name="city" label={t.checkoutPage.cityLabel} defaultValue={initialValues?.city} required />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field name="discountCode" label={t.checkoutPage.discountCodeLabel} placeholder={t.checkoutPage.discountCodePlaceholder} />
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isGift}
|
||||
onChange={(event) => setIsGift(event.target.checked)}
|
||||
className="h-4 w-4 border-ink/20 accent-forest"
|
||||
/>
|
||||
{t.checkoutPage.isGiftLabel}
|
||||
</label>
|
||||
{isGift ? (
|
||||
<label className="block text-sm mt-3">
|
||||
<span className="text-ink/70">{t.checkoutPage.giftMessageLabel}</span>
|
||||
<textarea
|
||||
name="giftMessage"
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
placeholder={t.checkoutPage.giftMessagePlaceholder}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest resize-none"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-700">{error}</p> : null}
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full sm:w-auto">
|
||||
{submitting ? t.checkoutPage.submitting : t.checkoutPage.placeOrder}
|
||||
</Button>
|
||||
<p className="text-xs text-ink/65">
|
||||
{t.checkoutPage.paymentMethodsNote}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<aside className="border border-ink/10 p-8 h-fit lg:sticky lg:top-24">
|
||||
<h2 className="text-lg text-forest mb-6">{t.checkoutPage.yourOrderHeading}</h2>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{items.map((item) => (
|
||||
<li key={item.productId} className="flex justify-between">
|
||||
<span>
|
||||
{item.quantity} × {item.name}
|
||||
</span>
|
||||
<span>{formatPrice(item.priceCents * item.quantity)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 pt-4 border-t border-ink/10 space-y-2 text-sm">
|
||||
<div className="flex justify-between text-ink/70">
|
||||
<span>{t.cart.subtotal}</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-ink/70">
|
||||
<span>
|
||||
{fulfillment === "VERSAND"
|
||||
? t.checkoutPage.shippingWithCount(totalQuantity)
|
||||
: t.checkoutPage.shippingPickup}
|
||||
</span>
|
||||
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping)}</span>
|
||||
</div>
|
||||
{activeBulkDiscount ? (
|
||||
<div className="flex justify-between text-forest">
|
||||
<span>
|
||||
{t.cartPage.discountLabel} ({activeBulkDiscount.percent}%)
|
||||
</span>
|
||||
<span>-{formatPrice(bulkDiscountCents)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-ink/10 flex justify-between text-base text-forest">
|
||||
<span>{t.cartPage.total}</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</div>
|
||||
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
|
||||
</aside>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
defaultValue,
|
||||
placeholder,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Container } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Next.js-Suspense-Skeleton fuer /checkout: die Seite ist "force-dynamic"
|
||||
* und laedt Versandstaffeln sowie (bei angemeldeten Kund:innen) gespeicherte
|
||||
* Adressen serverseitig, bevor CheckoutForm ueberhaupt rendern kann.
|
||||
* Orientiert sich am Formular-plus-Zusammenfassung-Layout der Seite.
|
||||
*/
|
||||
export default function CheckoutLoading() {
|
||||
return (
|
||||
<div className="py-16 animate-pulse">
|
||||
<Container className="grid lg:grid-cols-[1fr_360px] gap-12">
|
||||
<div className="space-y-4">
|
||||
<div className="h-9 w-40 bg-ink/10 rounded-sm mb-4" />
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="h-12 w-full bg-ink/10" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-80 bg-ink/5 border border-ink/10" />
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getAddressesForCustomer } from "@/lib/account-data";
|
||||
import { getShippingTiers } from "@/lib/shipping";
|
||||
import { getBulkDiscountTiers } from "@/lib/bulk-discount";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { CheckoutForm, type CheckoutInitialValues } from "./checkout-form";
|
||||
|
||||
// Vorausfuellen aus Kundendaten aendert sich pro Nutzer/Anmeldestatus, und
|
||||
// Versandstaffeln koennen sich im Admin-Bereich jederzeit aendern —
|
||||
// Rendering pro Request statt statischem Build entkoppelt zudem den
|
||||
// Docker-Build-Schritt von einer zur Build-Zeit erreichbaren Datenbank.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
return { title: t.checkoutPage.eyebrow };
|
||||
}
|
||||
|
||||
export default async function CheckoutPage() {
|
||||
const [tiers, bulkDiscountTiers] = await Promise.all([getShippingTiers(), getBulkDiscountTiers()]);
|
||||
const tierValues = tiers.map((t) => ({
|
||||
minQuantity: t.minQuantity,
|
||||
maxQuantity: t.maxQuantity,
|
||||
priceCents: t.priceCents,
|
||||
}));
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) return <CheckoutForm tiers={tierValues} bulkDiscountTiers={bulkDiscountTiers} />;
|
||||
|
||||
// getAddressesForCustomer sortiert Standardadresse (isDefault) zuerst, sonst
|
||||
// die zuletzt angelegte — die erste Adresse in der Liste ist also immer die
|
||||
// sinnvollste Vorbelegung fuer den Checkout.
|
||||
const addresses = await getAddressesForCustomer(customer.id);
|
||||
const defaultAddress = addresses[0];
|
||||
|
||||
const initialValues: CheckoutInitialValues = {
|
||||
firstName: customer.firstName,
|
||||
lastName: customer.lastName,
|
||||
email: customer.email,
|
||||
phone: customer.phone ?? "",
|
||||
street: defaultAddress?.street ?? "",
|
||||
zip: defaultAddress?.zip ?? "",
|
||||
city: defaultAddress?.city ?? "",
|
||||
};
|
||||
|
||||
return <CheckoutForm initialValues={initialValues} tiers={tierValues} bulkDiscountTiers={bulkDiscountTiers} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
return { title: t.checkoutSuccessPage.successEyebrow };
|
||||
}
|
||||
|
||||
export default function CheckoutSuccessLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { use } from "react";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { useCartStore } from "@/store/cart-store";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
export default function CheckoutSuccessPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ order?: string; pending?: string }>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { order, pending } = use(searchParams);
|
||||
const clear = useCartStore((s) => s.clear);
|
||||
|
||||
useEffect(() => {
|
||||
clear();
|
||||
// Warenkorb wird nur einmal nach erfolgreichem Checkout geleert.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="py-24">
|
||||
<Container className="max-w-xl text-center">
|
||||
<SectionHeading
|
||||
eyebrow={pending ? t.checkoutSuccessPage.pendingEyebrow : t.checkoutSuccessPage.successEyebrow}
|
||||
title={pending ? t.checkoutSuccessPage.pendingTitle : t.checkoutSuccessPage.successTitle}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
{order ? (
|
||||
<p className="mt-4 text-ink/70">
|
||||
{t.checkoutSuccessPage.orderNumberLabel}: <span className="text-forest">{order}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-6 text-ink/70">
|
||||
{pending ? t.checkoutSuccessPage.pendingText : t.checkoutSuccessPage.successText}
|
||||
</p>
|
||||
<div className="mt-10 flex items-center justify-center gap-4">
|
||||
<LinkButton href="/shop">{t.checkoutSuccessPage.continueShopping}</LinkButton>
|
||||
{order ? (
|
||||
<LinkButton href={`/bestellung-verfolgen?order=${encodeURIComponent(order)}`} variant="secondary">
|
||||
{t.checkoutSuccessPage.trackOrder}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/legal-page";
|
||||
import { CookieSettingsButton } from "@/components/cookie-consent";
|
||||
import { Container } from "@/components/ui";
|
||||
import { getContentValue, parseSections } from "@/lib/content";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Cookie-Richtlinie", alternates: { canonical: "/cookie-richtlinie" } };
|
||||
|
||||
export default async function CookieRichtliniePage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const sections = parseSections(await getContentValue("legal.cookieRichtlinie", locale));
|
||||
return (
|
||||
<>
|
||||
<LegalPage
|
||||
title={t.cookieRichtliniePage.title}
|
||||
noticeText={t.legalPage.reviewNotice}
|
||||
sections={sections}
|
||||
/>
|
||||
<Container className="max-w-3xl pb-20 -mt-10">
|
||||
<CookieSettingsButton />
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/legal-page";
|
||||
import { getContentValue, parseSections } from "@/lib/content";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Datenschutz", alternates: { canonical: "/datenschutz" } };
|
||||
|
||||
export default async function DatenschutzPage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const [company, raw] = await Promise.all([getCompanyInfo(), getContentValue("legal.datenschutz", locale)]);
|
||||
const [verantwortlicherSection, ...restSections] = parseSections(raw);
|
||||
|
||||
return (
|
||||
<LegalPage
|
||||
title={t.datenschutzPage.title}
|
||||
noticeText={t.legalPage.reviewNotice}
|
||||
sections={[
|
||||
{
|
||||
heading: verantwortlicherSection.heading,
|
||||
paragraphs: [
|
||||
`${company.name}, ${company.address.street}, ${company.address.zip} ${company.address.city}, ${company.email}`,
|
||||
...verantwortlicherSection.paragraphs,
|
||||
],
|
||||
},
|
||||
...restSections,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ProductTeaserLink } from "@/components/product-card";
|
||||
import { getHarvestByYearType } from "@/lib/data";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
// Siehe Begruendung in app/honig/[slug]/page.tsx: dynamisches Rendering
|
||||
// entkoppelt den Build vom DB-Zugriff und haelt Chargen-/Bestandsdaten aktuell.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps<"/ernten/[year]/[type]">
|
||||
): Promise<Metadata> {
|
||||
const { year, type } = await props.params;
|
||||
const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type));
|
||||
if (!harvest) return {};
|
||||
return {
|
||||
title: `${harvest.type} ${harvest.year}`,
|
||||
description: harvest.description,
|
||||
alternates: { canonical: `/ernten/${year}/${type}` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HarvestDetailPage(
|
||||
props: PageProps<"/ernten/[year]/[type]">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { year, type } = await props.params;
|
||||
// Next.js liefert dynamische Segmente hier unkodiert (z.B. "Fr%C3%BChtracht"
|
||||
// statt "Frühtracht") — deshalb wird explizit dekodiert, statt sich auf
|
||||
// automatisches Decoding zu verlassen.
|
||||
const harvest = await getHarvestByYearType(Number(year), decodeURIComponent(type));
|
||||
if (!harvest) notFound();
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<Container>
|
||||
<BackLink href="/ernten" label={t.erntenDetailPage.backLabel} />
|
||||
</Container>
|
||||
<Container className="grid md:grid-cols-2 gap-14">
|
||||
<PlaceholderImage tone="forest" className="aspect-[4/5] w-full" label={harvest.location} />
|
||||
<div>
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||
{harvest.year} · {formatDate(harvest.harvestDate)}
|
||||
</p>
|
||||
<h1 className="text-4xl text-forest">{harvest.type}</h1>
|
||||
<p className="mt-6 text-ink/75 leading-relaxed">{harvest.description}</p>
|
||||
|
||||
<h2 className="mt-10 text-lg text-forest">{t.erntenDetailPage.batchesHeading}</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{harvest.batches.map((batch) => (
|
||||
<Link
|
||||
key={batch.id}
|
||||
href={`/charge/${batch.code}`}
|
||||
className="flex items-center justify-between gap-4 border border-ink/10 px-4 py-3 text-sm transition-colors hover:border-forest"
|
||||
>
|
||||
<span className="text-forest">{batch.code}</span>
|
||||
<span className="text-ink/65">
|
||||
{t.erntenDetailPage.glassesAvailable(batch.remainingGlasses, batch.glassCount)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-10 text-lg text-forest">{t.erntenDetailPage.productsHeading}</h2>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{harvest.products.map((product) => (
|
||||
<ProductTeaserLink key={product.id} slug={product.slug} name={product.name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { getHarvests } from "@/lib/data";
|
||||
import { formatMonthYear } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Unsere Ernten",
|
||||
description: "Alle bisherigen Wabenhain-Ernten im Überblick — jede mit eigenem Charakter.",
|
||||
alternates: { canonical: "/ernten" },
|
||||
};
|
||||
|
||||
// Siehe Begruendung in app/page.tsx.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ErntenPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const harvests = await getHarvests();
|
||||
const byYear = harvests.reduce<Record<number, typeof harvests>>((acc, h) => {
|
||||
acc[h.year] = [...(acc[h.year] ?? []), h];
|
||||
return acc;
|
||||
}, {});
|
||||
const years = Object.keys(byYear).map(Number).sort((a, b) => b - a);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container>
|
||||
<SectionHeading level="h1" eyebrow={t.erntenPage.eyebrow} title={t.erntenPage.title} />
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{years.map((year) => (
|
||||
<div key={year}>
|
||||
<h2 className="text-2xl text-forest mb-6">{year}</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{byYear[year].map((harvest) => (
|
||||
<Link
|
||||
key={harvest.id}
|
||||
href={`/ernten/${harvest.year}/${encodeURIComponent(harvest.type)}`}
|
||||
className="group"
|
||||
>
|
||||
<PlaceholderImage
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
label={formatMonthYear(harvest.harvestDate)}
|
||||
/>
|
||||
<h3 className="mt-4 text-lg text-forest">{harvest.type}</h3>
|
||||
<p className="text-sm text-ink/65">{t.erntenPage.batchCount(harvest.batches.length)}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{years.length === 0 ? (
|
||||
<p className="text-ink/70">{t.erntenPage.emptyText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Container, Button, LinkButton } from "@/components/ui";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="py-32">
|
||||
<Container className="max-w-xl text-center">
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p>
|
||||
<h1 className="text-3xl md:text-4xl text-forest">{t.errorPage.title}</h1>
|
||||
<p className="mt-4 text-ink/70 leading-relaxed">{t.errorPage.description}</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<Button type="button" onClick={() => reset()}>
|
||||
{t.errorPage.retry}
|
||||
</Button>
|
||||
<LinkButton href="/" variant="secondary">
|
||||
{t.errorPage.backHome}
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="de">
|
||||
<body style={{ margin: 0, fontFamily: "Arial, Helvetica, sans-serif", background: "#FAF8F3", color: "#242424" }}>
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "2rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center", maxWidth: "28rem" }}>
|
||||
<p style={{ fontSize: "0.75rem", letterSpacing: "0.1em", color: "#B88A44", marginBottom: "0.75rem" }}>
|
||||
FEHLER
|
||||
</p>
|
||||
<h1 style={{ fontSize: "1.75rem", color: "#24352B", margin: 0 }}>Etwas ist schiefgelaufen.</h1>
|
||||
<p style={{ marginTop: "1rem", color: "#555", lineHeight: 1.6 }}>
|
||||
Bitte lade die Seite neu oder versuche es später erneut.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
style={{
|
||||
marginTop: "2rem",
|
||||
padding: "0.75rem 1.75rem",
|
||||
background: "#24352B",
|
||||
color: "#F4EFE5",
|
||||
border: "none",
|
||||
fontSize: "0.875rem",
|
||||
letterSpacing: "0.05em",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
ERNEUT VERSUCHEN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+51
-14
@@ -1,26 +1,63 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--color-forest: #24352b;
|
||||
--color-cream: #f4efe5;
|
||||
--color-honey: #b88a44;
|
||||
/* WCAG-AA-taugliche Textvarianten (4.5:1): #b88a44 selbst besteht auf
|
||||
keinem der beiden Hintergruende zuverlaessig. -dark fuer Text auf
|
||||
hellem Grund (Hintergrund/Cream), -light fuer Text auf bg-forest. */
|
||||
--color-honey-dark: #8a6531;
|
||||
--color-honey-light: #c9a468;
|
||||
--color-background: #faf8f3;
|
||||
--color-ink: #242424;
|
||||
--color-sage: #788774;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-forest: var(--color-forest);
|
||||
--color-cream: var(--color-cream);
|
||||
--color-honey: var(--color-honey);
|
||||
--color-honey-dark: var(--color-honey-dark);
|
||||
--color-honey-light: var(--color-honey-light);
|
||||
--color-background: var(--color-background);
|
||||
--color-ink: var(--color-ink);
|
||||
--color-sage: var(--color-sage);
|
||||
|
||||
--font-serif: var(--font-cormorant), Georgia, serif;
|
||||
--font-sans: var(--font-inter), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: var(--color-background);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-serif);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.tracking-wide-caps {
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-honey);
|
||||
color: var(--color-cream);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
return {
|
||||
title: t.herkunftPage.eyebrow,
|
||||
description: t.herkunftPage.subtitle,
|
||||
alternates: { canonical: "/herkunft" },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HerkunftPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
return (
|
||||
<div className="py-20">
|
||||
<PlaceholderImage
|
||||
tone="forest"
|
||||
className="h-[50vh] min-h-[360px] w-full mb-20"
|
||||
label={t.herkunftPage.eyebrow}
|
||||
/>
|
||||
<Container>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.herkunftPage.eyebrow}
|
||||
title={t.herkunftPage.title}
|
||||
subtitle={t.herkunftPage.subtitle}
|
||||
/>
|
||||
<div className="mt-16 grid sm:grid-cols-2 gap-10">
|
||||
{t.herkunftPage.aspects.map((a) => (
|
||||
<div key={a.title}>
|
||||
<h3 className="text-lg text-forest">{a.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">{a.text}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-16 text-xs text-ink/65 max-w-md">
|
||||
{t.herkunftPage.privacyNote}
|
||||
</p>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Container } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Next.js-Suspense-Skeleton fuer /honig/[slug]: die Seite ist "force-dynamic"
|
||||
* (Bestand/Charge/Bewertungen aendern sich laufend) und laedt mehrere
|
||||
* DB-Abfragen parallel, ohne bisher waehrend der Navigation Feedback zu
|
||||
* zeigen. Orientiert sich am tatsaechlichen Zwei-Spalten-Layout der Seite.
|
||||
*/
|
||||
export default function ProductDetailLoading() {
|
||||
return (
|
||||
<div className="py-16 animate-pulse">
|
||||
<Container className="grid md:grid-cols-2 gap-12">
|
||||
<div className="aspect-square w-full bg-ink/10" />
|
||||
<div>
|
||||
<div className="h-3 w-24 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-3 h-9 w-3/4 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-3 h-4 w-1/2 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-6 h-8 w-32 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-6 space-y-2">
|
||||
<div className="h-4 w-full bg-ink/10 rounded-sm" />
|
||||
<div className="h-4 w-5/6 bg-ink/10 rounded-sm" />
|
||||
<div className="h-4 w-2/3 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
<div className="mt-8 h-12 w-full bg-ink/10" />
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Container className="mt-20">
|
||||
<div className="h-3 w-32 bg-ink/10 rounded-sm mb-8" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i}>
|
||||
<div className="aspect-[4/5] w-full bg-ink/10" />
|
||||
<div className="mt-4 h-5 w-2/3 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-2 h-4 w-1/2 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { getProductBySlug } from "@/lib/data";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
|
||||
export const alt = "Wabenhain Produkt";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
/**
|
||||
* Produktspezifisches Social-Preview-Bild statt des generischen
|
||||
* Seiten-Fallbacks (app/opengraph-image.tsx) — wer einen Honig-Link teilt,
|
||||
* soll den tatsaechlichen Produktnamen und Preis in der Vorschau sehen statt
|
||||
* nur des Wabenhain-Markenbilds.
|
||||
*/
|
||||
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const product = await getProductBySlug(slug);
|
||||
|
||||
const title = product?.name ?? "Wabenhain";
|
||||
const tags = product?.tasteTags.slice(0, 3).join(" · ") ?? "";
|
||||
const price = product ? formatPrice(product.priceCents, product.currency) : "";
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(135deg, #1a271f 0%, #24352b 55%, #3a4d3f 100%)",
|
||||
padding: "80px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 20,
|
||||
letterSpacing: 6,
|
||||
color: "#b88a44",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
WABENHAIN
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 76,
|
||||
letterSpacing: 1,
|
||||
color: "#f4efe5",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{tags ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 26,
|
||||
color: "#f4efe5",
|
||||
opacity: 0.7,
|
||||
marginTop: 28,
|
||||
letterSpacing: 1,
|
||||
}}
|
||||
>
|
||||
{tags}
|
||||
</div>
|
||||
) : null}
|
||||
{price ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 34,
|
||||
color: "#b88a44",
|
||||
marginTop: 36,
|
||||
}}
|
||||
>
|
||||
{price}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Fragment } from "react";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container, Button } from "@/components/ui";
|
||||
import { ProductGallery } from "@/components/product-gallery";
|
||||
import { TasteScale, AddToCartWithQuantity, ProductCard } from "@/components/product-card";
|
||||
import { RecordProductView } from "@/components/record-product-view";
|
||||
import { RecentlyViewedSection } from "@/components/recently-viewed-section";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import { TrustBadges } from "@/components/trust-badges";
|
||||
import { getProductBySlug, getRelatedProducts } from "@/lib/data";
|
||||
import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews";
|
||||
import { isProductWishlisted } from "@/lib/wishlist";
|
||||
import { submitReviewAction, toggleWishlistAction, subscribeStockNotificationAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
// Produktdaten (Preis, Bestand, Charge) aendern sich laufend — Rendering pro
|
||||
// Request statt statischem Build entkoppelt zudem den Docker-Build-Schritt
|
||||
// von einer zur Build-Zeit erreichbaren Datenbank.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(
|
||||
props: PageProps<"/honig/[slug]">
|
||||
): Promise<Metadata> {
|
||||
const { slug } = await props.params;
|
||||
const product = await getProductBySlug(slug);
|
||||
if (!product) return {};
|
||||
|
||||
return {
|
||||
title: product.name,
|
||||
description: `${product.name} — ${product.tasteTags.join(", ")}. ${product.weightGrams} g aus eigener Imkerei in Altenmünster.`,
|
||||
alternates: { canonical: `/honig/${slug}` },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage(
|
||||
props: PageProps<"/honig/[slug]">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { slug } = await props.params;
|
||||
const { error, message, notifyError, notifyMessage } = await props.searchParams;
|
||||
const product = await getProductBySlug(slug);
|
||||
if (!product) notFound();
|
||||
|
||||
const isHoney = product.category === "HONIG" && product.harvest && product.batch;
|
||||
const stock = product.inventory?.quantity ?? 0;
|
||||
const inStock = stock > 0;
|
||||
const lowStock = inStock && stock <= 5;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
const [reviews, reviewSummary, relatedProducts, ownReview, canReview, isWishlisted, company] = await Promise.all([
|
||||
getApprovedReviews(product.id),
|
||||
getReviewSummary(product.id),
|
||||
getRelatedProducts(product.id, product.category),
|
||||
customer ? getCustomerReview(product.id, customer.id) : Promise.resolve(null),
|
||||
customer ? hasCustomerPurchased(product.id, customer.id) : Promise.resolve(false),
|
||||
customer ? isProductWishlisted(product.id, customer.id) : Promise.resolve(false),
|
||||
getCompanyInfo(),
|
||||
]);
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
sku: product.batch?.code ?? product.slug,
|
||||
brand: { "@type": "Brand", name: company.name },
|
||||
category: t.common.categories[product.category],
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
priceCurrency: product.currency,
|
||||
price: (product.priceCents / 100).toFixed(2),
|
||||
availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
|
||||
url: `https://wabenhain.com/honig/${product.slug}`,
|
||||
},
|
||||
...(reviewSummary.count > 0
|
||||
? {
|
||||
aggregateRating: {
|
||||
"@type": "AggregateRating",
|
||||
ratingValue: reviewSummary.average?.toFixed(1),
|
||||
reviewCount: reviewSummary.count,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const breadcrumbJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Shop", item: `https://${siteConfig.domain}/shop` },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: t.common.categories[product.category],
|
||||
item: `https://${siteConfig.domain}/shop?kategorie=${product.category}`,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: product.name,
|
||||
item: `https://${siteConfig.domain}/honig/${product.slug}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<RecordProductView productId={product.id} />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
// "<" escapen, damit Produkttexte niemals ein schliessendes
|
||||
// </script> einschleusen koennen (XSS-Schutz fuer JSON-LD).
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd).replace(/</g, "\\u003c") }}
|
||||
/>
|
||||
<Container className="grid md:grid-cols-2 gap-14">
|
||||
<ProductGallery images={product.images} productName={product.name} />
|
||||
|
||||
<div>
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">
|
||||
{company.name} · {t.common.categories[product.category]}
|
||||
</p>
|
||||
<h1 className="text-4xl text-forest">{product.name}</h1>
|
||||
<p className="mt-2 text-ink/70">{product.tasteTags.join(" · ")}</p>
|
||||
{reviewSummary.count > 0 ? (
|
||||
<Link href="#bewertungen" className="mt-4 flex items-center gap-2 text-sm">
|
||||
<StarRating value={reviewSummary.average ?? 0} size={16} />
|
||||
<span className="text-ink/65">
|
||||
{reviewSummary.average?.toFixed(1)} ({t.productPage.reviewsLink(reviewSummary.count)})
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviews}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-baseline gap-3">
|
||||
<span className="text-2xl text-forest">
|
||||
{formatPrice(product.priceCents, product.currency)}
|
||||
</span>
|
||||
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink/65">
|
||||
{formatPricePerKg(product.pricePerKgCents, product.currency)}
|
||||
</p>
|
||||
|
||||
{isHoney ? (
|
||||
<div className="mt-8 space-y-3">
|
||||
<TasteScale label={t.product.tasteMild} value={product.intensityMild} />
|
||||
<TasteScale label={t.product.tasteFloral} value={product.intensityFloral} />
|
||||
<TasteScale label={t.product.tasteIntense} value={product.intensityKraeftig} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{inStock ? (
|
||||
<>
|
||||
<AddToCartWithQuantity product={product} maxQuantity={stock} className="mt-8 max-w-xs" />
|
||||
{lowStock ? (
|
||||
<p className="mt-2 text-xs text-honey-dark">{t.productPage.onlyLeftInStock(stock)}</p>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button disabled className="mt-8 max-w-xs w-full">
|
||||
{t.productPage.soldOut}
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-ink/65">
|
||||
{t.productPage.currentlyUnavailable}
|
||||
</p>
|
||||
<form action={subscribeStockNotificationAction} className="mt-4 max-w-xs">
|
||||
<input type="hidden" name="productId" value={product.id} />
|
||||
<label htmlFor="notify-email" className="text-xs text-ink/65">
|
||||
{t.productPage.notifyMeLabel}
|
||||
</label>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
id="notify-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder={t.productPage.emailPlaceholder}
|
||||
className="flex-1 min-w-0 border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 border border-ink/20 px-3 py-2 text-sm hover:border-forest"
|
||||
>
|
||||
{t.productPage.notifySubmit}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{notifyError ? (
|
||||
<p className="mt-2 text-xs text-red-700">{notifyError}</p>
|
||||
) : null}
|
||||
{notifyMessage ? (
|
||||
<p className="mt-2 text-xs text-forest">{notifyMessage}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TrustBadges className="mt-6" />
|
||||
|
||||
{customer ? (
|
||||
<form action={toggleWishlistAction} className="mt-3">
|
||||
<input type="hidden" name="productId" value={product.id} />
|
||||
<input type="hidden" name="redirectPath" value={`/honig/${product.slug}`} />
|
||||
<button type="submit" className="text-sm text-ink/70 hover:text-forest underline">
|
||||
{isWishlisted ? t.productPage.removeFromWishlist : t.productPage.addToWishlist}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-ink/65">
|
||||
<Link href="/konto" className="text-forest underline">
|
||||
{t.productPage.loginLinkLabel}
|
||||
</Link>
|
||||
, {t.productPage.loginToSave}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="mt-8 space-y-2 text-sm text-ink/70">
|
||||
{(t.productPage.checklist[product.category] ?? t.productPage.checklist.HONIG).map((item) => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<span className="text-honey-dark">✓</span> {item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-12 border-t border-ink/10 divide-y divide-ink/10 text-sm">
|
||||
<DetailRow label={t.productPage.detailLabels.description} value={product.description} />
|
||||
{isHoney && product.harvest && product.batch ? (
|
||||
<>
|
||||
<DetailRow
|
||||
label={t.productPage.detailLabels.origin}
|
||||
value={`${product.harvest.location} — ${product.harvest.type} ${product.harvest.year}`}
|
||||
/>
|
||||
<DetailRow label={t.productPage.detailLabels.harvest} value={formatDate(product.harvest.harvestDate)} />
|
||||
<DetailRow label={t.productPage.detailLabels.batch} value={product.batch.code} />
|
||||
<DetailRow label={t.productPage.detailLabels.storage} value={t.productPage.storageText} />
|
||||
<DetailRow label={t.productPage.detailLabels.note} value={t.productPage.infantWarningText} />
|
||||
</>
|
||||
) : (
|
||||
<DetailRow label={t.productPage.detailLabels.origin} value={t.productPage.nonHoneyOriginText} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHoney && product.batch ? (
|
||||
<Link
|
||||
href={`/charge/${product.batch.code}`}
|
||||
className="mt-6 inline-block text-sm text-forest underline"
|
||||
>
|
||||
{t.productPage.viewBatchOrigin}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<Container id="bewertungen" className="mt-20 max-w-3xl">
|
||||
<h2 className="text-2xl text-forest">{t.productPage.reviewsHeading}</h2>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-4 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-4 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
{reviews.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-ink/65">{t.productPage.noReviewsForProduct}</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-6 divide-y divide-ink/10">
|
||||
{reviews.map((review) => (
|
||||
<li key={review.id} className="pt-6 first:pt-0">
|
||||
<StarRating value={review.rating} />
|
||||
<span className="sr-only">{t.productPage.starsOutOf5(review.rating)}</span>
|
||||
{review.comment ? <p className="mt-2 text-sm text-ink/80">{review.comment}</p> : null}
|
||||
<p className="mt-2 text-xs text-ink/65">
|
||||
{review.customer.firstName} · {formatDate(review.createdAt)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-10 border-t border-ink/10 pt-8">
|
||||
{!customer ? (
|
||||
<p className="text-sm text-ink/70">
|
||||
<Link href="/konto" className="text-forest underline">
|
||||
{t.productPage.loginLinkLabel}
|
||||
</Link>
|
||||
, {t.productPage.loginToReview}
|
||||
</p>
|
||||
) : ownReview ? (
|
||||
<p className="text-sm text-ink/70">
|
||||
{!ownReview.isApproved ? t.productPage.alreadyReviewedPending : t.productPage.alreadyReviewed}
|
||||
</p>
|
||||
) : !canReview ? (
|
||||
<p className="text-sm text-ink/70">{t.productPage.onlyBuyersCanReview}</p>
|
||||
) : (
|
||||
<form action={submitReviewAction} className="space-y-4 max-w-sm">
|
||||
<input type="hidden" name="productId" value={product.id} />
|
||||
<h3 className="text-sm text-ink/70">{t.productPage.yourRating}</h3>
|
||||
<div className="flex flex-row-reverse gap-2" role="radiogroup" aria-label="Sternebewertung">
|
||||
{[5, 4, 3, 2, 1].map((value) => (
|
||||
<Fragment key={value}>
|
||||
<input
|
||||
type="radio"
|
||||
id={`rating-${value}`}
|
||||
name="rating"
|
||||
value={value}
|
||||
required
|
||||
defaultChecked={value === 5}
|
||||
aria-label={t.productPage.starLabel(value)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`rating-${value}`}
|
||||
className="cursor-pointer text-2xl text-honey-dark/30 peer-checked:text-honey-dark"
|
||||
>
|
||||
★
|
||||
</label>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
name="comment"
|
||||
rows={3}
|
||||
placeholder={t.productPage.commentPlaceholder}
|
||||
className="w-full border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
|
||||
/>
|
||||
<Button type="submit">{t.productPage.submitReview}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{relatedProducts.length > 0 ? (
|
||||
<Container className="mt-20">
|
||||
<h2 className="text-2xl text-forest mb-8">{t.productPage.relatedProductsHeading}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{relatedProducts.map((related) => (
|
||||
<ProductCard key={related.id} product={related} />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
) : null}
|
||||
|
||||
<Container>
|
||||
<RecentlyViewedSection excludeProductId={product.id} />
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="py-4">
|
||||
<p className="text-ink/65 text-xs tracking-wide-caps mb-1">{label}</p>
|
||||
<p className="text-ink/80 leading-relaxed">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { hasCustomerPurchased, getCustomerReview } from "@/lib/reviews";
|
||||
import { isProductWishlisted } from "@/lib/wishlist";
|
||||
import { reviewSchema, stockNotificationSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
|
||||
const REVIEW_ATTEMPTS_PER_WINDOW = 10;
|
||||
const REVIEW_WINDOW_MS = 60 * 60 * 1000;
|
||||
const STOCK_NOTIFY_ATTEMPTS_PER_WINDOW = 5;
|
||||
const STOCK_NOTIFY_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
// redirectPath kommt aus einem Formularfeld (siehe toggleWishlistAction) und
|
||||
// darf deshalb nicht ungeprueft an redirect()/revalidatePath() durchgereicht
|
||||
// werden — sonst waeren ein Open Redirect bzw. eine beliebige Cache-
|
||||
// Invalidierung ueber ein manipuliertes Formular moeglich. Erlaubt sind nur
|
||||
// interne, relative Pfade wie "/honig/produkt-slug" oder "/konto/wunschliste".
|
||||
function isSafeRedirectPath(path: string): boolean {
|
||||
return /^\/(?!\/)[\w-]+(\/[\w-]+)*$/.test(path);
|
||||
}
|
||||
|
||||
export async function submitReviewAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an, um eine Bewertung abzugeben.");
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`review:${ip}`, REVIEW_ATTEMPTS_PER_WINDOW, REVIEW_WINDOW_MS)) {
|
||||
fail("/shop", "Zu viele Bewertungen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const parsed = reviewSchema.safeParse({
|
||||
productId: formData.get("productId"),
|
||||
rating: formData.get("rating"),
|
||||
comment: formData.get("comment"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: parsed.data.productId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!product) fail("/shop", "Produkt nicht gefunden.");
|
||||
|
||||
const redirectPath = `/honig/${product.slug}`;
|
||||
|
||||
// Nur Kunden mit einer bezahlten Bestellung dieses Produkts duerfen
|
||||
// bewerten ("verifizierter Kauf") — verhindert beliebige Fremdbewertungen.
|
||||
const purchased = await hasCustomerPurchased(parsed.data.productId, customer.id);
|
||||
if (!purchased) {
|
||||
fail(redirectPath, "Nur Käufer:innen dieses Produkts können eine Bewertung abgeben.");
|
||||
}
|
||||
|
||||
const existing = await getCustomerReview(parsed.data.productId, customer.id);
|
||||
if (existing) {
|
||||
fail(redirectPath, "Du hast dieses Produkt bereits bewertet.");
|
||||
}
|
||||
|
||||
await prisma.review.create({
|
||||
data: {
|
||||
productId: parsed.data.productId,
|
||||
customerId: customer.id,
|
||||
rating: parsed.data.rating,
|
||||
comment: parsed.data.comment || null,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(redirectPath);
|
||||
redirect(`${redirectPath}?message=${encodeURIComponent("Danke für deine Bewertung! Sie wird nach Prüfung veröffentlicht.")}`);
|
||||
}
|
||||
|
||||
/** Fuegt ein Produkt der Wunschliste hinzu bzw. entfernt es, je nach aktuellem Zustand. */
|
||||
export async function toggleWishlistAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an, um Produkte zu merken.");
|
||||
|
||||
const productId = String(formData.get("productId") ?? "");
|
||||
const submittedPath = String(formData.get("redirectPath") ?? "/shop");
|
||||
const redirectPath = isSafeRedirectPath(submittedPath) ? submittedPath : "/shop";
|
||||
|
||||
const alreadyWishlisted = await isProductWishlisted(productId, customer.id);
|
||||
if (alreadyWishlisted) {
|
||||
await prisma.wishlistItem.deleteMany({ where: { customerId: customer.id, productId } });
|
||||
} else {
|
||||
await prisma.wishlistItem.create({ data: { customerId: customer.id, productId } });
|
||||
}
|
||||
|
||||
revalidatePath(redirectPath);
|
||||
revalidatePath("/konto/wunschliste");
|
||||
redirect(redirectPath);
|
||||
}
|
||||
|
||||
// Eigene Query-Parameter statt der von submitReviewAction genutzten
|
||||
// error/message: beide Actions redirecten auf dieselbe Produktseite, ein
|
||||
// gemeinsamer Parametername wuerde sonst z.B. eine Bewertungsbestaetigung
|
||||
// faelschlich auch im Ausverkauft-Block der Seite anzeigen lassen.
|
||||
function failNotify(path: string, message: string): never {
|
||||
redirect(`${path}?notifyError=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet eine E-Mail-Adresse fuer die Wiederverfuegbarkeits-Benachrichtigung
|
||||
* eines Produkts an. Der tatsaechliche Versand erfolgt beim naechsten
|
||||
* Bestands-Update im Admin-Bereich (siehe lib/stock-notifications.ts).
|
||||
*/
|
||||
export async function subscribeStockNotificationAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`stock-notify:${ip}`, STOCK_NOTIFY_ATTEMPTS_PER_WINDOW, STOCK_NOTIFY_WINDOW_MS)) {
|
||||
failNotify("/shop", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const parsed = stockNotificationSchema.safeParse({
|
||||
productId: formData.get("productId"),
|
||||
email: formData.get("email"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
failNotify("/shop", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: parsed.data.productId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!product) failNotify("/shop", "Produkt nicht gefunden.");
|
||||
|
||||
const redirectPath = `/honig/${product.slug}`;
|
||||
|
||||
// Erneute Anmeldung setzt eine bereits versendete Benachrichtigung zurueck,
|
||||
// damit bei einem weiteren Ausverkauf wieder benachrichtigt wird.
|
||||
const locale = await getLocale();
|
||||
await prisma.stockNotification.upsert({
|
||||
where: { productId_email: { productId: parsed.data.productId, email: parsed.data.email } },
|
||||
create: { productId: parsed.data.productId, email: parsed.data.email, locale },
|
||||
update: { notifiedAt: null, locale },
|
||||
});
|
||||
|
||||
redirect(
|
||||
`${redirectPath}?notifyMessage=${encodeURIComponent("Wir benachrichtigen dich per E-Mail, sobald das Produkt wieder verfügbar ist.")}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#24352B" />
|
||||
<polygon points="50,12 82,30 82,68 50,86 18,68 18,30" fill="none" stroke="#B88A44" stroke-width="4" />
|
||||
<g fill="#F4EFE5">
|
||||
<g transform="translate(26,42) scale(0.62)">
|
||||
<polygon points="10,2 5,13 15,13" />
|
||||
<polygon points="10,10 2,22 18,22" />
|
||||
<rect x="8" y="22" width="4" height="4" />
|
||||
</g>
|
||||
<g transform="translate(40,30) scale(0.82)">
|
||||
<polygon points="10,2 5,13 15,13" />
|
||||
<polygon points="10,10 2,22 18,22" />
|
||||
<rect x="8" y="22" width="4" height="4" />
|
||||
</g>
|
||||
<g transform="translate(56,42) scale(0.62)">
|
||||
<polygon points="10,2 5,13 15,13" />
|
||||
<polygon points="10,10 2,22 18,22" />
|
||||
<rect x="8" y="22" width="4" height="4" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 844 B |
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
return {
|
||||
title: t.imkereiPage.eyebrow,
|
||||
description: t.imkereiPage.title,
|
||||
alternates: { canonical: "/imkerei" },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ImkereiPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="grid md:grid-cols-2 gap-14 items-center">
|
||||
<div>
|
||||
<SectionHeading
|
||||
level="h1"
|
||||
eyebrow={t.imkereiPage.eyebrow}
|
||||
title={t.imkereiPage.title}
|
||||
/>
|
||||
<div className="mt-8 space-y-5 text-ink/75 leading-relaxed">
|
||||
{t.imkereiPage.paragraphs.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PlaceholderImage tone="honey" className="aspect-[4/5] w-full" label={t.imkereiPage.imageLabel} />
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LegalPage } from "@/components/legal-page";
|
||||
import { getContentValue, parseSections } from "@/lib/content";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Impressum", alternates: { canonical: "/impressum" } };
|
||||
|
||||
export default async function ImpressumPage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const [company, raw] = await Promise.all([getCompanyInfo(), getContentValue("legal.impressum", locale)]);
|
||||
const [angabenSection, ustSection, streitSection] = parseSections(raw);
|
||||
|
||||
return (
|
||||
<LegalPage
|
||||
title={t.impressumPage.title}
|
||||
noticeText={t.legalPage.reviewNotice}
|
||||
sections={[
|
||||
{
|
||||
heading: angabenSection.heading,
|
||||
paragraphs: [
|
||||
`${company.name}\n${company.address.street}\n${company.address.zip} ${company.address.city}`,
|
||||
...angabenSection.paragraphs,
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: t.impressumPage.contactHeading,
|
||||
paragraphs: [`${t.impressumPage.phoneLabel}: ${company.phone}`, `${t.impressumPage.emailLabel}: ${company.email}`],
|
||||
},
|
||||
ustSection,
|
||||
streitSection,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading } from "@/components/ui";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = getDictionary(await getLocale());
|
||||
return {
|
||||
title: t.kontaktPage.eyebrow,
|
||||
description: t.kontaktPage.title,
|
||||
alternates: { canonical: "/kontakt" },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function KontaktPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const company = await getCompanyInfo();
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-2xl">
|
||||
<SectionHeading level="h1" eyebrow={t.kontaktPage.eyebrow} title={t.kontaktPage.title} />
|
||||
|
||||
<div className="mt-10 space-y-2 text-ink/75">
|
||||
<p>{company.name}</p>
|
||||
<p>{company.address.street}</p>
|
||||
<p>
|
||||
{company.address.zip} {company.address.city}
|
||||
</p>
|
||||
<p className="pt-4">
|
||||
<a href={`mailto:${company.email}`} className="text-forest underline">
|
||||
{company.email}
|
||||
</a>
|
||||
</p>
|
||||
<p>{company.phone}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 border-t border-ink/10 pt-8">
|
||||
<h2 className="text-lg text-forest">{t.kontaktPage.pickupHeading}</h2>
|
||||
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
|
||||
{t.kontaktPage.pickupText}
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Barrel-Datei: die eigentlichen Server Actions leben domainweise unter
|
||||
// ./actions/* (waren zuvor alle in dieser einen 600+-Zeilen-Datei). Bestehende
|
||||
// Importe ("@/app/konto/actions" bzw. "../actions" aus den Konto-Seiten)
|
||||
// bleiben dadurch unveraendert funktionsfaehig.
|
||||
export * from "./actions/registration";
|
||||
export * from "./actions/login";
|
||||
export * from "./actions/two-factor";
|
||||
export * from "./actions/account";
|
||||
export * from "./actions/addresses";
|
||||
export * from "./actions/newsletter";
|
||||
export * from "./actions/password-reset";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
// Passwortbestaetigung fuer sicherheitskritische Kontoaktionen (2FA
|
||||
// deaktivieren, Konto loeschen) — ohne dieses Limit koennte eine gestohlene
|
||||
// Session das Passwort per Brute-Force erraten und damit z.B. den
|
||||
// 2FA-Schutz aushebeln, den verifyTwoFactorAction gerade durchsetzt.
|
||||
export const ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW = 8;
|
||||
export const ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS = 15 * 60 * 1000;
|
||||
@@ -0,0 +1,65 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createSession, getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
||||
|
||||
/**
|
||||
* Selbstbedienungs-Kontoloeschung (Art. 17 DSGVO). Bestellungen bleiben aus
|
||||
* Aufbewahrungspflichten erhalten (Order.customer hat onDelete: SetNull) —
|
||||
* sie speichern Name/Adresse bereits als eigenstaendigen Snapshot, sodass die
|
||||
* Rechnung auch ohne verknuepftes Kundenkonto vollstaendig bleibt. Alle
|
||||
* anderen persoenlichen Daten (Adressen, Wunschliste, Bewertungen,
|
||||
* Newsletter, Backup-Codes) werden per Cascade mitgeloescht.
|
||||
*/
|
||||
export async function deleteOwnAccountAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
if (customer.role === "ADMIN") {
|
||||
fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (
|
||||
!rateLimit(
|
||||
`delete-account:${ip}:${customer.id}`,
|
||||
ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW,
|
||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const password = String(formData.get("password") ?? "");
|
||||
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||
if (!passwordValid) {
|
||||
fail("/konto", "Passwort ist falsch.");
|
||||
}
|
||||
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
|
||||
await sendMail({ to: customer.email, subject, html, text });
|
||||
|
||||
await prisma.customer.delete({ where: { id: customer.id } });
|
||||
await destroySession();
|
||||
redirect("/konto?message=" + encodeURIComponent("Dein Konto wurde endgültig gelöscht."));
|
||||
}
|
||||
|
||||
/** Widerruft alle bestehenden Sessions (auch andere Geraete) und meldet nur das aktuelle Tab neu an. */
|
||||
export async function logoutAllDevicesAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const updated = await prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: { sessionVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await createSession(updated.id, updated.role, updated.sessionVersion);
|
||||
redirect("/konto?message=" + encodeURIComponent("Alle anderen Geräte wurden abgemeldet."));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { addressSchema } from "@/lib/validation";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
export async function addAddressAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const parsed = addressSchema.safeParse({
|
||||
label: formData.get("label"),
|
||||
firstName: formData.get("firstName"),
|
||||
lastName: formData.get("lastName"),
|
||||
street: formData.get("street"),
|
||||
zip: formData.get("zip"),
|
||||
city: formData.get("city"),
|
||||
country: "DE",
|
||||
isDefault: formData.get("isDefault") === "on",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto/adressen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
if (parsed.data.isDefault) {
|
||||
await prisma.address.updateMany({ where: { customerId: customer.id }, data: { isDefault: false } });
|
||||
}
|
||||
|
||||
await prisma.address.create({ data: { ...parsed.data, customerId: customer.id } });
|
||||
revalidatePath("/konto/adressen");
|
||||
}
|
||||
|
||||
export async function deleteAddressAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const addressId = String(formData.get("addressId") ?? "");
|
||||
await prisma.address.deleteMany({ where: { id: addressId, customerId: customer.id } });
|
||||
revalidatePath("/konto/adressen");
|
||||
}
|
||||
|
||||
export async function setDefaultAddressAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const addressId = String(formData.get("addressId") ?? "");
|
||||
const address = await prisma.address.findFirst({ where: { id: addressId, customerId: customer.id } });
|
||||
if (!address) fail("/konto/adressen", "Adresse nicht gefunden.");
|
||||
|
||||
await prisma.address.updateMany({ where: { customerId: customer.id }, data: { isDefault: false } });
|
||||
await prisma.address.update({ where: { id: address.id }, data: { isDefault: true } });
|
||||
revalidatePath("/konto/adressen");
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createSession, destroySession, verifyPassword, getTwoFactorChallengeCustomerId, setTwoFactorChallengeCookie, clearTwoFactorChallengeCookie } from "@/lib/auth";
|
||||
import { signTwoFactorChallenge } from "@/lib/session";
|
||||
import { verifyTotpCodeAndGetStep, hashBackupCode } from "@/lib/two-factor";
|
||||
import { loginSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import type { Customer } from "@/generated/prisma/client";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
const LOGIN_ATTEMPTS_PER_WINDOW = 10;
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||
const LOGIN_ATTEMPTS_PER_EMAIL_WINDOW = 8;
|
||||
|
||||
// Konto-Sperre nach wiederholten Fehlversuchen — zusaetzlich zum IP-basierten
|
||||
// Rate-Limit, da dieses In-Memory ist und einen Neustart nicht ueberlebt.
|
||||
const LOCKOUT_THRESHOLD = 5;
|
||||
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
|
||||
|
||||
const TWO_FACTOR_VERIFY_ATTEMPTS_PER_WINDOW = 8;
|
||||
const TWO_FACTOR_VERIFY_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
async function registerFailedLogin(customer: Pick<Customer, "id" | "failedLoginAttempts">): Promise<void> {
|
||||
const attempts = customer.failedLoginAttempts + 1;
|
||||
await prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: {
|
||||
failedLoginAttempts: attempts,
|
||||
lockedUntil: attempts >= LOCKOUT_THRESHOLD ? new Date(Date.now() + LOCKOUT_DURATION_MS) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`login:ip:${ip}`, LOGIN_ATTEMPTS_PER_WINDOW, LOGIN_WINDOW_MS)) {
|
||||
fail("/konto", "Zu viele Anmeldeversuche. Bitte versuche es in 15 Minuten erneut.");
|
||||
}
|
||||
|
||||
const parsed = loginSchema.safeParse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto", "Bitte E-Mail und Passwort angeben.");
|
||||
}
|
||||
|
||||
// Zusaetzlich zum IP-Limit ein Limit pro E-Mail-Adresse, damit verteilte
|
||||
// Versuche (viele IPs, ein Konto) nicht am IP-Rate-Limit vorbeikommen.
|
||||
if (!rateLimit(`login:email:${parsed.data.email}`, LOGIN_ATTEMPTS_PER_EMAIL_WINDOW, LOGIN_WINDOW_MS)) {
|
||||
fail("/konto", "Zu viele Anmeldeversuche für dieses Konto. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
|
||||
if (customer?.blockedAt) {
|
||||
fail("/konto", "Dieses Konto wurde gesperrt. Bitte kontaktiere uns.");
|
||||
}
|
||||
|
||||
if (customer?.lockedUntil && customer.lockedUntil > new Date()) {
|
||||
fail(
|
||||
"/konto",
|
||||
"Dieses Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte versuche es später erneut oder setze dein Passwort zurück."
|
||||
);
|
||||
}
|
||||
|
||||
// verifyPassword vergleicht bei unbekannter E-Mail gegen einen Dummy-Hash,
|
||||
// damit die Antwortzeit nicht verraet, ob ein Konto existiert.
|
||||
const passwordValid = await verifyPassword(parsed.data.password, customer?.passwordHash ?? null);
|
||||
|
||||
if (!customer || !passwordValid) {
|
||||
if (customer) await registerFailedLogin(customer);
|
||||
fail("/konto", "E-Mail-Adresse oder Passwort ist falsch.");
|
||||
}
|
||||
|
||||
if (customer.failedLoginAttempts > 0 || customer.lockedUntil) {
|
||||
await prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: { failedLoginAttempts: 0, lockedUntil: null },
|
||||
});
|
||||
}
|
||||
|
||||
if (customer.twoFactorEnabled) {
|
||||
const challengeToken = await signTwoFactorChallenge(customer.id);
|
||||
await setTwoFactorChallengeCookie(challengeToken);
|
||||
redirect("/konto/zwei-faktor-code");
|
||||
}
|
||||
|
||||
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||
redirect("/konto");
|
||||
}
|
||||
|
||||
export async function verifyTwoFactorAction(formData: FormData): Promise<void> {
|
||||
const customerId = await getTwoFactorChallengeCustomerId();
|
||||
if (!customerId) {
|
||||
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`2fa-verify:${ip}:${customerId}`, TWO_FACTOR_VERIFY_ATTEMPTS_PER_WINDOW, TWO_FACTOR_VERIFY_WINDOW_MS)) {
|
||||
fail("/konto/zwei-faktor-code", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "").trim();
|
||||
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer || !customer.twoFactorEnabled || !customer.twoFactorSecret) {
|
||||
fail("/konto", "Die Anmeldung ist abgelaufen. Bitte melde dich erneut an.");
|
||||
}
|
||||
|
||||
const totpStep =
|
||||
code.length > 0 ? verifyTotpCodeAndGetStep(customer.twoFactorSecret, code, customer.email) : null;
|
||||
// Ein Code aus einem bereits verbrauchten (oder frueheren) Zeitfenster gilt
|
||||
// nicht mehr als gueltig — verhindert, dass ein mitgelesener/abgefangener
|
||||
// Code ein zweites Mal funktioniert.
|
||||
let authenticated =
|
||||
totpStep !== null && (customer.lastTotpStep === null || totpStep > customer.lastTotpStep);
|
||||
|
||||
if (authenticated) {
|
||||
await prisma.customer.update({ where: { id: customer.id }, data: { lastTotpStep: totpStep } });
|
||||
}
|
||||
|
||||
if (!authenticated && code.length > 0) {
|
||||
const codeHash = hashBackupCode(code.toUpperCase());
|
||||
const backupCode = await prisma.twoFactorBackupCode.findFirst({
|
||||
where: { customerId: customer.id, codeHash, usedAt: null },
|
||||
});
|
||||
if (backupCode) {
|
||||
await prisma.twoFactorBackupCode.update({ where: { id: backupCode.id }, data: { usedAt: new Date() } });
|
||||
authenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
fail("/konto/zwei-faktor-code", "Der Code ist ungültig oder abgelaufen.");
|
||||
}
|
||||
|
||||
await clearTwoFactorChallengeCookie();
|
||||
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||
redirect("/konto");
|
||||
}
|
||||
|
||||
export async function logoutAction(): Promise<void> {
|
||||
await destroySession();
|
||||
redirect("/konto");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
export async function toggleNewsletterAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const existing = await prisma.newsletterSubscriber.findUnique({ where: { customerId: customer.id } });
|
||||
|
||||
if (existing?.unsubscribedAt === null) {
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: existing.id },
|
||||
data: { unsubscribedAt: new Date() },
|
||||
});
|
||||
} else if (existing) {
|
||||
await prisma.newsletterSubscriber.update({
|
||||
where: { id: existing.id },
|
||||
data: { unsubscribedAt: null, confirmed: true },
|
||||
});
|
||||
} else {
|
||||
await prisma.newsletterSubscriber.create({
|
||||
data: { email: customer.email, customerId: customer.id, confirmed: true },
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/konto");
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createSession, hashPassword } from "@/lib/auth";
|
||||
import { setTwoFactorChallengeCookie } from "@/lib/auth";
|
||||
import { signTwoFactorChallenge } from "@/lib/session";
|
||||
import { requestPasswordResetSchema, resetPasswordSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { hashResetToken, createAndSendPasswordResetEmail } from "@/lib/password-reset";
|
||||
import { isPasswordBreached } from "@/lib/password-breach-check";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
const RESET_REQUEST_ATTEMPTS_PER_WINDOW = 5;
|
||||
const RESET_REQUEST_WINDOW_MS = 60 * 60 * 1000;
|
||||
const RESET_SUBMIT_ATTEMPTS_PER_WINDOW = 10;
|
||||
const RESET_SUBMIT_WINDOW_MS = 15 * 60 * 1000;
|
||||
// Mindestlaufzeit fuer requestPasswordResetAction, unabhaengig davon, ob ein
|
||||
// Konto existiert — siehe Kommentar dort.
|
||||
const MIN_RESET_REQUEST_RESPONSE_MS = 400;
|
||||
|
||||
export async function requestPasswordResetAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`reset-request:${ip}`, RESET_REQUEST_ATTEMPTS_PER_WINDOW, RESET_REQUEST_WINDOW_MS)) {
|
||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const parsed = requestPasswordResetSchema.safeParse({ email: formData.get("email") });
|
||||
if (!parsed.success) {
|
||||
fail("/konto/passwort-vergessen", "Bitte gib eine gültige E-Mail-Adresse ein.");
|
||||
}
|
||||
|
||||
const customer = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
|
||||
// Immer derselbe Erfolgspfad UND dieselbe Mindestlaufzeit, unabhaengig
|
||||
// davon, ob ein Konto existiert — verraet sonst nicht nur per Seiteninhalt,
|
||||
// sondern auch per Antwortzeit (echter Pfad macht DB-Schreibvorgaenge +
|
||||
// SMTP-Versand, der andere sofort) welche E-Mail-Adressen registriert
|
||||
// sind. Promise.all erzwingt die Mindestlaufzeit auch dann, wenn der echte
|
||||
// Pfad schneller waere als MIN_RESET_REQUEST_RESPONSE_MS.
|
||||
const work = customer ? createAndSendPasswordResetEmail(customer) : Promise.resolve();
|
||||
await Promise.all([work, new Promise((resolve) => setTimeout(resolve, MIN_RESET_REQUEST_RESPONSE_MS))]);
|
||||
|
||||
redirect("/konto/passwort-vergessen?sent=1");
|
||||
}
|
||||
|
||||
export async function resetPasswordAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`reset-submit:${ip}`, RESET_SUBMIT_ATTEMPTS_PER_WINDOW, RESET_SUBMIT_WINDOW_MS)) {
|
||||
fail("/konto/passwort-vergessen", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const parsed = resetPasswordSchema.safeParse({
|
||||
token: formData.get("token"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto/passwort-vergessen", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||
where: { tokenHash: hashResetToken(parsed.data.token) },
|
||||
});
|
||||
|
||||
const isValid = resetToken && resetToken.usedAt === null && resetToken.expiresAt > new Date();
|
||||
if (!isValid) {
|
||||
fail(
|
||||
"/konto/passwort-vergessen",
|
||||
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
||||
);
|
||||
}
|
||||
|
||||
const targetCustomer = await prisma.customer.findUnique({ where: { id: resetToken.customerId } });
|
||||
|
||||
// Ein Passwort-Reset darf eine admin-gesperrte Kundschaft nicht wieder
|
||||
// handlungsfaehig machen — sonst waere die Sperre per Reset-Link
|
||||
// eigenstaendig umgehbar.
|
||||
if (targetCustomer?.blockedAt) {
|
||||
fail(
|
||||
"/konto/passwort-vergessen",
|
||||
"Dieser Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an."
|
||||
);
|
||||
}
|
||||
|
||||
if (await isPasswordBreached(parsed.data.password)) {
|
||||
// Anders als bei fail(): der Token bleibt gueltig und wird in der
|
||||
// Weiterleitung mitgegeben, damit die Kundschaft direkt ein anderes
|
||||
// Passwort versuchen kann, statt einen komplett neuen Reset-Link
|
||||
// anfordern zu muessen.
|
||||
redirect(
|
||||
`/konto/passwort-zuruecksetzen?token=${encodeURIComponent(parsed.data.token)}&error=${encodeURIComponent(
|
||||
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(parsed.data.password);
|
||||
|
||||
// sessionVersion wird hochgezaehlt, damit ein Passwort-Reset (z.B. weil das
|
||||
// Konto kompromittiert wurde) alle bestehenden Sessions ungueltig macht.
|
||||
// Ein erfolgreich ueber den zugesandten Link gesetztes Passwort beweist
|
||||
// ausserdem die Kontrolle ueber das Postfach — deckt sowohl "Passwort
|
||||
// vergessen" als auch admin-angelegte Konten (erste Anmeldung laeuft ueber
|
||||
// denselben Mechanismus) ab, ohne eine zusaetzliche Bestaetigungs-Mail zu
|
||||
// benoetigen.
|
||||
const [updatedCustomer] = await prisma.$transaction([
|
||||
prisma.customer.update({
|
||||
where: { id: resetToken.customerId },
|
||||
data: {
|
||||
passwordHash,
|
||||
sessionVersion: { increment: 1 },
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
emailVerifiedAt: targetCustomer?.emailVerifiedAt ?? new Date(),
|
||||
},
|
||||
}),
|
||||
// Alle offenen Reset-Tokens des Kontos entwerten, nicht nur das benutzte.
|
||||
prisma.passwordResetToken.deleteMany({ where: { customerId: resetToken.customerId } }),
|
||||
]);
|
||||
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "passwordChanged");
|
||||
await sendMail({ to: updatedCustomer.email, subject, html, text });
|
||||
|
||||
// Ein Passwort-Reset darf 2FA nicht umgehen — sonst wuerde der zweite
|
||||
// Faktor genau in dem Szenario ausgehebelt, fuer das er gedacht ist (Zugriff
|
||||
// auf das Postfach, z.B. weil das Passwort kompromittiert war).
|
||||
if (updatedCustomer.twoFactorEnabled) {
|
||||
const challengeToken = await signTwoFactorChallenge(updatedCustomer.id);
|
||||
await setTwoFactorChallengeCookie(challengeToken);
|
||||
redirect("/konto/zwei-faktor-code");
|
||||
}
|
||||
|
||||
await createSession(updatedCustomer.id, updatedCustomer.role, updatedCustomer.sessionVersion);
|
||||
redirect("/konto?message=" + encodeURIComponent("Dein Passwort wurde geändert."));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createSession, getCurrentCustomer, hashPassword } from "@/lib/auth";
|
||||
import { registerSchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { createAndSendVerificationEmail } from "@/lib/email-verification";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { isPasswordBreached } from "@/lib/password-breach-check";
|
||||
import { fail } from "./_shared";
|
||||
|
||||
const REGISTER_ATTEMPTS_PER_WINDOW = 5;
|
||||
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
|
||||
const VERIFY_RESEND_ATTEMPTS_PER_WINDOW = 5;
|
||||
const VERIFY_RESEND_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export async function registerAction(formData: FormData): Promise<void> {
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`register:${ip}`, REGISTER_ATTEMPTS_PER_WINDOW, REGISTER_WINDOW_MS)) {
|
||||
fail("/konto", "Zu viele Registrierungsversuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const turnstileToken = String(formData.get("turnstileToken") ?? "");
|
||||
if (!(await verifyTurnstileToken(turnstileToken, ip))) {
|
||||
fail("/konto", "Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const parsed = registerSchema.safeParse({
|
||||
firstName: formData.get("firstName"),
|
||||
lastName: formData.get("lastName"),
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
fail("/konto", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
if (formData.get("password") !== formData.get("passwordConfirm")) {
|
||||
fail("/konto", "Die Passwörter stimmen nicht überein.");
|
||||
}
|
||||
|
||||
if (await isPasswordBreached(parsed.data.password)) {
|
||||
fail(
|
||||
"/konto",
|
||||
"Dieses Passwort wurde in bekannten Datenlecks gefunden. Bitte wähle ein anderes Passwort."
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
|
||||
if (existing) {
|
||||
// Absichtlich keine eindeutige "Konto existiert bereits"-Meldung und
|
||||
// absichtlich derselbe bcrypt-Aufwand wie im Erfolgsfall (Zeile unten) —
|
||||
// beides wuerde sonst verraten, welche E-Mail-Adressen registriert sind
|
||||
// (Timing- bzw. Text-Oracle), analog zum Login-Dummy-Hash in lib/auth.ts.
|
||||
await hashPassword(parsed.data.password);
|
||||
fail(
|
||||
"/konto",
|
||||
"Falls für diese E-Mail-Adresse noch kein Konto besteht, erhältst du in Kürze eine Bestätigungsmail."
|
||||
);
|
||||
}
|
||||
|
||||
// Rolle wird bewusst nicht aus formData gelesen — neue Konten sind immer
|
||||
// KUNDE (Prisma-Default). Admin-Rechte werden ausschliesslich ueber die
|
||||
// Datenbank vergeben, nie ueber ein Client-Feld.
|
||||
const customer = await prisma.customer.create({
|
||||
data: {
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
email: parsed.data.email,
|
||||
passwordHash: await hashPassword(parsed.data.password),
|
||||
},
|
||||
});
|
||||
|
||||
await createAndSendVerificationEmail(customer);
|
||||
await createSession(customer.id, customer.role, customer.sessionVersion);
|
||||
redirect("/konto");
|
||||
}
|
||||
|
||||
/** Rate-limitiert erneutes Zusenden des Bestaetigungslinks, falls die erste Mail verloren ging. */
|
||||
export async function resendVerificationEmailAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (customer.emailVerifiedAt) redirect("/konto");
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`verify-resend:${ip}:${customer.id}`, VERIFY_RESEND_ATTEMPTS_PER_WINDOW, VERIFY_RESEND_WINDOW_MS)) {
|
||||
fail("/konto", "Zu viele Anfragen. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
await createAndSendVerificationEmail(customer);
|
||||
redirect("/konto?message=" + encodeURIComponent("Bestätigungslink wurde erneut gesendet."));
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
createSession,
|
||||
getCurrentCustomer,
|
||||
verifyPassword,
|
||||
setTwoFactorSetupCookie,
|
||||
getTwoFactorSetup,
|
||||
clearTwoFactorSetupCookie,
|
||||
setTwoFactorBackupCodesDisplayCookie,
|
||||
clearTwoFactorBackupCodesDisplayCookie,
|
||||
} from "@/lib/auth";
|
||||
import { signTwoFactorSetup, signTwoFactorBackupCodesDisplay } from "@/lib/session";
|
||||
import { generateTotpSecret, verifyTotpCode, generateBackupCodes } from "@/lib/two-factor";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { securityAlertEmail } from "@/lib/email-templates";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { fail, ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW, ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS } from "./_shared";
|
||||
|
||||
const TWO_FACTOR_SETUP_CONFIRM_ATTEMPTS_PER_WINDOW = 8;
|
||||
const TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
export async function startTwoFactorSetupAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
if (customer.twoFactorEnabled) redirect("/konto/zwei-faktor");
|
||||
|
||||
const secret = generateTotpSecret();
|
||||
const token = await signTwoFactorSetup(customer.id, secret);
|
||||
await setTwoFactorSetupCookie(token);
|
||||
redirect("/konto/zwei-faktor/einrichten");
|
||||
}
|
||||
|
||||
export async function confirmTwoFactorSetupAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const setup = await getTwoFactorSetup();
|
||||
if (!setup || setup.customerId !== customer.id) {
|
||||
fail("/konto/zwei-faktor", "Die Einrichtung ist abgelaufen. Bitte starte erneut.");
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (
|
||||
!rateLimit(
|
||||
`2fa-setup-confirm:${ip}:${customer.id}`,
|
||||
TWO_FACTOR_SETUP_CONFIRM_ATTEMPTS_PER_WINDOW,
|
||||
TWO_FACTOR_SETUP_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
fail("/konto/zwei-faktor/einrichten", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const code = String(formData.get("code") ?? "").trim();
|
||||
if (!verifyTotpCode(setup.secret, code, customer.email)) {
|
||||
fail("/konto/zwei-faktor/einrichten", "Der Code ist ungültig. Bitte versuche es erneut.");
|
||||
}
|
||||
|
||||
const { plain, hashes } = generateBackupCodes();
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: { twoFactorSecret: setup.secret, twoFactorEnabled: true, sessionVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.twoFactorBackupCode.createMany({
|
||||
data: hashes.map((codeHash) => ({ customerId: customer.id, codeHash })),
|
||||
}),
|
||||
]);
|
||||
|
||||
await clearTwoFactorSetupCookie();
|
||||
|
||||
const updated = await prisma.customer.findUniqueOrThrow({ where: { id: customer.id } });
|
||||
await createSession(updated.id, updated.role, updated.sessionVersion);
|
||||
|
||||
const displayToken = await signTwoFactorBackupCodesDisplay(customer.id, plain);
|
||||
await setTwoFactorBackupCodesDisplayCookie(displayToken);
|
||||
redirect("/konto/zwei-faktor/backup-codes");
|
||||
}
|
||||
|
||||
export async function dismissBackupCodesAction(): Promise<void> {
|
||||
await clearTwoFactorBackupCodesDisplayCookie();
|
||||
redirect("/konto/zwei-faktor");
|
||||
}
|
||||
|
||||
export async function disableTwoFactorAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (
|
||||
!rateLimit(
|
||||
`2fa-disable:${ip}:${customer.id}`,
|
||||
ACCOUNT_PASSWORD_CONFIRM_ATTEMPTS_PER_WINDOW,
|
||||
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
|
||||
)
|
||||
) {
|
||||
fail("/konto/zwei-faktor", "Zu viele Versuche. Bitte versuche es später erneut.");
|
||||
}
|
||||
|
||||
const password = String(formData.get("password") ?? "");
|
||||
const passwordValid = await verifyPassword(password, customer.passwordHash);
|
||||
if (!passwordValid) {
|
||||
fail("/konto/zwei-faktor", "Passwort ist falsch.");
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: { twoFactorEnabled: false, twoFactorSecret: null, sessionVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.twoFactorBackupCode.deleteMany({ where: { customerId: customer.id } }),
|
||||
]);
|
||||
|
||||
const updated = await prisma.customer.findUniqueOrThrow({ where: { id: customer.id } });
|
||||
await createSession(updated.id, updated.role, updated.sessionVersion);
|
||||
|
||||
const { subject, html, text } = await securityAlertEmail(await getLocale(), "twoFactorDisabled");
|
||||
await sendMail({ to: updated.email, subject, html, text });
|
||||
|
||||
redirect("/konto/zwei-faktor?message=" + encodeURIComponent("Zwei-Faktor-Authentifizierung wurde deaktiviert."));
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AccountEmptyState, AddressIcon } from "@/components/account-ui";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getAddressesForCustomer } from "@/lib/account-data";
|
||||
import { addAddressAction, deleteAddressAction, setDefaultAddressAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Adressen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function KontoAdressenPage(props: PageProps<"/konto/adressen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const addresses = await getAddressesForCustomer(customer.id);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.addressesPage.title} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-10 space-y-4">
|
||||
{addresses.map((address) => (
|
||||
<div
|
||||
key={address.id}
|
||||
className="border border-ink/10 p-6 flex justify-between gap-4 transition-colors hover:border-ink/25"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-11 h-11 border border-ink/15 text-forest">
|
||||
<AddressIcon />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-forest">
|
||||
{address.label}{" "}
|
||||
{address.isDefault ? <span className="text-honey-dark text-xs">· {t.addressesPage.standard}</span> : null}
|
||||
</p>
|
||||
<p className="text-sm text-ink/70 mt-1">
|
||||
{address.firstName} {address.lastName}
|
||||
<br />
|
||||
{address.street}
|
||||
<br />
|
||||
{address.zip} {address.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-sm flex-shrink-0">
|
||||
{!address.isDefault ? (
|
||||
<form action={setDefaultAddressAction}>
|
||||
<input type="hidden" name="addressId" value={address.id} />
|
||||
<button type="submit" className="text-forest hover:underline">
|
||||
{t.addressesPage.setDefault}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
<form action={deleteAddressAction}>
|
||||
<input type="hidden" name="addressId" value={address.id} />
|
||||
<button type="submit" className="text-ink/65 hover:text-red-700 hover:underline">
|
||||
{t.addressesPage.delete}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{addresses.length === 0 ? (
|
||||
<AccountEmptyState
|
||||
icon={<AddressIcon />}
|
||||
title={t.addressesPage.emptyTitle}
|
||||
description={t.addressesPage.emptyDescription}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-14 border-t border-ink/10 pt-10">
|
||||
<h2 className="text-lg text-forest mb-6">{t.addressesPage.addNewHeading}</h2>
|
||||
<form action={addAddressAction} className="space-y-4 max-w-md">
|
||||
<Field name="label" label={t.addressesPage.labelField} defaultValue={t.addressesPage.labelDefaultValue} required />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.account.firstNameLabel} required />
|
||||
<Field name="lastName" label={t.account.lastNameLabel} required />
|
||||
</div>
|
||||
<Field name="street" label={t.checkoutPage.streetLabel} required />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="zip" label={t.checkoutPage.zipLabel} required />
|
||||
<Field name="city" label={t.checkoutPage.cityLabel} required />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isDefault" />
|
||||
{t.addressesPage.useAsDefault}
|
||||
</label>
|
||||
<Button type="submit">{t.addressesPage.save}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
required = false,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
required={required}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requestReturnAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const RETURN_ELIGIBLE_STATUSES = [
|
||||
"BEZAHLT",
|
||||
"IN_VORBEREITUNG",
|
||||
"VERSANDBEREIT",
|
||||
"VERSENDET",
|
||||
"ABHOLBEREIT",
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
export default async function RetourePage(props: PageProps<"/konto/bestellungen/[id]/retoure">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { id } = await props.params;
|
||||
const { error } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
orderNumber: true,
|
||||
customerId: true,
|
||||
status: true,
|
||||
items: { select: { id: true, productName: true, quantity: true } },
|
||||
returnRequest: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!order || order.customerId !== customer.id) notFound();
|
||||
if (!RETURN_ELIGIBLE_STATUSES.includes(order.status) || order.returnRequest) {
|
||||
redirect("/konto/bestellungen");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-lg">
|
||||
<BackLink href="/konto/bestellungen" label={t.retourePage.backLabel} />
|
||||
<SectionHeading eyebrow={t.retourePage.orderEyebrow(order.orderNumber)} title={t.retourePage.title} />
|
||||
|
||||
<ul className="mt-8 text-sm text-ink/70 space-y-1">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} × {item.productName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={requestReturnAction} className="mt-6 space-y-4">
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<div>
|
||||
<label htmlFor="reason" className="text-sm text-ink/70">
|
||||
{t.retourePage.reasonLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="reason"
|
||||
name="reason"
|
||||
required
|
||||
minLength={3}
|
||||
rows={5}
|
||||
placeholder={t.retourePage.reasonPlaceholder}
|
||||
className="mt-2 w-full border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">{t.retourePage.submit}</Button>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { returnRequestSchema } from "@/lib/validation";
|
||||
|
||||
// Rueckgabe nur fuer Bestellungen, die tatsaechlich bezahlt und nicht bereits
|
||||
// storniert wurden — "NEU" (unbezahlt) und "STORNIERT" sind ausgeschlossen.
|
||||
const RETURN_ELIGIBLE_STATUSES = [
|
||||
"BEZAHLT",
|
||||
"IN_VORBEREITUNG",
|
||||
"VERSANDBEREIT",
|
||||
"VERSENDET",
|
||||
"ABHOLBEREIT",
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
redirect(`${path}?error=${encodeURIComponent(message)}`);
|
||||
}
|
||||
|
||||
export async function requestReturnAction(formData: FormData): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an, um eine Rücksendung anzufragen.");
|
||||
|
||||
const parsed = returnRequestSchema.safeParse({
|
||||
orderId: formData.get("orderId"),
|
||||
reason: formData.get("reason"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
const orderId = String(formData.get("orderId") ?? "");
|
||||
fail(`/konto/bestellungen/${orderId}/retoure`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
}
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: parsed.data.orderId },
|
||||
select: { customerId: true, status: true, returnRequest: { select: { id: true } } },
|
||||
});
|
||||
|
||||
if (!order || order.customerId !== customer.id) {
|
||||
fail("/konto/bestellungen", "Bestellung nicht gefunden.");
|
||||
}
|
||||
if (!RETURN_ELIGIBLE_STATUSES.includes(order.status)) {
|
||||
fail("/konto/bestellungen", "Für diese Bestellung kann keine Rücksendung angefragt werden.");
|
||||
}
|
||||
if (order.returnRequest) {
|
||||
fail("/konto/bestellungen", "Für diese Bestellung liegt bereits eine Rücksendeanfrage vor.");
|
||||
}
|
||||
|
||||
await prisma.returnRequest.create({
|
||||
data: { orderId: parsed.data.orderId, reason: parsed.data.reason },
|
||||
});
|
||||
|
||||
revalidatePath("/konto/bestellungen");
|
||||
redirect(
|
||||
`/konto/bestellungen?message=${encodeURIComponent("Deine Rücksendeanfrage wurde übermittelt. Wir melden uns bei dir.")}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Container } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* Next.js-Suspense-Skeleton fuer /konto/bestellungen: die Seite ist
|
||||
* "force-dynamic" und laedt die vollstaendige Bestellhistorie samt Items,
|
||||
* Versand/Abholstatus und Rechnung bei jedem Aufruf neu. Orientiert sich am
|
||||
* tatsaechlichen Karten-Layout der Seite.
|
||||
*/
|
||||
export default function OrdersLoading() {
|
||||
return (
|
||||
<div className="py-20 animate-pulse">
|
||||
<Container className="max-w-3xl">
|
||||
<div className="h-3 w-32 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-3 h-10 w-56 bg-ink/10 rounded-sm" />
|
||||
|
||||
<div className="mt-10 space-y-6">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="border border-ink/10 p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="h-4 w-32 bg-ink/10 rounded-sm" />
|
||||
<div className="mt-2 h-3 w-24 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
<div className="h-6 w-24 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
<div className="mt-4 space-y-1.5">
|
||||
<div className="h-3 w-2/3 bg-ink/10 rounded-sm" />
|
||||
<div className="h-3 w-1/2 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
<div className="mt-5 h-2 w-full bg-ink/10" />
|
||||
<div className="mt-5 h-4 w-20 bg-ink/10 rounded-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ReorderButton } from "@/components/reorder-button";
|
||||
import { AccountEmptyState, OrderStatusBadge, OrderStatusTimeline, OrderIcon } from "@/components/account-ui";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getOrdersForCustomer } from "@/lib/account-data";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Bestellungen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Deckt sich mit RETURN_ELIGIBLE_STATUSES in bestellungen/actions.ts.
|
||||
const RETURN_ELIGIBLE_STATUSES = [
|
||||
"BEZAHLT",
|
||||
"IN_VORBEREITUNG",
|
||||
"VERSANDBEREIT",
|
||||
"VERSENDET",
|
||||
"ABHOLBEREIT",
|
||||
"ABGEHOLT",
|
||||
];
|
||||
|
||||
export default async function KontoBestellungenPage(props: PageProps<"/konto/bestellungen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const returnStatusLabels = t.ordersPage.returnStatusLabels;
|
||||
const { error, message } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const orders = await getOrdersForCustomer(customer.id);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.ordersPage.title} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<AccountEmptyState
|
||||
icon={<OrderIcon />}
|
||||
title={t.ordersPage.emptyTitle}
|
||||
description={t.ordersPage.emptyDescription}
|
||||
action={<LinkButton href="/shop">{t.cartPage.goToShop}</LinkButton>}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-10 space-y-6">
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="border border-ink/10 p-6 transition-colors hover:border-ink/25"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-forest">{order.orderNumber}</p>
|
||||
<p className="text-xs text-ink/65">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
|
||||
<p className="mt-1.5 text-xs text-ink/65">
|
||||
{order.fulfillmentMethod === "VERSAND"
|
||||
? order.shipment?.shippedAt
|
||||
? t.ordersPage.shippedOn(formatDate(order.shipment.shippedAt))
|
||||
: t.ordersPage.shippingPending
|
||||
: order.pickup?.pickedUpAt
|
||||
? t.ordersPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
|
||||
: order.pickup?.readyAt
|
||||
? t.ordersPage.readySince(formatDate(order.pickup.readyAt))
|
||||
: t.ordersPage.pickupPending}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 text-sm text-ink/70 space-y-1">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.quantity} × {item.productName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-5 max-w-md">
|
||||
<OrderStatusTimeline
|
||||
status={order.status}
|
||||
fulfillmentMethod={order.fulfillmentMethod}
|
||||
labels={{
|
||||
ordered: t.orderStatusTimeline.ordered,
|
||||
paid: t.orderStatusTimeline.paid,
|
||||
preparing: t.orderStatusTimeline.preparing,
|
||||
readyToShip: t.orderStatusTimeline.readyToShip,
|
||||
shipped: t.orderStatusTimeline.shipped,
|
||||
readyForPickup: t.orderStatusTimeline.readyForPickup,
|
||||
pickedUp: t.orderStatusTimeline.pickedUp,
|
||||
cancelled: t.orderStatusTimeline.cancelled,
|
||||
ariaLabel: t.orderStatusTimeline.ariaLabel,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{order.shipment?.carrier && order.shipment.trackingNumber ? (
|
||||
<p className="mt-3 text-xs text-ink/65">
|
||||
{t.shipmentInfo.carrierLabel}: {order.shipment.carrier} · {t.shipmentInfo.trackingNumberLabel}:{" "}
|
||||
{order.shipment.trackingNumber}
|
||||
{(() => {
|
||||
const url = getCarrierTrackingUrl(order.shipment.carrier, order.shipment.trackingNumber);
|
||||
return url ? (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="text-forest underline">
|
||||
{t.shipmentInfo.trackPackage}
|
||||
</a>
|
||||
</>
|
||||
) : null;
|
||||
})()}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{order.giftMessage ? (
|
||||
<p className="mt-3 text-xs text-ink/65 bg-honey/10 border border-honey-dark/20 px-3 py-2">
|
||||
🎁 {t.ordersPage.giftMessagePrefix} „{order.giftMessage}“
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-forest">{formatPrice(order.totalCents, order.currency)}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{order.invoice ? (
|
||||
<Link
|
||||
href={`/rechnung/${order.invoice.invoiceNumber}`}
|
||||
className="text-sm text-forest underline"
|
||||
>
|
||||
{t.ordersPage.viewInvoice}
|
||||
</Link>
|
||||
) : null}
|
||||
{order.returnRequest ? (
|
||||
<span className="text-sm text-ink/65">
|
||||
{returnStatusLabels[order.returnRequest.status] ?? order.returnRequest.status}
|
||||
</span>
|
||||
) : RETURN_ELIGIBLE_STATUSES.includes(order.status) ? (
|
||||
<Link
|
||||
href={`/konto/bestellungen/${order.id}/retoure`}
|
||||
className="text-sm text-forest underline"
|
||||
>
|
||||
{t.ordersPage.requestReturn}
|
||||
</Link>
|
||||
) : null}
|
||||
<ReorderButton
|
||||
items={order.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
slug: item.product.slug,
|
||||
name: item.productName,
|
||||
weightGrams: item.weightGrams,
|
||||
priceCents: item.unitPriceCents,
|
||||
quantity: item.quantity,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Container, SectionHeading, Button, LinkButton } from "@/components/ui";
|
||||
import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon } from "@/components/account-ui";
|
||||
import { TurnstileField } from "@/components/turnstile-widget";
|
||||
import { PasswordField } from "@/components/password-field";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
registerAction,
|
||||
loginAction,
|
||||
logoutAction,
|
||||
toggleNewsletterAction,
|
||||
logoutAllDevicesAction,
|
||||
resendVerificationEmailAction,
|
||||
deleteOwnAccountAction,
|
||||
} from "./actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Mein Konto" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, message } = await props.searchParams;
|
||||
const customer = await getCurrentCustomer();
|
||||
|
||||
if (!customer) {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.loginRegisterTitle} />
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-10 grid md:grid-cols-2 md:divide-x md:divide-ink/10 gap-12 md:gap-0">
|
||||
<form action={loginAction} className="space-y-4 md:pr-12">
|
||||
<h2 className="text-lg text-forest">{t.account.login}</h2>
|
||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||
<PasswordField name="password" label={t.account.passwordLabel} required autoComplete="current-password" />
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Button type="submit">{t.account.login}</Button>
|
||||
<Link href="/konto/passwort-vergessen" className="text-xs text-ink/65 hover:text-forest underline">
|
||||
{t.account.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={registerAction} className="space-y-4 md:pl-12">
|
||||
<h2 className="text-lg text-forest">{t.account.register}</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="firstName" label={t.account.firstNameLabel} required autoComplete="given-name" />
|
||||
<Field name="lastName" label={t.account.lastNameLabel} required autoComplete="family-name" />
|
||||
</div>
|
||||
<Field name="email" label={t.account.emailLabel} type="email" required autoComplete="email" />
|
||||
<PasswordField
|
||||
name="password"
|
||||
label={t.account.passwordMinLabel}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
showStrength
|
||||
/>
|
||||
<PasswordField
|
||||
name="passwordConfirm"
|
||||
label={t.account.passwordConfirmLabel}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ? (
|
||||
<TurnstileField siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />
|
||||
) : null}
|
||||
<Button type="submit">{t.account.register}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const newsletter = await prisma.newsletterSubscriber.findUnique({
|
||||
where: { customerId: customer.id },
|
||||
});
|
||||
const isSubscribed = Boolean(newsletter && newsletter.unsubscribedAt === null);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-2xl">
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.account.greeting(customer.firstName)} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!customer.emailVerifiedAt ? (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 text-sm bg-honey/10 border border-honey/30 px-4 py-3">
|
||||
<p className="text-ink/70">
|
||||
{t.account.verifyEmailPrompt}
|
||||
</p>
|
||||
<form action={resendVerificationEmailAction}>
|
||||
<button type="submit" className="text-forest underline whitespace-nowrap">
|
||||
{t.account.resendVerification}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-10 grid sm:grid-cols-2 gap-6">
|
||||
<AccountNavCard
|
||||
href="/konto/bestellungen"
|
||||
icon={<OrderIcon />}
|
||||
title={t.account.navOrders.title}
|
||||
description={t.account.navOrders.description}
|
||||
/>
|
||||
<AccountNavCard
|
||||
href="/konto/adressen"
|
||||
icon={<AddressIcon />}
|
||||
title={t.account.navAddresses.title}
|
||||
description={t.account.navAddresses.description}
|
||||
/>
|
||||
<AccountNavCard
|
||||
href="/konto/wunschliste"
|
||||
icon={<HeartIcon />}
|
||||
title={t.account.navWishlist.title}
|
||||
description={t.account.navWishlist.description}
|
||||
/>
|
||||
<AccountNavCard
|
||||
href="/konto/zwei-faktor"
|
||||
icon={<ShieldIcon />}
|
||||
title={t.account.navSecurity.title}
|
||||
description={`${t.account.navSecurity.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSecurity.twoFactorActive : t.account.navSecurity.twoFactorInactive}`}
|
||||
/>
|
||||
{customer.role === "ADMIN" ? (
|
||||
<AccountNavCard
|
||||
href="/admin"
|
||||
icon={<ShieldIcon />}
|
||||
title={t.account.navAdmin.title}
|
||||
description={t.account.navAdmin.description}
|
||||
accent
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg text-forest">{t.account.newsletterHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
|
||||
</p>
|
||||
</div>
|
||||
<form action={toggleNewsletterAction}>
|
||||
<Button type="submit" variant="secondary">
|
||||
{isSubscribed ? t.account.unsubscribe : t.account.subscribe}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg text-forest">{t.account.logoutAllDevicesHeading}</h2>
|
||||
<p className="mt-1 text-sm text-ink/70">
|
||||
{t.account.logoutAllDevicesDescription}
|
||||
</p>
|
||||
</div>
|
||||
<form action={logoutAllDevicesAction}>
|
||||
<Button type="submit" variant="secondary">
|
||||
{t.account.logout}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form action={logoutAction} className="mt-6 mb-10">
|
||||
<Button type="submit" variant="ghost">
|
||||
{t.account.logout}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<h2 className="text-lg text-forest mb-4">{t.account.privacy.heading}</h2>
|
||||
<div className="border border-ink/10 p-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm text-forest">{t.account.privacy.downloadDataHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
|
||||
</div>
|
||||
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
|
||||
{t.account.privacy.downloadDataButton}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
|
||||
<h3 className="text-sm text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
|
||||
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
|
||||
<ConfirmForm
|
||||
action={deleteOwnAccountAction}
|
||||
confirmMessage={t.account.privacy.deleteAccountConfirm}
|
||||
className="mt-4 flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.account.privacy.deleteAccountPasswordLabel}</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-red-400"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" variant="secondary" className="border-red-300 text-red-700 hover:bg-red-100">
|
||||
{t.account.privacy.deleteAccountButton}
|
||||
</Button>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
label,
|
||||
type = "text",
|
||||
required = false,
|
||||
autoComplete,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
autoComplete?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{label}</span>
|
||||
<input
|
||||
name={name}
|
||||
type={type}
|
||||
required={required}
|
||||
autoComplete={autoComplete}
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { requestPasswordResetAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Passwort vergessen" };
|
||||
|
||||
export default async function PasswortVergessenPage(props: PageProps<"/konto/passwort-vergessen">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, sent } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<BackLink href="/konto" label={t.passwortVergessenPage.backLabel} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortVergessenPage.title} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
{sent ? (
|
||||
<p className="mt-6 text-sm text-ink/70 leading-relaxed">
|
||||
{t.passwortVergessenPage.sentMessage}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-6 text-sm text-ink/70">
|
||||
{t.passwortVergessenPage.intro}
|
||||
</p>
|
||||
<form action={requestPasswordResetAction} className="mt-6 space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.account.emailLabel}</span>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">{t.passwortVergessenPage.requestLink}</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { PasswordField } from "@/components/password-field";
|
||||
import { resetPasswordAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Neues Passwort vergeben" };
|
||||
|
||||
export default async function PasswortZuruecksetzenPage(
|
||||
props: PageProps<"/konto/passwort-zuruecksetzen">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { token, error } = await props.searchParams;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<BackLink href="/konto" label={t.passwortZuruecksetzenPage.backLabel} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.passwortZuruecksetzenPage.title} />
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
{!token ? (
|
||||
<p className="mt-6 text-sm text-ink/70">
|
||||
{t.passwortZuruecksetzenPage.incompleteLinkTextBefore}
|
||||
<a href="/konto/passwort-vergessen" className="underline text-forest">
|
||||
{t.passwortZuruecksetzenPage.incompleteLinkLabel}
|
||||
</a>
|
||||
{t.passwortZuruecksetzenPage.incompleteLinkTextAfter}
|
||||
</p>
|
||||
) : (
|
||||
<form action={resetPasswordAction} className="mt-6 space-y-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<PasswordField
|
||||
name="password"
|
||||
label={t.passwortZuruecksetzenPage.newPasswordLabel}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
showStrength
|
||||
/>
|
||||
<Button type="submit">{t.passwortZuruecksetzenPage.save}</Button>
|
||||
</form>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ProductCard } from "@/components/product-card";
|
||||
import { AccountEmptyState, HeartIcon } from "@/components/account-ui";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getWishlistProducts } from "@/lib/wishlist";
|
||||
import { toggleWishlistAction } from "../../honig/actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Meine Wunschliste" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function KontoWunschlistePage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const products = await getWishlistProducts(customer.id);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container>
|
||||
<BackLink href="/konto" label={t.ordersPage.backToAccount} />
|
||||
<SectionHeading eyebrow={t.account.eyebrow} title={t.wishlistPage.title} />
|
||||
|
||||
{products.length === 0 ? (
|
||||
<AccountEmptyState
|
||||
icon={<HeartIcon />}
|
||||
title={t.wishlistPage.emptyTitle}
|
||||
description={t.wishlistPage.emptyDescription}
|
||||
action={<LinkButton href="/shop">{t.cartPage.goToShop}</LinkButton>}
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
|
||||
{products.map((product) => (
|
||||
<div key={product.id}>
|
||||
<ProductCard product={product} />
|
||||
<form action={toggleWishlistAction} className="mt-3">
|
||||
<input type="hidden" name="productId" value={product.id} />
|
||||
<input type="hidden" name="redirectPath" value="/konto/wunschliste" />
|
||||
<button type="submit" className="text-sm text-ink/65 hover:text-red-700 underline">
|
||||
{t.cart.remove}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { getTwoFactorChallengeCustomerId } from "@/lib/auth";
|
||||
import { verifyTwoFactorAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Anmeldung bestätigen" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZweiFaktorCodePage(props: PageProps<"/konto/zwei-faktor-code">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
const customerId = await getTwoFactorChallengeCustomerId();
|
||||
if (!customerId) redirect("/konto");
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<BackLink href="/konto" label={t.twoFactorCodePage.cancelLabel} />
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorCodePage.eyebrow}
|
||||
title={t.twoFactorCodePage.title}
|
||||
subtitle={t.twoFactorCodePage.subtitle}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<form action={verifyTwoFactorAction} className="mt-6 space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.twoFactorCodePage.codeLabel}</span>
|
||||
<input
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">{t.twoFactorCodePage.confirm}</Button>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { getCurrentCustomer, getTwoFactorBackupCodesDisplay } from "@/lib/auth";
|
||||
import { dismissBackupCodesAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Backup-Codes" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZweiFaktorBackupCodesPage() {
|
||||
const t = getDictionary(await getLocale());
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const codes = await getTwoFactorBackupCodesDisplay();
|
||||
if (!codes) redirect("/konto/zwei-faktor");
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorBackupCodesPage.eyebrow}
|
||||
title={t.twoFactorBackupCodesPage.title}
|
||||
subtitle={t.twoFactorBackupCodesPage.subtitle}
|
||||
/>
|
||||
|
||||
<ul className="mt-8 border border-ink/10 divide-y divide-ink/10 font-mono text-sm">
|
||||
{codes.map((code) => (
|
||||
<li key={code} className="px-4 py-3">
|
||||
{code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{t.twoFactorBackupCodesPage.warning}
|
||||
</p>
|
||||
|
||||
<form action={dismissBackupCodesAction} className="mt-8">
|
||||
<Button type="submit">{t.twoFactorBackupCodesPage.done}</Button>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { getCurrentCustomer, getTwoFactorSetup } from "@/lib/auth";
|
||||
import { generateTotpQrCodeDataUrl } from "@/lib/two-factor";
|
||||
import { confirmTwoFactorSetupAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung einrichten" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZweiFaktorEinrichtenPage(
|
||||
props: PageProps<"/konto/zwei-faktor/einrichten">
|
||||
) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error } = await props.searchParams;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const setup = await getTwoFactorSetup();
|
||||
if (!setup || setup.customerId !== customer.id) redirect("/konto/zwei-faktor");
|
||||
|
||||
const qrCodeDataUrl = await generateTotpQrCodeDataUrl(setup.secret, customer.email);
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<BackLink href="/konto/zwei-faktor" label={t.twoFactorSetupPage.backLabel} />
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorSetupPage.eyebrow}
|
||||
title={t.twoFactorSetupPage.title}
|
||||
subtitle={t.twoFactorSetupPage.subtitle}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 border border-ink/10 p-6 flex flex-col items-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- Data-URI, kein Next-Image-Optimierungspfad noetig */}
|
||||
<img src={qrCodeDataUrl} alt={t.twoFactorSetupPage.qrAlt} width={240} height={240} />
|
||||
<p className="mt-4 text-xs text-ink/65 break-all text-center">
|
||||
{t.twoFactorSetupPage.manualEntryLabel} <span className="font-mono">{setup.secret}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={confirmTwoFactorSetupAction} className="mt-8 space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.twoFactorSetupPage.appCodeLabel}</span>
|
||||
<input
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 tracking-widest focus:outline-none focus:border-forest"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">{t.twoFactorSetupPage.activate}</Button>
|
||||
</form>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Container, SectionHeading, Button } from "@/components/ui";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ShieldIcon } from "@/components/account-ui";
|
||||
import { PasswordField } from "@/components/password-field";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { startTwoFactorSetupAction, disableTwoFactorAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export const metadata: Metadata = { title: "Zwei-Faktor-Authentifizierung" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZweiFaktorPage(props: PageProps<"/konto/zwei-faktor">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { error, message } = await props.searchParams;
|
||||
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) redirect("/konto");
|
||||
|
||||
const remainingBackupCodes = customer.twoFactorEnabled
|
||||
? await prisma.twoFactorBackupCode.count({ where: { customerId: customer.id, usedAt: null } })
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-md">
|
||||
<BackLink href="/konto" label={t.twoFactorPage.backLabel} />
|
||||
<SectionHeading
|
||||
eyebrow={t.twoFactorPage.eyebrow}
|
||||
title={t.twoFactorPage.title}
|
||||
subtitle={t.twoFactorPage.subtitle}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
{customer.twoFactorEnabled ? (
|
||||
<div className="mt-8 space-y-6">
|
||||
<p className="flex items-center gap-3 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
<ShieldIcon />
|
||||
<span>
|
||||
{t.twoFactorPage.activeStatus(remainingBackupCodes)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<form action={disableTwoFactorAction} className="space-y-4 border border-ink/10 p-6">
|
||||
<h2 className="text-forest">{t.twoFactorPage.disableHeading}</h2>
|
||||
<PasswordField
|
||||
name="password"
|
||||
label={t.twoFactorPage.passwordConfirmLabel}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="ghost">
|
||||
{t.twoFactorPage.disableButton}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<form action={startTwoFactorSetupAction} className="mt-8">
|
||||
<Button type="submit">{t.twoFactorPage.setupButton}</Button>
|
||||
</form>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+101
-15
@@ -1,29 +1,115 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Cormorant_Garamond, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Header, Footer, CartDrawer } from "@/components/site-chrome";
|
||||
import { CookieConsentBanner } from "@/components/cookie-consent";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const cormorant = Cormorant_Garamond({
|
||||
variable: "--font-cormorant",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const company = await getCompanyInfo();
|
||||
const title = `${company.name} | Regionaler Honig aus Altenmünster`;
|
||||
return {
|
||||
metadataBase: new URL(`https://${siteConfig.domain}`),
|
||||
title: { default: title, template: `%s | ${company.name}` },
|
||||
description: company.description,
|
||||
openGraph: {
|
||||
title,
|
||||
description: company.description,
|
||||
siteName: company.name,
|
||||
locale: "de_DE",
|
||||
type: "website",
|
||||
},
|
||||
twitter: { card: "summary_large_image", title, description: company.description },
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const locale = await getLocale();
|
||||
const dict = getDictionary(locale);
|
||||
const company = await getCompanyInfo();
|
||||
|
||||
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
|
||||
// auf jeder Seite einzeln) — hilft insbesondere bei lokaler Google-Suche
|
||||
// ("Honig Altenmünster") und der Kartenanzeige. telephone ist hier bewusst
|
||||
// NICHT enthalten: solange niemand im Admin-Bereich (Inhalte) eine echte
|
||||
// Telefonnummer eingetragen hat, bliebe sonst der site-config-Platzhalter
|
||||
// als strukturierte Daten faelschlich indexiert.
|
||||
const localBusinessJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Store",
|
||||
"@id": `https://${siteConfig.domain}/#business`,
|
||||
name: company.name,
|
||||
description: company.description,
|
||||
url: `https://${siteConfig.domain}`,
|
||||
address: {
|
||||
"@type": "PostalAddress",
|
||||
streetAddress: company.address.street,
|
||||
postalCode: company.address.zip,
|
||||
addressLocality: company.address.city,
|
||||
addressCountry: "DE",
|
||||
},
|
||||
};
|
||||
|
||||
// Ermoeglicht Googles Sitelinks-Suchfeld direkt in den Suchergebnissen — die
|
||||
// Ziel-URL entspricht der tatsaechlich vorhandenen Shop-Suche (?q=...).
|
||||
const websiteJsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: company.name,
|
||||
url: `https://${siteConfig.domain}`,
|
||||
potentialAction: {
|
||||
"@type": "SearchAction",
|
||||
target: `https://${siteConfig.domain}/shop?q={search_term_string}`,
|
||||
"query-input": "required name=search_term_string",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
lang={locale}
|
||||
data-scroll-behavior="smooth"
|
||||
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full flex flex-col bg-background text-ink">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(localBusinessJsonLd).replace(/</g, "\\u003c") }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd).replace(/</g, "\\u003c") }}
|
||||
/>
|
||||
<I18nProvider locale={locale}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:bg-forest focus:text-cream focus:px-4 focus:py-2 focus:text-sm"
|
||||
>
|
||||
{dict.nav.skipToContent}
|
||||
</a>
|
||||
<Header companyName={company.name} />
|
||||
<main id="main-content" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer companyInfo={company} />
|
||||
<CartDrawer />
|
||||
<CookieConsentBanner />
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
/**
|
||||
* Web-App-Manifest fuer "Zum Startbildschirm hinzufuegen" auf Mobilgeraeten —
|
||||
* nutzt das bestehende icon.svg direkt statt zusaetzlich generierter
|
||||
* Raster-Varianten (Chrome/Android unterstuetzen SVG-Manifest-Icons mit
|
||||
* sizes:"any" nativ, siehe MDN). Sprachabhaengig wie der Rest der Seite.
|
||||
*/
|
||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const company = await getCompanyInfo();
|
||||
|
||||
return {
|
||||
name: `${company.name} — ${t.footer.tagline}`,
|
||||
short_name: company.name,
|
||||
description: t.common.siteDescription,
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#FAF8F3",
|
||||
theme_color: "#24352B",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { NEWSLETTER_DISCOUNT_CODE, NEWSLETTER_DISCOUNT_PERCENT } from "@/lib/newsletter-discount";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Erntepost bestätigt",
|
||||
alternates: { canonical: "/newsletter-bestaetigt" },
|
||||
robots: { index: false },
|
||||
};
|
||||
|
||||
export default async function NewsletterBestaetigtPage(props: PageProps<"/newsletter-bestaetigt">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { status } = await props.searchParams;
|
||||
const title =
|
||||
status === "invalid"
|
||||
? t.newsletterBestaetigtPage.invalidTitle
|
||||
: status === "unsubscribed"
|
||||
? t.newsletterBestaetigtPage.unsubscribedTitle
|
||||
: t.newsletterBestaetigtPage.confirmedTitle;
|
||||
const text =
|
||||
status === "invalid"
|
||||
? t.newsletterBestaetigtPage.invalidText
|
||||
: status === "unsubscribed"
|
||||
? t.newsletterBestaetigtPage.unsubscribedText
|
||||
: t.newsletterBestaetigtPage.confirmedText;
|
||||
|
||||
return (
|
||||
<div className="py-24">
|
||||
<Container className="max-w-xl text-center">
|
||||
<SectionHeading
|
||||
eyebrow={t.newsletterBestaetigtPage.eyebrow}
|
||||
title={title}
|
||||
align="center"
|
||||
className="mx-auto"
|
||||
/>
|
||||
<p className="mt-6 text-ink/70">{text}</p>
|
||||
|
||||
{status === "ok" ? (
|
||||
<div className="mt-8 border border-forest/20 bg-forest/5 p-6">
|
||||
<p className="text-sm text-ink/70">{t.newsletterBestaetigtPage.discountHeading}</p>
|
||||
<p className="mt-2 text-2xl text-forest tracking-wide">
|
||||
{t.newsletterBestaetigtPage.discountText(NEWSLETTER_DISCOUNT_PERCENT)}{" "}
|
||||
<span className="font-medium">{NEWSLETTER_DISCOUNT_CODE}</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-ink/50">{t.newsletterBestaetigtPage.discountCopyHint}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LinkButton href="/" className="mt-8">
|
||||
{t.newsletterBestaetigtPage.backHome}
|
||||
</LinkButton>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Container, LinkButton } from "@/components/ui";
|
||||
|
||||
export const metadata: Metadata = { title: "Seite nicht gefunden", robots: { index: false } };
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="py-32">
|
||||
<Container className="max-w-xl text-center">
|
||||
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">404</p>
|
||||
<h1 className="text-3xl md:text-4xl text-forest">Diese Seite gibt es hier nicht.</h1>
|
||||
<p className="mt-4 text-ink/70 leading-relaxed">
|
||||
Der Link ist möglicherweise veraltet, oder die Seite wurde verschoben. Im Shop findest du
|
||||
garantiert, wonach du suchst.
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<LinkButton href="/shop">Zum Shop</LinkButton>
|
||||
<LinkButton href="/" variant="secondary">
|
||||
Zur Startseite
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user