Files
dashboards/components/BusinessCardList.tsx
T

365 lines
13 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { openBusinessDashboardAsAdmin } from "@/lib/session-handoff";
import { getSpecialBusinesses } from "@/services/special-businesses";
import type { BusinessStatus, MarketplaceBusiness } from "@/types/business";
import styles from "./BusinessCardList.module.css";
const STATUS_LABEL: Record<BusinessStatus, string> = {
draft: "Pending",
pending_review: "Pending",
published: "Published",
hidden: "Hidden",
rejected: "Rejected",
};
const websiteUrl =
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
"http://localhost:3000";
function statusClass(status: BusinessStatus) {
switch (status) {
case "published":
return styles.statusLive;
case "pending_review":
case "draft":
return styles.statusReview;
case "hidden":
return styles.statusHidden;
case "rejected":
return styles.statusRejected;
default:
return styles.statusReview;
}
}
function publicUrl(slug: string) {
return `${websiteUrl}/businesses/${slug}`;
}
export default function BusinessCardList({
items,
onApprove,
onDisable,
onRemove,
onMetaTag,
onMakeSpecial,
busyId,
specialTick = 0,
emptyTitle = "No businesses yet",
emptyCopy = "Marketplace listings will appear here once you link a Meshkee business.",
}: {
items: MarketplaceBusiness[];
onApprove?: (business: MarketplaceBusiness) => void;
onDisable?: (business: MarketplaceBusiness) => void;
onRemove?: (business: MarketplaceBusiness) => void;
onMetaTag?: (business: MarketplaceBusiness) => void;
onMakeSpecial?: (business: MarketplaceBusiness) => void;
busyId?: string | null;
/** Bump to reload special-carousel membership chips */
specialTick?: number;
emptyTitle?: string;
emptyCopy?: string;
}) {
const router = useRouter();
const [openMoreId, setOpenMoreId] = useState<string | null>(null);
const [specialCountByBusinessId, setSpecialCountByBusinessId] = useState<
Map<string, number>
>(() => new Map());
const moreRefs = useRef<Map<string, HTMLDivElement>>(new Map());
useEffect(() => {
if (!openMoreId) return;
const onPointerDown = (event: PointerEvent) => {
const root = moreRefs.current.get(openMoreId);
if (root && !root.contains(event.target as Node)) {
setOpenMoreId(null);
}
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [openMoreId]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const config = await getSpecialBusinesses();
if (cancelled) return;
const next = new Map<string, number>();
for (const carousel of config.carousels) {
for (const businessId of carousel.businessIds) {
next.set(businessId, (next.get(businessId) ?? 0) + 1);
}
}
setSpecialCountByBusinessId(next);
} catch {
if (!cancelled) setSpecialCountByBusinessId(new Map());
}
})();
return () => {
cancelled = true;
};
}, [specialTick, items.length]);
if (items.length === 0) {
return (
<div className={styles.empty}>
<p className={styles.emptyTitle}>{emptyTitle}</p>
<p className={styles.emptyCopy}>{emptyCopy}</p>
</div>
);
}
return (
<ul className={styles.grid}>
{items.map((business) => {
const displayName = business.name || business.slug;
const category = business.categoryName || "Uncategorized";
const categoryTrail =
business.categoryTrail || business.categoryName || "Uncategorized";
const href = publicUrl(business.slug);
const showHref = `/businesses/${business.id}`;
const isHidden = business.status === "hidden";
const isPending =
business.status === "pending_review" ||
business.status === "draft";
const canPublish =
isPending || business.status === "rejected";
const isBusy = busyId === business.id;
const moreOpen = openMoreId === business.id;
const specialCount = specialCountByBusinessId.get(business.id) ?? 0;
function openShow() {
router.push(showHref);
}
return (
<li key={business.id}>
<article
className={styles.card}
role="link"
tabIndex={0}
aria-label={`Show ${displayName}`}
onClick={openShow}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openShow();
}
}}
>
<div className={styles.coverWrap}>
{business.bannerUrl ? (
<img
src={business.bannerUrl}
alt=""
className={styles.cover}
/>
) : (
<div className={styles.coverFallback} aria-hidden />
)}
<span
className={`${styles.status} ${statusClass(business.status)}`}
>
{STATUS_LABEL[business.status]}
</span>
</div>
<div className={`tile-hover ${styles.body}`}>
<div className={styles.info}>
<div className={styles.identity}>
{business.logoUrl ? (
<img
className={styles.logo}
src={business.logoUrl}
alt=""
height={52}
/>
) : (
<span className={styles.logoMark} aria-hidden>
{displayName.slice(0, 1).toUpperCase()}
</span>
)}
<div className={styles.copy}>
<h2 className={styles.name}>{displayName}</h2>
<p className={styles.tagline}>{category}</p>
</div>
</div>
</div>
<div className={styles.bottom}>
<div className={styles.foot}>
<span className={styles.slug}>{categoryTrail}</span>
{business.isFeatured ? (
<span className={styles.featured}>Featured</span>
) : null}
</div>
<div
className={styles.controls}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{canPublish && onApprove ? (
<button
type="button"
className={`${styles.control} ${styles.controlPrimary}`}
disabled={isBusy}
onClick={() => onApprove(business)}
>
{isBusy ? "…" : "Approve"}
</button>
) : null}
<button
type="button"
className={styles.control}
disabled={isBusy}
onClick={() =>
router.push(`/businesses/${business.id}/edit`)
}
>
Edit
</button>
<button
type="button"
className={styles.control}
disabled={isBusy || !business.meshkeeBusinessId}
onClick={() =>
openBusinessDashboardAsAdmin(
business.meshkeeBusinessId,
"/profile",
)
}
>
Manage
</button>
<button
type="button"
className={styles.control}
disabled={isBusy}
onClick={async () => {
try {
await navigator.clipboard.writeText(href);
} catch {
window.prompt("Copy link", href);
}
}}
>
Link
</button>
<div
className={`${styles.moreWrap} ${moreOpen ? styles.moreWrapOpen : ""}`}
ref={(node) => {
if (node) moreRefs.current.set(business.id, node);
else moreRefs.current.delete(business.id);
}}
>
<button
type="button"
className={`${styles.control} ${styles.moreTrigger}`}
aria-haspopup="menu"
aria-expanded={moreOpen}
disabled={isBusy}
onClick={() =>
setOpenMoreId((id) =>
id === business.id ? null : business.id,
)
}
>
More
<img
src="/images/icons/add.svg"
alt=""
width={10}
height={10}
/>
</button>
<div className={styles.moreMenu} role="menu">
<a
role="menuitem"
className={styles.moreItem}
href={href}
target="_blank"
rel="noreferrer"
onClick={() => setOpenMoreId(null)}
>
Website
</a>
<button
type="button"
role="menuitem"
className={styles.moreItem}
disabled={isBusy}
onClick={() => {
setOpenMoreId(null);
onMetaTag?.(business);
}}
>
Meta tag
</button>
{onMakeSpecial ? (
<button
type="button"
role="menuitem"
className={`${styles.moreItem} ${styles.moreItemSpecial}`}
disabled={isBusy}
onClick={() => {
setOpenMoreId(null);
onMakeSpecial(business);
}}
>
Make special
{specialCount > 0 ? (
<em
className={styles.specialChip}
aria-label={`In ${specialCount} special carousel${specialCount === 1 ? "" : "s"}`}
>
{specialCount}
</em>
) : null}
</button>
) : null}
{!isPending ? (
<button
type="button"
role="menuitem"
className={styles.moreItem}
disabled={isBusy || !onDisable}
onClick={() => {
setOpenMoreId(null);
onDisable?.(business);
}}
>
{isBusy ? "…" : isHidden ? "Enable" : "Disable"}
</button>
) : null}
<button
type="button"
role="menuitem"
className={`${styles.moreItem} ${styles.moreItemDanger}`}
disabled={isBusy || !onRemove}
onClick={() => {
setOpenMoreId(null);
onRemove?.(business);
}}
>
Remove
</button>
</div>
</div>
</div>
</div>
</div>
</article>
</li>
);
})}
</ul>
);
}