Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user