Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user