"use client"; import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import AddBusinessModal from "@/components/AddBusinessModal"; import BusinessCardList from "@/components/BusinessCardList"; import CategoryTreeSelect from "@/components/CategoryTreeSelect"; import ConfirmDialog from "@/components/ConfirmDialog"; import MakeSpecialModal from "@/components/MakeSpecialModal"; import { deleteMarketplaceBusiness, listBusinesses, updateMarketplaceBusiness, } from "@/services/marketplace"; import { getBusinessProfile, getMeshkeeBusiness, } from "@/services/meshkee-businesses"; import { categoryLabel, searchMeshkeeCategories, type MeshkeeCategory, } from "@/services/meshkee-categories"; import type { BusinessStatus, MarketplaceBusiness } from "@/types/business"; import styles from "./BusinessesPage.module.css"; type FilterKey = "all" | "pending_review" | "published" | "rejected"; const FILTERS: { key: FilterKey; label: string }[] = [ { key: "all", label: "All" }, { key: "pending_review", label: "Pending" }, { key: "published", label: "Published" }, { key: "rejected", label: "Rejected" }, ]; function normalizeStatusParam(value: string | null): FilterKey { if (!value) return "all"; if (value === "draft" || value === "pending_review") return "pending_review"; if (value === "published" || value === "rejected" || value === "all") { return value; } return "all"; } function isPendingStatus(status: BusinessStatus) { return status === "pending_review" || status === "draft"; } function categoryAncestors( categoryId: string | undefined, byId: Map, ): string[] { if (!categoryId) return []; const names: string[] = []; const seen = new Set(); let current = byId.get(categoryId); while (current && !seen.has(current.id)) { seen.add(current.id); names.unshift(current.name); current = current.parentId ? byId.get(current.parentId) : undefined; } return names; } function lastTwoCategoryLevels(trail: string[]) { if (trail.length === 0) return undefined; return trail.slice(-2).join(" / "); } function businessMatchesCategory( categoryId: string | undefined, selectedId: string, byId: Map, ) { if (!categoryId) return false; const seen = new Set(); let current = byId.get(categoryId); while (current && !seen.has(current.id)) { if (current.id === selectedId) return true; seen.add(current.id); current = current.parentId ? byId.get(current.parentId) : undefined; } return false; } async function enrichListings( items: MarketplaceBusiness[], categories: MeshkeeCategory[], ): Promise { const byId = new Map(categories.map((category) => [category.id, category])); return Promise.all( items.map(async (item) => { try { const id = String(item.meshkeeBusinessId); const [meshkee, profile] = await Promise.all([ getMeshkeeBusiness(id), getBusinessProfile(id), ]); const primaryCategoryId = profile.profile.categoryIds[0] ?? meshkee.categories[0]?.id; const trail = categoryAncestors(primaryCategoryId, byId); return { ...item, name: meshkee.name, categoryId: primaryCategoryId, categoryName: trail[trail.length - 1] ?? meshkee.categories[0]?.name, categoryTrail: lastTwoCategoryLevels(trail), logoUrl: profile.profile.logoUrl ?? meshkee.logoUrl ?? undefined, bannerUrl: profile.profile.bannerUrl ?? meshkee.bannerUrl ?? undefined, }; } catch { return { ...item, name: item.slug }; } }), ); } export default function BusinessesPage() { const router = useRouter(); const searchParams = useSearchParams(); const filterTitleId = useId(); const initialFilter = normalizeStatusParam(searchParams.get("status")); const [filter, setFilter] = useState(initialFilter); const [items, setItems] = useState([]); const [categories, setCategories] = useState([]); const [total, setTotal] = useState(0); const [error, setError] = useState(""); const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); const [filterModalOpen, setFilterModalOpen] = useState(false); const [draftCategoryIds, setDraftCategoryIds] = useState([]); const [categoryFilterId, setCategoryFilterId] = useState(null); const [removing, setRemoving] = useState(null); const [makingSpecial, setMakingSpecial] = useState(null); const [specialTick, setSpecialTick] = useState(0); const [busyId, setBusyId] = useState(null); const categoriesById = useMemo( () => new Map(categories.map((category) => [category.id, category])), [categories], ); const activeCategory = categoryFilterId ? categoriesById.get(categoryFilterId) : undefined; const activeFilterCount = categoryFilterId ? 1 : 0; const reload = useCallback(async () => { setLoading(true); setError(""); try { const [data, categoryItems] = await Promise.all([ listBusinesses({ status: "all", limit: 100 }), searchMeshkeeCategories("").catch(() => [] as MeshkeeCategory[]), ]); setCategories(categoryItems); const enriched = await enrichListings(data.items, categoryItems); setItems(enriched); setTotal(data.total); } catch (err) { setError( err instanceof Error ? err.message : "Could not load businesses from the API.", ); setItems([]); setTotal(0); } finally { setLoading(false); } }, []); useEffect(() => { void reload(); }, [reload]); useEffect(() => { setFilter(normalizeStatusParam(searchParams.get("status"))); }, [searchParams]); useEffect(() => { if (!filterModalOpen) return; const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setFilterModalOpen(false); }; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", onKey); }; }, [filterModalOpen]); const counts = useMemo(() => { const next: Record = { all: 0, pending_review: 0, published: 0, rejected: 0, }; for (const item of items) { if ( categoryFilterId && !businessMatchesCategory( item.categoryId, categoryFilterId, categoriesById, ) ) { continue; } next.all += 1; if (isPendingStatus(item.status)) next.pending_review += 1; else if (item.status === "published") next.published += 1; else if (item.status === "rejected") next.rejected += 1; } return next; }, [items, categoryFilterId, categoriesById]); const visible = useMemo(() => { return items.filter((item) => { if ( categoryFilterId && !businessMatchesCategory( item.categoryId, categoryFilterId, categoriesById, ) ) { return false; } if (filter === "all") return true; if (filter === "pending_review") return isPendingStatus(item.status); return item.status === filter; }); }, [items, filter, categoryFilterId, categoriesById]); function selectFilter(next: FilterKey) { setFilter(next); const url = new URL(window.location.href); if (next === "all") url.searchParams.delete("status"); else url.searchParams.set("status", next); router.replace(`${url.pathname}${url.search}`); } function openFilterModal() { setDraftCategoryIds(categoryFilterId ? [categoryFilterId] : []); setFilterModalOpen(true); } function applyCategoryFilter() { setCategoryFilterId(draftCategoryIds[0] ?? null); setFilterModalOpen(false); } function clearCategoryFilter() { setCategoryFilterId(null); setDraftCategoryIds([]); } async function setStatus(business: MarketplaceBusiness, status: BusinessStatus) { setBusyId(business.id); setError(""); try { const updated = await updateMarketplaceBusiness(business.id, { status }); setItems((current) => current.map((item) => item.id === business.id ? { ...item, status: updated.status, isFeatured: updated.isFeatured ?? item.isFeatured, sortWeight: updated.sortWeight ?? item.sortWeight, slug: updated.slug || item.slug, } : item, ), ); } catch (err) { setError( err instanceof Error ? err.message : "Could not update listing status.", ); } finally { setBusyId(null); } } const removingBusy = Boolean(removing && busyId === removing.id); const removingName = removing?.name || removing?.slug || "this business"; return (

Businesses

Review submissions, publish listings, and manage the marketplace gate.

{loading ? "Loading…" : filter === "all" && !categoryFilterId ? `${total} total` : `${visible.length} shown · ${total} total`}

{activeCategory ? ( <>
) : null}
{FILTERS.map((item) => { const active = filter === item.key; const count = counts[item.key]; return ( ); })}
{error ? (

API unavailable

{error}

Start the marketplace backend on port 3001, then refresh.

) : loading ? (
Loading businesses…
) : ( void setStatus(business, "published")} onDisable={async (business) => { const nextStatus = business.status === "hidden" ? "published" : "hidden"; await setStatus(business, nextStatus); }} onRemove={setRemoving} onMakeSpecial={setMakingSpecial} onMetaTag={(business) => { const lines = [ `Name: ${business.name || business.slug}`, `Slug: /${business.slug}`, `Category: ${business.categoryName || "Uncategorized"}`, `Status: ${business.status}`, `Featured: ${business.isFeatured ? "yes" : "no"}`, ].join("\n"); window.alert(lines); }} /> )} {filterModalOpen ? (
{ if (event.target === event.currentTarget) { setFilterModalOpen(false); } }} >

Businesses

Filter

{ event.preventDefault(); applyCategoryFilter(); }} >
) : null} setModalOpen(false)} onConnected={() => { void reload(); }} /> setMakingSpecial(null)} onSaved={() => setSpecialTick((value) => value + 1)} /> { if (!removingBusy) setRemoving(null); }} onConfirm={async () => { if (!removing) return; setBusyId(removing.id); setError(""); try { await deleteMarketplaceBusiness(removing.id); setRemoving(null); await reload(); } catch (err) { setError( err instanceof Error ? err.message : "Could not remove listing.", ); } finally { setBusyId(null); } }} />
); }