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:
1 parent
9e8da18875
commit
04cc3061cf
13 files changed
+344
-215
No files matched your search
@@ -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–<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
// Footer wurde nach @/components/site-footer.tsx ausgelagert (Server
|
||||
// Component statt Teil dieser "use client"-Datei) — siehe Kommentar dort.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user