410 lines
13 KiB
JavaScript
410 lines
13 KiB
JavaScript
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||
import { gsap } from "gsap";
|
||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||
import perfumes from "../data/perfumes";
|
||
import SharedNavbar from "../components/SharedNavbar";
|
||
import Grid, { Col } from "../components/layout/Grid";
|
||
import PageMeta from "../components/seo/PageMeta";
|
||
import StickyBuyBar from "../components/StickyBuyBar";
|
||
import { useShop } from "../shop/useShop";
|
||
import "./DiscoverySetPage.css";
|
||
|
||
gsap.registerPlugin(ScrollTrigger);
|
||
|
||
const DISCOVERY_SET_IMAGE = "/atmos-discovery-set-thumbnail.webp";
|
||
|
||
const discoveryPanelFacts = [
|
||
{ label: "Umfang", value: "6 × 2ml" },
|
||
{ label: "Gutschrift", value: "CHF 48 werden beim späteren 50-ml-Kauf berücksichtigt." },
|
||
];
|
||
|
||
const discoveryBenefits = [
|
||
{
|
||
title: "6 × 2-ml-Proben aller Signature-Düfte",
|
||
text: "Kalter Beton, Schwarzes Benzin, Verbranntes Chrom, Blasse Seide, Weisse Asche und Nasser Marmor.",
|
||
},
|
||
{
|
||
title: "CHF 48 Gutschein automatisch im Set",
|
||
text: "Nur das erste Discovery Set erstellt den einmaligen Rabatt. Er wird bei einem späteren 50-ml-Kauf automatisch angerechnet.",
|
||
},
|
||
{
|
||
title: "Jede Probe = ca. 20 Anwendungen",
|
||
text: "Genug, um jeden Duft mehrere Tage im Alltag und in unterschiedlichen Situationen zu testen.",
|
||
},
|
||
{
|
||
title: "Hochwertige Mini-Flacons",
|
||
text: "Sorgfältig zusammengestellt, reduziert gestaltet und als Teil des atmos Konzepts gedacht.",
|
||
},
|
||
];
|
||
|
||
const discoverySteps = [
|
||
{
|
||
number: "01",
|
||
title: "Bestellen",
|
||
text: "Discovery Set für CHF 48 bestellen. Nur dein erstes Set erzeugt automatisch einen einmaligen Rabatt.",
|
||
},
|
||
{
|
||
number: "02",
|
||
title: "Testen",
|
||
text: "Jeden Duft mindestens einige Tage tragen. Im Alltag, zu verschiedenen Anlässen und auf der eigenen Haut.",
|
||
},
|
||
{
|
||
number: "03",
|
||
title: "Entscheiden",
|
||
text: "50-ml-Flakon bestellen. CHF 48 werden automatisch angerechnet, sofern der Rabatt noch nicht genutzt wurde.",
|
||
},
|
||
];
|
||
|
||
function DiscoveryOrderPanel({ onBuy, panelRef }) {
|
||
return (
|
||
<aside className="discovery-order-panel" ref={panelRef}>
|
||
<div className="discovery-price-row">
|
||
<span>Preis</span>
|
||
<strong>CHF 48.–</strong>
|
||
</div>
|
||
|
||
<div className="discovery-panel-facts">
|
||
{discoveryPanelFacts.map((item) => (
|
||
<div key={item.label}>
|
||
<span>{item.label}</span>
|
||
<p>{item.value}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="discovery-panel-actions">
|
||
<button
|
||
type="button"
|
||
className="atmos-btn atmos-btn--primary atmos-btn--block atmos-btn--uppercase"
|
||
onClick={onBuy}
|
||
>
|
||
Kaufen
|
||
</button>
|
||
<p>Nur das erste Set erstellt einen einmaligen CHF 48 Rabatt auf den 50-ml-Kauf.</p>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function DiscoveryHero({ onBuy, panelRef }) {
|
||
return (
|
||
<section className="discovery-hero" data-reveal-group data-reveal-start="top 90%">
|
||
<div className="discovery-hero-stage">
|
||
<div className="discovery-hero-copy" data-reveal="fade">
|
||
<span className="discovery-kicker">Discovery Set</span>
|
||
<h1>Der Einstieg</h1>
|
||
|
||
<p className="discovery-intro">
|
||
6 Düfte × 2ml. Jeden Duft eine Woche tragen. Verstehen, was
|
||
wirklich funktioniert. Ohne Risiko. Der sichere Einstieg in die
|
||
Welt der Nischendüfte, bevor du dich für einen 50-ml-Flakon entscheidest.
|
||
</p>
|
||
</div>
|
||
|
||
<figure className="discovery-hero-visual" data-reveal="fade">
|
||
<img
|
||
src={DISCOVERY_SET_IMAGE}
|
||
alt="Atmos Discovery Set"
|
||
loading="eager"
|
||
decoding="async"
|
||
/>
|
||
</figure>
|
||
</div>
|
||
|
||
<DiscoveryOrderPanel onBuy={onBuy} panelRef={panelRef} />
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DiscoveryStorySection() {
|
||
return (
|
||
<section className="discovery-story-grid" data-reveal-group>
|
||
<div className="discovery-story-copy">
|
||
<span className="discovery-label" data-reveal="fade">
|
||
Warum Discovery Set
|
||
</span>
|
||
<h2 data-reveal="lines">Der klügere Einstieg.</h2>
|
||
<p data-reveal="fade">
|
||
Nischen-Parfums sind keine Impulskäufe. Sie brauchen Zeit, um zu
|
||
verstehen, wie sie auf deiner Haut funktionieren, wie sie sich im
|
||
Alltag entwickeln und ob sie wirklich zu dir passen.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="discovery-benefit-panel" data-reveal="fade">
|
||
<div className="discovery-benefit-panel-head">
|
||
<span>Testlogik</span>
|
||
<strong>6 × 2ml</strong>
|
||
</div>
|
||
|
||
{discoveryBenefits.map((benefit) => (
|
||
<article className="discovery-benefit" key={benefit.title}>
|
||
<div>
|
||
<strong>{benefit.title}</strong>
|
||
<p>{benefit.text}</p>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DiscoveryProcessSection() {
|
||
return (
|
||
<section className="discovery-process-section" data-reveal-group>
|
||
<div className="discovery-section-intro">
|
||
<span className="discovery-label" data-reveal="fade">
|
||
Ablauf
|
||
</span>
|
||
<h2 data-reveal="lines">So funktioniert's</h2>
|
||
</div>
|
||
|
||
<div className="discovery-steps">
|
||
{discoverySteps.map((step) => (
|
||
<article key={step.number} className="discovery-step" data-reveal="fade">
|
||
<span className="discovery-step__number">{step.number}</span>
|
||
<h3 className="discovery-step__title">{step.title}</h3>
|
||
<p className="discovery-step__text">{step.text}</p>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DiscoveryIncludedSection() {
|
||
const sectionRef = useRef(null);
|
||
const itemRefs = useRef([]);
|
||
const activeIndexRef = useRef(0);
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
|
||
const setItemRef = useCallback((element, index) => {
|
||
itemRefs.current[index] = element;
|
||
}, []);
|
||
|
||
const activateItem = useCallback((index) => {
|
||
if (activeIndexRef.current === index) {
|
||
return;
|
||
}
|
||
|
||
activeIndexRef.current = index;
|
||
setActiveIndex(index);
|
||
|
||
const items = itemRefs.current.filter(Boolean);
|
||
|
||
gsap.killTweensOf(items);
|
||
gsap.to(items, {
|
||
opacity: (itemIndex) => (itemIndex === index ? 1 : 0.64),
|
||
duration: 0.36,
|
||
ease: "power3.out",
|
||
overwrite: "auto",
|
||
});
|
||
}, []);
|
||
|
||
useLayoutEffect(() => {
|
||
const section = sectionRef.current;
|
||
const items = itemRefs.current.filter(Boolean);
|
||
|
||
if (!section || items.length === 0 || typeof window === "undefined") {
|
||
return undefined;
|
||
}
|
||
|
||
const prefersReducedMotion = window.matchMedia(
|
||
"(prefers-reduced-motion: reduce)"
|
||
).matches;
|
||
|
||
if (prefersReducedMotion) {
|
||
activeIndexRef.current = 0;
|
||
return undefined;
|
||
}
|
||
|
||
const ctx = gsap.context(() => {
|
||
gsap.set(items, {
|
||
opacity: (index) => (index === activeIndexRef.current ? 1 : 0.64),
|
||
force3D: true,
|
||
});
|
||
|
||
const updateActiveItem = () => {
|
||
const viewportCenter = window.innerHeight / 2;
|
||
let closestIndex = 0;
|
||
let closestDistance = Number.POSITIVE_INFINITY;
|
||
|
||
items.forEach((item, index) => {
|
||
const rect = item.getBoundingClientRect();
|
||
const itemCenter = rect.top + rect.height / 2;
|
||
const distance = Math.abs(itemCenter - viewportCenter);
|
||
|
||
if (distance < closestDistance) {
|
||
closestDistance = distance;
|
||
closestIndex = index;
|
||
}
|
||
});
|
||
|
||
activateItem(closestIndex);
|
||
};
|
||
|
||
ScrollTrigger.create({
|
||
trigger: section,
|
||
start: "top bottom",
|
||
end: "bottom top",
|
||
onEnter: updateActiveItem,
|
||
onEnterBack: updateActiveItem,
|
||
onUpdate: updateActiveItem,
|
||
onRefresh: updateActiveItem,
|
||
});
|
||
|
||
updateActiveItem();
|
||
ScrollTrigger.refresh();
|
||
}, section);
|
||
|
||
return () => {
|
||
gsap.killTweensOf(items);
|
||
ctx.revert();
|
||
};
|
||
}, [activateItem]);
|
||
|
||
return (
|
||
<section className="discovery-included" data-reveal-group ref={sectionRef}>
|
||
<div className="discovery-section-heading">
|
||
<span className="discovery-label" data-reveal="fade">
|
||
Im Set enthalten
|
||
</span>
|
||
<h2 className="discovery-included-title" data-reveal="lines">
|
||
6 Signature
|
||
<br />
|
||
Düfte
|
||
</h2>
|
||
</div>
|
||
|
||
<div className="discovery-included-layout">
|
||
<ol className="discovery-scroll-list" aria-label="Düfte im Discovery Set">
|
||
{perfumes.map((perfume, index) => {
|
||
const isActive = activeIndex === index;
|
||
|
||
return (
|
||
<li className="discovery-scroll-list-item" key={perfume.id} data-reveal-group data-reveal-start="top 84%">
|
||
<article
|
||
className={`discovery-scroll-item${
|
||
isActive ? " discovery-scroll-item--active" : ""
|
||
}`}
|
||
aria-current={isActive ? "true" : undefined}
|
||
ref={(element) => setItemRef(element, index)}
|
||
>
|
||
<span className="discovery-product-index" data-reveal="fade">{perfume.id}</span>
|
||
|
||
<div className="discovery-scroll-copy" data-reveal="fade">
|
||
<h3>{perfume.name}</h3>
|
||
<div className="discovery-scroll-line" aria-hidden="true" />
|
||
<div className="discovery-scroll-mobile-detail">
|
||
<p>{perfume.text}</p>
|
||
<p>{perfume.mood}</p>
|
||
</div>
|
||
|
||
<div className="discovery-product-tags discovery-scroll-mobile-tags">
|
||
{perfume.materialTags.slice(0, 3).map((tag) => (
|
||
<span key={tag}>{tag}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
|
||
<aside className="discovery-story-visual-panel" aria-label="Aktiver Duft">
|
||
<div className="discovery-story-visual-images" aria-hidden="true">
|
||
{perfumes.map((perfume, index) => {
|
||
const moodImage =
|
||
perfume.storyVisualImage || perfume.phaseVisualImages?.[0];
|
||
|
||
return (
|
||
<img
|
||
key={perfume.id}
|
||
src={moodImage}
|
||
alt=""
|
||
className={activeIndex === index ? "is-active" : ""}
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="discovery-story-visual-content">
|
||
<span>Material-Komposition</span>
|
||
<div className="discovery-story-tags">
|
||
{perfumes[activeIndex].materialTags.map((tag) => (
|
||
<span key={tag}>{tag}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DiscoveryFinalCta({ onBuy }) {
|
||
return (
|
||
<section className="discovery-final-cta" data-reveal-group data-on-accent>
|
||
<div>
|
||
<span className="discovery-label" data-reveal="fade">
|
||
Discovery Set
|
||
</span>
|
||
<h2 data-reveal="lines">Der sichere Einstieg.</h2>
|
||
<p data-reveal="fade">
|
||
Kostenloser Versand · 2–3 Werktage · Einmalige Anrechnung beim 50-ml-Kauf
|
||
</p>
|
||
</div>
|
||
|
||
<div className="discovery-final-actions" data-reveal="fade">
|
||
<button
|
||
type="button"
|
||
className="atmos-btn atmos-btn--primary atmos-btn--uppercase"
|
||
onClick={onBuy}
|
||
>
|
||
Discovery Set bestellen
|
||
</button>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DiscoverySetPage() {
|
||
const { addToCart } = useShop();
|
||
const orderPanelRef = useRef(null);
|
||
const buyDiscoverySet = () =>
|
||
addToCart("discovery-set", 1, "Discovery Set wurde in den Warenkorb gelegt.").catch(() => {});
|
||
|
||
return (
|
||
<div className="discovery-page">
|
||
<PageMeta
|
||
title="Discovery Set"
|
||
description="6 × 2-ml-Proben aller atmos-Düfte. Eine Woche pro Duft tragen, verstehen, welcher passt. CHF 48, anrechenbar beim 50-ml-Kauf."
|
||
path="/discovery-set"
|
||
/>
|
||
<SharedNavbar variant="hero" active="testen" />
|
||
|
||
<main id="main-content" className="shell">
|
||
<DiscoveryHero onBuy={buyDiscoverySet} panelRef={orderPanelRef} />
|
||
<DiscoveryStorySection />
|
||
<DiscoveryIncludedSection />
|
||
<DiscoveryProcessSection />
|
||
<DiscoveryFinalCta onBuy={buyDiscoverySet} />
|
||
</main>
|
||
|
||
<StickyBuyBar
|
||
label="Discovery Set"
|
||
price="CHF 48.–"
|
||
observeRef={orderPanelRef}
|
||
onBuy={buyDiscoverySet}
|
||
alwaysVisibleMobile
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default DiscoverySetPage;
|