feat(ui): Dunkelmodus fuer Shop und Admin mit Umschalter
Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem Satz Klassen entstehen: - heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme - surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen - forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe, Admin-Leiste), cream der helle Text darauf - honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird im Dunkelmodus heller und bleibt lesbar Codemod ueber src: text-/border-forest -> *-heading, bg-cream -> bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich. Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme" (nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der Systemeinstellung. Gewaehlter und automatischer Dunkelblock in globals.css werden per Test auf Gleichheit geprueft. Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst ab 1680 px. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
b77516b9f5
commit
6950657c91
95 files changed
+514
-272
No files matched your search
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Verlinkte Karte fuer die Konto-Uebersicht (Bestellungen, Adressen, ...).
|
||||
* Ersetzt den zuvor identischen, icon-losen "border + hover:border-forest"-
|
||||
* Ersetzt den zuvor identischen, icon-losen "border + hover:border-heading"-
|
||||
* Kartenstil, der auf jeder Konto-Seite gleich aussah.
|
||||
*/
|
||||
export function AccountNavCard({
|
||||
@@ -27,7 +27,7 @@ export function AccountNavCard({
|
||||
className={`group relative flex items-start gap-5 border p-6 transition-[transform,border-color,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-1 ${
|
||||
accent
|
||||
? "border-honey/40 bg-honey/5 hover:border-honey hover:shadow-[0_22px_40px_-24px_rgba(184,138,68,0.45)]"
|
||||
: "border-ink/10 bg-background hover:border-forest/60 hover:shadow-[0_22px_40px_-24px_rgba(36,53,43,0.35)]"
|
||||
: "border-ink/10 bg-background hover:border-heading/60 hover:shadow-[0_22px_40px_-24px_rgba(36,53,43,0.35)]"
|
||||
} ${className}`}
|
||||
>
|
||||
{/* Icon in einer Wabe, die sich beim Hover mit Honig fuellt. */}
|
||||
@@ -39,20 +39,20 @@ export function AccountNavCard({
|
||||
/>
|
||||
<span
|
||||
className={`relative transition-colors duration-500 group-hover:text-cream ${
|
||||
accent ? "text-honey-dark" : "text-forest"
|
||||
accent ? "text-honey-dark" : "text-heading"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex-1 pt-1">
|
||||
<span className="block font-serif text-2xl leading-tight text-forest">{title}</span>
|
||||
<span className="block font-serif text-2xl leading-tight text-heading">{title}</span>
|
||||
<span className="block mt-1.5 text-sm text-ink/70">{description}</span>
|
||||
</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 -translate-x-2 text-forest opacity-0 transition-[opacity,transform] duration-500 ease-[var(--ease-arrive)] group-hover:translate-x-0 group-hover:opacity-100"
|
||||
className="mt-2 h-4 w-4 flex-shrink-0 -translate-x-2 text-heading opacity-0 transition-[opacity,transform] duration-500 ease-[var(--ease-arrive)] group-hover:translate-x-0 group-hover:opacity-100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
@@ -68,9 +68,9 @@ const ORDER_STATUS_STYLES: Record<string, string> = {
|
||||
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",
|
||||
VERSENDET: "border-heading/40 text-heading bg-forest/5",
|
||||
ABHOLBEREIT: "border-heading/40 text-heading bg-forest/5",
|
||||
ABGEHOLT: "border-heading/40 text-heading bg-forest/5",
|
||||
STORNIERT: "border-red-200 text-red-700 bg-red-50",
|
||||
};
|
||||
|
||||
@@ -182,7 +182,7 @@ export function OrderStatusTimeline({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-forest" : "text-ink/55"}`}>
|
||||
<p className={`mt-2 text-[11px] text-center leading-tight ${isDone ? "text-heading" : "text-ink/55"}`}>
|
||||
{step.label}
|
||||
</p>
|
||||
</div>
|
||||
@@ -218,7 +218,7 @@ export function AccountEmptyState({
|
||||
</svg>
|
||||
<span className="relative">{icon}</span>
|
||||
</span>
|
||||
<p className="mt-5 font-serif text-2xl text-forest">{title}</p>
|
||||
<p className="mt-5 font-serif text-2xl text-heading">{title}</p>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-ink/70 max-w-sm mx-auto">{description}</p>
|
||||
) : null}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function AdminArchiveTabs({
|
||||
|
||||
const tabClass = (selected: boolean): string =>
|
||||
`px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||
selected ? "border-forest text-forest" : "border-transparent text-ink/60 hover:text-forest"
|
||||
selected ? "border-heading text-heading" : "border-transparent text-ink/60 hover:text-heading"
|
||||
}`;
|
||||
|
||||
return (
|
||||
@@ -72,7 +72,7 @@ export function AdminArchiveToggle({
|
||||
{archivedManually ? ` · ${labels.manualHint}` : null}
|
||||
</span>
|
||||
) : null}
|
||||
<button type="submit" className="link-draw cursor-pointer text-ink/70 hover:text-forest">
|
||||
<button type="submit" className="link-draw cursor-pointer text-ink/70 hover:text-heading">
|
||||
{archivedAt ? labels.restore : labels.archive}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -148,7 +148,7 @@ export function AdminCommandPalette({
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => go(entry)}
|
||||
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-sm transition-colors ${
|
||||
i === active ? "bg-honey/10 text-forest" : "text-ink/75"
|
||||
i === active ? "bg-honey/10 text-heading" : "text-ink/75"
|
||||
}`}
|
||||
>
|
||||
<HexMark
|
||||
|
||||
@@ -102,7 +102,7 @@ export function AdminFieldset({
|
||||
}) {
|
||||
return (
|
||||
<fieldset className={`space-y-4 ${last ? "" : "border-b border-ink/10 pb-8"} ${className}`}>
|
||||
<legend className="mb-4 font-serif text-xl text-forest">{legend}</legend>
|
||||
<legend className="mb-4 font-serif text-xl text-heading">{legend}</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,7 @@ export function AdminImageField({ name, label, initialValue }: AdminImageFieldPr
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={status === "uploading"}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-heading disabled:opacity-50"
|
||||
>
|
||||
{t.adminImageField.chooseFile}
|
||||
</button>
|
||||
|
||||
@@ -111,7 +111,7 @@ export function AdminImageListField({ name, label, initialValues }: AdminImageLi
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={status === "uploading"}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-heading disabled:opacity-50"
|
||||
>
|
||||
{t.adminImageField.chooseFile}
|
||||
</button>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function AdminPageHeader({
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 className="text-2xl text-forest">{title}</h1>
|
||||
<h1 className="text-2xl text-heading">{title}</h1>
|
||||
{action ? (
|
||||
<LinkButton href={action.href} className="px-5 py-2.5">
|
||||
{action.label}
|
||||
@@ -74,7 +74,7 @@ export function AdminSearchForm({
|
||||
{children}
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-forest px-4 py-2 text-sm text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
className="border border-heading px-4 py-2 text-sm text-heading transition-colors duration-300 hover:bg-forest hover:text-cream"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
@@ -90,7 +90,7 @@ export function AdminSearchForm({
|
||||
export type AdminBadgeTone = "positive" | "pending" | "neutral" | "danger";
|
||||
|
||||
const BADGE_TONES: Record<AdminBadgeTone, string> = {
|
||||
positive: "border-forest/40 bg-forest/5 text-forest",
|
||||
positive: "border-heading/40 bg-forest/5 text-heading",
|
||||
pending: "border-honey/40 bg-honey/5 text-honey-dark",
|
||||
neutral: "border-ink/20 text-ink/70",
|
||||
danger: "border-red-200 bg-red-50 text-red-700",
|
||||
@@ -155,14 +155,14 @@ export function AdminRowActions({ children }: { children: ReactNode }) {
|
||||
|
||||
export function AdminActionLink({ href, children }: { href: string; children: ReactNode }) {
|
||||
return (
|
||||
<Link href={href} className="link-draw text-forest">
|
||||
<Link href={href} className="link-draw text-heading">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_TONES = {
|
||||
default: "text-forest",
|
||||
default: "text-heading",
|
||||
muted: "text-ink/70",
|
||||
danger: "text-red-700",
|
||||
} as const;
|
||||
|
||||
@@ -10,6 +10,8 @@ import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||
import { AdminCommandPalette, type PaletteEntry } from "@/components/admin-command-palette";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import type { ThemeChoice } from "@/lib/theme";
|
||||
|
||||
/** Was an einer Seite gerade auf Bearbeitung wartet (Zaehler in der Seitenleiste). */
|
||||
export type AdminBadges = Partial<Record<string, number>>;
|
||||
@@ -118,7 +120,7 @@ function NavLinks({
|
||||
{active ? (
|
||||
<motion.span
|
||||
layoutId="admin-nav-active"
|
||||
className="absolute inset-y-0.5 left-2 right-2 -z-10 bg-cream/10"
|
||||
className="absolute inset-y-0.5 left-2 right-2 -z-10 bg-surface/10"
|
||||
transition={{ type: "spring", stiffness: 420, damping: 36 }}
|
||||
/>
|
||||
) : null}
|
||||
@@ -131,7 +133,7 @@ function NavLinks({
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{count > 0 ? (
|
||||
<span
|
||||
className="min-w-5 rounded-full bg-honey-dark px-1.5 text-center text-[11px] leading-5 text-cream tabular-nums"
|
||||
className="min-w-5 rounded-full bg-honey-deep px-1.5 text-center text-[11px] leading-5 text-cream tabular-nums"
|
||||
title={waitingLabel(count)}
|
||||
>
|
||||
<span aria-hidden="true">{count}</span>
|
||||
@@ -175,6 +177,8 @@ function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; lab
|
||||
|
||||
interface AdminShellProps {
|
||||
adminName: string;
|
||||
/** Gewaehlte Darstellung (Cookie), siehe lib/theme.ts. */
|
||||
themeChoice?: ThemeChoice;
|
||||
/** Logo fuer dunkle Flaechen, siehe lib/brand.ts. */
|
||||
logoSrc?: string | null;
|
||||
badges?: AdminBadges;
|
||||
@@ -190,7 +194,14 @@ interface AdminShellProps {
|
||||
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
|
||||
* (components/hide-on-admin.tsx).
|
||||
*/
|
||||
export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutAction, children }: AdminShellProps) {
|
||||
export function AdminShell({
|
||||
adminName,
|
||||
logoSrc = null,
|
||||
themeChoice = "system",
|
||||
badges = {},
|
||||
logoutAction,
|
||||
children,
|
||||
}: AdminShellProps) {
|
||||
const { t } = useI18n();
|
||||
const groups = getAdminNavGroups(t);
|
||||
const pathname = usePathname();
|
||||
@@ -209,9 +220,10 @@ export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutActio
|
||||
<div className="px-5 py-4 border-t border-cream/10">
|
||||
{/* Der Shop-Kopf ist im Admin ausgeblendet (HideOnAdmin) — ohne diesen
|
||||
Link fuehrte kein Weg zurueck auf die Website. */}
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="group mb-4 inline-flex items-center gap-2 py-1 text-sm text-cream/90 hover:text-cream"
|
||||
className="group inline-flex items-center gap-2 py-1 text-sm text-cream/90 hover:text-cream"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -227,6 +239,8 @@ export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutActio
|
||||
</svg>
|
||||
<span className="link-draw">{t.adminShell.backToWebsite}</span>
|
||||
</Link>
|
||||
<ThemeToggle initial={themeChoice} className="-mr-2 text-cream/70 hover:text-cream" />
|
||||
</div>
|
||||
<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">
|
||||
@@ -237,7 +251,7 @@ export function AdminShell({ adminName, logoSrc = null, badges = {}, logoutActio
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cream/40 lg:flex">
|
||||
<div className="min-h-screen bg-surface/40 lg:flex">
|
||||
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
||||
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
|
||||
<div className="flex items-center h-16 px-5 border-b border-cream/10">
|
||||
|
||||
@@ -94,7 +94,7 @@ export function AdminVideoField({ name, label, initialValue }: AdminVideoFieldPr
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={status === "uploading"}
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
|
||||
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-heading disabled:opacity-50"
|
||||
>
|
||||
{t.adminVideoField.chooseFile}
|
||||
</button>
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSe
|
||||
</p>
|
||||
) : null}
|
||||
{done ? (
|
||||
<div className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3 space-y-1">
|
||||
<div className="mb-6 text-sm text-heading bg-forest/5 border border-heading/20 px-4 py-3 space-y-1">
|
||||
<p>
|
||||
{t.resultHarvests(
|
||||
count(searchParams.hNew),
|
||||
@@ -92,7 +92,7 @@ export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {
|
||||
|
||||
return (
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-lg text-forest mb-4">{t.heading}</h2>
|
||||
<h2 className="text-lg text-heading mb-4">{t.heading}</h2>
|
||||
<p className="text-sm text-ink/65 mb-4">{t.intro}</p>
|
||||
<form action={syncApiCoreAction}>
|
||||
<input type="hidden" name="returnTo" value={returnTo} />
|
||||
|
||||
@@ -9,7 +9,7 @@ export function BackLink({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="-mt-1.5 mb-4.5 inline-flex items-center gap-1.5 py-1.5 text-sm text-ink/70 hover:text-forest"
|
||||
className="-mt-1.5 mb-4.5 inline-flex items-center gap-1.5 py-1.5 text-sm text-ink/70 hover:text-heading"
|
||||
>
|
||||
<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" />
|
||||
|
||||
@@ -19,8 +19,8 @@ export function BeeGlyph({ className = "h-8 w-10" }: { className?: string }) {
|
||||
</defs>
|
||||
{/* Fluegel — kippen um ihre Unterkante */}
|
||||
<g className="motion-flutter origin-bottom animate-[wh-flutter_0.11s_ease-in-out_infinite_alternate] [transform-box:fill-box]">
|
||||
<ellipse cx="16.5" cy="9.5" rx="5.2" ry="8" transform="rotate(-22 16.5 9.5)" fill="#FAF8F3" fillOpacity="0.9" stroke="#24352B" strokeWidth="1.1" />
|
||||
<ellipse cx="23.5" cy="10" rx="4.4" ry="6.8" transform="rotate(14 23.5 10)" fill="#FAF8F3" fillOpacity="0.9" stroke="#24352B" strokeWidth="1.1" />
|
||||
<ellipse cx="16.5" cy="9.5" rx="5.2" ry="8" transform="rotate(-22 16.5 9.5)" fill="#FAF8F3" fillOpacity="0.9" stroke="var(--color-heading)" strokeWidth="1.1" />
|
||||
<ellipse cx="23.5" cy="10" rx="4.4" ry="6.8" transform="rotate(14 23.5 10)" fill="#FAF8F3" fillOpacity="0.9" stroke="var(--color-heading)" strokeWidth="1.1" />
|
||||
</g>
|
||||
{/* Stachel */}
|
||||
<path d="M9.6 19.2 5.2 20l4.4.9z" fill="#24352B" />
|
||||
@@ -31,8 +31,8 @@ export function BeeGlyph({ className = "h-8 w-10" }: { className?: string }) {
|
||||
</g>
|
||||
<ellipse cx="20" cy="20" rx="10.5" ry="6.8" fill="none" stroke="#24352B" strokeWidth="1.2" />
|
||||
{/* Kopf und Fuehler */}
|
||||
<circle cx="32" cy="18.8" r="4.3" fill="#24352B" />
|
||||
<path d="M33.5 15c1-2.6 2.6-3.9 4.8-4.1M31.4 14.8c-.1-2.8.8-4.6 2.6-5.6" fill="none" stroke="#24352B" strokeWidth="1" strokeLinecap="round" />
|
||||
<circle cx="32" cy="18.8" r="4.3" fill="var(--color-heading)" />
|
||||
<path d="M33.5 15c1-2.6 2.6-3.9 4.8-4.1M31.4 14.8c-.1-2.8.8-4.6 2.6-5.6" fill="none" stroke="var(--color-heading)" strokeWidth="1" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function BusinessInquiryForm({ turnstileSiteKey }: { turnstileSiteKey?: s
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<p className="text-forest">
|
||||
<p className="text-heading">
|
||||
{t.businessInquiryForm.successMessage}
|
||||
</p>
|
||||
);
|
||||
@@ -95,14 +95,14 @@ export function BusinessInquiryForm({ turnstileSiteKey }: { turnstileSiteKey?: s
|
||||
{t.unternehmenPage.packages.map((p, i) => (
|
||||
<label
|
||||
key={p.key}
|
||||
className="group relative flex cursor-pointer flex-col border border-ink/20 bg-background p-4 transition-colors duration-300 hover:border-ink/40 has-[:checked]:border-forest has-[:checked]:bg-forest/[0.04] has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-honey-dark"
|
||||
className="group relative flex cursor-pointer flex-col border border-ink/20 bg-background p-4 transition-colors duration-300 hover:border-ink/40 has-[:checked]:border-heading has-[:checked]:bg-forest/[0.04] has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-honey-dark"
|
||||
>
|
||||
<input type="radio" name="package" value={p.key} defaultChecked={i === 0} className="sr-only" />
|
||||
<span className="flex items-center justify-between gap-3">
|
||||
<span className="font-serif text-xl text-forest">{p.title}</span>
|
||||
<span className="font-serif text-xl text-heading">{p.title}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 rounded-full border border-ink/30 transition-[border-width,border-color] duration-200 group-has-[:checked]:border-[4.5px] group-has-[:checked]:border-forest"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 rounded-full border border-ink/30 transition-[border-width,border-color] duration-200 group-has-[:checked]:border-[4.5px] group-has-[:checked]:border-heading"
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-2 text-ink/70 leading-relaxed">{p.text}</span>
|
||||
@@ -151,7 +151,7 @@ function TextField({
|
||||
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"
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-4 py-3 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export function CopyCodeButton({ code, label, copiedLabel }: { code: string; lab
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="inline-flex h-10 items-center gap-2 border border-forest px-4 text-xs tracking-wide-caps text-forest transition-colors hover:bg-forest hover:text-cream"
|
||||
className="inline-flex h-10 items-center gap-2 border border-heading px-4 text-xs tracking-wide-caps text-heading transition-colors hover:bg-forest hover:text-cream"
|
||||
aria-live="polite"
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FLASH_COOKIE, type Flash } from "@/lib/flash-core";
|
||||
const AUTO_HIDE_MS: Record<Flash["kind"], number> = { success: 6000, info: 8000, error: 12000 };
|
||||
|
||||
const STYLES: Record<Flash["kind"], string> = {
|
||||
success: "bg-forest text-cream border-forest",
|
||||
success: "bg-forest text-cream border-heading",
|
||||
info: "bg-background text-ink border-ink/20",
|
||||
error: "bg-red-50 text-red-800 border-red-300",
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function OriginStory() {
|
||||
return (
|
||||
// Randlos geteilt: das Foto fuellt die linke Haelfte bis an den
|
||||
// Fensterrand, der Text steht daneben — kein Rahmen, keine Karte.
|
||||
<section className="grid bg-cream/60 md:grid-cols-2">
|
||||
<section className="grid bg-surface/60 md:grid-cols-2">
|
||||
<ScrollReveal className="h-full">
|
||||
<ParallaxMedia className="h-full min-h-[24rem] md:min-h-[40rem]">
|
||||
<ContentImage
|
||||
@@ -129,7 +129,7 @@ export async function Philosophy() {
|
||||
delay={i * 0.06}
|
||||
className="border-b border-ink/15 py-8 sm:pr-8 lg:border-b-0 lg:border-l lg:px-8 lg:first:border-l-0 lg:first:pl-0"
|
||||
>
|
||||
<h3 className="font-serif text-3xl leading-tight text-forest text-balance">{item.title}</h3>
|
||||
<h3 className="font-serif text-3xl leading-tight text-heading text-balance">{item.title}</h3>
|
||||
<p className="mt-3 max-w-[34ch] text-sm leading-relaxed text-ink/70">{item.text}</p>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
@@ -151,7 +151,7 @@ export async function BeekeeperNote() {
|
||||
const [content, company] = await Promise.all([getContentValues(["imkerei.image"], locale), getCompanyInfo()]);
|
||||
const quote = t.imkereiPage.paragraphs[0];
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-cream/60 py-24 md:py-32">
|
||||
<section className="relative overflow-hidden bg-surface/60 py-24 md:py-32">
|
||||
<HoneycombPattern className="text-honey/[0.08]" size={26} fade="right" />
|
||||
<Container className="relative grid items-center gap-16 md:grid-cols-12 md:gap-12">
|
||||
<ScrollReveal className="mx-auto w-64 sm:w-72 md:col-span-5 md:w-full md:max-w-sm">
|
||||
@@ -195,7 +195,7 @@ export async function BeekeeperNote() {
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1} className="md:col-span-7">
|
||||
<blockquote>
|
||||
<p className="font-serif text-3xl leading-snug text-forest text-balance md:text-4xl lg:text-[2.75rem]">
|
||||
<p className="font-serif text-3xl leading-snug text-heading text-balance md:text-4xl lg:text-[2.75rem]">
|
||||
<span aria-hidden="true" className="mr-1 text-honey">„</span>
|
||||
{quote}
|
||||
<span aria-hidden="true" className="ml-0.5 text-honey">“</span>
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function ProcessTimeline() {
|
||||
const content = await getContentValues(["home.processTimeline.steps"], locale);
|
||||
const steps = parseListNumbered(content["home.processTimeline.steps"]);
|
||||
return (
|
||||
<section className="py-24 bg-cream/60">
|
||||
<section className="py-24 bg-surface/60">
|
||||
<Container>
|
||||
<ScrollReveal>
|
||||
<SectionHeading
|
||||
@@ -86,7 +86,7 @@ export async function Sustainability() {
|
||||
const content = await getContentValues(["home.sustainability.items"], locale);
|
||||
const items = parseListTitled(content["home.sustainability.items"]);
|
||||
return (
|
||||
<section className="py-28 bg-cream/60">
|
||||
<section className="py-28 bg-surface/60">
|
||||
{/* Geteilt statt Viererreihe: die Ueberschrift laeuft mit, die Punkte
|
||||
werden als ruhige Liste gelesen. */}
|
||||
<Container className="grid gap-12 lg:grid-cols-12 lg:gap-16">
|
||||
@@ -102,7 +102,7 @@ export async function Sustainability() {
|
||||
<div className="group grid grid-cols-[auto_1fr] gap-x-5 py-7">
|
||||
<HexMark className="mt-2 h-3 w-3 text-honey transition-transform duration-500 ease-[var(--ease-arrive)] group-hover:rotate-90" />
|
||||
<div>
|
||||
<h3 className="text-2xl text-forest">{item.title}</h3>
|
||||
<h3 className="text-2xl text-heading">{item.title}</h3>
|
||||
<p className="mt-2 max-w-[55ch] text-ink/70 leading-relaxed">{item.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function LanguageSwitcher({ className = "" }: { className?: string }) {
|
||||
aria-current={code === locale}
|
||||
title={localeLabels[code]}
|
||||
className={`uppercase tracking-wide transition-colors disabled:opacity-50 ${
|
||||
code === locale ? "text-forest font-medium" : "text-ink/70 hover:text-forest"
|
||||
code === locale ? "text-heading font-medium" : "text-ink/70 hover:text-heading"
|
||||
}`}
|
||||
>
|
||||
{code}
|
||||
|
||||
@@ -23,14 +23,14 @@ export function LegalPage({
|
||||
return (
|
||||
<div className="py-20">
|
||||
<Container className="max-w-3xl">
|
||||
<h1 className="text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] text-forest">{title}</h1>
|
||||
<h1 className="text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] text-heading">{title}</h1>
|
||||
{noticeText ? (
|
||||
<p className="mt-2 text-xs text-honey-dark tracking-wide-caps">{noticeText}</p>
|
||||
) : null}
|
||||
<div className="mt-10 space-y-10">
|
||||
{sections.map((section) => (
|
||||
<section key={section.heading}>
|
||||
<h2 className="text-2xl text-forest">{section.heading}</h2>
|
||||
<h2 className="text-2xl text-heading">{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>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function Logomark({
|
||||
}
|
||||
|
||||
const hexColor = variant === "inverted" ? "#F4EFE5" : "#B88A44";
|
||||
const treeColor = variant === "inverted" ? "#F4EFE5" : "#24352B";
|
||||
const treeColor = variant === "inverted" ? "#F4EFE5" : "var(--color-heading)";
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" className={className} fill="none" aria-hidden="true">
|
||||
|
||||
@@ -91,7 +91,7 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
/>
|
||||
</svg>
|
||||
<motion.p
|
||||
className="mt-4 font-serif text-2xl text-forest"
|
||||
className="mt-4 font-serif text-2xl text-heading"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.45 }}
|
||||
@@ -115,7 +115,7 @@ export function NewsletterSignup({ turnstileSiteKey }: { turnstileSiteKey?: stri
|
||||
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"
|
||||
className="min-w-0 flex-1 border border-ink/20 bg-background px-4 py-3 text-sm focus:outline-none focus:border-heading"
|
||||
/>
|
||||
<Button type="submit" disabled={status === "loading"} className="flex-shrink-0 whitespace-nowrap">
|
||||
{status === "loading" ? t.home.newsletter.subscribing : t.home.newsletter.subscribe}
|
||||
|
||||
@@ -26,7 +26,7 @@ export function PageOutro({ links, ariaLabel }: { links: PageOutroLink[]; ariaLa
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
<span className="block font-serif text-4xl leading-tight text-forest md:text-5xl">
|
||||
<span className="block font-serif text-4xl leading-tight text-heading md:text-5xl">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="mt-3 block max-w-[40ch] text-sm leading-relaxed text-ink/65">{link.text}</span>
|
||||
|
||||
@@ -102,13 +102,13 @@ export function PasswordField({
|
||||
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"
|
||||
className="w-full border border-ink/20 bg-background px-4 py-3 pr-12 focus:outline-none focus:border-heading"
|
||||
/>
|
||||
<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"
|
||||
className="absolute inset-y-0 right-0 flex items-center px-3 text-ink/50 hover:text-heading"
|
||||
>
|
||||
{visible ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Logomark } from "@/components/logomark";
|
||||
|
||||
const SURFACES = {
|
||||
forest: "bg-forest text-cream",
|
||||
cream: "bg-cream text-forest",
|
||||
cream: "bg-surface text-heading",
|
||||
honey: "bg-honey-pale/60 text-forest",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ function ProcessStep({
|
||||
</svg>
|
||||
</motion.span>
|
||||
<span className="font-serif text-sm text-honey-dark tabular-nums">{step.number}</span>
|
||||
<h3 className="mt-1 text-xl text-forest">{step.title}</h3>
|
||||
<h3 className="mt-1 text-xl text-heading">{step.title}</h3>
|
||||
<p className="mt-2 text-sm text-ink/70 leading-relaxed">{step.text}</p>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ProductTeaserLink({
|
||||
return (
|
||||
<Link
|
||||
href={`/honig/${slug}`}
|
||||
className="group flex items-center gap-5 border border-ink/10 p-4 transition-[border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-0.5 hover:border-forest/50 hover:shadow-[0_18px_34px_-24px_rgba(36,53,43,0.45)]"
|
||||
className="group flex items-center gap-5 border border-ink/10 p-4 transition-[border-color,transform,box-shadow] duration-500 ease-[var(--ease-arrive)] hover:-translate-y-0.5 hover:border-heading/50 hover:shadow-[0_18px_34px_-24px_rgba(36,53,43,0.45)]"
|
||||
>
|
||||
<span className="block overflow-hidden rounded-lg">
|
||||
<ContentImage
|
||||
@@ -53,8 +53,8 @@ export function ProductTeaserLink({
|
||||
/>
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<span className="block font-serif text-xl text-forest">{name}</span>
|
||||
<span className="mt-1 inline-flex items-center gap-1.5 text-sm text-ink/70 transition-colors group-hover:text-forest">
|
||||
<span className="block font-serif text-xl text-heading">{name}</span>
|
||||
<span className="mt-1 inline-flex items-center gap-1.5 text-sm text-ink/70 transition-colors group-hover:text-heading">
|
||||
{t.product.viewProduct.replace(/\s*→\s*$/, "")}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
@@ -101,7 +101,7 @@ export function TasteScale({
|
||||
<span
|
||||
key={i}
|
||||
className={`relative h-1.5 w-6 overflow-hidden rounded-full ${
|
||||
tone === "inverted" ? "bg-cream/15" : "bg-ink/10"
|
||||
tone === "inverted" ? "bg-surface/15" : "bg-ink/10"
|
||||
}`}
|
||||
>
|
||||
{i < value ? (
|
||||
@@ -295,14 +295,14 @@ export function ProductCard({
|
||||
{t.product.outOfStock}
|
||||
</span>
|
||||
) : stock <= 5 ? (
|
||||
<span className="absolute top-3 left-3 bg-honey-dark text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
||||
<span className="absolute top-3 left-3 bg-honey-deep text-cream text-[10px] tracking-wide-caps px-2.5 py-1">
|
||||
{t.product.onlyLeft(stock)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<div className="mt-5 flex flex-1 flex-col">
|
||||
<Link href={`/honig/${product.slug}`} className="self-start">
|
||||
<h3 className="link-draw text-2xl leading-tight text-forest">{product.name}</h3>
|
||||
<h3 className="link-draw text-2xl leading-tight text-heading">{product.name}</h3>
|
||||
</Link>
|
||||
<p className="text-sm text-ink/70 mt-1">
|
||||
{product.tasteTags.join(" · ")}
|
||||
@@ -319,7 +319,7 @@ export function ProductCard({
|
||||
</p>
|
||||
<div className="mt-auto flex items-baseline justify-between pt-3">
|
||||
<span className="text-sm text-ink/65 tabular-nums">{product.weightGrams} g</span>
|
||||
<span className="font-serif text-2xl text-forest tabular-nums">
|
||||
<span className="font-serif text-2xl text-heading tabular-nums">
|
||||
{formatPrice(product.priceCents, product.currency, locale)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -331,7 +331,7 @@ export function ProductCard({
|
||||
) : null}
|
||||
<Link
|
||||
href={`/honig/${product.slug}`}
|
||||
className="group/more mt-3 inline-flex items-center gap-2 self-start py-1 text-sm tracking-wide-caps text-forest"
|
||||
className="group/more mt-3 inline-flex items-center gap-2 self-start py-1 text-sm tracking-wide-caps text-heading"
|
||||
>
|
||||
<span className="link-draw pb-0.5">
|
||||
{product.category === "HONIG" ? t.product.toHoney : t.product.toProduct}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function ProductGallery({ images, productName, description }: ProductGall
|
||||
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
||||
aria-current={index === activeIndex}
|
||||
className={`relative aspect-square overflow-hidden rounded-lg border-2 transition-colors ${
|
||||
index === activeIndex ? "border-forest" : "border-transparent hover:border-ink/20"
|
||||
index === activeIndex ? "border-heading" : "border-transparent hover:border-ink/20"
|
||||
}`}
|
||||
>
|
||||
{isImageSlide(slide) ? (
|
||||
|
||||
@@ -22,7 +22,7 @@ export function QuantityStepper({
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const buttonSize = size === "sm" ? "w-8 h-8" : "w-10 h-10";
|
||||
const buttonClass = `${buttonSize} flex items-center justify-center text-ink/70 transition-[color,background-color,transform] duration-200 hover:bg-honey/10 hover:text-forest active:scale-90 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink/70 disabled:active:scale-100`;
|
||||
const buttonClass = `${buttonSize} flex items-center justify-center text-ink/70 transition-[color,background-color,transform] duration-200 hover:bg-honey/10 hover:text-heading active:scale-90 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink/70 disabled:active:scale-100`;
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-stretch border border-ink/20 bg-background">
|
||||
|
||||
@@ -43,7 +43,7 @@ export function RecentlyViewedSection({ excludeProductId }: { excludeProductId?:
|
||||
|
||||
return (
|
||||
<div className="mt-20">
|
||||
<h2 className="text-2xl text-forest mb-8">{t.productPage.recentlyViewedHeading}</h2>
|
||||
<h2 className="text-2xl text-heading 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} />
|
||||
|
||||
@@ -24,7 +24,7 @@ const STATUS_FILL: Record<DhlTrackingStatus, string> = {
|
||||
const STATUS_TEXT: Record<DhlTrackingStatus, string> = {
|
||||
"pre-transit": "text-ink/70",
|
||||
transit: "text-honey-dark",
|
||||
delivered: "text-forest",
|
||||
delivered: "text-heading",
|
||||
failure: "text-red-700",
|
||||
unknown: "text-ink/70",
|
||||
};
|
||||
@@ -50,7 +50,7 @@ export function ShipmentTrackingHistory({
|
||||
if (result.kind !== "ok") {
|
||||
return (
|
||||
<section className="mt-5 border-t border-ink/10 pt-5">
|
||||
<h3 className="text-sm text-forest">{labels.heading}</h3>
|
||||
<h3 className="text-sm text-heading">{labels.heading}</h3>
|
||||
<p className="mt-2 text-xs text-ink/65 leading-relaxed">
|
||||
{result.kind === "not-found" ? labels.notYetScanned : labels.unavailable}
|
||||
</p>
|
||||
@@ -62,7 +62,7 @@ export function ShipmentTrackingHistory({
|
||||
return (
|
||||
<section className="mt-5 border-t border-ink/10 pt-5" aria-label={labels.ariaLabel}>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm text-forest">{labels.heading}</h3>
|
||||
<h3 className="text-sm text-heading">{labels.heading}</h3>
|
||||
<span className={`text-xs ${STATUS_TEXT[info.status]}`}>{labels.statuses[info.status]}</span>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,7 @@ export function ShipmentTrackingHistory({
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
<p className={`text-sm ${i === 0 ? "font-serif text-lg text-forest" : "text-ink/70"}`}>{event.description}</p>
|
||||
<p className={`text-sm ${i === 0 ? "font-serif text-lg text-heading" : "text-ink/70"}`}>{event.description}</p>
|
||||
<p className="mt-0.5 text-xs text-ink/55">
|
||||
<time dateTime={event.timestamp.toISOString()}>{formatDateTime(event.timestamp, locale)}</time>
|
||||
{event.location ? ` · ${event.location}` : null}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useI18n } from "@/i18n/i18n-provider";
|
||||
import { CartFlight, onCartLanded } from "@/components/cart-flight";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import type { ThemeChoice } from "@/lib/theme";
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
@@ -81,10 +83,10 @@ function Logo({ t, companyName, logoSrc }: { t: Dictionary; companyName: string;
|
||||
{/* Kopfzeile ist bei 1280-1440 px knapp: breite Logos hier enger begrenzt. */}
|
||||
<Logomark className="h-9 w-9 max-w-14 flex-shrink-0 sm:max-w-28" src={logoSrc} />
|
||||
<span className="flex flex-col leading-none">
|
||||
<span className="text-2xl tracking-[0.2em] text-forest font-serif">
|
||||
<span className="text-2xl tracking-[0.2em] text-heading font-serif">
|
||||
{companyName}
|
||||
</span>
|
||||
<span className="hidden 2xl:block text-[10px] tracking-[0.3em] uppercase text-honey-dark mt-1 whitespace-nowrap">
|
||||
<span className="hidden min-[1680px]:block text-[10px] tracking-[0.3em] uppercase text-honey-dark mt-1 whitespace-nowrap">
|
||||
{t.footer.tagline}
|
||||
</span>
|
||||
</span>
|
||||
@@ -166,7 +168,7 @@ function SearchButton() {
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
placeholder={t.nav.searchPlaceholder}
|
||||
className="w-full border-b border-forest bg-transparent px-2 py-1.5 text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
|
||||
className="w-full border-b border-heading bg-transparent px-2 py-1.5 text-sm focus:outline-none [&::-webkit-search-cancel-button]:appearance-none"
|
||||
/>
|
||||
{visibleSuggestions.length > 0 ? (
|
||||
<motion.ul
|
||||
@@ -214,7 +216,7 @@ function SearchButton() {
|
||||
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"
|
||||
className="flex items-center justify-center w-10 h-10 text-heading flex-shrink-0"
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.svg
|
||||
@@ -253,7 +255,7 @@ function HighlightMatch({ text, query }: { text: string; query: string }) {
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, index)}
|
||||
<mark className="bg-honey-pale/70 text-forest">{text.slice(index, index + needle.length)}</mark>
|
||||
<mark className="bg-honey-pale/70 text-heading">{text.slice(index, index + needle.length)}</mark>
|
||||
{text.slice(index + needle.length)}
|
||||
</>
|
||||
);
|
||||
@@ -265,7 +267,7 @@ function AccountIconButton() {
|
||||
<Link
|
||||
href="/konto"
|
||||
aria-label={t.nav.account}
|
||||
className="flex items-center justify-center w-10 h-10 text-forest"
|
||||
className="flex items-center justify-center w-10 h-10 text-heading"
|
||||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
@@ -301,7 +303,7 @@ function CartIconButton() {
|
||||
onClick={openCart}
|
||||
aria-label={t.nav.openCart(count)}
|
||||
data-cart-target=""
|
||||
className="relative flex items-center justify-center w-10 h-10 text-forest transition-colors hover:text-honey-dark"
|
||||
className="relative flex items-center justify-center w-10 h-10 text-heading transition-colors hover:text-honey-dark"
|
||||
>
|
||||
<motion.svg
|
||||
animate={basketControls}
|
||||
@@ -326,7 +328,7 @@ function CartIconButton() {
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.4, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 520, damping: 18 }}
|
||||
className="absolute -top-1 -right-1 bg-honey-dark text-cream text-[10px] min-w-4 h-4 px-1 rounded-full flex items-center justify-center tabular-nums"
|
||||
className="absolute -top-1 -right-1 bg-honey-deep text-cream text-[10px] min-w-4 h-4 px-1 rounded-full flex items-center justify-center tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</motion.span>
|
||||
@@ -380,10 +382,12 @@ export function Header({
|
||||
companyName,
|
||||
logoSrc = null,
|
||||
logoInvertedSrc = null,
|
||||
themeChoice = "system",
|
||||
}: {
|
||||
companyName: string;
|
||||
logoSrc?: string | null;
|
||||
logoInvertedSrc?: string | null;
|
||||
themeChoice?: ThemeChoice;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
@@ -430,7 +434,7 @@ export function Header({
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`link-draw pb-1 text-sm whitespace-nowrap transition-colors ${
|
||||
active ? "text-forest" : "text-ink/80 hover:text-forest"
|
||||
active ? "text-heading" : "text-ink/80 hover:text-heading"
|
||||
}`}
|
||||
>
|
||||
{t.nav[item.key]}
|
||||
@@ -440,13 +444,16 @@ export function Header({
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:block">
|
||||
{/* Zwischen xl und 2xl steht "Shop" schon in der Navigation - dort waere der
|
||||
Knopf eine Dopplung und nahm der knappen Kopfzeile den Platz. */}
|
||||
<div className="hidden md:block xl:hidden 2xl:block">
|
||||
<LinkButton href="/shop" variant="primary" className="whitespace-nowrap">
|
||||
{t.nav.discoverHoney}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle initial={themeChoice} className="text-ink/70 hover:text-heading" />
|
||||
<SearchButton />
|
||||
<AccountIconButton />
|
||||
</div>
|
||||
@@ -455,7 +462,7 @@ export function Header({
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
aria-label={t.nav.openMenu}
|
||||
className="xl:hidden flex items-center justify-center w-10 h-10 text-forest"
|
||||
className="xl:hidden flex items-center justify-center w-10 h-10 text-heading"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -494,6 +501,7 @@ export function Header({
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<LanguageSwitcher className="!text-cream/70" />
|
||||
<ThemeToggle initial={themeChoice} className="text-cream/70 hover:text-cream" />
|
||||
<button
|
||||
aria-label={t.nav.closeMenu}
|
||||
className="w-10 h-10 flex items-center justify-center"
|
||||
@@ -625,7 +633,7 @@ export function CartDrawer() {
|
||||
className="fixed right-0 top-0 h-full w-full sm:w-[420px] bg-background z-50 flex flex-col shadow-[-24px_0_60px_-30px_rgba(36,53,43,0.45)] outline-none"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 h-20 border-b border-ink/10">
|
||||
<h2 id="cart-drawer-title" className="flex items-baseline gap-2 text-2xl font-serif text-forest">
|
||||
<h2 id="cart-drawer-title" className="flex items-baseline gap-2 text-2xl font-serif text-heading">
|
||||
{t.cart.title}
|
||||
{items.length > 0 ? (
|
||||
<span className="font-sans text-xs text-ink/55 tabular-nums">({cartItemCount(items)})</span>
|
||||
@@ -634,7 +642,7 @@ export function CartDrawer() {
|
||||
<button
|
||||
aria-label={t.cart.close}
|
||||
onClick={closeCart}
|
||||
className="flex w-9 h-9 items-center justify-center text-ink/70 transition-[color,transform] duration-300 hover:rotate-90 hover:text-forest"
|
||||
className="flex w-9 h-9 items-center justify-center text-ink/70 transition-[color,transform] duration-300 hover:rotate-90 hover:text-heading"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
@@ -655,7 +663,7 @@ export function CartDrawer() {
|
||||
strokeDasharray="6 5"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-6 font-serif text-2xl text-forest">{t.cart.empty}</p>
|
||||
<p className="mt-6 font-serif text-2xl text-heading">{t.cart.empty}</p>
|
||||
<LinkButton href="/shop" variant="secondary" onClick={closeCart} className="mt-8">
|
||||
{t.nav.discoverHoney}
|
||||
</LinkButton>
|
||||
@@ -676,7 +684,7 @@ export function CartDrawer() {
|
||||
>
|
||||
<CartItemThumbnail image={item.image} className="w-16 h-16 flex-shrink-0 rounded-lg" />
|
||||
<div className="flex-1">
|
||||
<p className="font-serif text-lg leading-tight text-forest">{item.name}</p>
|
||||
<p className="font-serif text-lg leading-tight text-heading">{item.name}</p>
|
||||
<p className="text-xs text-ink/65">{item.weightGrams} g</p>
|
||||
{/* Live-Bestand trifft erst NACH dem ersten Rendern ein (siehe
|
||||
use-cart-stock.ts) — der Wrapper bleibt beim Uebergang von
|
||||
@@ -726,7 +734,7 @@ export function CartDrawer() {
|
||||
key={total}
|
||||
initial={{ opacity: 0.4, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="font-serif text-2xl text-forest tabular-nums"
|
||||
className="font-serif text-2xl text-heading tabular-nums"
|
||||
>
|
||||
{formatPrice(total, "EUR", locale)}
|
||||
</motion.span>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function StickyAddToCartBar({
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-ink">{product.name}</p>
|
||||
<p className="text-sm text-forest tabular-nums">{formatPrice(product.priceCents, product.currency, locale)}</p>
|
||||
<p className="text-sm text-heading tabular-nums">{formatPrice(product.priceCents, product.currency, locale)}</p>
|
||||
</div>
|
||||
{inStock ? (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import {
|
||||
THEME_COOKIE,
|
||||
THEME_COOKIE_MAX_AGE,
|
||||
nextThemeChoice,
|
||||
parseThemeChoice,
|
||||
type ThemeChoice,
|
||||
} from "@/lib/theme";
|
||||
|
||||
const THEME_EVENT = "wh-theme-change";
|
||||
|
||||
function readChoice(): ThemeChoice {
|
||||
return parseThemeChoice(document.documentElement.dataset.theme);
|
||||
}
|
||||
|
||||
function subscribe(onChange: () => void): () => void {
|
||||
window.addEventListener(THEME_EVENT, onChange);
|
||||
return () => window.removeEventListener(THEME_EVENT, onChange);
|
||||
}
|
||||
|
||||
function applyChoice(choice: ThemeChoice): void {
|
||||
const root = document.documentElement;
|
||||
const secure = window.location.protocol === "https:" ? "; secure" : "";
|
||||
if (choice === "system") {
|
||||
delete root.dataset.theme;
|
||||
document.cookie = `${THEME_COOKIE}=; path=/; max-age=0; samesite=lax${secure}`;
|
||||
} else {
|
||||
root.dataset.theme = choice;
|
||||
document.cookie = `${THEME_COOKIE}=${choice}; path=/; max-age=${THEME_COOKIE_MAX_AGE}; samesite=lax${secure}`;
|
||||
}
|
||||
// Alle Umschalter (Kopfzeile, Mobilmenue, Admin-Leiste) bleiben synchron.
|
||||
window.dispatchEvent(new Event(THEME_EVENT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Umschalter Automatisch -> Hell -> Dunkel. Das Symbol zeigt die aktuelle
|
||||
* Wahl; Name und Tooltip sagen, was ein Klick bewirkt. "initial" kommt vom
|
||||
* Server (Cookie), damit der erste Render mit dem Dokument uebereinstimmt.
|
||||
*/
|
||||
export function ThemeToggle({ initial, className = "" }: { initial: ThemeChoice; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const choice = useSyncExternalStore(subscribe, readChoice, () => initial);
|
||||
const next = nextThemeChoice(choice);
|
||||
const label = t.theme.toggleLabel(t.theme[choice], t.theme[next]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyChoice(next)}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`inline-flex h-10 w-10 items-center justify-center transition-colors duration-300 ${className}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
{choice === "light" ? (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2.5v2M12 19.5v2M4.6 4.6l1.4 1.4M18 18l1.4 1.4M2.5 12h2M19.5 12h2M4.6 19.4 6 18M18 6l1.4-1.4" strokeLinecap="round" />
|
||||
</>
|
||||
) : choice === "dark" ? (
|
||||
<path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z" strokeLinejoin="round" />
|
||||
) : (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 4a8 8 0 0 1 0 16Z" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function TrustBadges({ className = "" }: { className?: string }) {
|
||||
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">
|
||||
<Link href={badge.href} className="flex items-center gap-2 hover:text-heading">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -28,14 +28,14 @@ const fillBase =
|
||||
"bg-no-repeat bg-bottom bg-[length:100%_0%] hover:bg-[length:100%_100%] focus-visible:bg-[length:100%_100%] motion-reduce:transition-colors";
|
||||
|
||||
const buttonStyles: Record<ButtonVariant, string> = {
|
||||
primary: `${fillBase} bg-forest bg-[linear-gradient(var(--color-honey-dark),var(--color-honey-dark))] text-cream border border-forest hover:border-honey-dark`,
|
||||
secondary: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-forest),var(--color-forest))] text-forest border border-forest hover:text-cream focus-visible:text-cream`,
|
||||
primary: `${fillBase} bg-forest bg-[linear-gradient(var(--color-honey-deep),var(--color-honey-deep))] text-cream border border-heading hover:border-honey-dark`,
|
||||
secondary: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-forest),var(--color-forest))] text-heading border border-heading hover:text-cream focus-visible:text-cream`,
|
||||
ghost: "bg-transparent text-ink border border-ink/20 hover:border-ink hover:bg-ink/[0.03]",
|
||||
// Fuer dunkle Flaechen (Hero, Waldgruen-Abschnitte, Menue): Creme-Rahmen,
|
||||
// fuellt sich mit Creme, Text wechselt auf Waldgruen.
|
||||
inverted: `${fillBase} bg-transparent bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-cream hover:text-forest focus-visible:text-forest`,
|
||||
// Hauptaktion auf dunklen Flaechen, wo Waldgruen im Grund verschwinden wuerde.
|
||||
accent: `${fillBase} bg-honey-dark bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-honey-dark hover:text-forest hover:border-cream focus-visible:text-forest`,
|
||||
accent: `${fillBase} bg-honey-deep bg-[linear-gradient(var(--color-cream),var(--color-cream))] text-cream border border-honey-dark hover:text-forest hover:border-cream focus-visible:text-forest`,
|
||||
};
|
||||
|
||||
const buttonBase =
|
||||
@@ -108,7 +108,7 @@ export function SectionHeading({
|
||||
Ueberschrift traegt den Abschnitt allein. */}
|
||||
<Heading
|
||||
className={`text-4xl md:text-5xl leading-[1.05] tracking-[-0.01em] ${
|
||||
tone === "inverted" ? "text-cream" : "text-forest"
|
||||
tone === "inverted" ? "text-cream" : "text-heading"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
|
||||
Reference in new issue
Block a user