feat: Wabenhain Honig-Shop Anwendung

Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei:
Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich
(Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare
Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung,
PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma-
Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
import Link from "next/link";
import type { ReactNode } from "react";
/**
* Verlinkte Karte fuer die Konto-Uebersicht (Bestellungen, Adressen, ...).
* Ersetzt den zuvor identischen, icon-losen "border + hover:border-forest"-
* Kartenstil, der auf jeder Konto-Seite gleich aussah.
*/
export function AccountNavCard({
href,
icon,
title,
description,
accent = false,
className = "",
}: {
href: string;
icon: ReactNode;
title: string;
description: string;
accent?: boolean;
className?: string;
}) {
return (
<Link
href={href}
className={`group flex items-start gap-4 border p-6 transition-all duration-300 hover:-translate-y-0.5 ${
accent
? "border-honey/40 bg-honey/5 hover:border-honey hover:shadow-[0_16px_32px_-20px_rgba(184,138,68,0.35)]"
: "border-ink/10 hover:border-forest hover:shadow-[0_16px_32px_-20px_rgba(36,53,43,0.25)]"
} ${className}`}
>
<span
className={`flex-shrink-0 flex items-center justify-center w-11 h-11 border transition-colors ${
accent ? "border-honey/40 text-honey-dark" : "border-ink/15 text-forest"
}`}
>
{icon}
</span>
<span>
<span className="block text-lg text-forest">{title}</span>
<span className="block mt-1 text-sm text-ink/70">{description}</span>
</span>
</Link>
);
}
const ORDER_STATUS_STYLES: Record<string, string> = {
NEU: "border-ink/20 text-ink/70",
BEZAHLT: "border-honey/40 text-honey-dark bg-honey/5",
IN_VORBEREITUNG: "border-honey/40 text-honey-dark bg-honey/5",
VERSANDBEREIT: "border-honey/40 text-honey-dark bg-honey/5",
VERSENDET: "border-forest/40 text-forest bg-forest/5",
ABHOLBEREIT: "border-forest/40 text-forest bg-forest/5",
ABGEHOLT: "border-forest/40 text-forest bg-forest/5",
STORNIERT: "border-red-200 text-red-700 bg-red-50",
};
/** Farbcodiertes Status-Label statt reinem Fließtext, damit der Bestellstatus auf einen Blick erkennbar ist. */
export function OrderStatusBadge({ status, label }: { status: string; label: string }) {
const style = ORDER_STATUS_STYLES[status] ?? "border-ink/20 text-ink/70";
return (
<span className={`inline-flex items-center border px-2.5 py-1 text-xs tracking-wide-caps ${style}`}>
{label}
</span>
);
}
const VERSAND_STEP_KEYS = ["NEU", "BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "VERSENDET"] as const;
const ABHOLUNG_STEP_KEYS = ["NEU", "BEZAHLT", "IN_VORBEREITUNG", "ABHOLBEREIT", "ABGEHOLT"] as const;
export interface OrderStatusTimelineLabels {
ordered: string;
paid: string;
preparing: string;
readyToShip: string;
shipped: string;
readyForPickup: string;
pickedUp: string;
cancelled: string;
ariaLabel: string;
}
/**
* Visuelle Fortschrittsanzeige statt reinem Status-Badge — nimmt das
* Punkt/Linie-Muster der Chargen-Seite (app/charge/[code]) auf, damit
* Kundschaft auf einen Blick sieht, wie weit die Bestellung bereits ist,
* statt nur den aktuellen Einzelstatus als Wort zu lesen. Nimmt uebersetzte
* Labels als Prop entgegen (statt selbst zu uebersetzen), damit diese Datei
* server-sicher bleibt — sie wird auch von Admin-Server-Seiten importiert.
*/
export function OrderStatusTimeline({
status,
fulfillmentMethod,
labels,
}: {
status: string;
fulfillmentMethod: "VERSAND" | "ABHOLUNG";
labels: OrderStatusTimelineLabels;
}) {
if (status === "STORNIERT") {
return (
<div className="flex items-center gap-2 text-sm text-red-700">
<span className="w-2 h-2 rounded-full bg-red-500" aria-hidden="true" />
{labels.cancelled}
</div>
);
}
const stepLabels: Record<(typeof VERSAND_STEP_KEYS)[number] | (typeof ABHOLUNG_STEP_KEYS)[number], string> = {
NEU: labels.ordered,
BEZAHLT: labels.paid,
IN_VORBEREITUNG: labels.preparing,
VERSANDBEREIT: labels.readyToShip,
VERSENDET: labels.shipped,
ABHOLBEREIT: labels.readyForPickup,
ABGEHOLT: labels.pickedUp,
};
const stepKeys = fulfillmentMethod === "VERSAND" ? VERSAND_STEP_KEYS : ABHOLUNG_STEP_KEYS;
const steps = stepKeys.map((key) => ({ key, label: stepLabels[key] }));
const currentIndex = steps.findIndex((step) => step.key === status);
return (
<div className="flex items-start gap-x-1" role="list" aria-label={labels.ariaLabel}>
{steps.map((step, i) => {
const isDone = currentIndex >= 0 && i <= currentIndex;
const isLineDone = i < currentIndex;
return (
<div key={step.key} role="listitem" className="flex-1 min-w-0 relative">
<div className="flex items-center">
<span
className={`flex-shrink-0 w-2.5 h-2.5 rounded-full ${isDone ? "bg-forest" : "bg-ink/20"}`}
aria-hidden="true"
/>
{i < steps.length - 1 ? (
<span
className={`ml-1 h-px flex-1 ${isLineDone ? "bg-forest" : "bg-ink/15"}`}
aria-hidden="true"
/>
) : null}
</div>
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-forest" : "text-ink/45"}`}>
{step.label}
</p>
</div>
);
})}
</div>
);
}
/** Leerer-Zustand mit Icon statt eines einzelnen, unauffälligen Fließtext-Satzes. */
export function AccountEmptyState({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="mt-10 border border-dashed border-ink/15 px-8 py-14 text-center">
<span className="inline-flex items-center justify-center w-12 h-12 border border-ink/15 text-ink/40 mx-auto">
{icon}
</span>
<p className="mt-4 text-forest">{title}</p>
{description ? (
<p className="mt-1 text-sm text-ink/70 max-w-sm mx-auto">{description}</p>
) : null}
{action ? <div className="mt-6">{action}</div> : null}
</div>
);
}
const ICON_PROPS = {
width: 20,
height: 20,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
export function OrderIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M4 8l8-4 8 4-8 4-8-4z" />
<path d="M4 8v8l8 4 8-4V8" />
<path d="M12 12v8" />
</svg>
);
}
export function AddressIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M12 21s7-7.5 7-12a7 7 0 1 0-14 0c0 4.5 7 12 7 12z" />
<circle cx="12" cy="9" r="2.5" />
</svg>
);
}
export function HeartIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M12 20s-7-4.5-9.5-9A5 5 0 0 1 12 6a5 5 0 0 1 9.5 5c-2.5 4.5-9.5 9-9.5 9z" />
</svg>
);
}
export function ShieldIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6l7-3z" />
</svg>
);
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { Logomark } from "@/components/logomark";
import { useI18n } from "@/i18n/i18n-provider";
import type { Dictionary } from "@/i18n/dictionary-type";
function getAdminNav(t: Dictionary) {
return [
{ href: "/admin", label: t.adminShell.navDashboard },
{ href: "/admin/bestellungen", label: t.adminShell.navOrders },
{ href: "/admin/retouren", label: t.adminShell.navReturns },
{ href: "/admin/produkte", label: t.adminShell.navProducts },
{ href: "/admin/ernten", label: t.adminShell.navHarvests },
{ href: "/admin/chargen", label: t.adminShell.navBatches },
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
{ href: "/admin/versand", label: t.adminShell.navShipping },
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
{ href: "/admin/bewertungen", label: t.adminShell.navReviews },
{ href: "/admin/inhalte", label: t.adminShell.navContent },
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
];
}
function isActiveHref(pathname: string, href: string): boolean {
if (href === "/admin") return pathname === "/admin";
return pathname === href || pathname.startsWith(`${href}/`);
}
function NavLinks({
pathname,
adminNav,
onNavigate,
}: {
pathname: string;
adminNav: ReturnType<typeof getAdminNav>;
onNavigate?: () => void;
}) {
return (
<>
{adminNav.map((item) => {
const active = isActiveHref(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
aria-current={active ? "page" : undefined}
className={`block px-5 py-2.5 text-sm border-l-2 transition-colors ${
active
? "border-honey bg-cream/10 text-cream"
: "border-transparent text-cream/70 hover:text-cream hover:bg-cream/5"
}`}
>
{item.label}
</Link>
);
})}
</>
);
}
function Wordmark() {
return (
<span className="flex items-center gap-2">
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" />
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
</span>
);
}
interface AdminShellProps {
adminName: string;
logoutAction: () => void | Promise<void>;
children: ReactNode;
}
/**
* Sidebar-Layout fuer den Admin-Bereich statt der frueheren einzeiligen,
* horizontal scrollenden Navigation (11 Punkte passten auf typischen
* Bildschirmbreiten nicht nebeneinander). Ab lg persistente Sidebar links,
* darunter ein Vollbild-Overlay-Menue nach demselben Muster wie die mobile
* Hauptnavigation in site-chrome.tsx.
*/
export function AdminShell({ adminName, logoutAction, children }: AdminShellProps) {
const { t } = useI18n();
const adminNav = getAdminNav(t);
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<div className="min-h-screen bg-cream/40 lg:flex">
<aside className="hidden lg:flex lg:flex-col lg:w-60 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
<div className="flex items-center h-16 px-5 border-b border-cream/10">
<Wordmark />
</div>
<nav aria-label={t.adminShell.navAriaLabel} className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5">
<NavLinks pathname={pathname} adminNav={adminNav} />
</nav>
<div className="px-5 py-4 border-t border-cream/10">
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
<form action={logoutAction} className="mt-2">
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
{t.adminShell.logout}
</button>
</form>
</div>
</aside>
<div className="flex-1 min-w-0">
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
<Wordmark />
<button
aria-label={t.adminShell.openMenu}
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
onClick={() => setMobileOpen(true)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
</header>
<main className="max-w-6xl mx-auto px-6 py-10 overflow-x-hidden">
{children}
</main>
</div>
<AnimatePresence>
{mobileOpen ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="lg:hidden fixed inset-0 z-50 bg-forest text-cream flex flex-col"
>
<div className="flex items-center justify-between h-16 px-5 border-b border-cream/10 flex-shrink-0">
<Wordmark />
<button
aria-label={t.adminShell.closeMenu}
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
onClick={() => setMobileOpen(false)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<nav aria-label={t.adminShell.mobileNavAriaLabel} className="flex-1 overflow-y-auto py-4">
<NavLinks pathname={pathname} adminNav={adminNav} onNavigate={() => setMobileOpen(false)} />
</nav>
<div className="px-5 py-4 border-t border-cream/10 flex-shrink-0">
<p className="text-xs text-cream/60">{t.adminShell.loggedInAs(adminName)}</p>
<form action={logoutAction} className="mt-2">
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
{t.adminShell.logout}
</button>
</form>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Link from "next/link";
/**
* Kleiner "Zurueck"-Link fuer Unterseiten im Kundenkonto und Admin-Bereich,
* die sonst nur ueber den Browser-Zurueck-Button oder einen Nav-Umweg
* verlassen werden koennten (z.B. Bestellungen -> Konto-Uebersicht).
*/
export function BackLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
className="inline-flex items-center gap-1.5 text-sm text-ink/70 hover:text-forest mb-6"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M15 6l-6 6 6 6" />
</svg>
{label}
</Link>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState, type FormEvent } from "react";
import { Button } from "@/components/ui";
import { TurnstileWidget } from "@/components/turnstile-widget";
import { useI18n } from "@/i18n/i18n-provider";
type Status = "idle" | "loading" | "success" | "error";
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
export function BusinessInquiryForm() {
const { t } = useI18n();
const [status, setStatus] = useState<Status>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [turnstileToken, setTurnstileToken] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("loading");
setErrorMessage(null);
const formData = new FormData(event.currentTarget);
const payload = {
companyName: String(formData.get("companyName") ?? ""),
contactPerson: String(formData.get("contactPerson") ?? ""),
email: String(formData.get("email") ?? ""),
phone: String(formData.get("phone") ?? "") || undefined,
desiredQuantity: String(formData.get("desiredQuantity") ?? ""),
desiredDeliveryDate: String(formData.get("desiredDeliveryDate") ?? "") || undefined,
package: String(formData.get("package") ?? "KLASSIK"),
message: String(formData.get("message") ?? "") || undefined,
turnstileToken,
};
try {
const response = await fetch("/api/business-inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("request failed");
setStatus("success");
event.currentTarget.reset();
} catch {
setStatus("error");
setErrorMessage(t.businessInquiryForm.errorMessage);
}
}
if (status === "success") {
return (
<p className="text-forest">
{t.businessInquiryForm.successMessage}
</p>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid sm:grid-cols-2 gap-4">
<TextField name="companyName" label={t.businessInquiryForm.companyLabel} required />
<TextField name="contactPerson" label={t.businessInquiryForm.contactPersonLabel} required />
</div>
<div className="grid sm:grid-cols-2 gap-4">
<TextField name="email" label={t.businessInquiryForm.emailLabel} type="email" required />
<TextField name="phone" label={t.businessInquiryForm.phoneOptionalLabel} />
</div>
<div className="grid sm:grid-cols-2 gap-4">
<TextField name="desiredQuantity" label={t.businessInquiryForm.quantityLabel} type="number" required />
<TextField name="desiredDeliveryDate" label={t.businessInquiryForm.deliveryDateLabel} type="date" />
</div>
<label className="block text-sm">
<span className="text-ink/70">{t.businessInquiryForm.packageLabel}</span>
<select
name="package"
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3"
>
{t.unternehmenPage.packages.map((p) => (
<option key={p.key} value={p.key}>
{p.title} — {p.text}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.businessInquiryForm.messageLabel}</span>
<textarea
name="message"
rows={4}
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3"
/>
</label>
{errorMessage ? <p className="text-sm text-red-700">{errorMessage}</p> : null}
{TURNSTILE_SITE_KEY ? (
<TurnstileWidget siteKey={TURNSTILE_SITE_KEY} onVerify={setTurnstileToken} />
) : null}
<Button type="submit" disabled={status === "loading"}>
{status === "loading" ? t.businessInquiryForm.submitting : t.businessInquiryForm.submit}
</Button>
</form>
);
}
function TextField({
name,
label,
type = "text",
required = false,
}: {
name: string;
label: string;
type?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-forest"
/>
</label>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { PlaceholderImage } from "@/components/placeholder-image";
/**
* Zeigt das echte Produktbild, sobald CartItem.image gesetzt ist (aktuell nie
* der Fall, siehe README — echte Produktfotografie fehlt noch), ansonsten
* denselben Honig-Platzhalter wie ProductCard fuer Produkte ohne Foto.
*/
export function CartItemThumbnail({ image, className }: { image: string | null; className: string }) {
if (image) {
return (
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, siehe product-gallery.tsx
<img src={image} alt="" className={`${className} object-cover`} />
);
}
return <PlaceholderImage tone="honey" className={className} />;
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
/**
* Umhuellt ein Server-Action-Formular mit einer Browser-Bestaetigung vor dem
* Absenden — fuer destruktive Aktionen (z.B. Benutzer loeschen), fuer die es
* im Admin-Bereich sonst keinen Rueckgaengig gibt.
*/
export function ConfirmForm({
action,
confirmMessage,
className,
children,
}: {
action: (formData: FormData) => void | Promise<void>;
confirmMessage: string;
className?: string;
children: ReactNode;
}) {
return (
<form
action={action}
className={className}
onSubmit={(e) => {
if (!confirm(confirmMessage)) e.preventDefault();
}}
>
{children}
</form>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider";
export const COOKIE_CONSENT_KEY = "wabenhain-cookie-consent";
type Consent = "all" | "essential";
export function CookieConsentBanner() {
const { t } = useI18n();
const [visible, setVisible] = useState(false);
useEffect(() => {
// Liest bewusst erst nach dem Mount aus localStorage (Browser-API, im
// SSR-Markup nicht verfuegbar), um einen Hydration-Mismatch zu vermeiden.
const stored = window.localStorage.getItem(COOKIE_CONSENT_KEY);
// eslint-disable-next-line react-hooks/set-state-in-effect -- externe Browser-Quelle (localStorage) einmalig nach dem Mount synchronisieren
if (!stored) setVisible(true);
}, []);
function choose(consent: Consent) {
window.localStorage.setItem(COOKIE_CONSENT_KEY, consent);
setVisible(false);
}
return (
<AnimatePresence>
{visible ? (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ duration: 0.3 }}
role="dialog"
aria-label={t.cookieConsent.dialogLabel}
className="fixed inset-x-0 bottom-0 z-50 bg-forest text-cream border-t border-cream/20"
>
<div className="mx-auto max-w-6xl px-6 py-6 flex flex-col md:flex-row md:items-center gap-4">
<p className="text-sm text-cream/85 leading-relaxed flex-1">
{t.cookieConsent.bannerTextBeforeLink}
<Link href="/cookie-richtlinie" className="underline">
{t.cookieConsent.policyLinkLabel}
</Link>
{t.cookieConsent.bannerTextAfterLink}
</p>
<div className="flex flex-shrink-0 flex-wrap gap-3">
<Button
variant="secondary"
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest whitespace-nowrap"
onClick={() => choose("essential")}
>
{t.cookieConsent.essentialOnly}
</Button>
<Button className="whitespace-nowrap" onClick={() => choose("all")}>
{t.cookieConsent.acceptAll}
</Button>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
/** Erlaubt es, die getroffene Cookie-Auswahl auf der Cookie-Richtlinie-Seite zu widerrufen. */
export function CookieSettingsButton() {
const { t } = useI18n();
return (
<Button
variant="secondary"
onClick={() => {
window.localStorage.removeItem(COOKIE_CONSENT_KEY);
window.location.reload();
}}
>
{t.cookieConsent.changeSettings}
</Button>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { motion, useReducedMotion, type Variants } from "motion/react";
import { Container, LinkButton } from "@/components/ui";
import { PlaceholderImage } from "@/components/placeholder-image";
import { useI18n } from "@/i18n/i18n-provider";
const sectionVariants: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.6 } },
};
const itemContainerVariants: Variants = {
hidden: {},
visible: { transition: { delayChildren: 0.3, staggerChildren: 0.12 } },
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.7, ease: "easeOut" } },
};
/**
* Startseiten-Hero mit grossflaechigem Bild und gestaffelter
* Einblend-Animation (Eyebrow, Headline, Text, Buttons) beim Laden.
*/
export function Hero({
intro,
footnote,
location,
}: {
intro: string;
footnote: string;
location: string;
}) {
const { t } = useI18n();
const shouldReduceMotion = useReducedMotion();
return (
<motion.section
className="relative"
initial={shouldReduceMotion ? false : "hidden"}
animate="visible"
variants={sectionVariants}
>
<PlaceholderImage
tone="forest"
className="h-[78vh] min-h-[520px] w-full"
label="Bienenstand im Morgenlicht · Altenmünster"
/>
<div className="absolute inset-0 flex items-center bg-gradient-to-t from-forest/70 via-forest/10 to-transparent">
<Container>
<motion.div
className="max-w-xl text-cream"
initial={shouldReduceMotion ? false : "hidden"}
animate="visible"
variants={itemContainerVariants}
>
<motion.p
variants={itemVariants}
className="tracking-wide-caps text-xs text-honey-light mb-4"
>
{location}
</motion.p>
<motion.h1 variants={itemVariants} className="text-4xl md:text-6xl leading-tight">
{t.footer.tagline}
</motion.h1>
<motion.p variants={itemVariants} className="mt-6 text-cream/85 max-w-md leading-relaxed">
{intro}
</motion.p>
<motion.div variants={itemVariants} className="mt-10 flex flex-wrap gap-4">
<LinkButton href="/shop">{t.home.hero.ctaPrimary}</LinkButton>
<LinkButton
href="/imkerei"
variant="secondary"
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest"
>
{t.home.hero.ctaSecondary}
</LinkButton>
</motion.div>
<motion.p variants={itemVariants} className="mt-8 text-xs text-cream/60 tracking-wide">
{footnote}
</motion.p>
</motion.div>
</Container>
</div>
</motion.section>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { PlaceholderImage } from "@/components/placeholder-image";
import { ProductCard, TasteScale, AddToCartButton } from "@/components/product-card";
import { formatPrice, formatDate } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { getContentValues, parseListSimple, parseListTitled } from "@/lib/content";
import type { ProductWithRelations } from "@/lib/data";
export async function CurrentHarvest({
products,
reviewSummaries,
}: {
products: ProductWithRelations[];
reviewSummaries?: Map<string, { average: number | null; count: number }>;
}) {
const t = getDictionary(await getLocale());
return (
<section className="py-24">
<Container>
<ScrollReveal>
<SectionHeading
eyebrow={t.home.currentHarvest.eyebrow}
title={t.home.currentHarvest.title}
subtitle={t.home.currentHarvest.subtitle}
/>
</ScrollReveal>
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
reviewSummary={reviewSummaries?.get(product.id)}
/>
))}
</div>
</Container>
</section>
);
}
export async function OriginStory() {
const locale = await getLocale();
const t = getDictionary(locale);
const content = await getContentValues(
["home.originStory.paragraph1", "home.originStory.paragraph2", "home.originStory.facts"],
locale
);
const facts = parseListSimple(content["home.originStory.facts"]);
return (
<section className="py-24 bg-cream/60">
<Container className="grid md:grid-cols-2 gap-12 items-center">
<ScrollReveal>
<PlaceholderImage
tone="forest"
className="aspect-[4/5] w-full"
label={t.home.originStory.eyebrow}
/>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<SectionHeading
eyebrow={t.home.originStory.eyebrow}
title={t.home.originStory.title}
/>
<p className="mt-6 text-ink/75 leading-relaxed">{content["home.originStory.paragraph1"]}</p>
<p className="mt-4 text-ink/75 leading-relaxed">{content["home.originStory.paragraph2"]}</p>
<ul className="mt-6 space-y-1 text-sm text-ink/70">
{facts.map((fact) => (
<li key={fact}>{fact}</li>
))}
</ul>
<LinkButton href="/herkunft" variant="secondary" className="mt-8">
{t.home.originStory.cta}
</LinkButton>
</ScrollReveal>
</Container>
</section>
);
}
export async function Philosophy() {
const locale = await getLocale();
const t = getDictionary(locale);
const content = await getContentValues(["home.philosophy.items"], locale);
const items = parseListTitled(content["home.philosophy.items"]);
return (
<section className="py-24">
<Container>
<ScrollReveal>
<SectionHeading
eyebrow={t.home.philosophy.eyebrow}
title={t.home.philosophy.title}
align="center"
className="mx-auto"
/>
</ScrollReveal>
<div className="mt-16 grid sm:grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-14">
{items.map((item, i) => (
<ScrollReveal key={item.title} delay={i * 0.08}>
<div className={`border-t border-ink/15 pt-6 ${i % 2 === 1 ? "lg:mt-10" : ""}`}>
<span className="font-serif text-3xl text-honey-dark">
{String(i + 1).padStart(2, "0")}
</span>
<h3 className="mt-3 text-lg text-forest">{item.title}</h3>
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
{item.text}
</p>
</div>
</ScrollReveal>
))}
</div>
</Container>
</section>
);
}
type HoneySpotlightProduct = ProductWithRelations & {
harvest: NonNullable<ProductWithRelations["harvest"]>;
batch: NonNullable<ProductWithRelations["batch"]>;
};
export async function ProductSpotlight({ product }: { product: HoneySpotlightProduct }) {
const t = getDictionary(await getLocale());
return (
<section className="py-24 bg-forest text-cream">
<Container className="grid md:grid-cols-2 gap-14 items-center">
<ScrollReveal>
<PlaceholderImage
tone="honey"
className="aspect-square w-full"
label={product.name}
/>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<p className="tracking-wide-caps text-xs text-honey-light mb-3">
{product.harvest.type} {product.harvest.year}
</p>
<h2 className="text-3xl md:text-4xl">{product.name}</h2>
<p className="mt-4 text-cream/75 leading-relaxed">
{t.home.productSpotlight.description}
</p>
<div className="mt-8 space-y-3">
<TasteScale label={t.product.tasteMild} value={product.intensityMild} tone="inverted" />
<TasteScale label={t.product.tasteFloral} value={product.intensityFloral} tone="inverted" />
<TasteScale label={t.product.tasteIntense} value={product.intensityKraeftig} tone="inverted" />
</div>
<p className="mt-4 text-sm text-cream/60">
{t.home.productSpotlight.consistency}: {product.consistency}
</p>
<dl className="mt-8 grid grid-cols-2 gap-4 text-sm text-cream/70">
<div>
<dt className="text-cream/65">{t.home.productSpotlight.harvest}</dt>
<dd>{formatDate(product.harvest.harvestDate)}</dd>
</div>
<div>
<dt className="text-cream/65">{t.home.productSpotlight.batch}</dt>
<dd>{product.batch.code}</dd>
</div>
<div>
<dt className="text-cream/65">{t.home.productSpotlight.weight}</dt>
<dd>{product.weightGrams} g</dd>
</div>
<div>
<dt className="text-cream/65">{t.home.productSpotlight.price}</dt>
<dd>{formatPrice(product.priceCents, product.currency)}</dd>
</div>
</dl>
<div className="mt-10 flex flex-wrap gap-4">
<LinkButton
href={`/honig/${product.slug}`}
variant="secondary"
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest"
>
{t.home.productSpotlight.cta}
</LinkButton>
<AddToCartButton product={product} />
</div>
</ScrollReveal>
</Container>
</section>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { PlaceholderImage } from "@/components/placeholder-image";
import { StarRating } from "@/components/star-rating";
import { fetchGoogleReviews } from "@/lib/google-reviews";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { getContentValues, parseListTitled, parseListNumbered } from "@/lib/content";
export async function ProcessTimeline() {
const locale = await getLocale();
const t = getDictionary(locale);
const content = await getContentValues(["home.processTimeline.steps"], locale);
const steps = parseListNumbered(content["home.processTimeline.steps"]);
return (
<section className="py-24 bg-cream/60">
<Container>
<ScrollReveal>
<SectionHeading
eyebrow={t.home.processTimeline.eyebrow}
title={t.home.processTimeline.title}
align="center"
className="mx-auto"
/>
</ScrollReveal>
<div className="mt-16 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-8">
{steps.map((step, i) => (
<ScrollReveal key={step.number} delay={i * 0.06}>
<div>
<span className="text-honey-dark text-sm tracking-wide-caps">
{step.number}
</span>
<h3 className="mt-2 text-lg text-forest">{step.title}</h3>
<p className="mt-2 text-xs text-ink/70 leading-relaxed">
{step.text}
</p>
</div>
</ScrollReveal>
))}
</div>
</Container>
</section>
);
}
export async function RegionalSection() {
const locale = await getLocale();
const t = getDictionary(locale);
const content = await getContentValues(
["home.regionalSection.paragraph1", "home.regionalSection.paragraph2"],
locale
);
return (
<section className="py-24">
<Container className="grid md:grid-cols-2 gap-12 items-center">
<ScrollReveal className="order-2 md:order-1">
<SectionHeading
eyebrow={t.home.regionalSection.eyebrow}
title={t.home.regionalSection.title}
/>
<p className="mt-6 text-ink/75 leading-relaxed">{content["home.regionalSection.paragraph1"]}</p>
<p className="mt-4 text-ink/75 leading-relaxed">{content["home.regionalSection.paragraph2"]}</p>
<div className="mt-8 flex flex-wrap gap-4">
<LinkButton href="/imkerei" variant="secondary">
{t.home.regionalSection.ctaAbout}
</LinkButton>
<LinkButton href="/kontakt" variant="ghost">
{t.home.regionalSection.ctaPickup}
</LinkButton>
</div>
</ScrollReveal>
<ScrollReveal className="order-1 md:order-2" delay={0.1}>
<PlaceholderImage tone="forest" className="aspect-[4/5] w-full" label={t.home.regionalSection.eyebrow} />
</ScrollReveal>
</Container>
</section>
);
}
export async function Sustainability() {
const locale = await getLocale();
const t = getDictionary(locale);
const content = await getContentValues(["home.sustainability.items"], locale);
const items = parseListTitled(content["home.sustainability.items"]);
return (
<section className="py-24 bg-cream/60">
<Container>
<ScrollReveal>
<SectionHeading
eyebrow={t.home.sustainability.eyebrow}
title={t.home.sustainability.title}
align="center"
className="mx-auto"
/>
</ScrollReveal>
<div className="mt-14 grid sm:grid-cols-2 lg:grid-cols-4 gap-8">
{items.map((item, i) => (
<ScrollReveal key={item.title} delay={i * 0.08}>
<h3 className="text-lg text-forest">{item.title}</h3>
<p className="mt-2 text-sm text-ink/65 leading-relaxed">
{item.text}
</p>
</ScrollReveal>
))}
</div>
</Container>
</section>
);
}
export async function BusinessTeaser() {
const locale = await getLocale();
const t = getDictionary(locale);
const content = await getContentValues(["home.businessTeaser.paragraph"], locale);
return (
<section className="py-24">
<Container className="grid md:grid-cols-2 gap-12 items-center">
<ScrollReveal>
<PlaceholderImage
tone="honey"
className="aspect-[4/5] w-full"
label={t.home.businessTeaser.eyebrow}
/>
</ScrollReveal>
<ScrollReveal delay={0.1}>
<SectionHeading
eyebrow={t.home.businessTeaser.eyebrow}
title={t.home.businessTeaser.title}
/>
<p className="mt-6 text-ink/75 leading-relaxed">{content["home.businessTeaser.paragraph"]}</p>
<LinkButton href="/unternehmen" className="mt-8">
{t.home.businessTeaser.cta}
</LinkButton>
</ScrollReveal>
</Container>
</section>
);
}
export async function Reviews() {
const t = getDictionary(await getLocale());
const google = await fetchGoogleReviews();
const placeId = process.env.GOOGLE_PLACE_ID;
const mapsUrl = placeId ? `https://www.google.com/maps/place/?q=place_id:${placeId}` : null;
// Echte Google-Rezensionen sind Zitate realer Kund:innen und werden nie
// uebersetzt oder umformuliert — nur die eigenen Fallback-Testimonials
// (ohne konfigurierte Google-Anbindung) existieren in beiden Sprachen.
const items = google && google.reviews.length > 0
? google.reviews.map((r) => ({ quote: r.text, author: r.author, rating: r.rating }))
: t.home.reviews.testimonials.map((r) => ({ ...r, rating: 5 }));
return (
<section className="py-24 bg-forest text-cream">
<Container>
<ScrollReveal>
<SectionHeading
eyebrow={t.home.reviews.eyebrow}
title={t.home.reviews.title}
align="center"
className="mx-auto"
tone="inverted"
/>
{google?.rating ? (
<p className="mt-4 text-center text-cream/60 text-sm">
{t.home.reviews.googleReviewsCount(google.rating.toFixed(1), google.totalCount ?? 0)}
</p>
) : null}
</ScrollReveal>
<div className="mt-14 grid md:grid-cols-3 gap-10">
{items.slice(0, 6).map((review, i) => (
<ScrollReveal key={`${review.author}-${i}`} delay={i * 0.08}>
<StarRating value={review.rating} />
<span className="sr-only">{t.home.reviews.starsOutOf5(review.rating)}</span>
<p className="mt-4 text-cream/85 leading-relaxed italic">
„{review.quote}“
</p>
<p className="mt-4 text-sm text-cream/70">{review.author}</p>
</ScrollReveal>
))}
</div>
{mapsUrl ? (
<p className="mt-14 text-center">
<a href={mapsUrl} target="_blank" rel="noopener noreferrer" className="text-sm text-cream/70 underline">
{t.home.reviews.seeAllOnGoogle}
</a>
</p>
) : null}
</Container>
</section>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { locales, localeLabels, type Locale } from "@/i18n/config";
import { setLocaleAction } from "@/i18n/actions";
import { useI18n } from "@/i18n/i18n-provider";
/**
* Iteriert ueber locales aus i18n/config.ts statt DE/EN fest zu verdrahten —
* eine dritte Sprache erscheint hier automatisch, sobald sie dort ergaenzt
* wird, ohne diese Komponente anzufassen.
*/
export function LanguageSwitcher({ className = "" }: { className?: string }) {
const { locale } = useI18n();
const router = useRouter();
const [isPending, startTransition] = useTransition();
function handleChange(next: Locale) {
if (next === locale || isPending) return;
startTransition(async () => {
const formData = new FormData();
formData.set("locale", next);
await setLocaleAction(formData);
router.refresh();
});
}
return (
<div className={`flex items-center gap-1 text-xs ${className}`} role="group" aria-label="Sprache wählen / Choose language">
{locales.map((code, i) => (
<span key={code} className="flex items-center">
{i > 0 ? <span className="text-ink/30 mx-1" aria-hidden="true">/</span> : null}
<button
type="button"
onClick={() => handleChange(code)}
disabled={isPending}
aria-current={code === locale}
title={localeLabels[code]}
className={`uppercase tracking-wide transition-colors disabled:opacity-50 ${
code === locale ? "text-forest font-medium" : "text-ink/50 hover:text-forest"
}`}
>
{code}
</button>
</span>
))}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Container } from "@/components/ui";
export interface LegalSection {
heading: string;
paragraphs: string[];
}
/**
* Gemeinsames Layout fuer alle rechtlichen Seiten (Impressum, Datenschutz,
* AGB, Widerrufsbelehrung, Versand und Zahlung). Inhalte sind Platzhalter
* und muessen vor Live-Gang durch anwaltlich geprueften Text ersetzt werden.
*/
export function LegalPage({
title,
noticeText,
sections,
}: {
title: string;
noticeText: string;
sections: LegalSection[];
}) {
return (
<div className="py-20">
<Container className="max-w-3xl">
<h1 className="text-3xl text-forest">{title}</h1>
<p className="mt-2 text-xs text-honey-dark tracking-wide-caps">
{noticeText}
</p>
<div className="mt-10 space-y-10">
{sections.map((section) => (
<section key={section.heading}>
<h2 className="text-lg text-forest">{section.heading}</h2>
<div className="mt-3 space-y-3 text-sm text-ink/70 leading-relaxed">
{section.paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</section>
))}
</div>
</Container>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Wabenhain-Wortzeichen: Hexagon-Rahmen mit drei stilisierten Tannen,
* nachempfunden dem gedruckten Glas-Etikett. "default" fuer helle
* Hintergrunde (Honiggold-Hexagon, dunkle Tannen), "inverted" fuer dunkle
* Hintergruende wie Footer/Mobile-Menue (Creme-Hexagon und -Tannen).
*/
export function Logomark({
className = "h-8 w-8",
variant = "default",
}: {
className?: string;
variant?: "default" | "inverted";
}) {
const hexColor = variant === "inverted" ? "#F4EFE5" : "#B88A44";
const treeColor = variant === "inverted" ? "#F4EFE5" : "#24352B";
return (
<svg viewBox="0 0 100 100" className={className} fill="none" aria-hidden="true">
<polygon
points="50,5 88.97,27.5 88.97,72.5 50,95 11.03,72.5 11.03,27.5"
stroke={hexColor}
strokeWidth="2.5"
/>
{[
{ x: 20, y: 45, scale: 0.7 },
{ x: 38, y: 32, scale: 0.95 },
{ x: 56, y: 45, scale: 0.7 },
].map((tree) => (
<g key={tree.x} transform={`translate(${tree.x} ${tree.y}) scale(${tree.scale})`}>
<polygon points="10,2 5,13 15,13" stroke={treeColor} strokeWidth="1.4" strokeLinejoin="round" />
<polygon points="10,10 2,22 18,22" stroke={treeColor} strokeWidth="1.4" strokeLinejoin="round" />
<rect x="8" y="22" width="4" height="4" stroke={treeColor} strokeWidth="1.4" />
</g>
))}
</svg>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useState, type FormEvent } from "react";
import { Container, SectionHeading, Button } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { TurnstileWidget } from "@/components/turnstile-widget";
import { useI18n } from "@/i18n/i18n-provider";
type Status = "idle" | "loading" | "success" | "error";
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
export function NewsletterSignup() {
const { t } = useI18n();
const [email, setEmail] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [turnstileToken, setTurnstileToken] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("loading");
try {
const response = await fetch("/api/newsletter", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, turnstileToken }),
});
if (!response.ok) throw new Error("request failed");
setStatus("success");
setEmail("");
} catch {
setStatus("error");
}
}
return (
<section className="py-24">
<Container className="max-w-2xl text-center">
<ScrollReveal>
<SectionHeading
eyebrow={t.home.newsletter.eyebrow}
title={t.home.newsletter.title}
align="center"
className="mx-auto"
/>
<p className="mt-4 text-ink/70">{t.home.newsletter.description}</p>
{status === "success" ? (
<p className="mt-8 text-forest">{t.home.newsletter.successMessage}</p>
) : (
<form
onSubmit={handleSubmit}
className="mt-8 flex flex-col sm:flex-row gap-3 max-w-lg mx-auto"
>
<input
type="email"
required
placeholder={t.home.newsletter.emailPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="min-w-0 flex-1 border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-forest"
/>
<Button type="submit" disabled={status === "loading"} className="flex-shrink-0 whitespace-nowrap">
{status === "loading" ? t.home.newsletter.subscribing : t.home.newsletter.subscribe}
</Button>
{TURNSTILE_SITE_KEY ? (
<div className="w-full flex justify-center">
<TurnstileWidget siteKey={TURNSTILE_SITE_KEY} onVerify={setTurnstileToken} />
</div>
) : null}
</form>
)}
{status === "error" ? (
<p className="mt-4 text-sm text-red-700">{t.home.newsletter.errorMessage}</p>
) : null}
<p className="mt-6 text-xs text-ink/65">
{t.home.newsletter.consentBeforeLink}
<a href="/datenschutz" className="underline">
{t.home.newsletter.privacyLinkLabel}
</a>
{t.home.newsletter.consentAfterLink}
</p>
</ScrollReveal>
</Container>
</section>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useI18n } from "@/i18n/i18n-provider";
const ICON_PROPS = {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
function EyeIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M3 3l18 18" />
<path d="M10.6 5.2A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a16.5 16.5 0 0 1-3.2 4.1M6.5 6.6C3.9 8.3 2 12 2 12s3.5 7 10 7c1.4 0 2.7-.3 3.8-.8" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
</svg>
);
}
interface PasswordFieldProps {
name: string;
label: string;
required?: boolean;
minLength?: number;
autoComplete?: string;
showStrength?: boolean;
}
/**
* Grobe, rein clientseitige Heuristik statt einer zxcvbn-Abhaengigkeit —
* bewertet Laenge und Zeichenvielfalt (0-5), gruppiert zu drei Stufen fuer
* die Anzeige. Kein Ersatz fuer den serverseitigen HIBP-Leak-Check
* (lib/password-breach-check.ts), sondern nur sofortiges visuelles Feedback
* waehrend der Eingabe.
*/
function scorePasswordStrength(password: string): 0 | 1 | 2 | 3 {
if (!password) return 0;
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
if (/\d/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++;
if (score <= 2) return 1;
if (score === 3) return 2;
return 3;
}
const STRENGTH_BAR_COLOR: Record<0 | 1 | 2 | 3, string> = {
0: "bg-ink/10",
1: "bg-red-500",
2: "bg-honey",
3: "bg-forest",
};
/**
* Passwortfeld mit Anzeigen/Verbergen-Umschalter — reduziert Tippfehler bei
* Anmeldung, Registrierung und Passwort-Aenderung, ohne die Eingabe serverseitig
* anders zu behandeln (bleibt ein normales <input name=... type=password|text>
* innerhalb des umgebenden Server-Action-Formulars). Optional zusaetzlich eine
* visuelle Staerkeanzeige fuer neu vergebene Passwoerter (Registrierung,
* Passwort-Reset) — nicht sinnvoll bei der Eingabe eines bestehenden Passworts.
*/
export function PasswordField({
name,
label,
required = false,
minLength,
autoComplete,
showStrength = false,
}: PasswordFieldProps) {
const { t } = useI18n();
const [visible, setVisible] = useState(false);
const [value, setValue] = useState("");
const tier = showStrength ? scorePasswordStrength(value) : 0;
const tierLabel = tier === 1 ? t.passwordStrength.weak : tier === 2 ? t.passwordStrength.fair : t.passwordStrength.strong;
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<span className="relative mt-1 block">
<input
name={name}
type={visible ? "text" : "password"}
required={required}
minLength={minLength}
autoComplete={autoComplete}
onChange={showStrength ? (e) => setValue(e.target.value) : undefined}
className="w-full border border-ink/20 bg-background px-4 py-3 pr-12 focus:outline-none focus:border-forest"
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
aria-label={visible ? "Passwort verbergen" : "Passwort anzeigen"}
className="absolute inset-y-0 right-0 flex items-center px-3 text-ink/50 hover:text-forest"
>
{visible ? <EyeOffIcon /> : <EyeIcon />}
</button>
</span>
{showStrength && tier > 0 ? (
<span className="mt-2 flex items-center gap-2">
<span className="flex-1 flex gap-1" aria-hidden="true">
{[1, 2, 3].map((segment) => (
<span
key={segment}
className={`h-1 flex-1 rounded-full ${segment <= tier ? STRENGTH_BAR_COLOR[tier] : "bg-ink/10"}`}
/>
))}
</span>
<span className="text-xs text-ink/60 whitespace-nowrap">{tierLabel}</span>
</span>
) : null}
</label>
);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Abstrakter, hochwertiger Bild-Platzhalter (Farbverlauf + feine Linientextur)
* fuer Bereiche, in denen echte Produkt-/Naturfotografie noch fehlt. Bewusst
* ohne Comic-Motive oder Wabenmuster-Kitsch, passend zur Markenpalette.
*/
export function PlaceholderImage({
className = "",
tone = "forest",
label,
}: {
className?: string;
tone?: "forest" | "cream" | "honey";
label?: string;
}) {
const gradients: Record<string, string> = {
forest: "from-[#1a271f] via-[#24352b] to-[#3a4d3f]",
cream: "from-[#efe8d8] via-[#f4efe5] to-[#e2d9c4]",
honey: "from-[#8a6832] via-[#b88a44] to-[#c9a468]",
};
return (
<div
className={`relative overflow-hidden bg-gradient-to-br ${gradients[tone]} ${className}`}
>
<svg
className="absolute inset-0 w-full h-full opacity-[0.15]"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern id="lines" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M0 40L40 0" stroke="currentColor" strokeWidth="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#lines)" />
</svg>
{label ? (
<span
className={`absolute bottom-4 left-4 right-4 truncate text-[11px] tracking-wide-caps ${
tone === "cream" ? "text-forest/50" : "text-cream/50"
}`}
>
{label}
</span>
) : null}
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { motion } from "motion/react";
import { PlaceholderImage } from "@/components/placeholder-image";
import { QuantityStepper } from "@/components/quantity-stepper";
import { StarRating } from "@/components/star-rating";
import { Button } from "@/components/ui";
import { formatPrice, formatPricePerKg } from "@/lib/format";
import { useCartStore } from "@/store/cart-store";
import { useI18n } from "@/i18n/i18n-provider";
import type { ProductWithRelations } from "@/lib/data";
import type { ProductCategory } from "@/generated/prisma/client";
// Unterscheidet die Platzhalterfarbe im Shop-Grid nach Kategorie, statt
// jedes Produkt im selben Honigton zu zeigen — verbessert die Scanbarkeit
// des Rasters, bis echte Produktfotografie vorliegt.
const categoryTone: Record<ProductCategory, "forest" | "cream" | "honey"> = {
HONIG: "honey",
KERZEN: "forest",
PROPOLIS: "cream",
};
/**
* Leichtgewichtiger Produkt-Verweis fuer Herkunfts-/Chargenseiten (Charge,
* Ernte) — nur Bild, Name und Link, ohne Preis/Bestand/Warenkorb-Funktion.
* Diese Seiten geht es um Nachweis/Herkunft, nicht ums Verkaufen.
*/
export function ProductTeaserLink({ slug, name }: { slug: string; name: string }) {
const { t } = useI18n();
return (
<Link
href={`/honig/${slug}`}
className="group flex items-center gap-4 border border-ink/10 p-4 transition-colors hover:border-forest"
>
<PlaceholderImage tone="honey" className="w-16 h-16 flex-shrink-0" />
<span>
<span className="block text-forest">{name}</span>
<span className="block mt-1 text-sm text-ink/60 group-hover:text-forest transition-colors">
{t.product.viewProduct}
</span>
</span>
</Link>
);
}
export function TasteScale({
label,
value,
tone = "default",
}: {
label: string;
value: number;
/** "inverted" fuer Abschnitte auf dunklem Grund (z.B. bg-forest) — sonst waeren Label und leere Skalenpunkte auf dunklem Hintergrund kaum lesbar. */
tone?: "default" | "inverted";
}) {
return (
<div className="flex items-center gap-3 text-sm">
<span className={`w-16 ${tone === "inverted" ? "text-cream/70" : "text-ink/70"}`}>
{label}
</span>
<div className="flex gap-1">
{Array.from({ length: 5 }, (_, i) => (
<span
key={i}
className={`h-1.5 w-5 rounded-full ${
i < value ? "bg-honey" : tone === "inverted" ? "bg-cream/15" : "bg-ink/10"
}`}
/>
))}
</div>
</div>
);
}
function toCartItem(product: ProductWithRelations) {
return {
productId: product.id,
slug: product.slug,
name: product.name,
weightGrams: product.weightGrams,
priceCents: product.priceCents,
image: product.images[0] ?? null,
};
}
export function AddToCartButton({
product,
quantity = 1,
variant,
className = "",
}: {
product: ProductWithRelations;
quantity?: number;
variant?: "primary" | "secondary" | "ghost";
className?: string;
}) {
const { t } = useI18n();
const addItem = useCartStore((s) => s.addItem);
const [justAdded, setJustAdded] = useState(false);
return (
<motion.div whileTap={{ scale: 0.97 }} className={className}>
<Button
variant={variant}
className="w-full"
onClick={() => {
addItem(toCartItem(product), quantity);
setJustAdded(true);
window.setTimeout(() => setJustAdded(false), 1500);
}}
>
{justAdded ? t.product.added : t.product.addToCart}
</Button>
</motion.div>
);
}
/**
* Wie AddToCartButton, aber mit einem Mengen-Steller davor — fuer die
* Produktdetailseite, wo Kund:innen die Menge vor dem Hinzufuegen waehlen
* koennen sollen, statt sie erst im Warenkorb nachtraeglich anzupassen.
*/
export function AddToCartWithQuantity({
product,
maxQuantity,
className = "",
}: {
product: ProductWithRelations;
maxQuantity: number;
className?: string;
}) {
const { t } = useI18n();
const addItem = useCartStore((s) => s.addItem);
const [quantity, setQuantity] = useState(1);
const [justAdded, setJustAdded] = useState(false);
return (
<div className={className}>
<div className="flex items-stretch gap-3">
<QuantityStepper quantity={quantity} onChange={setQuantity} max={maxQuantity} />
<motion.div whileTap={{ scale: 0.97 }} className="flex-1">
<Button
className="w-full h-full"
onClick={() => {
addItem(toCartItem(product), quantity);
setJustAdded(true);
window.setTimeout(() => setJustAdded(false), 1500);
}}
>
{justAdded ? t.product.added : t.product.addToCart}
</Button>
</motion.div>
</div>
</div>
);
}
export function ProductCard({
product,
reviewSummary,
}: {
product: ProductWithRelations;
/** Optional — nur die Shop-Uebersicht laedt Bewertungen gebuendelt fuer das ganze Raster. */
reviewSummary?: { average: number | null; count: number };
}) {
const { t } = useI18n();
const stock = product.inventory?.quantity ?? 0;
return (
<motion.article
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5 }}
className="group"
>
<Link
href={`/honig/${product.slug}`}
className="relative block overflow-hidden transition-shadow duration-500 group-hover:shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)]"
>
<PlaceholderImage
tone={categoryTone[product.category]}
className="aspect-[4/5] w-full transition-transform duration-500 group-hover:scale-[1.03]"
label={
product.harvest
? `${product.harvest.year} · ${product.harvest.type}`
: t.common.categories[product.category]
}
/>
{stock === 0 ? (
<span className="absolute top-3 left-3 bg-ink/80 text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
{t.product.outOfStock}
</span>
) : stock <= 5 ? (
<span className="absolute top-3 left-3 bg-honey text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
{t.product.onlyLeft(stock)}
</span>
) : null}
</Link>
<div className="mt-4">
<Link href={`/honig/${product.slug}`}>
<h3 className="text-xl text-forest">{product.name}</h3>
</Link>
<p className="text-sm text-ink/70 mt-1">
{product.tasteTags.join(" · ")}
</p>
<p
className={`mt-1.5 flex items-center gap-1.5 ${
reviewSummary && reviewSummary.count > 0 ? "" : "invisible"
}`}
>
<StarRating value={reviewSummary?.average ?? 0} size={12} />
<span className="text-xs text-ink/65">
{(reviewSummary?.average ?? 0).toFixed(1)} ({reviewSummary?.count ?? 0})
</span>
</p>
<div className="mt-2 flex items-center justify-between">
<span className="text-sm text-ink/65">{product.weightGrams} g</span>
<span className="text-base text-forest">
{formatPrice(product.priceCents, product.currency)}
</span>
</div>
<p className="text-xs text-ink/65">
{formatPricePerKg(product.pricePerKgCents, product.currency)}
</p>
{stock > 0 ? (
<AddToCartButton product={product} variant="secondary" className="mt-4" />
) : null}
<Link
href={`/honig/${product.slug}`}
className="mt-4 inline-block text-sm tracking-wide-caps text-forest border-b border-forest/40 pb-0.5 hover:border-forest"
>
{product.category === "HONIG" ? t.product.toHoney : t.product.toProduct}
</Link>
</div>
</motion.article>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import { PlaceholderImage } from "@/components/placeholder-image";
type Slide = { src: string } | { tone: "forest" | "cream" | "honey"; label: string };
function isImageSlide(slide: Slide): slide is { src: string } {
return "src" in slide;
}
// Solange keine echte Produktfotografie hinterlegt ist (product.images ist
// aktuell bei allen Produkten leer, siehe README), zeigt die Galerie diese
// vier Platzhalter-Kacheln an — sobald echte Bild-URLs gepflegt werden,
// ersetzen sie diese Kacheln automatisch (siehe Verzweigung unten).
const PLACEHOLDER_SLIDES: Slide[] = [
{ tone: "honey", label: "Glas frontal" },
{ tone: "cream", label: "Glas seitlich" },
{ tone: "forest", label: "Bienenstand" },
{ tone: "honey", label: "Landschaft" },
];
interface ProductGalleryProps {
images: string[];
productName: string;
}
export function ProductGallery({ images, productName }: ProductGalleryProps) {
const slides: Slide[] = images.length > 0 ? images.map((src) => ({ src })) : PLACEHOLDER_SLIDES;
const [activeIndex, setActiveIndex] = useState(0);
const active = slides[activeIndex] ?? slides[0];
return (
<div>
<div className="aspect-[4/3] overflow-hidden">
{isImageSlide(active) ? (
// eslint-disable-next-line @next/next/no-img-element -- externe Bildquelle noch unbekannt, next/image braucht dafuer eine feste remotePatterns-Konfiguration.
<img src={active.src} alt={productName} className="w-full h-full object-cover" />
) : (
<PlaceholderImage tone={active.tone} label={active.label} className="w-full h-full" />
)}
</div>
{slides.length > 1 ? (
<div className="mt-3 grid grid-cols-4 gap-3">
{slides.map((slide, index) => (
<button
key={index}
type="button"
onClick={() => setActiveIndex(index)}
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
aria-current={index === activeIndex}
className={`aspect-square overflow-hidden border-2 transition-colors ${
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
}`}
>
{isImageSlide(slide) ? (
// eslint-disable-next-line @next/next/no-img-element -- s.o.
<img src={slide.src} alt="" className="w-full h-full object-cover" />
) : (
<PlaceholderImage tone={slide.tone} label={slide.label} className="w-full h-full" />
)}
</button>
))}
</div>
) : null}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Gemeinsamer Mengen-Steller (−/Zahl/+), bisher als Inline-Markup nur auf der
* Produktdetailseite vorhanden, waehrend Warenkorb-Drawer und Warenkorb-Seite
* noch ein natives <select> nutzten — vereinheitlicht das Bedienelement.
*/
export function QuantityStepper({
quantity,
onChange,
max = 10,
size = "md",
}: {
quantity: number;
onChange: (quantity: number) => void;
max?: number;
size?: "sm" | "md";
}) {
const buttonSize = size === "sm" ? "w-8 h-8 text-base" : "w-10 h-10 text-lg";
return (
<div className="inline-flex items-stretch border border-ink/20">
<button
type="button"
onClick={() => onChange(Math.max(1, quantity - 1))}
disabled={quantity <= 1}
aria-label="Menge verringern"
className={`${buttonSize} flex items-center justify-center text-ink/70 hover:text-forest disabled:opacity-30 disabled:hover:text-ink/70`}
>
−
</button>
<span className="w-8 flex items-center justify-center text-sm" aria-live="polite">
{quantity}
</span>
<button
type="button"
onClick={() => onChange(Math.min(max, quantity + 1))}
disabled={quantity >= max}
aria-label="Menge erhöhen"
className={`${buttonSize} flex items-center justify-center text-ink/70 hover:text-forest disabled:opacity-30 disabled:hover:text-ink/70`}
>
+
</button>
</div>
);
}
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useState } from "react";
import { useRecentlyViewedStore } from "@/store/recently-viewed-store";
import { getRecentlyViewedProducts } from "@/app/shop/actions";
import { ProductCard } from "@/components/product-card";
import { useI18n } from "@/i18n/i18n-provider";
import type { ProductWithRelations } from "@/lib/data";
/**
* Zeigt zuletzt angesehene Produkte (aus dem lokalen Verlauf) als Raster,
* optional ohne das aktuell angezeigte Produkt selbst. Rendert nichts, wenn
* (noch) keine oder keine anzeigbaren Produkte vorliegen.
*/
export function RecentlyViewedSection({ excludeProductId }: { excludeProductId?: string }) {
const { t } = useI18n();
const viewedIds = useRecentlyViewedStore((s) => s.viewedIds);
const [products, setProducts] = useState<ProductWithRelations[]>([]);
useEffect(() => {
const ids = viewedIds.filter((id) => id !== excludeProductId);
if (ids.length === 0) {
setProducts([]);
return;
}
let cancelled = false;
getRecentlyViewedProducts(ids).then((result) => {
if (!cancelled) setProducts(result);
});
return () => {
cancelled = true;
};
}, [viewedIds, excludeProductId]);
if (products.length === 0) return null;
return (
<div className="mt-20">
<h2 className="text-2xl text-forest mb-8">{t.productPage.recentlyViewedHeading}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-14">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { useEffect } from "react";
import { useRecentlyViewedStore } from "@/store/recently-viewed-store";
/** Rendert nichts — zeichnet den Produktbesuch nur im lokalen "zuletzt angesehen"-Verlauf auf. */
export function RecordProductView({ productId }: { productId: string }) {
const addViewed = useRecentlyViewedStore((s) => s.addViewed);
useEffect(() => {
addViewed(productId);
// Nur beim Mount/Produktwechsel aufzeichnen, nicht bei jedem Store-Update.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId]);
return null;
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui";
import { useCartStore } from "@/store/cart-store";
import { useI18n } from "@/i18n/i18n-provider";
interface ReorderItem {
productId: string;
slug: string;
name: string;
weightGrams: number;
priceCents: number;
quantity: number;
}
/**
* Fuegt alle Positionen einer vergangenen Bestellung erneut dem Warenkorb
* hinzu ("Bestellung erneut ausloesen"). Preise werden aus der Bestellung
* uebernommen — falls sich der aktuelle Produktpreis geaendert hat, zeigt
* der Checkout ohnehin den jeweils aktuellen Preis beim naechsten Laden.
*/
export function ReorderButton({ items }: { items: ReorderItem[] }) {
const { t } = useI18n();
const addItem = useCartStore((s) => s.addItem);
const router = useRouter();
return (
<Button
variant="secondary"
onClick={() => {
for (const item of items) {
addItem(
{
productId: item.productId,
slug: item.slug,
name: item.name,
weightGrams: item.weightGrams,
priceCents: item.priceCents,
image: null,
},
item.quantity
);
}
router.push("/warenkorb");
}}
>
{t.ordersPage.reorder}
</Button>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Dezente Scroll-Reveal-Animation (Fade-In + leichtes Anheben) fuer
* Startseiten-Sektionen. Nutzt motion/react's whileInView, respektiert
* reduced-motion-Praeferenzen und animiert nur einmal pro Element.
*/
export function ScrollReveal({
children,
className = "",
y = 24,
delay = 0,
}: {
children: ReactNode;
className?: string;
y?: number;
delay?: number;
}) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
className={className}
initial={shouldReduceMotion ? false : { opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15 }}
transition={{ duration: 0.7, delay, ease: "easeOut" }}
>
{children}
</motion.div>
);
}
+600
View File
@@ -0,0 +1,600 @@
"use client";
import Link from "next/link";
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 { Container, LinkButton } from "@/components/ui";
import { Logomark } from "@/components/logomark";
import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
import { QuantityStepper } from "@/components/quantity-stepper";
import { LanguageSwitcher } from "@/components/language-switcher";
import { useI18n } from "@/i18n/i18n-provider";
import type { Dictionary } from "@/i18n/dictionary-type";
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Haelt den Tab-Fokus innerhalb eines geoeffneten Overlays (Cart-Drawer,
* mobiles Menue) — sonst koennte man sich per Tab zu Links "hinter" dem
* abgedunkelten Hintergrund durchtabben, die dort unsichtbar, aber technisch
* weiterhin fokussierbar sind.
*/
function useFocusTrap(active: boolean, containerRef: RefObject<HTMLElement | null>) {
useEffect(() => {
if (!active) return;
// Fokus beim Oeffnen aktiv in den Container verschieben — sonst bleibt er
// auf dem jetzt visuell durch den Hintergrund verdeckten Ausloeser-Button
// stehen, und Tastaturnutzer:innen muessten sich erst durch den Rest der
// (fuer sie unsichtbaren) Seite tabben, bevor sie ueberhaupt im Overlay
// ankommen. Der Container selbst (tabindex=-1), nicht z.B. der
// Schliessen-Button, damit eine Screenreader-Ansage nicht sofort mit
// "Schliessen" beginnt.
containerRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
const container = containerRef.current;
if (event.key !== "Tab" || !container) return;
const focusable = Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) => el.offsetParent !== null
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [active, containerRef]);
}
/** Schliesst ein Overlay (Suche, Cart-Drawer, mobiles Menue) per Escape-Taste. */
function useEscapeKey(active: boolean, onEscape: () => void) {
useEffect(() => {
if (!active) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") onEscape();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [active, onEscape]);
}
function Logo({ t, companyName }: { t: Dictionary; companyName: string }) {
return (
<Link href="/" className="flex items-center gap-3 group">
<Logomark className="h-9 w-9 flex-shrink-0" />
<span className="flex flex-col leading-none">
<span className="text-2xl tracking-[0.2em] text-forest font-serif">
{companyName}
</span>
<span className="hidden xl:block text-[10px] tracking-[0.3em] uppercase text-honey-dark mt-1 whitespace-nowrap">
{t.footer.tagline}
</span>
</span>
</Link>
);
}
interface SearchSuggestion {
slug: string;
name: string;
price: string;
}
function SearchButton() {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
const toggleButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < 2) {
setSuggestions([]);
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => {
fetch(`/api/shop/search-suggestions?q=${encodeURIComponent(trimmed)}`, {
signal: controller.signal,
})
.then((res) => res.json())
.then((data: { suggestions?: SearchSuggestion[] }) => setSuggestions(data.suggestions ?? []))
.catch(() => {});
}, 200);
return () => {
clearTimeout(timeout);
controller.abort();
};
}, [query]);
function closeSearch() {
setOpen(false);
setQuery("");
setSuggestions([]);
toggleButtonRef.current?.focus();
}
useEscapeKey(open, closeSearch);
return (
<div className="relative flex items-center">
<AnimatePresence>
{open ? (
<motion.form
initial={{ width: 0, opacity: 0 }}
animate={{ width: 180, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
action="/shop"
method="GET"
className="overflow-visible"
>
<input
type="search"
name="q"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
autoComplete="off"
placeholder={t.nav.searchPlaceholder}
className="w-full border-b border-forest bg-transparent px-2 py-1 text-sm focus:outline-none"
/>
{suggestions.length > 0 ? (
<ul className="absolute top-full left-0 mt-2 w-72 bg-background border border-ink/10 shadow-[0_16px_32px_-16px_rgba(36,53,43,0.25)] z-50">
{suggestions.map((s) => (
<li key={s.slug}>
<Link
href={`/honig/${s.slug}`}
onClick={closeSearch}
className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm text-ink hover:bg-cream/60"
>
<span>{s.name}</span>
<span className="text-ink/60 whitespace-nowrap">{s.price}</span>
</Link>
</li>
))}
</ul>
) : null}
</motion.form>
) : null}
</AnimatePresence>
<button
ref={toggleButtonRef}
type="button"
onClick={() => (open ? closeSearch() : setOpen(true))}
aria-label={t.nav.openSearch}
className="flex items-center justify-center w-10 h-10 text-forest flex-shrink-0"
>
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</button>
</div>
);
}
function AccountIconButton() {
const { t } = useI18n();
return (
<Link
href="/konto"
aria-label={t.nav.account}
className="flex items-center justify-center w-10 h-10 text-forest"
>
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c1.5-4 5-6 8-6s6.5 2 8 6" />
</svg>
</Link>
);
}
function CartIconButton() {
const { t } = useI18n();
const items = useCartStore((s) => s.items);
const openCart = useCartStore((s) => s.openCart);
const count = cartItemCount(items);
return (
<button
onClick={openCart}
aria-label={t.nav.openCart(count)}
className="relative flex items-center justify-center w-10 h-10 text-forest"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 6h15l-1.5 9h-12z" />
<path d="M6 6 5 3H2" />
<circle cx="9" cy="20" r="1" />
<circle cx="18" cy="20" r="1" />
</svg>
{count > 0 ? (
<span className="absolute -top-1 -right-1 bg-honey text-cream text-[10px] w-4 h-4 rounded-full flex items-center justify-center">
{count}
</span>
) : null}
</button>
);
}
export function Header({ companyName }: { companyName: string }) {
const { t } = useI18n();
const [mobileOpen, setMobileOpen] = useState(false);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const mobileMenuRef = useRef<HTMLDivElement>(null);
function closeMobileMenu() {
setMobileOpen(false);
menuButtonRef.current?.focus();
}
useEscapeKey(mobileOpen, closeMobileMenu);
useFocusTrap(mobileOpen, mobileMenuRef);
return (
<>
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur border-b border-ink/10">
{/*
Eigener, breiterer Wrapper statt der gemeinsamen <Container> (max-w-6xl):
Logo + 7-Punkte-Navigation + CTA-Button + Icons brauchen mehr Raum als die
1152px-Inhaltsspalte der restlichen Seite hergibt — mit Container stiessen
Navigation und Button ab 1280px Breite ohne jeden Abstand aneinander.
*/}
<div className="mx-auto flex h-20 w-full max-w-[100rem] items-center justify-between px-6 2xl:px-10">
<Logo t={t} companyName={companyName} />
<nav aria-label="Hauptnavigation" className="hidden xl:flex items-center gap-3 2xl:gap-6">
{mainNav.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-ink/80 hover:text-forest transition-colors whitespace-nowrap"
>
{t.nav[item.key]}
</Link>
))}
</nav>
<div className="flex items-center gap-2">
<div className="hidden md:block">
<LinkButton href="/shop" variant="primary" className="whitespace-nowrap">
{t.nav.discoverHoney}
</LinkButton>
</div>
<div className="hidden xl:flex items-center gap-1">
<LanguageSwitcher />
<SearchButton />
<AccountIconButton />
</div>
<CartIconButton />
<button
ref={menuButtonRef}
aria-label={t.nav.openMenu}
className="xl:hidden flex items-center justify-center w-10 h-10 text-forest"
onClick={() => setMobileOpen(true)}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
</button>
</div>
</div>
</header>
{/*
Bewusst AUSSERHALB von <header>: backdrop-blur auf dem Header erzeugt
einen eigenen CSS-Containing-Block fuer position:fixed-Nachfahren
(wie transform/filter). Ein "fixed inset-0" Overlay INNERHALB des
Headers wuerde sich dadurch relativ zum Header statt zum Viewport
positionieren und den Seiteninhalt durchscheinen lassen, statt ihn
vollflaechig abzudecken.
*/}
<AnimatePresence>
{mobileOpen ? (
<motion.div
ref={mobileMenuRef}
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="fixed inset-0 z-50 bg-forest text-cream flex flex-col outline-none"
>
<Container className="flex items-center justify-between h-20">
<span className="flex items-center gap-3">
<Logomark className="h-8 w-8" variant="inverted" />
<span className="text-xl tracking-[0.2em] font-serif">
{companyName}
</span>
</span>
<div className="flex items-center gap-4">
<LanguageSwitcher className="!text-cream/70" />
<button
aria-label={t.nav.closeMenu}
className="w-10 h-10 flex items-center justify-center"
onClick={closeMobileMenu}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
</Container>
<Container>
<form action="/shop" method="GET">
<input
type="search"
name="q"
placeholder={t.nav.searchPlaceholder}
className="w-full border-b border-cream/40 bg-transparent py-2 text-lg placeholder:text-cream/50 focus:outline-none"
/>
</form>
</Container>
<nav aria-label="Mobile Hauptnavigation" className="flex-1 flex flex-col items-start justify-center gap-6 px-10">
{mainNav.map((item, i) => (
<motion.div
key={item.href}
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 * i, duration: 0.3 }}
>
<Link
href={item.href}
onClick={() => setMobileOpen(false)}
className="text-3xl font-serif"
>
{t.nav[item.key]}
</Link>
</motion.div>
))}
<motion.div
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 * mainNav.length, duration: 0.3 }}
>
<Link
href="/konto"
onClick={() => setMobileOpen(false)}
className="text-3xl font-serif"
>
{t.nav.account}
</Link>
</motion.div>
</nav>
<Container className="pb-10">
<LinkButton
href="/shop"
variant="secondary"
className="!border-cream !text-cream hover:!bg-cream hover:!text-forest w-full"
onClick={() => setMobileOpen(false)}
>
{t.nav.discoverHoney}
</LinkButton>
</Container>
</motion.div>
) : null}
</AnimatePresence>
</>
);
}
export function CartDrawer() {
const { t } = useI18n();
const isOpen = useCartStore((s) => s.isOpen);
const closeCart = useCartStore((s) => s.closeCart);
const items = useCartStore((s) => s.items);
const triggerRef = useRef<Element | null>(null);
// CartIconButton (der Ausloeser) und CartDrawer sind ueber den Zustand-Store
// entkoppelt, nicht ueber Props verbunden — deshalb wird beim Oeffnen einfach
// gemerkt, welches Element gerade fokussiert war (typischerweise der
// angeklickte Button), um den Fokus beim Schliessen dorthin zurueckzugeben,
// statt ihn auf <body> verpuffen zu lassen.
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement;
} else {
if (triggerRef.current instanceof HTMLElement) {
triggerRef.current.focus();
}
triggerRef.current = null;
}
}, [isOpen]);
const setQuantity = useCartStore((s) => s.setQuantity);
const removeItem = useCartStore((s) => s.removeItem);
const total = cartTotalCents(items);
const asideRef = useRef<HTMLElement>(null);
useEscapeKey(isOpen, closeCart);
useFocusTrap(isOpen, asideRef);
return (
<AnimatePresence>
{isOpen ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-ink/40 z-50"
onClick={closeCart}
/>
<motion.aside
ref={asideRef}
tabIndex={-1}
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 28, stiffness: 260 }}
className="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-background z-50 flex flex-col shadow-xl outline-none"
>
<div className="flex items-center justify-between px-6 h-20 border-b border-ink/10">
<h2 className="text-xl font-serif text-forest">{t.cart.title}</h2>
<button aria-label={t.cart.close} onClick={closeCart} className="w-9 h-9">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{items.length === 0 ? (
<p className="text-ink/70 mt-10 text-center">
{t.cart.empty}
</p>
) : (
<ul className="divide-y divide-ink/10">
{items.map((item) => (
<li key={item.productId} className="py-4 flex gap-4">
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-sm" />
<div className="flex-1">
<p className="text-sm font-medium text-ink">{item.name}</p>
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
<div className="flex items-center gap-3 mt-2">
<QuantityStepper
quantity={item.quantity}
onChange={(quantity) => setQuantity(item.productId, quantity)}
size="sm"
/>
<button
onClick={() => removeItem(item.productId)}
className="text-xs text-ink/65 hover:text-ink underline"
>
{t.cart.remove}
</button>
</div>
</div>
<p className="text-sm text-ink">
{formatPrice(item.priceCents * item.quantity)}
</p>
</li>
))}
</ul>
)}
</div>
{items.length > 0 ? (
<div className="border-t border-ink/10 p-6 space-y-4">
<div className="flex justify-between text-sm">
<span>{t.cart.subtotal}</span>
<span>{formatPrice(total)}</span>
</div>
<LinkButton href="/checkout" onClick={closeCart} className="w-full">
{t.cart.checkout}
</LinkButton>
<LinkButton
href="/warenkorb"
variant="secondary"
onClick={closeCart}
className="w-full"
>
{t.cart.viewCart}
</LinkButton>
</div>
) : null}
</motion.aside>
</>
) : null}
</AnimatePresence>
);
}
export function Footer({
companyInfo,
}: {
companyInfo: { name: string; email: string; address: { street: string; zip: string; city: string } };
}) {
const { t } = useI18n();
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,
shipping: t.footer.shipping,
pickup: t.footer.pickup,
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 justify-between gap-4 text-xs text-cream/70">
<span>© {new Date().getFullYear()} Wabenhain</span>
<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>
);
}
+40
View File
@@ -0,0 +1,40 @@
const STAR_PATH =
"M10 1.5l2.47 5.27 5.66.6-4.24 3.9 1.15 5.6L10 14.9l-5.04 2.97 1.15-5.6-4.24-3.9 5.66-.6z";
/**
* Reine Anzeige-Sternebewertung als SVG statt Unicode-Glyphen (★/☆) — die
* rendern je nach Betriebssystem/Schriftart sichtbar unterschiedlich breit
* und krumm. Nicht fuer die interaktive Bewertungsabgabe gedacht (dort bleibt
* der bestehende peer-checked-Radio-Trick, der einen echten Formularwert
* braucht).
*/
export function StarRating({
value,
size = 14,
className = "",
}: {
value: number;
size?: number;
className?: string;
}) {
const rounded = Math.round(value);
return (
<span className={`inline-flex items-center gap-0.5 ${className}`} aria-hidden="true">
{Array.from({ length: 5 }, (_, i) => (
<svg
key={i}
width={size}
height={size}
viewBox="0 0 20 20"
fill={i < rounded ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
className={i < rounded ? "text-honey" : "text-honey/30"}
>
<path d={STAR_PATH} />
</svg>
))}
</span>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import Link from "next/link";
import { useI18n } from "@/i18n/i18n-provider";
/**
* Kurze Vertrauenssignale direkt am Kaufabschluss (Warenkorb/Checkout) —
* bewusst nur Aussagen, die tatsaechlich zutreffen: HTTPS, der real
* verwendete Zahlungsdienstleister Stripe, und das gesetzliche
* Widerrufsrecht statt einer erfundenen "Kaeuferschutz"-Zusage.
* Client Component (statt Server-only), da sie sowohl von Server-Seiten
* (Produktdetail) als auch von Client-Formularen (Checkout, Warenkorb)
* eingebunden wird und ueberall dieselbe Uebersetzung braucht.
*/
export function TrustBadges({ className = "" }: { className?: string }) {
const { t } = useI18n();
const badges = [
{
label: t.trustBadges.sslEncrypted,
icon: (
<path d="M6 10V7a6 6 0 0 1 12 0v3M5 10h14v10H5z" />
),
},
{
label: t.trustBadges.securePayment,
icon: (
<>
<rect x="3" y="6" width="18" height="13" rx="1.5" />
<path d="M3 10.5h18" />
</>
),
},
{
label: t.trustBadges.returnPolicy,
href: "/widerrufsbelehrung",
icon: <path d="M4 8v6h6M4 14a8 8 0 1 0 2.5-6.5" />,
},
];
return (
<ul className={`space-y-2 ${className}`}>
{badges.map((badge) => {
const content = (
<>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="flex-shrink-0 text-honey"
>
{badge.icon}
</svg>
<span>{badge.label}</span>
</>
);
return (
<li key={badge.label} className="flex items-center gap-2 text-xs text-ink/70">
{badge.href ? (
<Link href={badge.href} className="flex items-center gap-2 hover:text-forest">
{content}
</Link>
) : (
content
)}
</li>
);
})}
</ul>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Script from "next/script";
declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: Record<string, unknown>) => string;
};
}
}
interface TurnstileWidgetProps {
siteKey: string;
onVerify: (token: string) => void;
}
/**
* Cloudflare-Turnstile-CAPTCHA-Widget. Wird nur eingebunden, wenn eine
* NEXT_PUBLIC_TURNSTILE_SITE_KEY konfiguriert ist (siehe README) — ohne
* Konfiguration rendern die umgebenden Formulare gar nicht erst diese
* Komponente und bleiben unveraendert nutzbar.
*/
export function TurnstileWidget({ siteKey, onVerify }: TurnstileWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scriptLoaded, setScriptLoaded] = useState(false);
useEffect(() => {
if (!scriptLoaded || !containerRef.current || !window.turnstile) return;
window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: onVerify,
"expired-callback": () => onVerify(""),
"error-callback": () => onVerify(""),
});
}, [scriptLoaded, siteKey, onVerify]);
return (
<>
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
strategy="afterInteractive"
onLoad={() => setScriptLoaded(true)}
/>
<div ref={containerRef} />
</>
);
}
/** Turnstile-Widget fuer klassische (nicht per fetch/JSON gesendete) Formulare: legt das Token als Hidden-Field ab. */
export function TurnstileField({ siteKey, name = "turnstileToken" }: { siteKey: string; name?: string }) {
const [token, setToken] = useState("");
return (
<>
<TurnstileWidget siteKey={siteKey} onVerify={setToken} />
<input type="hidden" name={name} value={token} />
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import Link from "next/link";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
export function Container({
id,
className = "",
children,
}: {
id?: string;
className?: string;
children: ReactNode;
}) {
return (
<div id={id} className={`mx-auto w-full max-w-6xl px-6 md:px-10 ${className}`}>
{children}
</div>
);
}
type ButtonVariant = "primary" | "secondary" | "ghost";
const buttonStyles: Record<ButtonVariant, string> = {
primary:
"bg-forest text-cream hover:bg-[#1a271f] border border-forest",
secondary:
"bg-transparent text-forest border border-forest hover:bg-forest hover:text-cream",
ghost: "bg-transparent text-ink border border-ink/20 hover:border-ink",
};
interface LinkButtonProps extends ComponentPropsWithoutRef<typeof Link> {
variant?: ButtonVariant;
}
export function LinkButton({
variant = "primary",
className = "",
children,
...props
}: LinkButtonProps) {
return (
<Link
className={`inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] ${buttonStyles[variant]} ${className}`}
{...props}
>
{children}
</Link>
);
}
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
variant?: ButtonVariant;
}
export function Button({
variant = "primary",
className = "",
children,
...props
}: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100 ${buttonStyles[variant]} ${className}`}
{...props}
>
{children}
</button>
);
}
export function SectionHeading({
eyebrow,
title,
subtitle,
align = "left",
className = "",
level = "h2",
tone = "default",
}: {
eyebrow?: string;
title: string;
subtitle?: string;
align?: "left" | "center";
className?: string;
/** "h1" fuer Seiten, auf denen diese Komponente die einzige/primaere Ueberschrift ist (SEO-Heading-Hierarchie). Default h2, da die meisten Verwendungen einzelne Abschnitte innerhalb einer Seite mit eigenem h1 sind. */
level?: "h1" | "h2";
/** "inverted" fuer Abschnitte auf dunklem Grund (z.B. bg-forest) — steuert die Eyebrow-Farbe fuer ausreichenden Kontrast. */
tone?: "default" | "inverted";
}) {
const Heading = level;
return (
<div
className={`${align === "center" ? "text-center mx-auto" : "text-left"} max-w-2xl ${className}`}
>
{eyebrow ? (
<p
className={`tracking-wide-caps text-xs mb-3 ${tone === "inverted" ? "text-honey-light" : "text-honey-dark"}`}
>
{eyebrow}
</p>
) : null}
<Heading className="text-3xl md:text-4xl text-forest">{title}</Heading>
{subtitle ? (
<p className="mt-4 text-ink/70 leading-relaxed">{subtitle}</p>
) : null}
</div>
);
}