add productdetailpage

This commit is contained in:
Salih Hasicic 2026-03-26 11:50:42 +01:00
parent 6479792acf
commit 624c8c8f1a
9 changed files with 1218 additions and 386 deletions

2
node_modules/.package-lock.json generated vendored
View File

@ -1,5 +1,5 @@
{
"name": ".parfum_agsd-1",
"name": "parfum_agsd",
"lockfileVersion": 3,
"requires": true,
"packages": {

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": ".parfum_agsd-1",
"name": "parfum_agsd",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@ -40,41 +40,6 @@
linear-gradient(to bottom, rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.45));
}
/* NAVBAR */
.navbar {
position: relative;
z-index: 2;
display: flex;
justify-content: center;
padding-top: 22px;
}
.nav-pill {
display: flex;
gap: 10px;
padding: 8px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
}
.nav-link {
text-decoration: none;
color: rgba(255, 255, 255, 0.88);
font-size: 13px;
padding: 8px 14px;
border-radius: 999px;
transition: 0.2s ease;
}
.nav-link:hover,
.nav-link.active {
background: rgba(255, 255, 255, 0.22);
}
/* --------------------------------------------------- */
.hero-content {
position: relative;
z-index: 2;

View File

@ -1,355 +1,14 @@
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import "./App.css";
// Hallo im Code,
// ich streue hier bewusst Kommentare ein, damit ihr euch nicht auf eine jahrlange
// archäologische Expedition begeben müsst. So bleibt besser sichtbar, wo
// einzelne Sections anfangen und aufhören und wo wichtige Elemente oder
// Funktionen liegen.
// Euer Freund und Helfer Salih
// Bei Bugs, kleinen Krisen oder emotionalem Kontrollverlust bitte
// https://stackoverflow.com/questions konsultieren
// oder fragen Sie Salih oder eine KI Ihres Vertrauens.
//Erreichbar unter salih.hasicic@stud.fhgr.ch oder telefonisch, falls ihr die Nummer habt*/
//Elements and Images for the grid
const perfumes = [
{
id: "01",
name: "KALTER BETON",
image:
"/kalter-beton-product.png",
fillImage: "/platzhalter.png",
fillVideo: "/kalter-beton-hover.webm",
text: "Mineralisch. Roh. Unberührt.",
},
{
id: "02",
name: "NASSER MARMOR",
image:
"/NASSER MARMOR.png",
fillImage: "/platzhalter.png",
fillVideo: "/nasser-marmor-hover.webm",
text: "Kühl. Glatt. Sinnlich.",
},
{
id: "03",
name: "BLASSE SEIDE",
image:
"/BLASSE SEIDE.png",
fillImage: "/platzhalter.png",
fillVideo: "/blasse-seide-hover.webm",
text: "Blass. Sanft. Kostbar.",
},
{
id: "04",
name: "WEISSE ASCHE",
image:
"/WEISSE ASCHE.png",
fillImage: "/platzhalter.png",
fillVideo: "/weisse-asche-hover.webm",
text: "Still. Staubig. Erhaben.",
},
{
id: "05",
name: "VERBRANNTES CHROM",
image:
"/VERBRANNTES CHROM.png",
fillImage: "/platzhalter.png",
fillVideo: "/verbranntes-chrom-hover.webm",
text: "Metallisch. Verzehrt. Edel.",
},
{
id: "06",
name: "SCHWARZES BENZIN",
image:
"/SCHWARZES BENZIN.png",
fillImage: "/platzhalter.png",
fillVideo: "/schwarzes-benzin-hover.webm",
text: "Dunkel. Glänzend. Verboten.",
},
];
import LandingPage from "./pages/LandingPage";
import ProductDetailPage from "./components/ProductDetailPage";
function App() {
const cardRefs = useRef([]);
const showDetailPage = true;
useEffect(() => {
const cards = cardRefs.current.filter(Boolean);
const cardStates = cards
.map((card) => {
const hoverFill = card.querySelector(".product-hover-fill");
const hoverVideo = card.querySelector(".product-hover-video");
const productImage = card.querySelector(".product-image");
const arrow = card.querySelector(".arrow");
if (showDetailPage) {
return <ProductDetailPage perfumeSlug="kalter-beton" />;
}
if (!hoverFill || !productImage || !arrow) {
return null;
}
gsap.set(hoverFill, { autoAlpha: 0, scale: 1.08 });
gsap.set(productImage, { autoAlpha: 1, scale: 1 });
gsap.set(arrow, { x: 0 });
return { card, hoverFill, hoverVideo, productImage, arrow };
})
.filter(Boolean);
const stopVideo = (video) => {
if (!video) {
return;
}
video.pause();
try {
video.currentTime = 0;
} catch {
// Ignore if browser blocks random access while buffering.
}
};
const playVideo = (video) => {
if (!video) {
return;
}
try {
video.currentTime = 0;
} catch {
// Ignore if browser blocks random access while buffering.
}
const playAttempt = video.play();
if (playAttempt && typeof playAttempt.catch === "function") {
playAttempt.catch(() => {});
}
};
const deactivate = (state) => {
gsap.killTweensOf([state.hoverFill, state.productImage, state.arrow]);
stopVideo(state.hoverVideo);
gsap.to(state.hoverFill, {
autoAlpha: 0,
scale: 1.08,
duration: 0.35,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(state.productImage, {
scale: 1,
autoAlpha: 1,
duration: 0.35,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(state.arrow, {
x: 0,
duration: 0.35,
ease: "power2.out",
overwrite: "auto",
});
};
const activate = (state) => {
cardStates.forEach((item) => {
if (item !== state) {
deactivate(item);
}
});
gsap.killTweensOf([state.hoverFill, state.productImage, state.arrow]);
playVideo(state.hoverVideo);
gsap.to(state.hoverFill, {
autoAlpha: 1,
scale: 1,
duration: 0.45,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(state.productImage, {
scale: 0.92,
autoAlpha: 0.35,
duration: 0.45,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(state.arrow, {
x: 8,
duration: 0.45,
ease: "power2.out",
overwrite: "auto",
});
};
const cardCleanups = cardStates.map((state) => {
const onEnter = () => activate(state);
const onLeave = () => deactivate(state);
state.card.addEventListener("pointerenter", onEnter);
state.card.addEventListener("pointerleave", onLeave);
return () => {
state.card.removeEventListener("pointerenter", onEnter);
state.card.removeEventListener("pointerleave", onLeave);
};
});
const resetAll = () => {
cardStates.forEach((state) => deactivate(state));
};
const onMouseOutWindow = (event) => {
if (!event.relatedTarget) {
resetAll();
}
};
window.addEventListener("blur", resetAll);
document.addEventListener("mouseout", onMouseOutWindow);
return () => {
window.removeEventListener("blur", resetAll);
document.removeEventListener("mouseout", onMouseOutWindow);
cardCleanups.forEach((cleanup) => cleanup());
};
}, []);
return (
<div className="page">
{/* Hero */}
<header className="hero">
{/* Navbar */}
<nav className="navbar">
<div className="nav-pill">
<a href="#home" className="nav-link active">
Name
</a>
<a href="#dufte" className="nav-link">
Düfte
</a>
<a href="#testen" className="nav-link">
Testen
</a>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
{/* --- Navbar End --- */}
<div className="hero-overlay" />
<div className="hero-content">
<p className="eyebrow">NISCHENDÜFTE</p>
<h1>
DÜFTE ALS
<br />
AUSDRUCK
<br />
VON KONZEPT
</h1>
<p className="hero-text">
Konzeptuelle Düfte zwischen Materialität, Raum und Charakter.
</p>
<div className="hero-actions">
<button className="btn btn-primary">Aktuelle Düfte</button>
<button className="btn btn-secondary">Discovery Set</button>
</div>
</div>
</header>
{/* --- Hero End --- */}
<main>
{/* Grid with Core Collection */}
<section className="section" id="dufte">
<div className="section-heading">
<h2>
WÄHLE EINE
<br />
ATMOSPHÄRE
</h2>
</div>
<div className="product-grid">
{perfumes.map((item, index) => (
<article
className="product-card"
key={item.id}
ref={(el) => {
cardRefs.current[index] = el;
}}
>
<div className="product-hover-fill" aria-hidden="true">
{item.fillVideo ? (
<video
className="product-hover-video"
src={item.fillVideo}
muted
loop
playsInline
preload="metadata"
/>
) : (
<div
className="product-hover-image"
style={{ backgroundImage: `url(${item.fillImage || item.image})` }}
/>
)}
</div>
<div className="product-top">
<span className="product-id">{item.id}</span>
<h3>{item.name}</h3>
</div>
<div className="product-image-wrap">
<img src={item.image} alt={item.name} className="product-image" />
</div>
<div className="product-bottom">
<p>{item.text}</p>
<span className="arrow"></span>
</div>
</article>
))}
</div>
</section>
{/* --- Grid End --- */}
{/* Dicovery Set Section */}
<section className="discovery-section" id="testen">
<div className="discovery-copy">
<h2>
DER SICHERE EINSTIEG
<br />
DISCOVERY SET
</h2>
<p>
6 Samples × 2ml.
<br />
Jeden Duft eine Woche tragen.
<br />
Verstehen, was funktioniert.
</p>
</div>
<div className="discovery-banner">
<img
src="/DISCOVERYSET.png"
alt="Discovery Set"
/>
<button className="banner-btn">Discovery Set bestellen</button>
</div>
</section>
{/* --- Dicovery Set Section End--- */}
</main>
</div>
);
return <LandingPage />;
}
export default App;
export default App;

View File

@ -0,0 +1,432 @@
/*
Hallo im CSS,
ich versuche auch hier die Struktur sauber zu kommentieren, damit ihr nicht
im Styling-Dschungel verloren geht und schneller versteht, was wohin gehört.
Bei Bugs, kleinen Krisen oder emotionalem Kontrollverlust bitte
https://stackoverflow.com/questions konsultieren
oder fragt Salih oder eine KI eures Vertrauens.
*/
/* --- Product Detail Page Wrapper Start --- */
.detail-page {
min-height: 100vh;
background: #efefef;
color: #191919;
padding: 20px;
}
.detail-shell {
background: #f7f7f7;
border: 1px solid #d6d6d6;
padding: 22px;
}
/* --- Product Detail Page Wrapper End --- */
/* --- Back Link Start --- */
.back-link {
background: none;
border: none;
padding: 0;
margin-bottom: 18px;
font-size: 14px;
cursor: pointer;
color: #222;
}
/* --- Back Link End --- */
/* --- Main Detail Layout Start --- */
.detail-layout {
display: grid;
grid-template-columns: 1.02fr 1fr;
gap: 28px;
align-items: start;
}
/* --- Main Detail Layout End --- */
/* --- Left Column / Gallery Start --- */
.detail-gallery {
display: flex;
flex-direction: column;
}
.detail-main-image {
background: #ddd;
aspect-ratio: 1 / 1;
overflow: hidden;
}
.detail-main-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.detail-thumbs {
display: flex;
gap: 14px;
margin-top: 14px;
}
.thumb-btn {
width: 88px;
height: 88px;
border: 1px solid #cfcfcf;
background: #fff;
padding: 0;
cursor: pointer;
}
.thumb-btn.active {
outline: 2px solid #4da3ff;
outline-offset: 1px;
}
.thumb-btn img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* --- Left Column / Gallery End --- */
/* --- Duftstruktur Start --- */
.detail-structure {
margin-top: 28px;
}
.detail-structure h3 {
font-size: 12px;
letter-spacing: 0.24em;
font-weight: 500;
margin: 0 0 14px;
}
.structure-block {
margin-bottom: 12px;
}
.structure-phase {
display: block;
margin-bottom: 6px;
font-size: 10px;
letter-spacing: 0.18em;
color: #555;
}
.structure-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.structure-tags span {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 8px 14px;
border: 1px solid #d2d2d2;
background: #ebebeb;
font-size: 12px;
}
.mood-box {
margin-top: 18px;
background: #dfdfdf;
padding: 16px;
}
.mood-label {
display: block;
margin-bottom: 8px;
font-size: 10px;
letter-spacing: 0.2em;
color: #555;
}
.mood-box p {
font-size: 15px;
line-height: 1.5;
margin: 0;
}
/* --- Duftstruktur End --- */
/* --- Meta Infos unter Duftstruktur Start --- */
.detail-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr 1.5fr;
gap: 20px;
margin-top: 24px;
}
.detail-meta-grid span {
display: block;
margin-bottom: 8px;
font-size: 10px;
letter-spacing: 0.2em;
color: #666;
}
.detail-meta-grid p {
font-size: 13px;
line-height: 1.5;
margin: 0;
}
/* --- Meta Infos unter Duftstruktur End --- */
/* --- Right Column Start --- */
.detail-info {
display: flex;
flex-direction: column;
gap: 22px;
}
.detail-heading h1 {
margin: 0 0 6px;
font-size: 52px;
line-height: 0.95;
font-weight: 400;
letter-spacing: -0.04em;
color: #131313;
}
.detail-heading p {
font-size: 22px;
color: #3f3f3f;
margin: 0;
}
.detail-section-block {
display: flex;
flex-direction: column;
}
.label-title {
display: block;
margin-bottom: 10px;
font-size: 11px;
letter-spacing: 0.24em;
color: #666;
}
/* --- Right Column End --- */
/* --- Material Tags Start --- */
.material-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.material-tags span {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 8px 14px;
border: 1px solid #d2d2d2;
background: #ebebeb;
font-size: 12px;
}
/* --- Material Tags End --- */
/* --- Size Selection Start --- */
.size-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.size-card {
border: 1px solid #d0d0d0;
background: #fefefe;
padding: 18px;
text-align: center;
cursor: pointer;
}
.size-card.active {
border-color: #111;
}
.size-title {
display: block;
margin-bottom: 8px;
font-size: 13px;
}
.size-card strong {
display: block;
font-size: 28px;
font-weight: 400;
margin-bottom: 6px;
}
.size-card small {
font-size: 11px;
color: #666;
}
/* --- Size Selection End --- */
/* --- Discovery Hinweis + Kaufen Button Start --- */
.discovery-note {
background: #050505;
color: #fff;
padding: 14px 16px;
}
.discovery-note strong {
display: block;
margin-bottom: 6px;
font-size: 13px;
}
.discovery-note p {
font-size: 11px;
color: rgba(255, 255, 255, 0.78);
margin: 0;
}
.buy-button {
width: 100%;
border: none;
background: #050505;
color: #fff;
padding: 18px;
font-size: 16px;
letter-spacing: 0.08em;
cursor: pointer;
}
/* --- Discovery Hinweis + Kaufen Button End --- */
/* --- Description Columns Start --- */
.detail-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 22px 30px;
}
.detail-copy-block p {
white-space: pre-line;
color: #2b2b2b;
line-height: 1.55;
margin: 0;
}
/* --- Description Columns End --- */
/* --- Bottom Upsell Bar Start --- */
.detail-upsell-bar {
margin-top: auto;
background: #060606;
color: #fff;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-upsell-bar strong {
display: block;
margin-bottom: 4px;
font-size: 13px;
}
.detail-upsell-bar p {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
/* --- Bottom Upsell Bar End --- */
/* --- Bottom CTA Start --- */
.detail-bottom-cta {
margin-top: 42px;
padding: 42px 20px 16px;
border-top: 1px solid #d4d4d4;
text-align: center;
}
.detail-bottom-cta h2 {
margin: 0 0 14px;
font-size: 34px;
font-weight: 400;
}
.detail-bottom-cta p {
max-width: 880px;
margin: 0 auto;
color: #333;
line-height: 1.7;
}
.detail-bottom-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 26px;
}
.detail-bottom-actions button {
border: 1px solid #101010;
background: transparent;
padding: 16px 24px;
min-width: 250px;
cursor: pointer;
}
/* --- Bottom CTA End --- */
/* --- Responsive Start --- */
@media (max-width: 1100px) {
.detail-layout {
grid-template-columns: 1fr;
}
.detail-heading h1 {
font-size: 40px;
}
}
@media (max-width: 700px) {
.detail-columns,
.detail-meta-grid,
.size-grid,
.detail-bottom-actions {
grid-template-columns: 1fr;
display: grid;
}
.detail-bottom-actions button {
min-width: 100%;
}
.detail-thumbs {
flex-wrap: wrap;
}
}
/* --- Responsive End --- */

View File

@ -0,0 +1,232 @@
import { useMemo, useState } from "react";
import perfumes from "../data/perfumes";
import "../navbar.css";
import "./ProductDetailPage.css";
function ProductDetailPage({ perfumeSlug = "kalter-beton" }) {
const perfume = useMemo(
() => perfumes.find((item) => item.slug === perfumeSlug) || perfumes[0],
[perfumeSlug]
);
const [selectedImage, setSelectedImage] = useState(
perfume.gallery?.[0] || perfume.image
);
const [selectedSize, setSelectedSize] = useState("sample");
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 (
<div className="detail-page">
{/* Navbar */}
<nav className="navbar navbar--light">
<div className="nav-pill">
<a href="#home" className="nav-link active">
Name
</a>
<a href="#dufte" className="nav-link">
Düfte
</a>
<a href="#testen" className="nav-link">
Testen
</a>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
{/* --- Navbar End --- */}
{/* Product detail content */}
<main className="detail-shell">
<button className="back-link" type="button">
Zurück zur Startseite
</button>
<section className="detail-layout">
{/* Left column */}
<div className="detail-gallery">
<div className="detail-main-image">
<img src={selectedImage} alt={perfume.name} />
</div>
<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}`} />
</button>
))}
</div>
<div className="detail-structure">
<h3>DUFTSTRUKTUR</h3>
<div className="structure-block">
<span className="structure-phase">PHASE 1: TOP NOTES (01 H)</span>
<div className="structure-tags">
{perfume.phases.top.map((note) => (
<span key={note}>{note}</span>
))}
</div>
</div>
<div className="structure-block">
<span className="structure-phase">PHASE 2: HEART NOTES (14 H)</span>
<div className="structure-tags">
{perfume.phases.heart.map((note) => (
<span key={note}>{note}</span>
))}
</div>
</div>
<div className="structure-block">
<span className="structure-phase">PHASE 3: BASE NOTES (4 H+)</span>
<div className="structure-tags">
{perfume.phases.base.map((note) => (
<span key={note}>{note}</span>
))}
</div>
</div>
<div className="mood-box">
<span className="mood-label">MOODSETTING</span>
<p>{perfume.mood}</p>
</div>
</div>
<div className="detail-meta-grid">
<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>
</div>
{/* Right column */}
<div className="detail-info">
<div className="detail-heading">
<h1>{perfume.name}</h1>
<p>{perfume.shortText}</p>
</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">
<strong>Discovery Set wird angerechnet</strong>
<p>
Hast du das Discovery Set gekauft, wird der volle Preis beim Kauf
automatisch abgezogen.
</p>
</div>
<button className="buy-button" type="button">
KAUFEN
</button>
<div className="detail-columns">
<div className="detail-copy-block">
<span className="label-title">BESCHREIBUNG</span>
<p>{perfume.description}</p>
</div>
<div className="detail-copy-block">
<span className="label-title">HERKUNFT</span>
<p>{perfume.origin}</p>
</div>
<div className="detail-copy-block">
<span className="label-title">KONZENTRATION</span>
<p>{perfume.concentration}</p>
</div>
<div className="detail-copy-block">
<span className="label-title">EDITION</span>
<p>{perfume.edition}</p>
</div>
</div>
<div className="detail-upsell-bar">
<div>
<strong>Noch unsicher?</strong>
<p>Discovery Set bestellen und Düfte testen</p>
</div>
<span></span>
</div>
</div>
</section>
{/* Bottom CTA */}
<section className="detail-bottom-cta">
<h2>Lieber erst testen?</h2>
<p>
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">
<button type="button">SAMPLE BESTELLEN CHF 12</button>
<button type="button">DISCOVERY SET CHF 48</button>
</div>
</section>
</main>
</div>
);
}
export default ProductDetailPage;

View File

@ -0,0 +1,196 @@
const perfumes = [
{
id: "01",
slug: "kalter-beton",
name: "KALTER BETON",
image: "/kalter-beton-product.png",
fillImage: "/platzhalter.png",
fillVideo: "/kalter-beton-hover.webm",
text: "Mineralisch. Roh. Unberührt.",
prices: {
sample: "CHF 12.",
full: "CHF 185.",
},
materialTags: ["Graue Iris", "Stein Akkord", "Zedernholz", "Weihrauch"],
phases: {
top: ["Graue Iris", "Mineral Note", "Betonakkord"],
heart: ["Stein Akkord", "Salz", "Staubnote"],
base: ["Zedernholz", "Pfeffer", "Weihrauch"],
},
mood: "Unbewohnte Räume. Erste Kälte. Architektonische Stille.",
dosage: "23 Sprühstösse",
longevity: "812 Stunden",
occasion: "Studio, Konzentration, Arbeit, Beginn, Zurückhaltung.",
description: "Parfümerie / Studio\nAtelier LM / Luz",
origin: "Frankreich, 2024",
concentration: "Eau de Parfum (18%)",
edition: "Batch 04/24 limitiert 500 Stück",
gallery: [
"/kalter-beton-product.png",
"/kalter-beton-product.png",
"/kalter-beton-product.png",
],
},
{
id: "02",
slug: "nasser-marmor",
name: "NASSER MARMOR",
image: "/NASSER MARMOR.png",
fillImage: "/platzhalter.png",
fillVideo: "/nasser-marmor-hover.webm",
text: "Kühl. Glatt. Sinnlich.",
prices: {
sample: "CHF 12.",
full: "CHF 185.",
},
materialTags: ["Marmorakkord", "Aldehyde", "Moschus", "Vetiver"],
phases: {
top: ["Aldehyde", "Ozonic Note", "Kalter Stein"],
heart: ["Marmorakkord", "Iris", "Nebelnote"],
base: ["Moschus", "Vetiver", "Ambra"],
},
mood: "Feuchte Flächen. Kühle Eleganz. Polierte Ruhe.",
dosage: "23 Sprühstösse",
longevity: "711 Stunden",
occasion: "Galerie, Abend, Konzentration, Übergang.",
description: "Parfümerie / Studio\nAtelier LM / Luz",
origin: "Italien, 2024",
concentration: "Eau de Parfum (17%)",
edition: "Batch 02/24 limitiert 450 Stück",
gallery: [
"/NASSER MARMOR.png",
"/NASSER MARMOR.png",
"/NASSER MARMOR.png",
],
},
{
id: "03",
slug: "blasse-seide",
name: "BLASSE SEIDE",
image: "/BLASSE SEIDE.png",
fillImage: "/platzhalter.png",
fillVideo: "/blasse-seide-hover.webm",
text: "Blass. Sanft. Kostbar.",
prices: {
sample: "CHF 12.",
full: "CHF 185.",
},
materialTags: ["Weisser Moschus", "Reispuder", "Kaschmirholz", "Iris"],
phases: {
top: ["Reispuder", "Helle Blüte", "Luft"],
heart: ["Iris", "Seidenakkord", "Puder"],
base: ["Kaschmirholz", "Weisser Moschus", "Ambrette"],
},
mood: "Helles Gewebe. Stille Hautnähe. Gedämpfte Wärme.",
dosage: "34 Sprühstösse",
longevity: "610 Stunden",
occasion: "Alltag, Nähe, Morgen, feine Präsenz.",
description: "Parfümerie / Studio\nAtelier LM / Luz",
origin: "Schweiz, 2024",
concentration: "Eau de Parfum (16%)",
edition: "Batch 03/24 limitiert 600 Stück",
gallery: [
"/BLASSE SEIDE.png",
"/BLASSE SEIDE.png",
"/BLASSE SEIDE.png",
],
},
{
id: "04",
slug: "weisse-asche",
name: "WEISSE ASCHE",
image: "/WEISSE ASCHE.png",
fillImage: "/platzhalter.png",
fillVideo: "/weisse-asche-hover.webm",
text: "Still. Staubig. Erhaben.",
prices: {
sample: "CHF 12.",
full: "CHF 185.",
},
materialTags: ["Ascheakkord", "Papyrus", "Rauch", "Weisses Holz"],
phases: {
top: ["Staub", "Kalter Rauch", "Papier"],
heart: ["Ascheakkord", "Papyrus", "Kreide"],
base: ["Weisses Holz", "Rauch", "Harz"],
},
mood: "Stille Rückstände. Helle Spuren. Erhobene Leere.",
dosage: "23 Sprühstösse",
longevity: "812 Stunden",
occasion: "Abend, Winter, Alleinsein, Konzentration.",
description: "Parfümerie / Studio\nAtelier LM / Luz",
origin: "Deutschland, 2024",
concentration: "Eau de Parfum (19%)",
edition: "Batch 01/24 limitiert 350 Stück",
gallery: [
"/WEISSE ASCHE.png",
"/WEISSE ASCHE.png",
"/WEISSE ASCHE.png",
],
},
{
id: "05",
slug: "verbranntes-chrom",
name: "VERBRANNTES CHROM",
image: "/VERBRANNTES CHROM.png",
fillImage: "/platzhalter.png",
fillVideo: "/verbranntes-chrom-hover.webm",
text: "Metallisch. Verzehrt. Edel.",
prices: {
sample: "CHF 14.",
full: "CHF 195.",
},
materialTags: ["Metallakkord", "Oud", "Schwarzer Pfeffer", "Labdanum"],
phases: {
top: ["Schwarzer Pfeffer", "Funke", "Metall"],
heart: ["Metallakkord", "Rauch", "Harz"],
base: ["Oud", "Labdanum", "Dunkles Holz"],
},
mood: "Hitze auf Metall. Dunkler Glanz. Kontrollierte Zerstörung.",
dosage: "12 Sprühstösse",
longevity: "1014 Stunden",
occasion: "Nacht, Statement, Kälte, Formalität.",
description: "Parfümerie / Studio\nAtelier LM / Luz",
origin: "Frankreich, 2024",
concentration: "Extrait de Parfum (24%)",
edition: "Batch 05/24 limitiert 300 Stück",
gallery: [
"/VERBRANNTES CHROM.png",
"/VERBRANNTES CHROM.png",
"/VERBRANNTES CHROM.png",
],
},
{
id: "06",
slug: "schwarzes-benzin",
name: "SCHWARZES BENZIN",
image: "/SCHWARZES BENZIN.png",
fillImage: "/platzhalter.png",
fillVideo: "/schwarzes-benzin-hover.webm",
text: "Dunkel. Glänzend. Verboten.",
prices: {
sample: "CHF 14.",
full: "CHF 195.",
},
materialTags: ["Petrol Akkord", "Leder", "Birke", "Patchouli"],
phases: {
top: ["Petrol Akkord", "Schwarzer Rauch", "Lack"],
heart: ["Leder", "Birke", "Harz"],
base: ["Patchouli", "Vetiver", "Dunkles Holz"],
},
mood: "Verbotene Oberfläche. Glanz im Schatten. Asphalt nach Regen.",
dosage: "12 Sprühstösse",
longevity: "913 Stunden",
occasion: "Abend, Urban, Nacht, Signaturduft.",
description: "Parfümerie / Studio\nAtelier LM / Luz",
origin: "Belgien, 2024",
concentration: "Eau de Parfum (20%)",
edition: "Batch 06/24 limitiert 280 Stück",
gallery: [
"/SCHWARZES BENZIN.png",
"/SCHWARZES BENZIN.png",
"/SCHWARZES BENZIN.png",
],
},
];
export default perfumes;

View File

@ -0,0 +1,76 @@
/* --- Shared Navbar Start --- */
.navbar {
position: relative;
z-index: 20;
display: flex;
justify-content: center;
}
.nav-pill {
display: flex;
gap: 10px;
padding: 8px 10px;
border-radius: 999px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.nav-link {
text-decoration: none;
font-size: 13px;
padding: 8px 14px;
border-radius: 999px;
transition: 0.2s ease;
}
/* Hero variant */
.navbar--hero {
padding-top: 22px;
}
.navbar--hero .nav-pill {
background: rgba(255, 255, 255, 0.15);
}
.navbar--hero .nav-link {
color: rgba(255, 255, 255, 0.88);
}
.navbar--hero .nav-link:hover,
.navbar--hero .nav-link.active {
background: rgba(255, 255, 255, 0.22);
}
/* Detail page variant */
.navbar--light {
margin-bottom: 18px;
}
.navbar--light .nav-pill {
background: rgba(255, 255, 255, 0.88);
border: 1px solid #d6d6d6;
}
.navbar--light .nav-link {
color: #1d1d1d;
}
.navbar--light .nav-link:hover,
.navbar--light .nav-link.active {
background: #ebebeb;
}
/* --- Shared Navbar End --- */
@media (max-width: 640px) {
.nav-pill {
gap: 4px;
padding: 6px;
}
.nav-link {
padding: 8px 10px;
font-size: 12px;
}
}

View File

@ -0,0 +1,272 @@
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import perfumes from "../data/perfumes";
import "../App.css";
import "../navbar.css";
function LandingPage() {
const cardRefs = useRef([]);
useEffect(() => {
const cards = cardRefs.current.filter(Boolean);
const cardStates = cards
.map((card) => {
const hoverFill = card.querySelector(".product-hover-fill");
const hoverVideo = card.querySelector(".product-hover-video");
const productImage = card.querySelector(".product-image");
const arrow = card.querySelector(".arrow");
if (!hoverFill || !productImage || !arrow) {
return null;
}
gsap.set(hoverFill, { autoAlpha: 0, scale: 1.08 });
gsap.set(productImage, { autoAlpha: 1, scale: 1 });
gsap.set(arrow, { x: 0 });
return { card, hoverFill, hoverVideo, productImage, arrow };
})
.filter(Boolean);
const stopVideo = (video) => {
if (!video) return;
video.pause();
try {
video.currentTime = 0;
} catch {}
};
const playVideo = (video) => {
if (!video) return;
try {
video.currentTime = 0;
} catch {}
const playAttempt = video.play();
if (playAttempt && typeof playAttempt.catch === "function") {
playAttempt.catch(() => {});
}
};
const deactivate = (state) => {
gsap.killTweensOf([state.hoverFill, state.productImage, state.arrow]);
stopVideo(state.hoverVideo);
gsap.to(state.hoverFill, {
autoAlpha: 0,
scale: 1.08,
duration: 0.35,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(state.productImage, {
scale: 1,
autoAlpha: 1,
duration: 0.35,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(state.arrow, {
x: 0,
duration: 0.35,
ease: "power2.out",
overwrite: "auto",
});
};
const activate = (state) => {
cardStates.forEach((item) => {
if (item !== state) {
deactivate(item);
}
});
gsap.killTweensOf([state.hoverFill, state.productImage, state.arrow]);
playVideo(state.hoverVideo);
gsap.to(state.hoverFill, {
autoAlpha: 1,
scale: 1,
duration: 0.45,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(state.productImage, {
scale: 0.92,
autoAlpha: 0.35,
duration: 0.45,
ease: "power2.out",
overwrite: "auto",
});
gsap.to(state.arrow, {
x: 8,
duration: 0.45,
ease: "power2.out",
overwrite: "auto",
});
};
const cardCleanups = cardStates.map((state) => {
const onEnter = () => activate(state);
const onLeave = () => deactivate(state);
state.card.addEventListener("pointerenter", onEnter);
state.card.addEventListener("pointerleave", onLeave);
return () => {
state.card.removeEventListener("pointerenter", onEnter);
state.card.removeEventListener("pointerleave", onLeave);
};
});
const resetAll = () => {
cardStates.forEach((state) => deactivate(state));
};
const onMouseOutWindow = (event) => {
if (!event.relatedTarget) {
resetAll();
}
};
window.addEventListener("blur", resetAll);
document.addEventListener("mouseout", onMouseOutWindow);
return () => {
window.removeEventListener("blur", resetAll);
document.removeEventListener("mouseout", onMouseOutWindow);
cardCleanups.forEach((cleanup) => cleanup());
};
}, []);
return (
<div className="page">
<header className="hero">
<nav className="navbar navbar--hero">
<div className="nav-pill">
<a href="#home" className="nav-link active">
Name
</a>
<a href="#dufte" className="nav-link">
Düfte
</a>
<a href="#testen" className="nav-link">
Testen
</a>
<a href="#cart" className="nav-link">
Cart
</a>
</div>
</nav>
<div className="hero-overlay" />
<div className="hero-content">
<p className="eyebrow">NISCHENDÜFTE</p>
<h1>
DÜFTE ALS
<br />
AUSDRUCK
<br />
VON KONZEPT
</h1>
<p className="hero-text">
Konzeptuelle Düfte zwischen Materialität, Raum und Charakter.
</p>
<div className="hero-actions">
<button className="btn btn-primary">Aktuelle Düfte</button>
<button className="btn btn-secondary">Discovery Set</button>
</div>
</div>
</header>
<main>
<section className="section" id="dufte">
<div className="section-heading">
<h2>
WÄHLE EINE
<br />
ATMOSPHÄRE
</h2>
</div>
<div className="product-grid">
{perfumes.map((item, index) => (
<article
className="product-card"
key={item.id}
ref={(el) => {
cardRefs.current[index] = el;
}}
>
<div className="product-hover-fill" aria-hidden="true">
{item.fillVideo ? (
<video
className="product-hover-video"
src={item.fillVideo}
muted
loop
playsInline
preload="metadata"
/>
) : (
<div
className="product-hover-image"
style={{
backgroundImage: `url(${item.fillImage || item.image})`,
}}
/>
)}
</div>
<div className="product-top">
<span className="product-id">{item.id}</span>
<h3>{item.name}</h3>
</div>
<div className="product-image-wrap">
<img src={item.image} alt={item.name} className="product-image" />
</div>
<div className="product-bottom">
<p>{item.text}</p>
<span className="arrow"></span>
</div>
</article>
))}
</div>
</section>
<section className="discovery-section" id="testen">
<div className="discovery-copy">
<h2>
DER SICHERE EINSTIEG
<br />
DISCOVERY SET
</h2>
<p>
6 Samples × 2ml.
<br />
Jeden Duft eine Woche tragen.
<br />
Verstehen, was funktioniert.
</p>
</div>
<div className="discovery-banner">
<img src="/DISCOVERYSET.png" alt="Discovery Set" />
<button className="banner-btn">Discovery Set bestellen</button>
</div>
</section>
</main>
</div>
);
}
export default LandingPage;