add new product flow arrangement

This commit is contained in:
Ermin Zoronjic 2026-04-29 00:39:49 +02:00
parent 41fa7ddf73
commit 5ca7152011
7 changed files with 1882 additions and 1933 deletions

View File

@ -13,6 +13,7 @@ import SupportChatbot from "./components/SupportChatbot";
import ScrollToTop from "./components/ScrollToTop";
import ShopDrawer from "./components/ShopDrawer";
import CartToast from "./components/CartToast";
import { ProductTransitionProvider } from "./components/ProductTransition";
import useScrollTextReveal from "./hooks/useScrollTextReveal";
import { ThemeProvider } from "./theme/ThemeContext";
import "./style/textReveal.css";
@ -52,10 +53,10 @@ function App() {
return (
<ThemeProvider value={{ theme, isLight, toggleTheme }}>
<>
<ProductTransitionProvider>
<ScrollToTop />
<div ref={routeContentRef}>
<div ref={routeContentRef} data-route-content>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/duft/:perfumeSlug" element={<ProductDetailPage />} />
@ -72,7 +73,7 @@ function App() {
<CartToast />
<Footer flushTop={shouldFlushFooter} />
<SupportChatbot />
</>
</ProductTransitionProvider>
</ThemeProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import perfumes from "../data/perfumes";
import SharedNavbar from "./SharedNavbar";
import { useProductTransition } from "../transitions/ProductTransitionContext";
import { formatChf } from "../shop/money";
import { useShop } from "../shop/useShop";
import "./ProductDetailPage.css";
@ -11,9 +12,457 @@ const priceToCents = (price) => {
return match ? Number(match[1]) * 100 : 0;
};
function ProductPurchasePanel({
perfume,
selectedSize,
setSelectedSize,
selectedPriceCents,
discountPreviewCents,
addToCart,
subscribeToProduct,
}) {
const selectedProductId = `${perfume.slug}-${selectedSize === "sample" ? "sample" : "full"}`;
const selectedProductLabel = selectedSize === "sample" ? "Sample" : "Full Size";
const sizeOptions = [
{
key: "sample",
title: "Sample 2ml",
price: perfume.prices.sample,
note: "Zum Testen, ca. 20 Anwendungen",
},
{
key: "full",
title: "Full Size 50ml",
price: perfume.prices.full,
note: "Nachkauf, 500+ Anwendungen",
},
];
return (
<aside className="product-purchase-panel" data-product-transition-reveal>
<div className="purchase-price-row">
<span>Preis</span>
<strong>{perfume.prices[selectedSize]}</strong>
</div>
<div className="purchase-size-group">
<span className="label-title">Größe wählen</span>
<div className="size-grid">
{sizeOptions.map((option) => (
<button
key={option.key}
type="button"
className={`size-card ${selectedSize === option.key ? "active" : ""}`}
onClick={() => setSelectedSize(option.key)}
aria-pressed={selectedSize === option.key}
>
<span className="size-title">{option.title}</span>
<strong>{option.price}</strong>
<small>{option.note}</small>
</button>
))}
</div>
</div>
<div className="purchase-actions">
<button
className="buy-button"
type="button"
onClick={() =>
addToCart(
selectedProductId,
1,
`${perfume.name} ${selectedProductLabel} added.`
).catch(() => {})
}
>
Kaufen
</button>
<button
className="restock-button"
type="button"
onClick={() => subscribeToProduct(selectedProductId, "restock").catch(() => {})}
>
Restock Update abonnieren
</button>
</div>
<div className="purchase-discovery-note">
<div>
<strong>Discovery Set wird angerechnet</strong>
<p>
Sample- und Set-Guthaben werden beim Full-Size-Kauf automatisch
abgezogen.
</p>
{discountPreviewCents > 0 && (
<p className="discount-preview">
Erwarteter Preis mit Rabatt:{" "}
<strong>{formatChf(selectedPriceCents - discountPreviewCents)}</strong>
</p>
)}
</div>
<Link to="/discovery-set">Zum Set</Link>
</div>
</aside>
);
}
function ProductHero({
perfume,
selectedImage,
setSelectedImage,
selectedSize,
setSelectedSize,
selectedPriceCents,
discountPreviewCents,
addToCart,
subscribeToProduct,
}) {
const galleryImages = [...new Set([perfume.image, ...(perfume.gallery || [])])].slice(0, 3);
return (
<section className="product-hero">
<div className="product-media-column">
<div className="product-hero-copy" data-product-transition-reveal>
<span>Edition {perfume.id}</span>
<h1>{perfume.name}</h1>
</div>
<div className="product-hero-visual">
<img
src={selectedImage}
alt={perfume.name}
className="product-hero-image"
data-product-transition-target={perfume.slug}
decoding="async"
/>
</div>
<div className="product-thumbs" data-product-transition-reveal>
{galleryImages.map((img, index) => (
<button
key={`${img}-${index}`}
type="button"
className={`thumb-btn ${selectedImage === img ? "active" : ""}`}
onClick={() => setSelectedImage(img)}
aria-label={`${perfume.name} Ansicht ${index + 1}`}
>
<img src={img} alt="" loading="lazy" decoding="async" />
</button>
))}
</div>
<div className="hero-fact-grid" data-product-transition-reveal>
<div>
<span>Tragehinweis</span>
<p>{perfume.dosage}</p>
</div>
<div>
<span>Konzentration</span>
<p>{perfume.concentration}</p>
</div>
<div>
<span>Studio</span>
<p>{perfume.description}</p>
</div>
</div>
</div>
<ProductPurchasePanel
perfume={perfume}
selectedSize={selectedSize}
setSelectedSize={setSelectedSize}
selectedPriceCents={selectedPriceCents}
discountPreviewCents={discountPreviewCents}
addToCart={addToCart}
subscribeToProduct={subscribeToProduct}
/>
</section>
);
}
function ProductStorySection({ perfume }) {
return (
<section className="product-story-grid" data-reveal-group>
<div className="story-copy">
<span className="eyebrow" data-reveal="fade">Beschreibung / Charakter</span>
<h2 data-reveal="lines">{perfume.text}</h2>
<p data-reveal="fade">{perfume.mood}</p>
<div className="character-facts" data-reveal="fade">
<div>
<span>Haltbarkeit</span>
<p>{perfume.longevity}</p>
</div>
<div>
<span>Anlass</span>
<p>{perfume.occasion}</p>
</div>
<div>
<span>Lieferung</span>
<p>Versand in 1-2 Werktagen. Zustellung in der Regel in 5-6 Tagen.</p>
</div>
</div>
</div>
<div className="story-visual-panel" data-reveal="fade">
<div>
<span>Material-Komposition</span>
<div className="material-tags">
{perfume.materialTags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</div>
</div>
</section>
);
}
function ProductStructureSection({ perfume }) {
const phaseGroups = [
{ label: "Top Notes / 0-1 h", notes: perfume.phases.top },
{ label: "Heart Notes / 1-4 h", notes: perfume.phases.heart },
{ label: "Base Notes / 4 h+", notes: perfume.phases.base },
];
return (
<section className="product-structure-section" data-reveal-group>
<div className="section-intro">
<span className="eyebrow" data-reveal="fade">Duftstruktur</span>
<h2 data-reveal="lines">Die Spur entwickelt sich in Schichten.</h2>
</div>
<div className="structure-timeline">
{phaseGroups.map((phase, index) => (
<article className="structure-phase-card" key={phase.label} data-reveal="fade">
<span className="structure-index">0{index + 1}</span>
<h3>{phase.label}</h3>
<div>
{phase.notes.map((note) => (
<span key={note}>{note}</span>
))}
</div>
</article>
))}
</div>
</section>
);
}
function ProductMetaSection({ perfume }) {
const metaItems = [
["Parfümerie / Studio", perfume.description],
["Herkunft", perfume.origin],
["Konzentration", perfume.concentration],
["Edition", perfume.edition],
];
return (
<section className="product-meta-section" data-reveal-group>
<div className="meta-grid">
{metaItems.map(([label, value]) => (
<div className="meta-card" key={label} data-reveal="fade">
<span>{label}</span>
<p>{value}</p>
</div>
))}
</div>
<div className="delivery-panel" data-reveal="fade">
<div className="delivery-panel-header">
<span className="eyebrow">Lieferung</span>
<span className="delivery-badge">CH</span>
</div>
<div className="delivery-grid">
<div>
<span>Versand</span>
<p>Innerhalb von 1-2 Werktagen</p>
</div>
<div>
<span>Zustellung</span>
<p>In der Regel in 5-6 Tagen bei dir</p>
</div>
<div>
<span>Hinweis</span>
<p>Sorgfältig verpackt und geschützt versendet.</p>
</div>
</div>
</div>
</section>
);
}
function ProductReviews({
reviewSummary,
safeCommentPages,
commentPage,
setCommentPage,
showReviewDetails,
setShowReviewDetails,
}) {
return (
<section className="product-reviews-section" data-reveal-group>
<div className="reviews-heading" data-reveal="fade">
<div>
<span className="eyebrow">Stimmen zum Duft</span>
<h2>Resonanz</h2>
<p>
Verdichtete Wahrnehmung aus bisherigen Stimmen zu Charakter,
Haltbarkeit, Sillage und Originalität.
</p>
</div>
<div className="review-score-block">
<strong>{reviewSummary.score.toFixed(1)}</strong>
<span></span>
<small>{reviewSummary.total} Stimmen</small>
</div>
</div>
<div className="comment-spotlight" data-reveal="fade">
<div className="comment-dots">
{safeCommentPages.map((_, index) => (
<button
key={index}
type="button"
className={`comment-dot ${commentPage === index ? "active" : ""}`}
onClick={() => setCommentPage(index)}
aria-label={`Kommentargruppe ${index + 1}`}
/>
))}
</div>
<div className="comment-spotlight-grid">
{safeCommentPages[commentPage].map((comment) => (
<article className="comment-card" key={comment.id}>
<span>{comment.title}</span>
<p>{comment.text}</p>
<small>{comment.name}</small>
</article>
))}
</div>
</div>
<div className={`review-panel ${showReviewDetails ? "is-open" : ""}`} data-reveal="fade">
<button
type="button"
className="review-toggle"
onClick={() => setShowReviewDetails((prev) => !prev)}
aria-expanded={showReviewDetails}
>
<span>Detailbewertungen</span>
<span className={showReviewDetails ? "review-toggle-icon open" : "review-toggle-icon"}>
+
</span>
</button>
{showReviewDetails && (
<div className="review-details">
{reviewSummary.metrics.map((metric) => (
<div className="review-detail-row" key={metric.label}>
<span>{metric.label}</span>
<div className="review-detail-bar">
<div
className="review-detail-fill"
style={{ width: `${(metric.value / 5) * 100}%` }}
/>
</div>
<strong>{metric.value.toFixed(1)}</strong>
</div>
))}
<button type="button" className="review-write-button" disabled>
Bewertung schreiben
</button>
</div>
)}
</div>
</section>
);
}
function ProductTestingCTA({ perfume, addToCart }) {
return (
<section className="detail-bottom-cta" data-reveal-group>
<div>
<span className="eyebrow" data-reveal="fade">Lieber erst testen?</span>
<h2 data-reveal="lines">Sample oder Discovery Set.</h2>
<p data-reveal="fade">
Bestelle ein 2ml Sample für CHF 12 oder das komplette Discovery Set mit
allen 6 Düften für CHF 48. Beide werden beim späteren Full-Size-Kauf
vollständig angerechnet.
</p>
</div>
<div className="detail-bottom-actions" data-reveal="fade">
<button
type="button"
onClick={() =>
addToCart(`${perfume.slug}-sample`, 1, `${perfume.name} Sample added.`).catch(
() => {}
)
}
>
Sample bestellen - {perfume.prices.sample}
</button>
<button
type="button"
onClick={() => addToCart("discovery-set", 1, "Discovery Set added.").catch(() => {})}
>
Discovery Set - CHF 48
</button>
</div>
</section>
);
}
function ProductRecommendations({ currentSlug, startProductTransition }) {
const recommendations = perfumes
.filter((item) => item.slug !== currentSlug)
.slice(0, 3);
return (
<section className="recommendation-section" data-reveal-group>
<div className="recommendation-heading">
<span className="eyebrow" data-reveal="fade">Empfehlungen</span>
<h2 data-reveal="lines">Weitere Atmosphären</h2>
</div>
<div className="recommendation-grid">
{recommendations.map((item) => (
<Link
to={`/duft/${item.slug}`}
className="recommendation-card"
key={item.slug}
onClick={(event) => startProductTransition(event, item)}
data-reveal="fade"
>
<span>{item.id}</span>
<img
src={item.image}
alt={item.name}
loading="lazy"
decoding="async"
data-product-transition-source
/>
<div>
<h3>{item.name}</h3>
<p>{item.text}</p>
</div>
</Link>
))}
</div>
</section>
);
}
function ProductDetailContent({ perfumeSlug }) {
const navigate = useNavigate();
const { addToCart, subscribeToProduct, user } = useShop();
const { activeSlug, phase, startProductTransition } = useProductTransition();
const perfume = useMemo(
() => perfumes.find((item) => item.slug === perfumeSlug) || perfumes[0],
@ -26,10 +475,6 @@ function ProductDetailContent({ perfumeSlug }) {
const [selectedSize, setSelectedSize] = useState("sample");
const [showReviewDetails, setShowReviewDetails] = useState(false);
const [commentPage, setCommentPage] = useState(0);
const [isStructureOpen, setIsStructureOpen] = useState(false);
const [isMoodOpen, setIsMoodOpen] = useState(false);
const selectedProductId = `${perfume.slug}-${selectedSize === "sample" ? "sample" : "full"}`;
const selectedProductLabel = selectedSize === "sample" ? "Sample" : "Full Size";
const selectedPriceCents = priceToCents(perfume.prices[selectedSize]);
const sampleCredit = user?.sampleCredits?.find(
(credit) => credit.slug === perfume.slug && credit.status === "available"
@ -43,47 +488,35 @@ function ProductDetailContent({ perfumeSlug }) {
)
: 0;
const sizeOptions = [
{
key: "sample",
title: "Sample 2ml",
price: perfume.prices.sample,
note: "Zum Testen · ca. 20 Anwendungen",
},
{
key: "full",
title: "Full Size 50ml",
price: perfume.prices.full,
note: "Nachkauf · 500+ Anwendungen",
},
];
const reviewSummary = perfume.reviews || {
score: 0,
total: 0,
metrics: [],
};
const reviewComments = perfume.commentSpotlight || [];
const safeCommentPages = useMemo(() => {
const reviewComments = perfume.commentSpotlight || [];
const pages = [];
const commentPages = [];
for (let i = 0; i < reviewComments.length; i += 2) {
commentPages.push(reviewComments.slice(i, i + 2));
}
for (let i = 0; i < reviewComments.length; i += 2) {
pages.push(reviewComments.slice(i, i + 2));
}
const safeCommentPages =
commentPages.length > 0
? commentPages
return pages.length > 0
? pages
: [
[
{
id: "fallback-1",
name: "Atelier",
title: "Noch keine Stimmen",
text: "Für diesen Duft sind aktuell noch keine Kommentare hinterlegt.",
},
],
];
[
{
id: "fallback-1",
name: "Atelier",
title: "Noch keine Stimmen",
text: "Für diesen Duft sind aktuell noch keine Kommentare hinterlegt.",
},
],
];
}, [perfume.commentSpotlight]);
const isTransitionArriving = activeSlug === perfume.slug && phase === "entering";
useEffect(() => {
const interval = window.setInterval(() => {
@ -94,412 +527,50 @@ function ProductDetailContent({ perfumeSlug }) {
}, [safeCommentPages.length]);
return (
<div className="detail-page">
<div className={`detail-page ${isTransitionArriving ? "is-transition-arriving" : ""}`}>
<SharedNavbar variant="light" />
<main className="detail-shell">
<div className="detail-topbar">
<div className="detail-topbar" data-product-transition-reveal>
<button className="back-link" type="button" onClick={() => navigate("/")}>
<span className="back-link-arrow"></span>
<span>Zurück zur Startseite</span>
</button>
<div className="detail-topbar-meta">
<span className="detail-topbar-label">DUFTDETAIL</span>
<span className="detail-topbar-name">{perfume.name}</span>
<span>Duftdetail</span>
<strong>{perfume.name}</strong>
</div>
</div>
<section className="detail-layout">
<div className="detail-gallery">
<div className="detail-main-image">
<img src={selectedImage} alt={perfume.name} decoding="async" />
</div>
<ProductHero
perfume={perfume}
selectedImage={selectedImage}
setSelectedImage={setSelectedImage}
selectedSize={selectedSize}
setSelectedSize={setSelectedSize}
selectedPriceCents={selectedPriceCents}
discountPreviewCents={discountPreviewCents}
addToCart={addToCart}
subscribeToProduct={subscribeToProduct}
/>
<div className="detail-thumbs">
{[perfume.image, ...(perfume.gallery || [])]
.slice(0, 3)
.map((img, index) => (
<button
key={`${img}-${index}`}
type="button"
className={`thumb-btn ${selectedImage === img ? "active" : ""}`}
onClick={() => setSelectedImage(img)}
>
<img
src={img}
alt={`${perfume.name} Ansicht ${index + 1}`}
loading="lazy"
decoding="async"
/>
</button>
))}
</div>
<div className="detail-meta-grid detail-meta-grid--top">
<div>
<span>TRAGEHINWEIS</span>
<p>{perfume.dosage}</p>
</div>
<div>
<span>HALTBARKEIT</span>
<p>{perfume.longevity}</p>
</div>
<div>
<span>ANLASS</span>
<p>{perfume.occasion}</p>
</div>
</div>
{/* --- Accordion Group Start --- */}
<div className="accordion-group">
{/* Dropdown: Duftstruktur */}
<div className={`accordion-item ${isStructureOpen ? "is-open" : ""}`}>
<button
type="button"
className="accordion-toggle"
onClick={() => setIsStructureOpen(!isStructureOpen)}
>
<span>DUFTSTRUKTUR</span>
<span className="accordion-icon">{isStructureOpen ? "" : "+"}</span>
</button>
{isStructureOpen && (
<div className="accordion-content">
<div className="detail-structure-layout">
<div className="detail-structure">
<div className="structure-block">
<span className="structure-phase">PHASE 1: TOP NOTES (01 H)</span>
<div className="structure-tags-grid">
{perfume.phases.top.map((note) => (
<span key={note} className="structure-tag">{note}</span>
))}
</div>
</div>
<div className="structure-block">
<span className="structure-phase">PHASE 2: HEART NOTES (14 H)</span>
<div className="structure-tags-grid">
{perfume.phases.heart.map((note) => (
<span key={note} className="structure-tag">{note}</span>
))}
</div>
</div>
<div className="structure-block">
<span className="structure-phase">PHASE 3: BASE NOTES (4 H+)</span>
<div className="structure-tags-grid">
{perfume.phases.base.map((note) => (
<span key={note} className="structure-tag">{note}</span>
))}
</div>
</div>
</div>
<aside className="structure-info-box">
<span className="structure-info-label">ZUR EINORDNUNG</span>
<p>
Die Duftstruktur zeigt, wie sich der Duft über die Zeit entfaltet:
der erste Eindruck im Auftakt, die eigentliche Signatur im Herzen
und die Spur, die lange auf Haut und Kleidung bleibt.
</p>
<div className="structure-info-legend">
<div>
<span className="structure-info-dot structure-info-dot--light" />
<span>Auftakt</span>
</div>
<div>
<span className="structure-info-dot structure-info-dot--mid" />
<span>Herz</span>
</div>
<div>
<span className="structure-info-dot structure-info-dot--strong" />
<span>Basis</span>
</div>
</div>
</aside>
</div>
</div>
)}
</div>
{/* Dropdown: Moodsetting */}
<div className={`accordion-item ${isMoodOpen ? "is-open" : ""}`}>
<button
type="button"
className="accordion-toggle"
onClick={() => setIsMoodOpen(!isMoodOpen)}
>
<span>MOODSETTING</span>
<span className="accordion-icon">{isMoodOpen ? "" : "+"}</span>
</button>
{isMoodOpen && (
<div className="accordion-content">
<div className="mood-box-content">
<p>{perfume.mood}</p>
</div>
</div>
)}
</div>
</div>
{/* --- Accordion Group End --- */}
</div>
<div className="detail-info">
<div className="detail-heading" data-reveal-group>
<div className="detail-heading-copy">
<span className="detail-kicker" data-reveal="fade">
Edition 04
</span>
<h1 data-reveal="fade">{perfume.name}</h1>
<p data-reveal="fade">{perfume.shortText}</p>
</div>
</div>
<div className="detail-section-block">
<span className="label-title">MATERIAL-KOMPOSITION</span>
<div className="material-tags">
{perfume.materialTags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</div>
<div className="detail-section-block">
<span className="label-title">GRÖSSE WÄHLEN</span>
<div className="size-grid">
{sizeOptions.map((option) => (
<button
key={option.key}
type="button"
className={`size-card ${selectedSize === option.key ? "active" : ""}`}
onClick={() => setSelectedSize(option.key)}
>
<span className="size-title">{option.title}</span>
<strong>{option.price}</strong>
<small>{option.note}</small>
</button>
))}
</div>
</div>
<div className="discovery-note" data-reveal-group data-reveal-start="top 88%">
<div className="discovery-note-text" data-reveal="fade">
<strong>Discovery Set wird einmalig angerechnet</strong>
<p>
Nur das erste Discovery Set erzeugt CHF 48 Guthaben. Es wird
einmal bei einem späteren Full-Size-Kauf automatisch abgezogen.
</p>
{discountPreviewCents > 0 && (
<p className="discount-preview">
Erwarteter Preis mit Rabatt:{" "}
<strong>{formatChf(selectedPriceCents - discountPreviewCents)}</strong>
</p>
)}
</div>
<Link to="/discovery-set" className="discovery-note-btn" data-reveal="fade">
Zum Set
</Link>
</div>
<button
className="buy-button"
type="button"
onClick={() =>
addToCart(
selectedProductId,
1,
`${perfume.name} ${selectedProductLabel} added.`
).catch(() => {})
}
>
KAUFEN
</button>
<button
className="restock-button"
type="button"
onClick={() => subscribeToProduct(selectedProductId, "restock").catch(() => {})}
>
RESTOCK UPDATE ABONNIEREN
</button>
<div className="detail-description-section">
<span className="label-title">BESCHREIBUNG</span>
<div className="detail-columns">
<div className="detail-copy-block">
<span className="detail-copy-label">PARFÜMERIE / STUDIO</span>
<p>{perfume.description}</p>
</div>
<div className="detail-copy-block">
<span className="detail-copy-label">HERKUNFT</span>
<p>{perfume.origin}</p>
</div>
<div className="detail-copy-block">
<span className="detail-copy-label">KONZENTRATION</span>
<p>{perfume.concentration}</p>
</div>
<div className="detail-copy-block">
<span className="detail-copy-label">EDITION</span>
<p>{perfume.edition}</p>
</div>
</div>
</div>
<div className="delivery-box">
<div className="delivery-box-header">
<span className="label-title">LIEFERUNG</span>
<span className="delivery-badge">CH</span>
</div>
<div className="delivery-grid">
<div className="delivery-item">
<span className="delivery-item-label">VERSAND</span>
<p>Innerhalb von 12 Werktagen</p>
</div>
<div className="delivery-item">
<span className="delivery-item-label">ZUSTELLUNG</span>
<p>In der Regel in 56 Tagen bei dir</p>
</div>
<div className="delivery-item delivery-item--full">
<span className="delivery-item-label">HINWEIS</span>
<p>Sorgfältig verpackt und geschützt versendet.</p>
</div>
</div>
</div>
<div className="comment-spotlight">
<div className="comment-spotlight-header">
<span className="label-title">STIMMEN ZUM DUFT</span>
<div className="comment-dots">
{safeCommentPages.map((_, index) => (
<button
key={index}
type="button"
className={`comment-dot ${commentPage === index ? "active" : ""}`}
onClick={() => setCommentPage(index)}
aria-label={`Kommentargruppe ${index + 1}`}
/>
))}
</div>
</div>
<div className="comment-spotlight-grid">
{safeCommentPages[commentPage].map((comment) => (
<article className="comment-card" key={comment.id}>
<span className="comment-card-title">{comment.title}</span>
<p>{comment.text}</p>
<span className="comment-card-author">{comment.name}</span>
</article>
))}
</div>
</div>
<div className="review-section">
<div className="review-section-header">
<div className="review-section-copy">
<span className="label-title">RESONANZ</span>
<p className="review-section-text">
Verdichtete Wahrnehmung aus bisherigen Stimmen zu Charakter,
Haltbarkeit, Sillage und Originalität.
</p>
</div>
<div className="review-section-main">
<span className="review-score">{reviewSummary.score.toFixed(1)}</span>
<div className="review-summary-copy">
<span className="review-stars"></span>
<span className="review-count">{reviewSummary.total} Stimmen</span>
</div>
</div>
</div>
<div className={`review-panel ${showReviewDetails ? "is-open" : ""}`}>
<button
type="button"
className="review-toggle"
onClick={() => setShowReviewDetails((prev) => !prev)}
aria-expanded={showReviewDetails}
>
<span>Detailbewertungen</span>
<span className={showReviewDetails ? "review-toggle-icon open" : "review-toggle-icon"}>
+
</span>
</button>
{showReviewDetails && (
<div className="review-popover">
<div className="review-details">
{reviewSummary.metrics.map((metric) => (
<div className="review-detail-row" key={metric.label}>
<span className="review-detail-label">{metric.label}</span>
<div className="review-detail-bar">
<div
className="review-detail-fill"
style={{ width: `${(metric.value / 5) * 100}%` }}
/>
</div>
<span className="review-detail-value">
{metric.value.toFixed(1)}
</span>
</div>
))}
<button
type="button"
className="review-write-button"
disabled
title="Später mit Login verfügbar"
>
Bewertung schreiben
</button>
</div>
</div>
)}
</div>
</div>
</div>
</section>
<section className="detail-bottom-cta" data-reveal-group>
<h2 data-reveal="fade">Lieber erst testen?</h2>
<p data-reveal="fade">
Bestelle ein 2ml Sample für CHF 12 oder das komplette Discovery Set
mit allen 6 Düften für CHF 48. Beide werden beim späteren Full-Size-Kauf
vollständig angerechnet.
</p>
<div className="detail-bottom-actions" data-reveal="fade">
<button
type="button"
onClick={() =>
addToCart(`${perfume.slug}-sample`, 1, `${perfume.name} Sample added.`).catch(() => {})
}
>
SAMPLE BESTELLEN {perfume.prices.sample}
</button>
<button
type="button"
onClick={() => addToCart("discovery-set", 1, "Discovery Set added.").catch(() => {})}
>
DISCOVERY SET CHF 48
</button>
</div>
</section>
<ProductStorySection perfume={perfume} />
<ProductStructureSection perfume={perfume} />
<ProductMetaSection perfume={perfume} />
<ProductReviews
reviewSummary={reviewSummary}
safeCommentPages={safeCommentPages}
commentPage={commentPage}
setCommentPage={setCommentPage}
showReviewDetails={showReviewDetails}
setShowReviewDetails={setShowReviewDetails}
/>
<ProductTestingCTA perfume={perfume} addToCart={addToCart} />
<ProductRecommendations
currentSlug={perfume.slug}
startProductTransition={startProductTransition}
/>
</main>
</div>
);

View File

@ -0,0 +1,40 @@
.product-transition {
position: fixed;
inset: 0;
z-index: 2500;
pointer-events: none;
opacity: 0;
visibility: hidden;
overflow: hidden;
}
.product-transition__wash {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 50% 50%, rgba(var(--theme-accent-rgb) / 0.16), transparent 32rem),
rgba(12, 12, 12, 0.78);
backdrop-filter: blur(14px) saturate(0.9);
-webkit-backdrop-filter: blur(14px) saturate(0.9);
}
.product-transition__image {
position: absolute;
top: 0;
left: 0;
display: block;
object-fit: contain;
transform-origin: 0 0;
will-change: transform, opacity;
filter: drop-shadow(0 34px 82px rgba(0, 0, 0, 0.42));
}
body.product-transition-active {
cursor: progress;
}
@media (prefers-reduced-motion: reduce) {
.product-transition {
display: none;
}
}

View File

@ -0,0 +1,327 @@
import {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useNavigate } from "react-router";
import { gsap } from "gsap";
import { ProductTransitionContext } from "../transitions/ProductTransitionContext";
import "./ProductTransition.css";
const supportsProductTransition = () => {
if (typeof window === "undefined") return false;
return !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
};
const rectToObject = (rect) => ({
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
});
const isPlainNavigationClick = (event) =>
event.button === 0 &&
!event.metaKey &&
!event.altKey &&
!event.ctrlKey &&
!event.shiftKey;
const getStageRect = (sourceRect) => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const sourceRatio = sourceRect.height / sourceRect.width || 1;
const stageWidth = Math.min(
Math.max(sourceRect.width * 1.16, 260),
viewportWidth * 0.56,
620
);
const stageHeight = Math.min(stageWidth * sourceRatio, viewportHeight * 0.72);
const normalizedWidth = stageHeight / sourceRatio;
return {
left: (viewportWidth - normalizedWidth) / 2,
top: (viewportHeight - stageHeight) / 2,
width: normalizedWidth,
height: stageHeight,
};
};
const getTransformForRect = (rect, sourceRect) => ({
x: rect.left,
y: rect.top,
scaleX: rect.width / sourceRect.width,
scaleY: rect.height / sourceRect.height,
});
export function ProductTransitionProvider({ children }) {
const navigate = useNavigate();
const overlayRef = useRef(null);
const imageRef = useRef(null);
const washRef = useRef(null);
const timelineRef = useRef(null);
const [transition, setTransition] = useState(null);
const clearTransition = useCallback(() => {
timelineRef.current?.kill();
timelineRef.current = null;
document.body.classList.remove("product-transition-active");
setTransition(null);
}, []);
const startProductTransition = useCallback(
(event, perfume) => {
if (!perfume?.slug || !isPlainNavigationClick(event)) {
return false;
}
if (!supportsProductTransition()) {
return false;
}
const card = event.currentTarget;
const image = card.querySelector("[data-product-transition-source]");
if (!image) {
return false;
}
const sourceRect = image.getBoundingClientRect();
if (sourceRect.width <= 0 || sourceRect.height <= 0) {
return false;
}
event.preventDefault();
timelineRef.current?.kill();
document.body.classList.add("product-transition-active");
setTransition({
id: `${perfume.slug}-${Date.now()}`,
slug: perfume.slug,
to: `/duft/${perfume.slug}`,
image: image.currentSrc || image.src || perfume.image,
alt: perfume.name,
sourceRect: rectToObject(sourceRect),
phase: "leaving",
});
return true;
},
[]
);
useLayoutEffect(() => {
if (transition?.phase !== "leaving") {
return undefined;
}
const overlay = overlayRef.current;
const image = imageRef.current;
const wash = washRef.current;
if (!overlay || !image || !wash) {
return undefined;
}
const routeContent = document.querySelector("[data-route-content]");
const sourceRect = transition.sourceRect;
const stageRect = getStageRect(sourceRect);
let completed = false;
gsap.set(overlay, { autoAlpha: 1, pointerEvents: "auto" });
gsap.set(wash, { autoAlpha: 0 });
gsap.set(image, {
x: sourceRect.left,
y: sourceRect.top,
width: sourceRect.width,
height: sourceRect.height,
scaleX: 1,
scaleY: 1,
autoAlpha: 1,
transformOrigin: "0 0",
force3D: true,
});
timelineRef.current = gsap.timeline({
defaults: { ease: "power4.inOut" },
onComplete: () => {
completed = true;
navigate(transition.to, {
state: {
productTransition: true,
transitionId: transition.id,
},
});
setTransition((current) =>
current?.id === transition.id
? { ...current, phase: "entering" }
: current
);
},
});
timelineRef.current
.to(wash, { autoAlpha: 1, duration: 0.42, ease: "power2.out" }, 0)
.to(
routeContent,
{
autoAlpha: 0.16,
filter: "blur(10px)",
scale: 0.985,
duration: 0.62,
ease: "power3.out",
},
0
)
.to(
image,
{
...getTransformForRect(stageRect, sourceRect),
duration: 0.78,
},
0.03
);
return () => {
if (!completed) {
timelineRef.current?.kill();
}
};
}, [navigate, transition]);
useLayoutEffect(() => {
if (transition?.phase !== "entering") {
return undefined;
}
const overlay = overlayRef.current;
const image = imageRef.current;
const wash = washRef.current;
if (!overlay || !image || !wash) {
return undefined;
}
let frame = 0;
let cancelled = false;
let completed = false;
const sourceRect = transition.sourceRect;
const runEnterAnimation = () => {
if (cancelled) return;
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
const target = document.querySelector(
`[data-product-transition-target="${transition.slug}"]`
);
if (!target && frame < 16) {
frame += 1;
window.requestAnimationFrame(runEnterAnimation);
return;
}
const routeContent = document.querySelector("[data-route-content]");
gsap.set(routeContent, {
autoAlpha: 1,
filter: "none",
scale: 1,
clearProps: "opacity,visibility,filter,transform",
});
if (!target) {
gsap.to(overlay, {
autoAlpha: 0,
duration: 0.3,
ease: "power2.out",
onComplete: clearTransition,
});
return;
}
const targetRect = rectToObject(target.getBoundingClientRect());
const revealItems = gsap.utils.toArray("[data-product-transition-reveal]");
gsap.set(target, { autoAlpha: 0 });
gsap.set(revealItems, { y: 28, autoAlpha: 0, force3D: true });
timelineRef.current?.kill();
timelineRef.current = gsap.timeline({
defaults: { ease: "power4.out" },
onComplete: () => {
completed = true;
clearTransition();
},
});
timelineRef.current
.to(image, {
...getTransformForRect(targetRect, sourceRect),
duration: 0.72,
})
.set(target, { autoAlpha: 1 }, ">-0.08")
.to(image, { autoAlpha: 0, duration: 0.16, ease: "power2.out" }, "<")
.to(wash, { autoAlpha: 0, duration: 0.5, ease: "power2.out" }, "<")
.to(
revealItems,
{
y: 0,
autoAlpha: 1,
duration: 0.82,
stagger: 0.07,
ease: "power4.out",
clearProps: "transform,opacity,visibility",
},
">-0.05"
);
};
window.requestAnimationFrame(runEnterAnimation);
return () => {
cancelled = true;
if (!completed) {
timelineRef.current?.kill();
}
};
}, [clearTransition, transition]);
const value = useMemo(
() => ({
activeSlug: transition?.slug || null,
phase: transition?.phase || "idle",
startProductTransition,
}),
[startProductTransition, transition]
);
return (
<ProductTransitionContext.Provider value={value}>
{children}
{transition && (
<div
className={`product-transition product-transition--${transition.phase}`}
ref={overlayRef}
aria-hidden="true"
>
<div className="product-transition__wash" ref={washRef} />
<img
className="product-transition__image"
src={transition.image}
alt={transition.alt}
ref={imageRef}
decoding="async"
/>
</div>
)}
</ProductTransitionContext.Provider>
);
}

View File

@ -10,6 +10,7 @@ import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import HeroSection from "../components/landing/HeroSection";
import SharedNavbar from "../components/SharedNavbar";
import { useProductTransition } from "../transitions/ProductTransitionContext";
import perfumes from "../data/perfumes";
import "../pages/LandingPage.css";
import "../style/navbar.css";
@ -28,6 +29,7 @@ function LandingPage() {
const headlineLineRefs = useRef([]);
const heroMetaRefs = useRef([]);
const cardRefs = useRef([]);
const { startProductTransition } = useProductTransition();
const [introSettings] = useState(() => {
if (typeof window === "undefined") {
@ -405,6 +407,7 @@ function LandingPage() {
to={`/duft/${item.slug}`}
className="product-card"
key={item.id}
onClick={(event) => startProductTransition(event, item)}
ref={(element) => {
cardRefs.current[index] = element;
}}
@ -439,6 +442,7 @@ function LandingPage() {
src={item.image}
alt={item.name}
className="product-image"
data-product-transition-source
loading={index < 3 ? "eager" : "lazy"}
decoding="async"
/>

View File

@ -0,0 +1,9 @@
import { createContext, useContext } from "react";
export const ProductTransitionContext = createContext({
activeSlug: null,
phase: "idle",
startProductTransition: () => false,
});
export const useProductTransition = () => useContext(ProductTransitionContext);