perf: DB-Indizes, Footer als Server Component, Site-weite Fehlerseiten

Datenbank (prisma/migrations/20260904120000_add_performance_indexes):
7 fehlende Indizes auf Fremdschluessel-/Filter-Spalten ergaenzt
(Order.customerId+createdAt, Order.status, Order.createdAt,
Order.discountCodeId, OrderItem.orderId, Address.customerId,
AuditLog.createdAt) — bislang volle Tabellenscans bei Bestelluebersicht,
Admin-Dashboard, Checkout-Gutscheinpruefung und Audit-Log.

Bundle-Groesse: Footer (TikTok/YouTube/Instagram-Icons, Copyright-Zeile)
aus dem client-seitigen site-chrome.tsx in eine eigene Server Component
(site-footer.tsx) ausgelagert — brauchte "use client" nur wegen
useI18n(), jetzt getDictionary()-Server-Aufruf wie auf jeder Admin-Seite.
Entfernt ungenutztes clientseitiges JS/Hydration aus jedem Seitenaufruf
(Footer steht im Root-Layout).

UX: app/not-found.tsx und app/error.tsx ergaenzt (Next.js-Konventionen
fuer 404 bzw. Laufzeitfehler) — beide fehlten komplett, unbekannte Routen
und Rendering-Fehler zeigten bisher Next.js' generische, unbrandete
Standardseiten statt einer zur Seite passenden Nutzerfuehrung.
Dictionary-Kollision dabei aufgefallen und aufgeloest: eine bereits
bestehende, admin-interne errorPage-Uebersetzung (src/app/admin/error.tsx,
seit dem urspruenglichen App-Grundgeruest vorhanden) blieb unangetastet,
die neue site-weite Fehlerseite nutzt einen eigenen Schluessel
(siteErrorPage).

Verifiziert: tsc --noEmit clean, 118/118 Tests gruen, pnpm build clean.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 09:07:37 +02:00
1 parent 9e8da18875
commit 04cc3061cf
13 files changed
+344 -215

No files matched your search

