feat(ui): Startseite lebt - fliegende Biene, Imker-Portraet, bewegte Fotos
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
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:
1 parent
d52a762e8a
commit
b77516b9f5
6 files changed
+317
-27
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { Container, SectionHeading, LinkButton } from "@/components/ui";
|
||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
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 { formatPrice, formatDate } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -22,7 +25,11 @@ export async function CurrentHarvest({
|
||||
// Dreierraster.
|
||||
const isFew = products.length <= 2;
|
||||
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" : ""}>
|
||||
<ScrollReveal className={isFew ? "lg:col-span-4" : ""}>
|
||||
<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.
|
||||
<section className="grid bg-cream/60 md:grid-cols-2">
|
||||
<ScrollReveal className="h-full">
|
||||
<ContentImage
|
||||
src={content["home.originStory.image"] || null}
|
||||
alt=""
|
||||
tone="forest"
|
||||
rounded={false}
|
||||
className="h-full min-h-[24rem] w-full md:min-h-[40rem]"
|
||||
label={t.home.originStory.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
<ParallaxMedia className="h-full min-h-[24rem] md:min-h-[40rem]">
|
||||
<ContentImage
|
||||
src={content["home.originStory.image"] || null}
|
||||
alt=""
|
||||
tone="forest"
|
||||
rounded={false}
|
||||
className="h-full w-full"
|
||||
label={t.home.originStory.eyebrow}
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
/>
|
||||
</ParallaxMedia>
|
||||
</ScrollReveal>
|
||||
<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">
|
||||
@@ -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 & {
|
||||
harvest: NonNullable<ProductWithRelations["harvest"]>;
|
||||
batch: NonNullable<ProductWithRelations["batch"]>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ProcessPath } from "@/components/process-path";
|
||||
import { HoneycombPattern, HexMark } from "@/components/honeycomb";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
import { ContentImage } from "@/components/content-image";
|
||||
import { ParallaxMedia } from "@/components/parallax-media";
|
||||
import { StarRating } from "@/components/star-rating";
|
||||
import { fetchGoogleReviews } from "@/lib/google-reviews";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
@@ -47,14 +48,16 @@ export async function RegionalSection() {
|
||||
{/* Breites Bild, der Text legt sich als Tafel darueber — Tiefe statt
|
||||
eines weiteren Bild-neben-Text-Wechsels. */}
|
||||
<ScrollReveal>
|
||||
<ContentImage
|
||||
src={content["home.regionalSection.image"] || null}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full sm:aspect-[16/9]"
|
||||
label={t.home.regionalSection.eyebrow}
|
||||
sizes="(min-width: 1280px) 1200px, 100vw"
|
||||
/>
|
||||
<ParallaxMedia className="rounded-lg">
|
||||
<ContentImage
|
||||
src={content["home.regionalSection.image"] || null}
|
||||
alt=""
|
||||
tone="forest"
|
||||
className="aspect-[4/3] w-full sm:aspect-[16/9]"
|
||||
label={t.home.regionalSection.eyebrow}
|
||||
sizes="(min-width: 1280px) 1200px, 100vw"
|
||||
/>
|
||||
</ParallaxMedia>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal
|
||||
delay={0.1}
|
||||
@@ -132,15 +135,17 @@ export async function BusinessTeaser() {
|
||||
{t.home.businessTeaser.cta}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<ContentImage
|
||||
src={content["home.businessTeaser.image"] || null}
|
||||
alt=""
|
||||
tone="honey"
|
||||
rounded={false}
|
||||
className="aspect-[4/3] w-full md:col-span-2 md:aspect-auto md:h-full md:min-h-[22rem]"
|
||||
label={t.home.businessTeaser.eyebrow}
|
||||
sizes="(min-width: 768px) 40vw, 100vw"
|
||||
/>
|
||||
<ParallaxMedia className="md:col-span-2">
|
||||
<ContentImage
|
||||
src={content["home.businessTeaser.image"] || null}
|
||||
alt=""
|
||||
tone="honey"
|
||||
rounded={false}
|
||||
className="aspect-[4/3] h-full w-full md:aspect-auto md:min-h-[22rem]"
|
||||
label={t.home.businessTeaser.eyebrow}
|
||||
sizes="(min-width: 768px) 40vw, 100vw"
|
||||
/>
|
||||
</ParallaxMedia>
|
||||
</ScrollReveal>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user