add new product flow arrangement
This commit is contained in:
parent
41fa7ddf73
commit
5ca7152011
@ -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
@ -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 (0–1 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 (1–4 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 1–2 Werktagen</p>
|
||||
</div>
|
||||
|
||||
<div className="delivery-item">
|
||||
<span className="delivery-item-label">ZUSTELLUNG</span>
|
||||
<p>In der Regel in 5–6 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>
|
||||
);
|
||||
|
||||
40
parfum-shop/src/components/ProductTransition.css
Normal file
40
parfum-shop/src/components/ProductTransition.css
Normal 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;
|
||||
}
|
||||
}
|
||||
327
parfum-shop/src/components/ProductTransition.jsx
Normal file
327
parfum-shop/src/components/ProductTransition.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
9
parfum-shop/src/transitions/ProductTransitionContext.js
Normal file
9
parfum-shop/src/transitions/ProductTransitionContext.js
Normal 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);
|
||||
Loading…
x
Reference in New Issue
Block a user