552 lines
17 KiB
TypeScript
552 lines
17 KiB
TypeScript
"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, 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(" / ");
|
||
}
|
||
|
||
function businessMatchesCategory(
|
||
categoryId: string | undefined,
|
||
selectedId: string,
|
||
byId: Map<string, MeshkeeCategory>,
|
||
) {
|
||
if (!categoryId) return false;
|
||
const seen = new Set<string>();
|
||
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<MarketplaceBusiness[]> {
|
||
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<FilterKey>(initialFilter);
|
||
const [items, setItems] = useState<MarketplaceBusiness[]>([]);
|
||
const [categories, setCategories] = useState<MeshkeeCategory[]>([]);
|
||
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<string[]>([]);
|
||
const [categoryFilterId, setCategoryFilterId] = useState<string | null>(null);
|
||
const [removing, setRemoving] = useState<MarketplaceBusiness | null>(null);
|
||
const [makingSpecial, setMakingSpecial] =
|
||
useState<MarketplaceBusiness | null>(null);
|
||
const [specialTick, setSpecialTick] = useState(0);
|
||
const [busyId, setBusyId] = useState<string | null>(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<FilterKey, number> = {
|
||
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 (
|
||
<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" && !categoryFilterId
|
||
? `${total} total`
|
||
: `${visible.length} shown · ${total} total`}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
className={`${styles.filterOpenBtn}${categoryFilterId ? ` ${styles.filterOpenBtnActive}` : ""}`}
|
||
onClick={openFilterModal}
|
||
aria-label={
|
||
categoryFilterId
|
||
? `Filter, ${activeFilterCount} active`
|
||
: "Filter"
|
||
}
|
||
>
|
||
<span>Filter</span>
|
||
{activeFilterCount > 0 ? (
|
||
<em className={styles.filterCount}>{activeFilterCount}</em>
|
||
) : null}
|
||
</button>
|
||
<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.filterBar}>
|
||
{activeCategory ? (
|
||
<>
|
||
<div className={styles.activeFilters}>
|
||
<button
|
||
type="button"
|
||
className={styles.activeChip}
|
||
onClick={clearCategoryFilter}
|
||
aria-label={`Clear category filter ${categoryLabel(activeCategory)}`}
|
||
>
|
||
<span>{categoryLabel(activeCategory)}</span>
|
||
<em aria-hidden>×</em>
|
||
</button>
|
||
</div>
|
||
<div className={styles.filterDivider} aria-hidden />
|
||
</>
|
||
) : null}
|
||
|
||
<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>
|
||
</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}
|
||
specialTick={specialTick}
|
||
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")}
|
||
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 ? (
|
||
<div
|
||
className={styles.overlay}
|
||
role="presentation"
|
||
onMouseDown={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
setFilterModalOpen(false);
|
||
}
|
||
}}
|
||
>
|
||
<div
|
||
className={styles.modal}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={filterTitleId}
|
||
>
|
||
<div className={styles.modalHead}>
|
||
<div>
|
||
<p className={styles.kicker}>Businesses</p>
|
||
<h2 id={filterTitleId} className={styles.modalTitle}>
|
||
Filter
|
||
</h2>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.closeBtn}
|
||
aria-label="Close"
|
||
onClick={() => setFilterModalOpen(false)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<form
|
||
className={styles.modalForm}
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
applyCategoryFilter();
|
||
}}
|
||
>
|
||
<CategoryTreeSelect
|
||
categories={categories}
|
||
value={draftCategoryIds}
|
||
selection="single"
|
||
dropdownVariant="inFlow"
|
||
label="Category"
|
||
placeholder="Select category"
|
||
onChange={setDraftCategoryIds}
|
||
/>
|
||
|
||
<div className={styles.modalActions}>
|
||
<button
|
||
type="button"
|
||
className={`outline-btn ${styles.ghostBtn}`}
|
||
onClick={() => {
|
||
setDraftCategoryIds([]);
|
||
}}
|
||
>
|
||
Clear
|
||
</button>
|
||
<button type="submit" className={`outline-btn ${styles.applyBtn}`}>
|
||
Apply
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<AddBusinessModal
|
||
open={modalOpen}
|
||
onClose={() => setModalOpen(false)}
|
||
onConnected={() => {
|
||
void reload();
|
||
}}
|
||
/>
|
||
|
||
<MakeSpecialModal
|
||
open={Boolean(makingSpecial)}
|
||
business={makingSpecial}
|
||
onClose={() => setMakingSpecial(null)}
|
||
onSaved={() => setSpecialTick((value) => value + 1)}
|
||
/>
|
||
|
||
<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>
|
||
);
|
||
}
|