chore: build
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user