diff --git a/prisma/migrations/20260904120000_add_performance_indexes/migration.sql b/prisma/migrations/20260904120000_add_performance_indexes/migration.sql
new file mode 100644
index 0000000..15f6738
--- /dev/null
+++ b/prisma/migrations/20260904120000_add_performance_indexes/migration.sql
@@ -0,0 +1,35 @@
+-- CreateIndex
+-- "Meine Bestellungen" (Konto) sowie der Datenexport filtern nach customerId
+-- und sortieren nach createdAt absteigend — ein zusammengesetzter Index
+-- bedient beides in einem Indexzugriff statt eines vollen Tabellenscans.
+CREATE INDEX "orders_customerId_createdAt_idx" ON "orders"("customerId", "createdAt");
+
+-- CreateIndex
+-- Statusfilter in der Admin-Bestellliste (exakte Gleichheit) und Basis fuer
+-- die Status-Auswertungen im Admin-Dashboard.
+CREATE INDEX "orders_status_idx" ON "orders"("status");
+
+-- CreateIndex
+-- Datumsbereichs-Filter im Admin-Dashboard (Tages-/Monatsumsatz, Umsatztrend)
+-- sowie Standardsortierung in Bestelllisten ohne weiteren Filter.
+CREATE INDEX "orders_createdAt_idx" ON "orders"("createdAt");
+
+-- CreateIndex
+-- Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach
+-- discountCodeId + status).
+CREATE INDEX "orders_discountCodeId_idx" ON "orders"("discountCodeId");
+
+-- CreateIndex
+-- Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
+-- Rechnung, Datenexport) ueber die order.items-Relation abgefragt.
+CREATE INDEX "order_items_orderId_idx" ON "order_items"("orderId");
+
+-- CreateIndex
+-- Adressliste und Standardadresse-Update im Kundenkonto filtern nach
+-- customerId.
+CREATE INDEX "addresses_customerId_idx" ON "addresses"("customerId");
+
+-- CreateIndex
+-- Admin-Audit-Log-Seite sortiert eine unbegrenzt wachsende Tabelle nach
+-- createdAt absteigend (ORDER BY + LIMIT ohne weiteren Filter).
+CREATE INDEX "audit_logs_createdAt_idx" ON "audit_logs"("createdAt");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f3ed299..505ffd3 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -124,6 +124,8 @@ model Address {
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
+ /// Adressliste und Standardadresse-Update im Kundenkonto filtern nach customerId.
+ @@index([customerId])
@@map("addresses")
}
@@ -359,6 +361,16 @@ model Order {
invoice Invoice?
returnRequest ReturnRequest?
+ /// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
+ /// createdAt) als auch den reinen customerId-Filter beim Datenexport.
+ @@index([customerId, createdAt])
+ /// Statusfilter im Admin-Bestellungen-Bereich (exakte Gleichheit).
+ @@index([status])
+ /// Datumsbereichs-Filter (Tages-/Monatsumsatz, Umsatztrend im Admin-Dashboard)
+ /// sowie Standard-Sortierung in Bestelllisten ohne weiteren Filter.
+ @@index([createdAt])
+ /// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
+ @@index([discountCodeId])
@@map("orders")
}
@@ -403,6 +415,9 @@ model OrderItem {
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
+ /// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
+ /// Rechnung) ueber die order.items-Relation abgefragt.
+ @@index([orderId])
@@map("order_items")
}
@@ -598,6 +613,9 @@ model AuditLog {
admin Customer? @relation(fields: [adminId], references: [id], onDelete: SetNull)
+ /// Admin-Audit-Log-Seite sortiert unbegrenzt wachsende Tabelle nach
+ /// createdAt absteigend (ORDER BY + LIMIT ohne weiteren Filter).
+ @@index([createdAt])
@@map("audit_logs")
}
diff --git a/src/app/error.tsx b/src/app/error.tsx
index 9080629..f7231d6 100644
--- a/src/app/error.tsx
+++ b/src/app/error.tsx
@@ -1,34 +1,41 @@
"use client";
import { useEffect } from "react";
-import { Container, Button, LinkButton } from "@/components/ui";
+import { Container, SectionHeading, Button, LinkButton } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider";
-export default function Error({
- error,
- reset,
-}: {
- error: Error & { digest?: string };
- reset: () => void;
-}) {
+/**
+ * Next.js' Datei-Konvention fuer Laufzeitfehler beim Rendern einer Seite —
+ * ohne diese Datei zeigt Next.js in Produktion eine generische, unbrandete
+ * Fehlerseite. MUSS eine Client Component sein (Next.js-Vorgabe fuer
+ * error.tsx); rendert innerhalb desselben Layout-Baums wie die restliche
+ * Seite, useI18n() ist daher verfuegbar (I18nProvider sitzt im Root-Layout
+ * oberhalb, siehe app/layout.tsx).
+ */
+export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const { t } = useI18n();
useEffect(() => {
+ // Serverseitig geloggt Next.js Rendering-Fehler bereits automatisch;
+ // dieses Log dient nur der Sichtbarkeit im Browser fuer den Fall eines
+ // client-seitig ausgeloesten Fehlers.
console.error(error);
}, [error]);
return (
-
+
- {t.errorPage.eyebrow}
- {t.errorPage.title}
- {t.errorPage.description}
-
-
reset()}>
- {t.errorPage.retry}
-
+
+
{t.siteErrorPage.text}
+
+ {t.siteErrorPage.retry}
- {t.errorPage.backHome}
+ {t.siteErrorPage.backToHome}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 8d0070c..d76e14e 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,7 +1,8 @@
import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import "./globals.css";
-import { Header, Footer, CartDrawer } from "@/components/site-chrome";
+import { Header, CartDrawer } from "@/components/site-chrome";
+import { Footer } from "@/components/site-footer";
import { CookieConsentBanner } from "@/components/cookie-consent";
import { siteConfig } from "@/lib/site-config";
import { getCompanyInfo } from "@/lib/company-info";
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
index 5a67f06..fbc674e 100644
--- a/src/app/not-found.tsx
+++ b/src/app/not-found.tsx
@@ -1,22 +1,31 @@
import type { Metadata } from "next";
-import { Container, LinkButton } from "@/components/ui";
+import { Container, SectionHeading, LinkButton } from "@/components/ui";
+import { getLocale } from "@/i18n/locale";
+import { getDictionary } from "@/i18n/get-dictionary";
-export const metadata: Metadata = { title: "Seite nicht gefunden", robots: { index: false } };
+export const metadata: Metadata = { title: "Seite nicht gefunden" };
-export default function NotFound() {
+/**
+ * Next.js' Datei-Konvention fuer nicht gefundene Routen (notFound()-Aufrufe
+ * und unbekannte Pfade) — ohne diese Datei zeigt Next.js eine generische,
+ * unbrandete 404-Seite statt einer zur Seite passenden Nutzerfuehrung.
+ */
+export default async function NotFound() {
+ const t = getDictionary(await getLocale());
return (
-
+
- 404
- Diese Seite gibt es hier nicht.
-
- Der Link ist möglicherweise veraltet, oder die Seite wurde verschoben. Im Shop findest du
- garantiert, wonach du suchst.
-
-
-
Zum Shop
+
+
{t.notFoundPage.text}
+
+ {t.notFoundPage.backToShop}
- Zur Startseite
+ {t.notFoundPage.backToHome}
diff --git a/src/components/site-chrome.tsx b/src/components/site-chrome.tsx
index 3d07054..3166342 100644
--- a/src/components/site-chrome.tsx
+++ b/src/components/site-chrome.tsx
@@ -1,11 +1,11 @@
"use client";
import Link from "next/link";
-import { useState, useEffect, useRef, type RefObject, type ReactNode } from "react";
+import { useState, useEffect, useRef, type RefObject } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
import { formatPrice } from "@/lib/format";
-import { mainNav, footerNav } from "@/lib/site-config";
+import { mainNav } from "@/lib/site-config";
import { Container, LinkButton } from "@/components/ui";
import { Logomark } from "@/components/logomark";
import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
@@ -512,180 +512,5 @@ export function CartDrawer() {
);
}
-// Erstes vollstaendiges Geschaeftsjahr — Startpunkt der im Footer
-// angezeigten Copyright-Spanne "2023–
". Das Endjahr wird
-// bewusst NICHT gespeichert, sondern bei jedem Render live aus
-// new Date().getFullYear() berechnet, damit es nie per Hand nachgepflegt
-// werden muss.
-const FOUNDING_YEAR = 2023;
-
-const SOCIAL_ICON_PROPS = { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.5 } as const;
-
-function TikTokIcon() {
- return (
-
-
-
-
- );
-}
-
-function YouTubeIcon() {
- return (
-
-
-
-
- );
-}
-
-function InstagramIcon() {
- return (
-
-
-
-
-
- );
-}
-
-interface SocialLinks {
- tiktok: string;
- youtube: string;
- instagram: string;
-}
-
-/**
- * Ein Icon+Name-Link wird nur gerendert, wenn im Admin-Bereich (Inhalte ->
- * Social Media) tatsaechlich eine URL hinterlegt ist — nie ein toter/
- * Platzhalter-Link (siehe getSocialLinks()/content-registry.ts).
- */
-function SocialLink({
- href,
- name,
- icon,
-}: {
- href: string;
- /** Plattformname (Eigenname, z.B. "TikTok") — unlokalisiert, dient als sichtbarer Linktext. */
- name: string;
- icon: ReactNode;
-}) {
- const { t } = useI18n();
- return (
-
- {icon}
- {name}
-
- );
-}
-
-export function Footer({
- companyInfo,
- socialLinks,
-}: {
- companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
- socialLinks: SocialLinks;
-}) {
- const { t } = useI18n();
- const hasAnySocialLink = socialLinks.tiktok || socialLinks.youtube || socialLinks.instagram;
-
- const wabenhainLabels: Record<(typeof footerNav.wabenhain)[number]["key"], string> = {
- aboutUs: t.footer.aboutUs,
- origin: t.nav.origin,
- harvests: t.nav.harvests,
- contact: t.nav.contact,
- };
- const einkaufenLabels: Record<(typeof footerNav.einkaufen)[number]["key"], string> = {
- shop: t.nav.shop,
- businessCustomers: t.footer.businessCustomers,
- trackOrder: t.footer.trackOrder,
- };
- const rechtlichesLabels: Record<(typeof footerNav.rechtliches)[number]["key"], string> = {
- impressum: t.legalTitles.impressum,
- datenschutz: t.legalTitles.datenschutz,
- agb: t.legalTitles.agb,
- widerruf: t.legalTitles.widerruf,
- versandZahlung: t.legalTitles.versandZahlung,
- cookieRichtlinie: t.legalTitles.cookieRichtlinie,
- };
-
- return (
-
-
-
-
-
-
-
- {companyInfo.name}
-
-
-
{t.footer.tagline}
-
- {companyInfo.address.street}
-
- {companyInfo.address.zip} {companyInfo.address.city}
-
- {companyInfo.email}
-
-
-
-
-
-
-
-
-
- © {FOUNDING_YEAR}–{new Date().getFullYear()} {companyInfo.name}
-
- {hasAnySocialLink ? (
-
- {socialLinks.tiktok ? (
- } />
- ) : null}
- {socialLinks.youtube ? (
- } />
- ) : null}
- {socialLinks.instagram ? (
- } />
- ) : null}
-
- ) : null}
-
-
{t.footer.bottomLine}
-
-
-
- );
-}
-
-function FooterColumn({
- title,
- links,
- labels,
-}: {
- title: string;
- links: readonly { href: string; key: string }[];
- labels: Record;
-}) {
- return (
-
-
{title}
-
- {links.map((link) => (
-
-
- {labels[link.key]}
-
-
- ))}
-
-
- );
-}
+// Footer wurde nach @/components/site-footer.tsx ausgelagert (Server
+// Component statt Teil dieser "use client"-Datei) — siehe Kommentar dort.
diff --git a/src/components/site-footer.tsx b/src/components/site-footer.tsx
new file mode 100644
index 0000000..923667c
--- /dev/null
+++ b/src/components/site-footer.tsx
@@ -0,0 +1,191 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+import { Container } from "@/components/ui";
+import { Logomark } from "@/components/logomark";
+import { footerNav } from "@/lib/site-config";
+import type { SocialLinks } from "@/lib/social-links";
+import { getDictionary } from "@/i18n/get-dictionary";
+import { getLocale } from "@/i18n/locale";
+import type { Dictionary } from "@/i18n/dictionary-type";
+
+// Erstes vollstaendiges Geschaeftsjahr — Startpunkt der im Footer
+// angezeigten Copyright-Spanne "2023–". Das Endjahr wird
+// bewusst NICHT gespeichert, sondern bei jedem Render live aus
+// new Date().getFullYear() berechnet, damit es nie per Hand nachgepflegt
+// werden muss.
+const FOUNDING_YEAR = 2023;
+
+const SOCIAL_ICON_PROPS = { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.5 } as const;
+
+function TikTokIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function YouTubeIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function InstagramIcon() {
+ return (
+
+
+
+
+
+ );
+}
+
+/**
+ * Ein Icon+Name-Link wird nur gerendert, wenn im Admin-Bereich (Inhalte ->
+ * Social Media) tatsaechlich eine URL hinterlegt ist — nie ein toter/
+ * Platzhalter-Link (siehe getSocialLinks()/content-registry.ts).
+ */
+function SocialLink({
+ href,
+ name,
+ icon,
+ t,
+}: {
+ href: string;
+ /** Plattformname (Eigenname, z.B. "TikTok") — unlokalisiert, dient als sichtbarer Linktext. */
+ name: string;
+ icon: ReactNode;
+ t: Dictionary;
+}) {
+ return (
+
+ {icon}
+ {name}
+
+ );
+}
+
+/**
+ * Server Component (bewusst NICHT Teil von site-chrome.tsx/"use client"):
+ * der Footer ist bis auf die Uebersetzungen rein statisches Markup, das auf
+ * jeder Seite gerendert wird. Statt useI18n() (Client-Hook) wird die
+ * Uebersetzung wie bei anderen Server Components direkt per
+ * getDictionary(await getLocale()) aufgeloest — dadurch verlaesst dieser
+ * Block das Client-JS-Bundle vollstaendig (siehe Kommentar in
+ * i18n/i18n-provider.tsx zur RSC-Serialisierungsgrenze).
+ */
+export async function Footer({
+ companyInfo,
+ socialLinks,
+}: {
+ companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
+ socialLinks: SocialLinks;
+}) {
+ const t = getDictionary(await getLocale());
+ const hasAnySocialLink = socialLinks.tiktok || socialLinks.youtube || socialLinks.instagram;
+
+ const wabenhainLabels: Record<(typeof footerNav.wabenhain)[number]["key"], string> = {
+ aboutUs: t.footer.aboutUs,
+ origin: t.nav.origin,
+ harvests: t.nav.harvests,
+ contact: t.nav.contact,
+ };
+ const einkaufenLabels: Record<(typeof footerNav.einkaufen)[number]["key"], string> = {
+ shop: t.nav.shop,
+ businessCustomers: t.footer.businessCustomers,
+ trackOrder: t.footer.trackOrder,
+ };
+ const rechtlichesLabels: Record<(typeof footerNav.rechtliches)[number]["key"], string> = {
+ impressum: t.legalTitles.impressum,
+ datenschutz: t.legalTitles.datenschutz,
+ agb: t.legalTitles.agb,
+ widerruf: t.legalTitles.widerruf,
+ versandZahlung: t.legalTitles.versandZahlung,
+ cookieRichtlinie: t.legalTitles.cookieRichtlinie,
+ };
+
+ return (
+
+
+
+
+
+
+
+ {companyInfo.name}
+
+
+
{t.footer.tagline}
+
+ {companyInfo.address.street}
+
+ {companyInfo.address.zip} {companyInfo.address.city}
+
+ {companyInfo.email}
+
+
+
+
+
+
+
+
+
+ © {FOUNDING_YEAR}–{new Date().getFullYear()} {companyInfo.name}
+
+ {hasAnySocialLink ? (
+
+ {socialLinks.tiktok ? (
+ } t={t} />
+ ) : null}
+ {socialLinks.youtube ? (
+ } t={t} />
+ ) : null}
+ {socialLinks.instagram ? (
+ } t={t} />
+ ) : null}
+
+ ) : null}
+
+
{t.footer.bottomLine}
+
+
+
+ );
+}
+
+function FooterColumn({
+ title,
+ links,
+ labels,
+}: {
+ title: string;
+ links: readonly { href: string; key: string }[];
+ labels: Record;
+}) {
+ return (
+
+
{title}
+
+ {links.map((link) => (
+
+
+ {labels[link.key]}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts
index c8dcb78..d9cf0f6 100644
--- a/src/i18n/dictionaries/de.ts
+++ b/src/i18n/dictionaries/de.ts
@@ -149,6 +149,20 @@ export const de: Dictionary = {
legalPage: {
reviewNotice: "[PLATZHALTER — vor Veröffentlichung rechtlich prüfen lassen]",
},
+ notFoundPage: {
+ eyebrow: "404",
+ title: "Diese Seite gibt es nicht — zumindest nicht mehr.",
+ text: "Der Link ist vielleicht veraltet, oder die Seite wurde verschoben. Schau dich im Shop um oder geh zurück zur Startseite.",
+ backToShop: "Zum Shop",
+ backToHome: "Zur Startseite",
+ },
+ siteErrorPage: {
+ eyebrow: "Fehler",
+ title: "Da ist etwas schiefgelaufen.",
+ text: "Das war ein technischer Fehler auf unserer Seite, nicht deine Schuld. Versuch es noch einmal, oder komm später wieder vorbei.",
+ retry: "Erneut versuchen",
+ backToHome: "Zur Startseite",
+ },
cookieConsent: {
dialogLabel: "Cookie-Einstellungen",
bannerTextBeforeLink:
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
index a50f9ba..1634882 100644
--- a/src/i18n/dictionaries/en.ts
+++ b/src/i18n/dictionaries/en.ts
@@ -149,6 +149,20 @@ export const en: Dictionary = {
legalPage: {
reviewNotice: "[PLACEHOLDER — must be reviewed by a lawyer before publication]",
},
+ notFoundPage: {
+ eyebrow: "404",
+ title: "This page doesn't exist — at least not anymore.",
+ text: "The link might be outdated, or the page has moved. Take a look around the shop, or head back to the homepage.",
+ backToShop: "Go to shop",
+ backToHome: "Back to homepage",
+ },
+ siteErrorPage: {
+ eyebrow: "Error",
+ title: "Something went wrong.",
+ text: "That was a technical error on our end, not something you did. Try again, or come back a bit later.",
+ retry: "Try again",
+ backToHome: "Back to homepage",
+ },
cookieConsent: {
dialogLabel: "Cookie settings",
bannerTextBeforeLink:
diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts
index d3eb8c1..82a46e4 100644
--- a/src/i18n/dictionary-type.ts
+++ b/src/i18n/dictionary-type.ts
@@ -172,6 +172,20 @@ export interface Dictionary {
legalPage: {
reviewNotice: string;
};
+ notFoundPage: {
+ eyebrow: string;
+ title: string;
+ text: string;
+ backToShop: string;
+ backToHome: string;
+ };
+ siteErrorPage: {
+ eyebrow: string;
+ title: string;
+ text: string;
+ retry: string;
+ backToHome: string;
+ };
cookieConsent: {
dialogLabel: string;
bannerTextBeforeLink: string;
diff --git a/src/lib/content-registry.ts b/src/lib/content-registry.ts
index 228ab24..8c844fa 100644
--- a/src/lib/content-registry.ts
+++ b/src/lib/content-registry.ts
@@ -68,7 +68,7 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
- // angezeigt, siehe components/site-chrome.tsx Footer()) ---
+ // angezeigt, siehe components/site-footer.tsx Footer()) ---
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
{ key: "social.youtube", group: "social", labelDe: "YouTube-Kanal (URL)", labelEn: "YouTube channel (URL)", type: "text", localized: false, getDefault: () => "" },
{ key: "social.instagram", group: "social", labelDe: "Instagram-Profil (URL)", labelEn: "Instagram profile (URL)", type: "text", localized: false, getDefault: () => "" },
diff --git a/src/lib/site-config.ts b/src/lib/site-config.ts
index de72187..8e5b85e 100644
--- a/src/lib/site-config.ts
+++ b/src/lib/site-config.ts
@@ -29,9 +29,10 @@ export const siteConfig = {
} as const;
// "key" verweist auf i18n/dictionary-type.ts (nav.* bzw. footer.*/legalTitles.*)
-// statt eines fest verdrahteten Labels — site-chrome.tsx loest den Text zur
-// Laufzeit aus der aktiven Dictionary auf, damit Navigation und Footer mit
-// der Sprachumschaltung mitziehen.
+// statt eines fest verdrahteten Labels — site-chrome.tsx (Header/CartDrawer)
+// bzw. site-footer.tsx (Footer) loesen den Text zur Laufzeit aus der aktiven
+// Dictionary auf, damit Navigation und Footer mit der Sprachumschaltung
+// mitziehen.
export const mainNav = [
{ href: "/shop", key: "shop" },
{ href: "/ernten", key: "harvests" },
diff --git a/src/lib/social-links.ts b/src/lib/social-links.ts
index e8e003c..1625456 100644
--- a/src/lib/social-links.ts
+++ b/src/lib/social-links.ts
@@ -12,7 +12,7 @@ export interface SocialLinks {
* Admin-gepflegte Social-Media-Links (siehe admin/inhalte, Gruppe "Social
* Media") — nicht lokalisiert, wie Firmendaten (company-info.ts). Ein leerer
* String bedeutet "kein Profil hinterlegt"; der Footer rendert dann kein
- * Icon fuer diese Plattform (siehe components/site-chrome.tsx Footer()).
+ * Icon fuer diese Plattform (siehe components/site-footer.tsx Footer()).
*/
export async function getSocialLinks(): Promise {
const v = await getContentValues([...SOCIAL_KEYS], "de");