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]>
192 lines
6.7 KiB
TypeScript
192 lines
6.7 KiB
TypeScript
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>
|
||
);
|
||
}
|