Files
Wabenhain/src/components/site-footer.tsx
T
Nick SzaunigandClaude Sonnet 5 04cc3061cf 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]>
2026-09-04 09:07:37 +02:00

192 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}