Files

348 lines
10 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import CatalogCardControls, {
type CatalogControlsMode,
} from "@/components/catalog/CatalogCardControls";
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
import CatalogPagination from "@/components/catalog/CatalogPagination";
import {
PRODUCT_PAGE_SIZE,
productImage,
productSubtitle,
} from "@/components/catalog/utils";
import ConfirmDialog from "@/components/ConfirmDialog";
import styles from "@/components/BusinessDetailsPage.module.css";
import { en } from "@/lib/i18n/en";
import {
collectCategorySubtreeIds,
deleteMeshkeeProduct,
listMeshkeeBusinessCategories,
listMeshkeeProducts,
type MeshkeeBusinessCategory,
type MeshkeeProduct,
} from "@/services/meshkee-content";
export default function ProductsPanel({
meshkeeId,
emptyLabel = en.products.empty,
addHref,
controlsMode = "edit-remove",
onManage,
}: {
meshkeeId: string;
emptyLabel?: string;
addHref?: string;
controlsMode?: CatalogControlsMode;
onManage?: () => void;
}) {
const sectionRef = useRef<HTMLDivElement>(null);
const [categories, setCategories] = useState<MeshkeeBusinessCategory[]>([]);
const [parentCategoryId, setParentCategoryId] = useState<string | null>(null);
const [childCategoryId, setChildCategoryId] = useState<string | null>(null);
const [products, setProducts] = useState<MeshkeeProduct[]>([]);
const [productTotal, setProductTotal] = useState(0);
const [productPage, setProductPage] = useState(1);
const [productBusy, setProductBusy] = useState(false);
const [productCatalog, setProductCatalog] = useState<MeshkeeProduct[] | null>(
null,
);
const [catalogBusy, setCatalogBusy] = useState(false);
const [removing, setRemoving] = useState<MeshkeeProduct | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const [error, setError] = useState("");
const rootCategories = useMemo(
() => categories.filter((item) => item.parentId == null),
[categories],
);
const childCategories = useMemo(() => {
if (!parentCategoryId) return [];
return categories.filter((item) => item.parentId === parentCategoryId);
}, [categories, parentCategoryId]);
const activeCategoryId = childCategoryId ?? parentCategoryId;
const filteredCatalog = useMemo(() => {
if (!productCatalog || !activeCategoryId) return productCatalog;
const ids = collectCategorySubtreeIds(categories, activeCategoryId);
return productCatalog.filter(
(item) => item.categoryId && ids.has(item.categoryId),
);
}, [productCatalog, activeCategoryId, categories]);
const pagedFiltered = useMemo(() => {
if (!filteredCatalog) return null;
const start = (productPage - 1) * PRODUCT_PAGE_SIZE;
return filteredCatalog.slice(start, start + PRODUCT_PAGE_SIZE);
}, [filteredCatalog, productPage]);
const displayed = activeCategoryId ? (pagedFiltered ?? products) : products;
const displayedTotal = activeCategoryId
? (filteredCatalog?.length ?? productTotal)
: productTotal;
useEffect(() => {
let cancelled = false;
void (async () => {
setProductBusy(true);
setError("");
try {
const [productsRes, categoriesRes] = await Promise.all([
listMeshkeeProducts(meshkeeId, {
page: 1,
pageSize: PRODUCT_PAGE_SIZE,
}),
listMeshkeeBusinessCategories(meshkeeId).catch(
() => [] as MeshkeeBusinessCategory[],
),
]);
if (cancelled) return;
setProducts(productsRes.items);
setProductTotal(productsRes.total);
setProductPage(1);
setCategories(categoriesRes);
setProductCatalog(null);
setParentCategoryId(null);
setChildCategoryId(null);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load products.",
);
}
} finally {
if (!cancelled) setProductBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId]);
function scrollToSection() {
const node = sectionRef.current;
if (!node) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
node.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
}
async function ensureCatalog() {
if (productCatalog) return productCatalog;
setCatalogBusy(true);
try {
const pageSize = 100;
let page = 1;
let total = Infinity;
const all: MeshkeeProduct[] = [];
while (all.length < total) {
const res = await listMeshkeeProducts(meshkeeId, { page, pageSize });
total = res.total;
all.push(...res.items);
if (!res.items.length) break;
page += 1;
}
setProductCatalog(all);
return all;
} finally {
setCatalogBusy(false);
}
}
async function loadPage(page: number) {
if (activeCategoryId) {
setProductPage(page);
scrollToSection();
return;
}
setProductBusy(true);
try {
const res = await listMeshkeeProducts(meshkeeId, {
page,
pageSize: PRODUCT_PAGE_SIZE,
});
setProducts(res.items);
setProductTotal(res.total);
setProductPage(res.page);
scrollToSection();
} finally {
setProductBusy(false);
}
}
async function selectParent(id: string | null) {
setParentCategoryId(id);
setChildCategoryId(null);
setProductPage(1);
if (id) await ensureCatalog();
scrollToSection();
}
async function selectChild(id: string | null) {
setChildCategoryId(id);
setProductPage(1);
if (id || parentCategoryId) await ensureCatalog();
scrollToSection();
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
try {
await deleteMeshkeeProduct(meshkeeId, removing.id);
setProducts((prev) => prev.filter((item) => item.id !== removing.id));
setProductCatalog((prev) =>
prev ? prev.filter((item) => item.id !== removing.id) : prev,
);
setProductTotal((total) => Math.max(0, total - 1));
setRemoving(null);
} catch (err) {
window.alert(
err instanceof Error ? err.message : "Could not remove product.",
);
} finally {
setRemoveBusy(false);
}
}
if (error) {
return <p className={styles.panelHint}>{error}</p>;
}
return (
<div ref={sectionRef} className={styles.productsSection}>
{productTotal > 0 ? (
<div className={styles.pills}>
<button
type="button"
className={!parentCategoryId ? styles.pillActive : styles.pill}
onClick={() => void selectParent(null)}
>
All
</button>
{rootCategories.map((item) => (
<button
key={item.id}
type="button"
className={
parentCategoryId === item.id ? styles.pillActive : styles.pill
}
onClick={() => void selectParent(item.id)}
>
{item.name}
</button>
))}
</div>
) : null}
{productTotal > 0 && childCategories.length ? (
<div className={styles.childPills}>
<button
type="button"
className={!childCategoryId ? styles.pillActive : styles.pill}
onClick={() => void selectChild(null)}
>
All in category
</button>
{childCategories.map((item) => (
<button
key={item.id}
type="button"
className={
childCategoryId === item.id ? styles.pillActive : styles.pill
}
onClick={() => void selectChild(item.id)}
>
{item.name}
</button>
))}
</div>
) : null}
{catalogBusy || productBusy ? (
<p className={styles.panelHint}>Loading products…</p>
) : null}
<div className={styles.productGrid}>
{displayed.map((product) => {
const image = productImage(product);
const subtitle = productSubtitle(product);
return (
<article key={product.id} className={styles.card}>
<div className={styles.cardMedia}>
{image ? (
<img src={image} alt="" />
) : (
<div className={styles.mediaFallback} aria-hidden />
)}
</div>
<div
className={`tile-hover ${styles.cardFoot}${
subtitle ? "" : ` ${styles.cardFootSolo}`
}`}
>
<div className={styles.cardBody}>
<h3 dir="auto">{product.title}</h3>
{subtitle ? <p>{subtitle}</p> : null}
</div>
<CatalogCardControls
mode={controlsMode}
busy={removeBusy}
onManage={onManage}
onEdit={() =>
window.alert(`Edit product coming soon: ${product.title}`)
}
onRemove={() => setRemoving(product)}
/>
</div>
</article>
);
})}
</div>
{!displayed.length && !catalogBusy && !productBusy ? (
<CatalogEmptyState
icon="/images/icons/category.svg"
title={
activeCategoryId ? en.products.emptyFiltered : emptyLabel
}
lead={
activeCategoryId
? en.products.emptyFilteredLead
: en.products.emptyLead
}
actionHref={activeCategoryId ? undefined : addHref}
actionLabel={activeCategoryId ? undefined : en.products.add}
/>
) : null}
<CatalogPagination
page={productPage}
total={displayedTotal}
pageSize={PRODUCT_PAGE_SIZE}
busy={productBusy || catalogBusy}
onChange={(page) => void loadPage(page)}
/>
<ConfirmDialog
open={controlsMode === "edit-remove" && Boolean(removing)}
kicker="Remove"
title={`Remove ${removing?.title ?? "product"}?`}
message="This deletes the product from Meshkee. This cannot be undone."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}