Files
dashboards/components/BusinessDetailsPage.tsx
T

438 lines
15 KiB
TypeScript

"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>
);
}