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