Files
dashboards/components/BusinessesPage.tsx
T
2026-10-03 23:17:36 +03:30

367 lines
11 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import AddBusinessModal from "@/components/AddBusinessModal";
import BusinessCardList from "@/components/BusinessCardList";
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 {
searchMeshkeeCategories,
type MeshkeeCategory,
} from "@/services/meshkee-categories";
import type { BusinessStatus, MarketplaceBusiness } from "@/types/business";
import styles from "./BusinessesPage.module.css";
type FilterKey = "all" | BusinessStatus;
const FILTERS: { key: FilterKey; label: string }[] = [
{ key: "all", label: "All" },
{ key: "pending_review", label: "Pending" },
{ key: "published", label: "Published" },
{ key: "draft", label: "Draft" },
{ key: "hidden", label: "Hidden" },
{ key: "rejected", label: "Rejected" },
];
function categoryAncestors(
categoryId: string | undefined,
byId: Map<string, MeshkeeCategory>,
): string[] {
if (!categoryId) return [];
const names: string[] = [];
const seen = new Set<string>();
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(" / ");
}
async function enrichListings(
items: MarketplaceBusiness[],
): Promise<MarketplaceBusiness[]> {
const categories = await searchMeshkeeCategories("").catch(
() => [] as MeshkeeCategory[],
);
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,
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 };
}
}),
);
}
function isFilterKey(value: string | null): value is FilterKey {
if (!value) return false;
return FILTERS.some((item) => item.key === value);
}
export default function BusinessesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const initialFilter = isFilterKey(searchParams.get("status"))
? (searchParams.get("status") as FilterKey)
: "all";
const [filter, setFilter] = useState<FilterKey>(initialFilter);
const [items, setItems] = useState<MarketplaceBusiness[]>([]);
const [total, setTotal] = useState(0);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [removing, setRemoving] = useState<MarketplaceBusiness | null>(null);
const [rejecting, setRejecting] = useState<MarketplaceBusiness | null>(null);
const [makingSpecial, setMakingSpecial] =
useState<MarketplaceBusiness | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError("");
try {
const data = await listBusinesses({ status: "all", limit: 100 });
const enriched = await enrichListings(data.items);
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(() => {
const next = isFilterKey(searchParams.get("status"))
? (searchParams.get("status") as FilterKey)
: "all";
setFilter(next);
}, [searchParams]);
const counts = useMemo(() => {
const next: Record<FilterKey, number> = {
all: items.length,
draft: 0,
pending_review: 0,
published: 0,
hidden: 0,
rejected: 0,
};
for (const item of items) {
next[item.status] += 1;
}
return next;
}, [items]);
const visible = useMemo(() => {
if (filter === "all") return items;
return items.filter((item) => item.status === filter);
}, [items, filter]);
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}`);
}
async function setStatus(business: MarketplaceBusiness, status: BusinessStatus) {
setBusyId(business.id);
setError("");
try {
await updateMarketplaceBusiness(business.id, { status });
await reload();
} catch (err) {
setError(
err instanceof Error
? err.message
: "Could not update listing status.",
);
} finally {
setBusyId(null);
}
}
const removingBusy = Boolean(removing && busyId === removing.id);
const rejectingBusy = Boolean(rejecting && busyId === rejecting.id);
const removingName = removing?.name || removing?.slug || "this business";
const rejectingName = rejecting?.name || rejecting?.slug || "this business";
return (
<div className={styles.page}>
<header className={styles.head}>
<div>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<a href="/">Home</a>
<span className={styles.breadcrumbSep} aria-hidden>
/
</span>
<span className={styles.breadcrumbCurrent}>Businesses</span>
</nav>
<h1 className={styles.title}>Businesses</h1>
<p className={styles.lead}>
Review submissions, publish listings, and manage the marketplace
gate.
</p>
</div>
<div className={styles.headActions}>
<p className={styles.count}>
{loading
? "Loading…"
: filter === "all"
? `${total} total`
: `${visible.length} shown · ${total} total`}
</p>
<button
type="button"
className={styles.addBtn}
aria-label="Add business"
onClick={() => setModalOpen(true)}
>
<img src="/images/icons/add.svg" alt="" width={18} height={18} />
</button>
</div>
</header>
<div className={styles.filters} role="tablist" aria-label="Listing status">
{FILTERS.map((item) => {
const active = filter === item.key;
const count = counts[item.key];
return (
<button
key={item.key}
type="button"
role="tab"
aria-selected={active}
className={`${styles.filter} ${active ? styles.filterActive : ""}`}
onClick={() => selectFilter(item.key)}
>
<span>{item.label}</span>
<em>{loading ? "—" : count}</em>
</button>
);
})}
</div>
{error ? (
<div className={styles.error} role="alert">
<p className={styles.errorTitle}>API unavailable</p>
<p className={styles.errorCopy}>{error}</p>
<p className={styles.errorHint}>
Start the marketplace backend on port 3001, then refresh.
</p>
</div>
) : loading ? (
<div className={styles.loading}>Loading businesses…</div>
) : (
<BusinessCardList
items={visible}
busyId={busyId}
emptyTitle={
filter === "pending_review"
? "No pending submissions"
: "No businesses in this view"
}
emptyCopy={
filter === "pending_review"
? "New shop registrations will appear here for approval."
: "Try another filter, or add a Meshkee business to the marketplace."
}
onApprove={(business) => void setStatus(business, "published")}
onReject={setRejecting}
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);
}}
/>
)}
<AddBusinessModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onConnected={() => {
void reload();
}}
/>
<MakeSpecialModal
open={Boolean(makingSpecial)}
business={makingSpecial}
onClose={() => setMakingSpecial(null)}
/>
<ConfirmDialog
open={Boolean(rejecting)}
kicker="Reject submission"
title={`Reject ${rejectingName}?`}
message="The Meshkee shop stays intact. The listing will be marked rejected and stay off the public site."
confirmLabel="Reject"
cancelLabel="Keep pending"
danger
busy={rejectingBusy}
onClose={() => {
if (!rejectingBusy) setRejecting(null);
}}
onConfirm={async () => {
if (!rejecting) return;
await setStatus(rejecting, "rejected");
setRejecting(null);
}}
/>
<ConfirmDialog
open={Boolean(removing)}
kicker="Remove listing"
title={`Remove ${removingName}?`}
message="This unlists the business from the marketplace. The Meshkee business stays intact."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removingBusy}
onClose={() => {
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);
}
}}
/>
</div>
);
}