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:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user