186 lines
7.0 KiB
TypeScript
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="/">← {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>
|
|
);
|
|
}
|