348 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|