feat(ui): Startseite lebt - fliegende Biene, Imker-Portraet, bewegte Fotos
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s

- Eine gezeichnete Biene fliegt beim Scrollen eine geschwungene Bahn ueber
  "Aktuelle Ernte" und zieht eine gepunktete Honigspur hinter sich her;
  Fluegelschlag und leichtes Schweben per CSS (components/bee.tsx).
- Neuer Abschnitt nach der Philosophie: Portraet des Imkers in Wabenform,
  die Punkte des Wabenrings wandern an der Kante entlang, eine Biene
  umkreist das Bild. Daneben der eigene Satz von der Imkerei-Seite als
  Zitat - kein neuer Text.
- Herkunft-, Region- und Firmenkunden-Foto bewegen sich beim Scrollen
  leicht mit (Zoom und Parallax, nur transform; components/parallax-media).

Alles rein dekorativ (aria-hidden) und mit Pfad fuer reduzierte Bewegung:
die Biene sitzt dann still, Ring und Fotos stehen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-26 05:57:45 +02:00
1 parent d52a762e8a
commit b77516b9f5
6 files changed
+293 -3

No files matched your search

+43
View File
@@ -305,6 +305,49 @@ textarea::placeholder {
} }
} }
/* Biene (components/bee.tsx): Fluegelschlag, leichtes Schweben im Flug und
die Runde um das Imker-Portraet auf der Startseite. */
@keyframes wh-flutter {
from {
transform: scaleY(1);
}
to {
transform: scaleY(0.35);
}
}
@keyframes wh-hover {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@keyframes wh-orbit {
to {
transform: rotate(360deg);
}
}
/* Punkte laufen entlang einer gestrichelten Linie (Wabenring um das Portraet). */
@keyframes wh-march {
to {
stroke-dashoffset: -35;
}
}
@media (prefers-reduced-motion: reduce) {
.motion-flutter,
.motion-hover,
.motion-orbit,
.motion-march {
animation: none !important;
}
}
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
Admin-Arbeitsbereich (components/admin-shell.tsx): einheitliche Details Admin-Arbeitsbereich (components/admin-shell.tsx): einheitliche Details
fuer alle Admin-Seiten zentral statt in jeder der rund 20 Seiten einzeln. fuer alle Admin-Seiten zentral statt in jeder der rund 20 Seiten einzeln.
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Hero } from "@/components/hero"; import { Hero } from "@/components/hero";
import { import {
BeekeeperNote,
CurrentHarvest, CurrentHarvest,
OriginStory, OriginStory,
Philosophy, Philosophy,
@@ -57,6 +58,7 @@ export default async function HomePage() {
<CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} /> <CurrentHarvest products={honeyProducts} reviewSummaries={reviewSummaries} />
<OriginStory /> <OriginStory />
<Philosophy /> <Philosophy />
<BeekeeperNote />
{spotlightProduct ? <ProductSpotlight product={spotlightProduct} /> : null} {spotlightProduct ? <ProductSpotlight product={spotlightProduct} /> : null}
<ProcessTimeline /> <ProcessTimeline />
<RegionalSection /> <RegionalSection />
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useCallback, useEffect, useId, useRef } from "react";
import { motion, useMotionValueEvent, useReducedMotion, useScroll, useSpring } from "motion/react";
/**
* Gezeichnete Biene (Blick nach rechts) im Strich der uebrigen Marken-Grafik.
* Die Fluegel schlagen per CSS (wh-flutter); bei reduzierter Bewegung stehen
* sie still (.motion-flutter, globals.css).
*/
export function BeeGlyph({ className = "h-8 w-10" }: { className?: string }) {
const clipId = `bee-body-${useId().replace(/:/g, "")}`;
return (
<svg viewBox="0 0 40 32" className={className} aria-hidden="true">
<defs>
<clipPath id={clipId}>
<ellipse cx="20" cy="20" rx="10.5" ry="6.8" />
</clipPath>
</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" />
</g>
{/* Stachel */}
<path d="M9.6 19.2 5.2 20l4.4.9z" fill="#24352B" />
{/* Koerper mit Streifen */}
<ellipse cx="20" cy="20" rx="10.5" ry="6.8" fill="#C9A468" />
<g clipPath={`url(#${clipId})`} stroke="#24352B" strokeWidth="2.4">
<path d="M15.5 12v16M20.5 12v16M25.5 12v16" />
</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" />
</svg>
);
}
// Flugbahn im Koordinatenraum 1000 x 200: kommt links herein, schlaegt zwei
// Boegen und verlaesst den Abschnitt rechts oben.
const FLIGHT_PATH = "M -40 150 C 120 40, 260 190, 430 100 S 700 10, 820 120 S 980 90, 1060 30";
const VIEW_W = 1000;
const VIEW_H = 200;
// Wo die Biene bei reduzierter Bewegung ruhig sitzt (Anteil der Bahn).
const RESTING_POINT = 0.62;
/**
* Eine Biene fliegt beim Scrollen eine geschwungene Bahn ueber den Abschnitt
* und zieht eine gepunktete Honigspur hinter sich her. Rein dekorativ
* (aria-hidden, pointer-events-none). Bei reduzierter Bewegung sitzt sie
* still an einer Stelle, die Spur ist bis dorthin sichtbar.
*/
export function BeeFlight({ className = "" }: { className?: string }) {
const boxRef = useRef<HTMLDivElement>(null);
const pathRef = useRef<SVGPathElement>(null);
const beeRef = useRef<HTMLDivElement>(null);
const maskId = `bee-trail-${useId().replace(/:/g, "")}`;
const reduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll({ target: boxRef, offset: ["start end", "end start"] });
// Gefedert: die Biene folgt dem Scrollen mit etwas Traegheit, statt ruckartig zu springen.
const progress = useSpring(scrollYProgress, { stiffness: 55, damping: 18, mass: 0.7 });
const placeBee = useCallback((p: number) => {
const path = pathRef.current;
const bee = beeRef.current;
const box = boxRef.current;
if (!path || !bee || !box) return;
const length = path.getTotalLength();
const at = Math.min(1, Math.max(0, p)) * length;
const point = path.getPointAtLength(at);
const ahead = path.getPointAtLength(Math.min(length, at + 2));
const sx = box.clientWidth / VIEW_W;
const sy = box.clientHeight / VIEW_H;
const angle = (Math.atan2((ahead.y - point.y) * sy, (ahead.x - point.x) * sx) * 180) / Math.PI;
bee.style.transform = `translate(${point.x * sx}px, ${point.y * sy}px) translate(-50%, -50%) rotate(${angle}deg)`;
}, []);
useMotionValueEvent(progress, "change", (p) => {
if (!reduceMotion) placeBee(p);
});
useEffect(() => {
const place = () => placeBee(reduceMotion ? RESTING_POINT : progress.get());
place();
window.addEventListener("resize", place);
return () => window.removeEventListener("resize", place);
}, [placeBee, progress, reduceMotion]);
return (
<div ref={boxRef} aria-hidden="true" className={`pointer-events-none relative ${className}`}>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
preserveAspectRatio="none"
className="absolute inset-0 h-full w-full overflow-visible"
>
<defs>
<mask id={maskId} maskUnits="userSpaceOnUse" x="-100" y="-100" width="1300" height="400">
<motion.path
d={FLIGHT_PATH}
fill="none"
stroke="white"
strokeWidth="8"
style={{ pathLength: reduceMotion ? RESTING_POINT : progress }}
/>
</mask>
</defs>
<path
ref={pathRef}
d={FLIGHT_PATH}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeDasharray="1 7"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
mask={`url(#${maskId})`}
className="text-honey"
/>
</svg>
<div ref={beeRef} className="absolute left-0 top-0 will-change-transform">
<div className="motion-hover animate-[wh-hover_1.4s_ease-in-out_infinite]">
<BeeGlyph className="h-11 w-14" />
</div>
</div>
</div>
);
}
+86 -2
View File
@@ -2,6 +2,9 @@ import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { HoneycombPattern, HexMark } from "@/components/honeycomb"; import { HoneycombPattern, HexMark } from "@/components/honeycomb";
import { ScrollReveal } from "@/components/scroll-reveal"; import { ScrollReveal } from "@/components/scroll-reveal";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { BeeFlight, BeeGlyph } from "@/components/bee";
import { ParallaxMedia } from "@/components/parallax-media";
import { getCompanyInfo } from "@/lib/company-info";
import { ProductCard, TasteScale, AddToCartButton } from "@/components/product-card"; import { ProductCard, TasteScale, AddToCartButton } from "@/components/product-card";
import { formatPrice, formatDate } from "@/lib/format"; import { formatPrice, formatDate } from "@/lib/format";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -22,7 +25,11 @@ export async function CurrentHarvest({
// Dreierraster. // Dreierraster.
const isFew = products.length <= 2; const isFew = products.length <= 2;
return ( return (
<section className="py-24"> // overflow-x-clip statt overflow-hidden: die Biene verlaesst den Abschnitt
// rechts, ohne eine Scrollleiste zu erzeugen — und die klebende
// Ueberschrift (sticky) funktioniert weiter.
<section className="relative overflow-x-clip py-24">
<BeeFlight className="!absolute inset-x-0 top-0 h-28 md:h-36" />
<Container className={isFew ? "grid gap-12 lg:grid-cols-12 lg:gap-16" : ""}> <Container className={isFew ? "grid gap-12 lg:grid-cols-12 lg:gap-16" : ""}>
<ScrollReveal className={isFew ? "lg:col-span-4" : ""}> <ScrollReveal className={isFew ? "lg:col-span-4" : ""}>
<div className={isFew ? "lg:sticky lg:top-32" : ""}> <div className={isFew ? "lg:sticky lg:top-32" : ""}>
@@ -68,15 +75,17 @@ export async function OriginStory() {
// Fensterrand, der Text steht daneben — kein Rahmen, keine Karte. // Fensterrand, der Text steht daneben — kein Rahmen, keine Karte.
<section className="grid bg-cream/60 md:grid-cols-2"> <section className="grid bg-cream/60 md:grid-cols-2">
<ScrollReveal className="h-full"> <ScrollReveal className="h-full">
<ParallaxMedia className="h-full min-h-[24rem] md:min-h-[40rem]">
<ContentImage <ContentImage
src={content["home.originStory.image"] || null} src={content["home.originStory.image"] || null}
alt="" alt=""
tone="forest" tone="forest"
rounded={false} rounded={false}
className="h-full min-h-[24rem] w-full md:min-h-[40rem]" className="h-full w-full"
label={t.home.originStory.eyebrow} label={t.home.originStory.eyebrow}
sizes="(min-width: 768px) 50vw, 100vw" sizes="(min-width: 768px) 50vw, 100vw"
/> />
</ParallaxMedia>
</ScrollReveal> </ScrollReveal>
<div className="flex items-center px-6 py-20 sm:px-10 lg:px-20 xl:px-28"> <div className="flex items-center px-6 py-20 sm:px-10 lg:px-20 xl:px-28">
<ScrollReveal delay={0.1} className="max-w-xl"> <ScrollReveal delay={0.1} className="max-w-xl">
@@ -130,6 +139,81 @@ export async function Philosophy() {
); );
} }
/**
* Das Gesicht hinter dem Honig: Portraet des Imkers in Wabenform, ein
* langsam drehender Wabenring und eine Biene, die das Bild umkreist.
* Daneben sein eigener Satz von der Imkerei-Seite — kein neuer Text, nur
* hierher geholt, damit die Leidenschaft schon auf der Startseite spricht.
*/
export async function BeekeeperNote() {
const locale = await getLocale();
const t = getDictionary(locale);
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">
<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">
<div className="relative aspect-square">
{/* Wabenring deckungsgleich zum Portraet; die Punkte wandern
langsam an der Kante entlang, wie Bienen auf ihrem Weg. */}
<svg
viewBox="0 0 100 100"
aria-hidden="true"
className="absolute -inset-5 h-[calc(100%+2.5rem)] w-[calc(100%+2.5rem)] text-honey"
>
<polygon
points="50,2 91.6,26 91.6,74 50,98 8.4,74 8.4,26"
fill="none"
stroke="currentColor"
strokeWidth="0.7"
strokeDasharray="0.5 3"
strokeLinecap="round"
className="motion-march animate-[wh-march_6s_linear_infinite]"
/>
</svg>
<div className="clip-hex absolute inset-0 bg-honey-pale">
<ContentImage
src={content["imkerei.image"] || null}
alt={t.imkereiPage.imageLabel}
tone="honey"
rounded={false}
className="h-full w-full"
sizes="(min-width: 768px) 24rem, 18rem"
/>
</div>
{/* Die Biene umkreist das Portraet */}
<div aria-hidden="true" className="motion-orbit absolute -inset-8 animate-[wh-orbit_16s_linear_infinite]">
<div className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
<div className="motion-hover animate-[wh-hover_1.4s_ease-in-out_infinite]">
<BeeGlyph className="h-10 w-12" />
</div>
</div>
</div>
</div>
</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]">
<span aria-hidden="true" className="mr-1 text-honey">„</span>
{quote}
<span aria-hidden="true" className="ml-0.5 text-honey">“</span>
</p>
<footer className="mt-8 flex items-center gap-3 text-sm text-ink/70">
<HexMark className="h-3 w-3 text-honey" />
{company.name} · {company.location}
</footer>
</blockquote>
<LinkButton href="/imkerei" variant="secondary" className="mt-10">
{t.home.regionalSection.ctaAbout}
</LinkButton>
</ScrollReveal>
</Container>
</section>
);
}
type HoneySpotlightProduct = ProductWithRelations & { type HoneySpotlightProduct = ProductWithRelations & {
harvest: NonNullable<ProductWithRelations["harvest"]>; harvest: NonNullable<ProductWithRelations["harvest"]>;
batch: NonNullable<ProductWithRelations["batch"]>; batch: NonNullable<ProductWithRelations["batch"]>;
+6 -1
View File
@@ -3,6 +3,7 @@ import { ProcessPath } from "@/components/process-path";
import { HoneycombPattern, HexMark } from "@/components/honeycomb"; import { HoneycombPattern, HexMark } from "@/components/honeycomb";
import { ScrollReveal } from "@/components/scroll-reveal"; import { ScrollReveal } from "@/components/scroll-reveal";
import { ContentImage } from "@/components/content-image"; import { ContentImage } from "@/components/content-image";
import { ParallaxMedia } from "@/components/parallax-media";
import { StarRating } from "@/components/star-rating"; import { StarRating } from "@/components/star-rating";
import { fetchGoogleReviews } from "@/lib/google-reviews"; import { fetchGoogleReviews } from "@/lib/google-reviews";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -47,6 +48,7 @@ export async function RegionalSection() {
{/* Breites Bild, der Text legt sich als Tafel darueber — Tiefe statt {/* Breites Bild, der Text legt sich als Tafel darueber — Tiefe statt
eines weiteren Bild-neben-Text-Wechsels. */} eines weiteren Bild-neben-Text-Wechsels. */}
<ScrollReveal> <ScrollReveal>
<ParallaxMedia className="rounded-lg">
<ContentImage <ContentImage
src={content["home.regionalSection.image"] || null} src={content["home.regionalSection.image"] || null}
alt="" alt=""
@@ -55,6 +57,7 @@ export async function RegionalSection() {
label={t.home.regionalSection.eyebrow} label={t.home.regionalSection.eyebrow}
sizes="(min-width: 1280px) 1200px, 100vw" sizes="(min-width: 1280px) 1200px, 100vw"
/> />
</ParallaxMedia>
</ScrollReveal> </ScrollReveal>
<ScrollReveal <ScrollReveal
delay={0.1} delay={0.1}
@@ -132,15 +135,17 @@ export async function BusinessTeaser() {
{t.home.businessTeaser.cta} {t.home.businessTeaser.cta}
</LinkButton> </LinkButton>
</div> </div>
<ParallaxMedia className="md:col-span-2">
<ContentImage <ContentImage
src={content["home.businessTeaser.image"] || null} src={content["home.businessTeaser.image"] || null}
alt="" alt=""
tone="honey" tone="honey"
rounded={false} rounded={false}
className="aspect-[4/3] w-full md:col-span-2 md:aspect-auto md:h-full md:min-h-[22rem]" className="aspect-[4/3] h-full w-full md:aspect-auto md:min-h-[22rem]"
label={t.home.businessTeaser.eyebrow} label={t.home.businessTeaser.eyebrow}
sizes="(min-width: 768px) 40vw, 100vw" sizes="(min-width: 768px) 40vw, 100vw"
/> />
</ParallaxMedia>
</ScrollReveal> </ScrollReveal>
</Container> </Container>
</section> </section>
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useRef, type ReactNode } from "react";
import { motion, useReducedMotion, useScroll, useTransform } from "motion/react";
/**
* Foto, das sich beim Scrollen leicht mitbewegt: es kommt etwas vergroessert
* herein, setzt sich beim Durchscrollen und wandert dabei ein wenig gegen die
* Scrollrichtung — die Aufnahme wirkt wie ein Blick durch ein Fenster statt
* wie ein aufgeklebtes Bild. Nur transform (scale/translate), kein Layout.
* Bei reduzierter Bewegung steht das Bild still.
*/
export function ParallaxMedia({ children, className = "" }: { children: ReactNode; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
const scale = useTransform(scrollYProgress, [0, 0.5, 1], [1.14, 1.05, 1]);
const y = useTransform(scrollYProgress, [0, 1], ["-4%", "4%"]);
return (
<div ref={ref} className={`overflow-hidden ${className}`}>
<motion.div className="h-full w-full will-change-transform" style={reduceMotion ? undefined : { scale, y }}>
{children}
</motion.div>
</div>
);
}