515 lines
19 KiB
JavaScript
515 lines
19 KiB
JavaScript
import { useEffect, useMemo, useState } from "react";
|
||
import { Link, useNavigate, useParams } from "react-router";
|
||
import perfumes from "../data/perfumes";
|
||
import SharedNavbar from "./SharedNavbar";
|
||
import { formatChf } from "../shop/money";
|
||
import { useShop } from "../shop/useShop";
|
||
import "./ProductDetailPage.css";
|
||
|
||
const priceToCents = (price) => {
|
||
const match = String(price).match(/(\d+)/);
|
||
return match ? Number(match[1]) * 100 : 0;
|
||
};
|
||
|
||
function ProductDetailContent({ perfumeSlug }) {
|
||
const navigate = useNavigate();
|
||
const { addToCart, subscribeToProduct, user } = useShop();
|
||
|
||
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 [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"
|
||
);
|
||
const discountPreviewCents =
|
||
selectedSize === "full"
|
||
? Math.min(
|
||
selectedPriceCents,
|
||
(user?.discoveryStatus === "Discount available" ? 4800 : 0) +
|
||
(sampleCredit?.amount_cents || 0)
|
||
)
|
||
: 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 commentPages = [];
|
||
for (let i = 0; i < reviewComments.length; i += 2) {
|
||
commentPages.push(reviewComments.slice(i, i + 2));
|
||
}
|
||
|
||
const safeCommentPages =
|
||
commentPages.length > 0
|
||
? commentPages
|
||
: [
|
||
[
|
||
{
|
||
id: "fallback-1",
|
||
name: "Atelier",
|
||
title: "Noch keine Stimmen",
|
||
text: "Für diesen Duft sind aktuell noch keine Kommentare hinterlegt.",
|
||
},
|
||
],
|
||
];
|
||
|
||
useEffect(() => {
|
||
const interval = window.setInterval(() => {
|
||
setCommentPage((prev) => (prev + 1) % safeCommentPages.length);
|
||
}, 5000);
|
||
|
||
return () => window.clearInterval(interval);
|
||
}, [safeCommentPages.length]);
|
||
|
||
return (
|
||
<div className="detail-page">
|
||
<SharedNavbar variant="light" />
|
||
|
||
<main className="detail-shell">
|
||
<div className="detail-topbar">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<section className="detail-layout">
|
||
<div className="detail-gallery">
|
||
<div className="detail-main-image">
|
||
<img src={selectedImage} alt={perfume.name} decoding="async" />
|
||
</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}`}
|
||
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>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProductDetailPage() {
|
||
const { perfumeSlug = "kalter-beton" } = useParams();
|
||
|
||
return <ProductDetailContent key={perfumeSlug} perfumeSlug={perfumeSlug} />;
|
||
}
|
||
|
||
export default ProductDetailPage;
|