367 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|