365 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|