Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import BlogPanel from "@/components/catalog/BlogPanel";
|
||||
import PortfoliosPanel from "@/components/catalog/PortfoliosPanel";
|
||||
import ProductsPanel from "@/components/catalog/ProductsPanel";
|
||||
import { toPlainText } from "@/components/catalog/utils";
|
||||
import { openBusinessDashboardAsAdmin } from "@/lib/session-handoff";
|
||||
import { getMarketplaceBusiness } from "@/services/marketplace";
|
||||
import {
|
||||
getBusinessProfile,
|
||||
getMeshkeeBusiness,
|
||||
type BusinessAddress,
|
||||
type BusinessPhoneNumber,
|
||||
} from "@/services/meshkee-businesses";
|
||||
import {
|
||||
listMeshkeeBlogs,
|
||||
listMeshkeePortfolios,
|
||||
listMeshkeeProducts,
|
||||
} from "@/services/meshkee-content";
|
||||
import styles from "./BusinessDetailsPage.module.css";
|
||||
|
||||
type TabId = "products" | "contact" | "blog" | "portfolios";
|
||||
|
||||
const TAB_LABELS: Record<TabId, string> = {
|
||||
products: "Products",
|
||||
contact: "Contact info",
|
||||
blog: "Blog",
|
||||
portfolios: "Portfolios",
|
||||
};
|
||||
|
||||
const SOCIAL_ICONS: Record<string, string> = {
|
||||
instagram: "/images/icons/instagram.png",
|
||||
telegram: "/images/icons/telegram.png",
|
||||
facebook: "/images/icons/facebook.png",
|
||||
whatsapp: "/images/icons/telegram.png",
|
||||
linkedin: "/images/icons/facebook.png",
|
||||
youtube: "/images/icons/facebook.png",
|
||||
aparat: "/images/icons/facebook.png",
|
||||
};
|
||||
|
||||
function hasContactData(input: {
|
||||
addresses: BusinessAddress[];
|
||||
phones: BusinessPhoneNumber[];
|
||||
emails: string[];
|
||||
socials: { network: string; handle: string }[];
|
||||
}) {
|
||||
return (
|
||||
input.addresses.some(
|
||||
(item) =>
|
||||
item.address.trim() ||
|
||||
item.city.trim() ||
|
||||
item.province.trim() ||
|
||||
(item.landline ?? "").trim(),
|
||||
) ||
|
||||
input.phones.some((item) => item.number.trim()) ||
|
||||
input.emails.some((item) => item.trim()) ||
|
||||
input.socials.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export default function BusinessDetailsPage({
|
||||
listingId,
|
||||
}: {
|
||||
listingId: string;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [meshkeeId, setMeshkeeId] = useState("");
|
||||
const [tab, setTab] = useState<TabId>("products");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [categoryName, setCategoryName] = useState("");
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
|
||||
const [about, setAbout] = useState("");
|
||||
const [vision, setVision] = useState("");
|
||||
const [sinceMonth, setSinceMonth] = useState("");
|
||||
const [sinceYear, setSinceYear] = useState("");
|
||||
const [province, setProvince] = useState("");
|
||||
const [telephone, setTelephone] = useState("");
|
||||
|
||||
const [productTotal, setProductTotal] = useState(0);
|
||||
const [blogTotal, setBlogTotal] = useState(0);
|
||||
const [portfolioTotal, setPortfolioTotal] = useState(0);
|
||||
|
||||
const [addresses, setAddresses] = useState<BusinessAddress[]>([]);
|
||||
const [phones, setPhones] = useState<BusinessPhoneNumber[]>([]);
|
||||
const [emails, setEmails] = useState<string[]>([]);
|
||||
const [socials, setSocials] = useState<
|
||||
{ network: string; handle: string; icon: string }[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const listing = await getMarketplaceBusiness(listingId);
|
||||
const id = String(listing.meshkeeBusinessId);
|
||||
|
||||
const [
|
||||
meshkee,
|
||||
profile,
|
||||
productsRes,
|
||||
blogsRes,
|
||||
portfoliosRes,
|
||||
] = await Promise.all([
|
||||
getMeshkeeBusiness(id),
|
||||
getBusinessProfile(id),
|
||||
listMeshkeeProducts(id, { page: 1, pageSize: 1 }).catch(() => ({
|
||||
total: 0,
|
||||
})),
|
||||
listMeshkeeBlogs(id, { page: 1, pageSize: 1 }).catch(() => ({
|
||||
total: 0,
|
||||
})),
|
||||
listMeshkeePortfolios(id, { page: 1, pageSize: 1 }).catch(() => ({
|
||||
total: 0,
|
||||
})),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const nextAddresses = profile.addresses ?? [];
|
||||
const nextPhones = profile.profile.phoneNumbers ?? [];
|
||||
const nextEmails = (profile.profile.emails ?? []).filter(Boolean);
|
||||
const socialMedia = profile.profile.socialMedia ?? {};
|
||||
const nextSocials = Object.entries(socialMedia)
|
||||
.filter(([, handle]) => Boolean(handle?.trim()))
|
||||
.map(([network, handle]) => ({
|
||||
network,
|
||||
handle: handle.trim(),
|
||||
icon: SOCIAL_ICONS[network] ?? "/images/icons/telegram.png",
|
||||
}));
|
||||
|
||||
setMeshkeeId(id);
|
||||
setName(meshkee.name || listing.slug);
|
||||
setSlug(listing.slug);
|
||||
setCategoryName(meshkee.categories[0]?.name ?? "");
|
||||
setLogoUrl(profile.profile.logoUrl ?? meshkee.logoUrl);
|
||||
setBannerUrl(profile.profile.bannerUrl ?? meshkee.bannerUrl);
|
||||
setAbout(profile.profile.about ?? "");
|
||||
setVision(profile.profile.vision ?? "");
|
||||
if (meshkee.createdAt) {
|
||||
const joined = new Date(meshkee.createdAt);
|
||||
setSinceMonth(
|
||||
joined.toLocaleDateString("en-US", { month: "long" }),
|
||||
);
|
||||
setSinceYear(String(joined.getFullYear()));
|
||||
} else {
|
||||
setSinceMonth("");
|
||||
setSinceYear("");
|
||||
}
|
||||
setProvince(nextAddresses[0]?.province ?? "");
|
||||
setTelephone(
|
||||
nextPhones.find((item) => item.number.trim())?.number ??
|
||||
nextAddresses.find((item) => (item.landline ?? "").trim())
|
||||
?.landline ??
|
||||
"",
|
||||
);
|
||||
setProductTotal(productsRes.total ?? 0);
|
||||
setBlogTotal(blogsRes.total ?? 0);
|
||||
setPortfolioTotal(portfoliosRes.total ?? 0);
|
||||
setAddresses(nextAddresses);
|
||||
setPhones(nextPhones as BusinessPhoneNumber[]);
|
||||
setEmails(nextEmails);
|
||||
setSocials(nextSocials);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not load business details.",
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [listingId]);
|
||||
|
||||
const contactReady = hasContactData({
|
||||
addresses,
|
||||
phones,
|
||||
emails,
|
||||
socials,
|
||||
});
|
||||
|
||||
const availableTabs = useMemo(() => {
|
||||
const tabs: TabId[] = [];
|
||||
if (productTotal > 0) tabs.push("products");
|
||||
if (contactReady) tabs.push("contact");
|
||||
if (blogTotal > 0) tabs.push("blog");
|
||||
// Gallery disabled until a dedicated gallery section exists (media library ≠ gallery).
|
||||
if (portfolioTotal > 0) tabs.push("portfolios");
|
||||
return tabs;
|
||||
}, [productTotal, contactReady, blogTotal, portfolioTotal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTabs.length) return;
|
||||
if (!availableTabs.includes(tab)) setTab(availableTabs[0]);
|
||||
}, [availableTabs, tab]);
|
||||
|
||||
const description = toPlainText(about) || toPlainText(vision);
|
||||
|
||||
function openBusinessDashboard(nextPath: string) {
|
||||
if (!meshkeeId) return;
|
||||
openBusinessDashboardAsAdmin(meshkeeId, nextPath);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>Loading business details…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.error} role="alert">
|
||||
<p className={styles.errorTitle}>Could not load details</p>
|
||||
<p className={styles.errorCopy}>{error}</p>
|
||||
<Link href="/businesses" className={styles.backLink}>
|
||||
Back to businesses
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||||
<Link href="/">Home</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<Link href="/businesses">Businesses</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className={styles.breadcrumbCurrent}>{name}</span>
|
||||
</nav>
|
||||
<div className={styles.headActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.actionBtn}`}
|
||||
disabled={!meshkeeId}
|
||||
onClick={() => openBusinessDashboard("/profile")}
|
||||
>
|
||||
Manage business
|
||||
</button>
|
||||
<Link
|
||||
href={`/businesses/${listingId}/edit`}
|
||||
className={`outline-btn ${styles.actionBtn}`}
|
||||
>
|
||||
Edit listing
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.hero}>
|
||||
{bannerUrl ? (
|
||||
<img src={bannerUrl} alt="" className={styles.heroImg} />
|
||||
) : (
|
||||
<div className={styles.heroFallback} aria-hidden />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.profile}>
|
||||
<div className={styles.profileBar}>
|
||||
<div className={styles.identity}>
|
||||
{logoUrl ? (
|
||||
<img className={styles.logo} src={logoUrl} alt="" height={56} />
|
||||
) : (
|
||||
<span className={styles.logoMark} aria-hidden>
|
||||
{name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<h2 className={styles.name}>{name}</h2>
|
||||
<p className={styles.tagline}>{categoryName || `/${slug}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
{sinceMonth && sinceYear ? (
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statLabel}>since</span>
|
||||
<strong className={styles.statValue}>{sinceYear}</strong>
|
||||
<span className={styles.statMonth}>{sinceMonth}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{province ? (
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statLabel}>province</span>
|
||||
<strong className={styles.statValue}>{province}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.statWide}>
|
||||
<strong className={styles.statValueLg}>{portfolioTotal}</strong>
|
||||
<span className={styles.statLabelStack}>
|
||||
active
|
||||
<br />
|
||||
{portfolioTotal === 1 ? "portfolio" : "portfolios"}
|
||||
</span>
|
||||
</div>
|
||||
{telephone ? (
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statLabel}>Telephone</span>
|
||||
<strong className={styles.statValue}>{telephone}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className={styles.description} dir="auto">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{availableTabs.length && meshkeeId ? (
|
||||
<>
|
||||
<nav className={styles.tabs} aria-label="Business sections">
|
||||
{availableTabs.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={tab === id ? styles.tabActive : styles.tab}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{TAB_LABELS[id]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className={styles.panel} aria-live="polite">
|
||||
{tab === "products" ? (
|
||||
<ProductsPanel
|
||||
meshkeeId={meshkeeId}
|
||||
controlsMode="manage"
|
||||
onManage={() => openBusinessDashboard("/products")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tab === "contact" ? (
|
||||
<div className={styles.contact}>
|
||||
<div className={styles.contactRows}>
|
||||
{addresses.map((address, index) => (
|
||||
<div
|
||||
key={address.id ?? `${address.city}-${index}`}
|
||||
className={styles.contactRow}
|
||||
>
|
||||
<span className={styles.contactLabel}>
|
||||
{address.name || "Address"}
|
||||
</span>
|
||||
<p dir="auto">
|
||||
{[address.address, address.city, address.province]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
{address.postalCode ? (
|
||||
<p>Postal: {address.postalCode}</p>
|
||||
) : null}
|
||||
{address.landline ? (
|
||||
<p>Landline: {address.landline}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{phones.length ? (
|
||||
<div className={styles.contactRow}>
|
||||
<span className={styles.contactLabel}>Phones</span>
|
||||
<p>
|
||||
{phones
|
||||
.filter((item) => item.number.trim())
|
||||
.map((item) => item.number)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{emails.length ? (
|
||||
<div className={styles.contactRow}>
|
||||
<span className={styles.contactLabel}>Email</span>
|
||||
<p>{emails.join(" · ")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{socials.length ? (
|
||||
<div className={styles.socials}>
|
||||
{socials.map((social) => (
|
||||
<span key={social.network} className={styles.socialChip}>
|
||||
<img src={social.icon} alt="" width={16} height={16} />
|
||||
<em>{social.network}</em> {social.handle}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.actionBtn}`}
|
||||
style={{ marginTop: 18 }}
|
||||
onClick={() => openBusinessDashboard("/profile")}
|
||||
>
|
||||
Manage business
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "blog" ? (
|
||||
<BlogPanel
|
||||
meshkeeId={meshkeeId}
|
||||
controlsMode="manage"
|
||||
onManage={() => openBusinessDashboard("/blog")}
|
||||
/>
|
||||
) : null}
|
||||
{tab === "portfolios" ? (
|
||||
<PortfoliosPanel
|
||||
meshkeeId={meshkeeId}
|
||||
controlsMode="manage"
|
||||
onManage={() => openBusinessDashboard("/portfolios")}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyTabs}>
|
||||
No catalog tabs yet — products, blog, portfolios, or contact data will
|
||||
appear here when Meshkee has them.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user