Files

552 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}