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
+293
-3
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 { 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"]>;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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