@@ -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");
+18
View File
@@ -124,6 +124,8 @@ model Address {
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// Adressliste und Standardadresse-Update im Kundenkonto filtern nach customerId.
@@index([customerId])
@@map("addresses") @@map("addresses")
} }
@@ -359,6 +361,16 @@ model Order {
invoice Invoice? invoice Invoice?
returnRequest ReturnRequest? 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") @@map("orders")
} }
@@ -403,6 +415,9 @@ model OrderItem {
product Product @relation(fields: [productId], references: [id], onDelete: Restrict) product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], 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") @@map("order_items")
} }
@@ -598,6 +613,9 @@ model AuditLog {
admin Customer? @relation(fields: [adminId], references: [id], onDelete: SetNull) 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") @@map("audit_logs")
} }
+24 -17
View File
@@ -1,34 +1,41 @@
"use client"; "use client";
import { useEffect } from "react"; 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"; import { useI18n } from "@/i18n/i18n-provider";
export default function Error({ /**
error, * Next.js' Datei-Konvention fuer Laufzeitfehler beim Rendern einer Seite —
reset, * ohne diese Datei zeigt Next.js in Produktion eine generische, unbrandete
}: { * Fehlerseite. MUSS eine Client Component sein (Next.js-Vorgabe fuer
error: Error & { digest?: string }; * error.tsx); rendert innerhalb desselben Layout-Baums wie die restliche
reset: () => void; * 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(); const { t } = useI18n();
useEffect(() => { 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); console.error(error);
}, [error]); }, [error]);
return ( return (
<div className="py-32"> <div className="py-24">
<Container className="max-w-xl text-center"> <Container className="max-w-xl text-center">
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p> <SectionHeading
<h1 className="text-3xl md:text-4xl text-forest">{t.errorPage.title}</h1> eyebrow={t.siteErrorPage.eyebrow}
<p className="mt-4 text-ink/70 leading-relaxed">{t.errorPage.description}</p> title={t.siteErrorPage.title}
<div className="mt-8 flex items-center justify-center gap-4"> align="center"
<Button type="button" onClick={() => reset()}> className="mx-auto"
{t.errorPage.retry} />
</Button> <p className="mt-6 text-ink/70">{t.siteErrorPage.text}</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<Button onClick={reset}>{t.siteErrorPage.retry}</Button>
<LinkButton href="/" variant="secondary"> <LinkButton href="/" variant="secondary">
{t.errorPage.backHome} {t.siteErrorPage.backToHome}
</LinkButton> </LinkButton>
</div> </div>
</Container> </Container>
+2 -1
View File
@@ -1,7 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google"; import { Cormorant_Garamond, Inter } from "next/font/google";
import "./globals.css"; 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 { CookieConsentBanner } from "@/components/cookie-consent";
import { siteConfig } from "@/lib/site-config"; import { siteConfig } from "@/lib/site-config";
import { getCompanyInfo } from "@/lib/company-info"; import { getCompanyInfo } from "@/lib/company-info";
+22 -13
View File
@@ -1,22 +1,31 @@
import type { Metadata } from "next"; 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 ( return (
<div className="py-32"> <div className="py-24">
<Container className="max-w-xl text-center"> <Container className="max-w-xl text-center">
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">404</p> <SectionHeading
<h1 className="text-3xl md:text-4xl text-forest">Diese Seite gibt es hier nicht.</h1> eyebrow={t.notFoundPage.eyebrow}
<p className="mt-4 text-ink/70 leading-relaxed"> title={t.notFoundPage.title}
Der Link ist möglicherweise veraltet, oder die Seite wurde verschoben. Im Shop findest du align="center"
garantiert, wonach du suchst. className="mx-auto"
</p> />
<div className="mt-8 flex items-center justify-center gap-4"> <p className="mt-6 text-ink/70">{t.notFoundPage.text}</p>
<LinkButton href="/shop">Zum Shop</LinkButton> <div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<LinkButton href="/shop">{t.notFoundPage.backToShop}</LinkButton>
<LinkButton href="/" variant="secondary"> <LinkButton href="/" variant="secondary">
Zur Startseite {t.notFoundPage.backToHome}
</LinkButton> </LinkButton>
</div> </div>
</Container> </Container>
+4 -179
View File
@@ -1,11 +1,11 @@
"use client"; "use client";
import Link from "next/link"; 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 { AnimatePresence, motion } from "motion/react";
import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store"; import { useCartStore, cartItemCount, cartTotalCents } from "@/store/cart-store";
import { formatPrice } from "@/lib/format"; 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 { Container, LinkButton } from "@/components/ui";
import { Logomark } from "@/components/logomark"; import { Logomark } from "@/components/logomark";
import { CartItemThumbnail } from "@/components/cart-item-thumbnail"; import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
@@ -512,180 +512,5 @@ export function CartDrawer() {
); );
} }
// Erstes vollstaendiges Geschaeftsjahr — Startpunkt der im Footer // Footer wurde nach @/components/site-footer.tsx ausgelagert (Server
// angezeigten Copyright-Spanne "2023–<aktuelles Jahr>". Das Endjahr wird // Component statt Teil dieser "use client"-Datei) — siehe Kommentar dort.
// 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 (
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
<path d="M14 4v10.5a3.5 3.5 0 1 1-3.5-3.5" />
<path d="M14 4c.3 1.8 1.6 3.6 4 4" />
</svg>
);
}
function YouTubeIcon() {
return (
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
<rect x="2" y="5" width="20" height="14" rx="4" />
<path d="M10 9l6 3-6 3V9z" fill="currentColor" stroke="none" />
</svg>
);
}
function InstagramIcon() {
return (
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.5" cy="6.5" r="0.75" fill="currentColor" stroke="none" />
</svg>
);
}
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 (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={t.footer.socialAriaLabel(name)}
className="flex items-center gap-1.5 text-cream/70 hover:text-cream transition-colors"
>
{icon}
<span className="text-xs">{name}</span>
</a>
);
}
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 (
<footer className="bg-forest text-cream mt-24">
<Container className="py-16">
<div className="grid grid-cols-2 md:grid-cols-5 gap-10">
<div className="col-span-2">
<div className="flex items-center gap-3">
<Logomark className="h-9 w-9" variant="inverted" />
<span className="text-2xl tracking-[0.2em] font-serif">
{companyInfo.name}
</span>
</div>
<p className="mt-3 text-cream/70 text-sm max-w-xs">{t.footer.tagline}</p>
<p className="mt-6 text-cream/60 text-sm">
{companyInfo.address.street}
<br />
{companyInfo.address.zip} {companyInfo.address.city}
<br />
{companyInfo.email}
</p>
</div>
<FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} />
<FooterColumn title={t.footer.columnShopping} links={footerNav.einkaufen} labels={einkaufenLabels} />
<FooterColumn title={t.footer.columnLegal} links={footerNav.rechtliches} labels={rechtlichesLabels} />
</div>
<div className="mt-16 pt-8 border-t border-cream/15 flex flex-col md:flex-row md:items-center justify-between gap-4 text-xs text-cream/70">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<span>
© {FOUNDING_YEAR}–{new Date().getFullYear()} {companyInfo.name}
</span>
{hasAnySocialLink ? (
<div className="flex items-center gap-4">
{socialLinks.tiktok ? (
<SocialLink href={socialLinks.tiktok} name="TikTok" icon={<TikTokIcon />} />
) : null}
{socialLinks.youtube ? (
<SocialLink href={socialLinks.youtube} name="YouTube" icon={<YouTubeIcon />} />
) : null}
{socialLinks.instagram ? (
<SocialLink href={socialLinks.instagram} name="Instagram" icon={<InstagramIcon />} />
) : null}
</div>
) : null}
</div>
<span>{t.footer.bottomLine}</span>
</div>
</Container>
</footer>
);
}
function FooterColumn({
title,
links,
labels,
}: {
title: string;
links: readonly { href: string; key: string }[];
labels: Record<string, string>;
}) {
return (
<div>
<p className="tracking-wide-caps text-xs text-honey-light mb-4">{title}</p>
<ul className="space-y-2">
{links.map((link) => (
<li key={link.key}>
<Link href={link.href} className="text-sm text-cream/75 hover:text-cream">
{labels[link.key]}
</Link>
</li>
))}
</ul>
</div>
);
}
+191
View File
@@ -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–<aktuelles Jahr>". 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 (
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
<path d="M14 4v10.5a3.5 3.5 0 1 1-3.5-3.5" />
<path d="M14 4c.3 1.8 1.6 3.6 4 4" />
</svg>
);
}
function YouTubeIcon() {
return (
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
<rect x="2" y="5" width="20" height="14" rx="4" />
<path d="M10 9l6 3-6 3V9z" fill="currentColor" stroke="none" />
</svg>
);
}
function InstagramIcon() {
return (
<svg {...SOCIAL_ICON_PROPS} aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.5" cy="6.5" r="0.75" fill="currentColor" stroke="none" />
</svg>
);
}
/**
* 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 (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={t.footer.socialAriaLabel(name)}
className="flex items-center gap-1.5 text-cream/70 hover:text-cream transition-colors"
>
{icon}
<span className="text-xs">{name}</span>
</a>
);
}
/**
* 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 (
<footer className="bg-forest text-cream mt-24">
<Container className="py-16">
<div className="grid grid-cols-2 md:grid-cols-5 gap-10">
<div className="col-span-2">
<div className="flex items-center gap-3">
<Logomark className="h-9 w-9" variant="inverted" />
<span className="text-2xl tracking-[0.2em] font-serif">
{companyInfo.name}
</span>
</div>
<p className="mt-3 text-cream/70 text-sm max-w-xs">{t.footer.tagline}</p>
<p className="mt-6 text-cream/60 text-sm">
{companyInfo.address.street}
<br />
{companyInfo.address.zip} {companyInfo.address.city}
<br />
{companyInfo.email}
</p>
</div>
<FooterColumn title={t.footer.columnWabenhain} links={footerNav.wabenhain} labels={wabenhainLabels} />
<FooterColumn title={t.footer.columnShopping} links={footerNav.einkaufen} labels={einkaufenLabels} />
<FooterColumn title={t.footer.columnLegal} links={footerNav.rechtliches} labels={rechtlichesLabels} />
</div>
<div className="mt-16 pt-8 border-t border-cream/15 flex flex-col md:flex-row md:items-center justify-between gap-4 text-xs text-cream/70">
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<span>
© {FOUNDING_YEAR}–{new Date().getFullYear()} {companyInfo.name}
</span>
{hasAnySocialLink ? (
<div className="flex items-center gap-4">
{socialLinks.tiktok ? (
<SocialLink href={socialLinks.tiktok} name="TikTok" icon={<TikTokIcon />} t={t} />
) : null}
{socialLinks.youtube ? (
<SocialLink href={socialLinks.youtube} name="YouTube" icon={<YouTubeIcon />} t={t} />
) : null}
{socialLinks.instagram ? (
<SocialLink href={socialLinks.instagram} name="Instagram" icon={<InstagramIcon />} t={t} />
) : null}
</div>
) : null}
</div>
<span>{t.footer.bottomLine}</span>
</div>
</Container>
</footer>
);
}
function FooterColumn({
title,
links,
labels,
}: {
title: string;
links: readonly { href: string; key: string }[];
labels: Record<string, string>;
}) {
return (
<div>
<p className="tracking-wide-caps text-xs text-honey-light mb-4">{title}</p>
<ul className="space-y-2">
{links.map((link) => (
<li key={link.key}>
<Link href={link.href} className="text-sm text-cream/75 hover:text-cream">
{labels[link.key]}
</Link>
</li>
))}
</ul>
</div>
);
}
+14
View File
@@ -149,6 +149,20 @@ export const de: Dictionary = {
legalPage: { legalPage: {
reviewNotice: "[PLATZHALTER — vor Veröffentlichung rechtlich prüfen lassen]", 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: { cookieConsent: {
dialogLabel: "Cookie-Einstellungen", dialogLabel: "Cookie-Einstellungen",
bannerTextBeforeLink: bannerTextBeforeLink:
+14
View File
@@ -149,6 +149,20 @@ export const en: Dictionary = {
legalPage: { legalPage: {
reviewNotice: "[PLACEHOLDER — must be reviewed by a lawyer before publication]", 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: { cookieConsent: {
dialogLabel: "Cookie settings", dialogLabel: "Cookie settings",
bannerTextBeforeLink: bannerTextBeforeLink:
+14
View File
@@ -172,6 +172,20 @@ export interface Dictionary {
legalPage: { legalPage: {
reviewNotice: string; 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: { cookieConsent: {
dialogLabel: string; dialogLabel: string;
bannerTextBeforeLink: string; bannerTextBeforeLink: string;
+1 -1
View File
@@ -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 }, { 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 // --- 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.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.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: () => "" }, { key: "social.instagram", group: "social", labelDe: "Instagram-Profil (URL)", labelEn: "Instagram profile (URL)", type: "text", localized: false, getDefault: () => "" },
+4 -3
View File
@@ -29,9 +29,10 @@ export const siteConfig = {
} as const; } as const;
// "key" verweist auf i18n/dictionary-type.ts (nav.* bzw. footer.*/legalTitles.*) // "key" verweist auf i18n/dictionary-type.ts (nav.* bzw. footer.*/legalTitles.*)
// statt eines fest verdrahteten Labels — site-chrome.tsx loest den Text zur // statt eines fest verdrahteten Labels — site-chrome.tsx (Header/CartDrawer)
// Laufzeit aus der aktiven Dictionary auf, damit Navigation und Footer mit // bzw. site-footer.tsx (Footer) loesen den Text zur Laufzeit aus der aktiven
// der Sprachumschaltung mitziehen. // Dictionary auf, damit Navigation und Footer mit der Sprachumschaltung
// mitziehen.
export const mainNav = [ export const mainNav = [
{ href: "/shop", key: "shop" }, { href: "/shop", key: "shop" },
{ href: "/ernten", key: "harvests" }, { href: "/ernten", key: "harvests" },
+1 -1
View File
@@ -12,7 +12,7 @@ export interface SocialLinks {
* Admin-gepflegte Social-Media-Links (siehe admin/inhalte, Gruppe "Social * Admin-gepflegte Social-Media-Links (siehe admin/inhalte, Gruppe "Social
* Media") — nicht lokalisiert, wie Firmendaten (company-info.ts). Ein leerer * Media") — nicht lokalisiert, wie Firmendaten (company-info.ts). Ein leerer
* String bedeutet "kein Profil hinterlegt"; der Footer rendert dann kein * 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<SocialLinks> { export async function getSocialLinks(): Promise<SocialLinks> {
const v = await getContentValues([...SOCIAL_KEYS], "de"); const v = await getContentValues([...SOCIAL_KEYS], "de");