Files
template-vitrine/frontend/src/features/storefront/ProductDetailPage.tsx
T
Xor290 919c807004
ci-api / test (push) Failing after 8m7s
ci-web / test (push) Failing after 5m5s
chore: build
2026-09-20 12:18:33 +02:00

186 lines
7.0 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { apiFetch } from "../../api/client";
import { formatCents, type Category, type PriceTier, type Product, type Unit } from "../../api/types";
import { useCart } from "./CartContext";
import { useMediaUrl } from "./useMediaUrl";
import { useSiteSettings } from "./SiteSettingsContext";
import GalleryThumb from "./GalleryThumb";
import { useI18n } from "../../i18n/LanguageContext";
export default function ProductDetailPage() {
const { t } = useI18n();
const site = useSiteSettings();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { addItem } = useCart();
const [product, setProduct] = useState<Product | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [siblings, setSiblings] = useState<Product[]>([]);
const [tiers, setTiers] = useState<PriceTier[]>([]);
const [units, setUnits] = useState<Unit[]>([]);
const [gallery, setGallery] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [selectedTierId, setSelectedTierId] = useState("");
const [multiplier, setMultiplier] = useState(1);
const [added, setAdded] = useState(false);
const primaryImage = useMediaUrl(product?.primary_media_id);
useEffect(() => {
if (!id) return;
setLoading(true);
setNotFound(false);
setAdded(false);
apiFetch<Product>(`/api/products/${id}`)
.then((p) => {
setProduct(p);
return Promise.all([
apiFetch<{ price_tiers: PriceTier[] }>(`/api/price-tiers/by-product/${p.id}`),
apiFetch<{ units: Unit[] }>("/api/units"),
apiFetch<{ gallery: { media_id: string }[] }>(`/api/product-gallery/${p.id}`),
apiFetch<{ categories: Category[] }>("/api/categories"),
apiFetch<{ products: Product[] }>(`/api/products?category_id=${p.category_id}`),
]);
})
.then(([t, u, g, c, siblingList]) => {
setTiers(t.price_tiers);
setUnits(u.units);
setGallery(g.gallery.map((item) => item.media_id));
setSelectedTierId(t.price_tiers[0]?.id ?? "");
setCategories(c.categories);
setSiblings(siblingList.products);
})
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [id]);
function unitSymbol(unitId: string) {
return units.find((u) => u.id === unitId)?.symbol ?? "";
}
function tierLabel(t: PriceTier) {
return `${t.quantity} ${unitSymbol(t.unit_id)}${formatCents(t.price_cents)}`;
}
function handleAddToCart() {
if (!product) return;
const tier = tiers.find((t) => t.id === selectedTierId);
if (!tier) return;
addItem({
productId: product.id,
productName: product.name,
priceTierId: tier.id,
unitLabel: `${tier.quantity} ${unitSymbol(tier.unit_id)}`,
unitPriceCents: tier.price_cents,
multiplier,
});
setAdded(true);
}
if (loading) return <div className="page-loading">{t("common.loading")}</div>;
if (notFound || !product) return <p>{t("storefront.productDetail.notFound")}</p>;
const category = categories.find((c) => c.id === product.category_id);
const siblingIndex = siblings.findIndex((p) => p.id === product.id);
const previousProduct = siblingIndex > 0 ? siblings[siblingIndex - 1] : undefined;
const nextProduct = siblingIndex >= 0 && siblingIndex < siblings.length - 1 ? siblings[siblingIndex + 1] : undefined;
return (
<div>
<p>
<Link to="/">&larr; {t("storefront.productDetail.backLink")}</Link>
</p>
<div className="product-detail">
<div>
{primaryImage && <img src={primaryImage} alt={product.name} className="product-detail-image" />}
{gallery.length > 0 && (
<div className="product-gallery">
{gallery.map((mediaId) => (
<GalleryThumb key={mediaId} mediaId={mediaId} alt={product.name} />
))}
</div>
)}
</div>
<div>
<h1>{product.name}</h1>
{category && (
<Link to={`/?category_id=${category.id}`} className="badge badge-link">
{category.name}
</Link>
)}
{product.description && <p>{product.description}</p>}
{tiers.length === 0 ? (
<p>{t("storefront.productDetail.notAvailable")}</p>
) : !(site.orders_enabled && site.customer_login_enabled) ? (
<div className="panel">
<h2>{t("storefront.productDetail.chooseQuantity")}</h2>
<ul>
{tiers.map((tier) => (
<li key={tier.id}>{tierLabel(tier)}</li>
))}
</ul>
</div>
) : (
<div className="panel">
<h2>{t("storefront.productDetail.chooseQuantity")}</h2>
<div className="field">
<select value={selectedTierId} onChange={(e) => setSelectedTierId(e.target.value)}>
{tiers.map((t) => (
<option key={t.id} value={t.id}>
{tierLabel(t)}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="multiplier">{t("storefront.productDetail.quantityLabel")}</label>
<input
id="multiplier"
type="number"
min="1"
value={multiplier}
onChange={(e) => setMultiplier(Math.max(1, Number(e.target.value)))}
/>
</div>
<div className="form-actions">
<button className="btn btn-primary" onClick={handleAddToCart}>
{t("storefront.productDetail.addToCart")}
</button>
{added && (
<button className="btn" onClick={() => navigate("/cart")}>
{t("storefront.productDetail.viewCart")}
</button>
)}
</div>
{added && <p>{t("storefront.productDetail.added")}</p>}
</div>
)}
</div>
</div>
{(previousProduct || nextProduct) && (
<div className="product-nav">
{previousProduct ? (
<Link to={`/products/${previousProduct.id}`} className="product-nav-link product-nav-prev">
<span className="product-nav-label"> {t("storefront.productDetail.previousProduct")}</span>
<span className="product-nav-name">{previousProduct.name}</span>
</Link>
) : (
<span />
)}
{nextProduct && (
<Link to={`/products/${nextProduct.id}`} className="product-nav-link product-nav-next">
<span className="product-nav-label">{t("storefront.productDetail.nextProduct")} </span>
<span className="product-nav-name">{nextProduct.name}</span>
</Link>
)}
</div>
)}
</div>
);
}