175 lines
6.2 KiB
TypeScript
175 lines
6.2 KiB
TypeScript
"use client";
|
||
|
||
import { ReactNode, useEffect, useState } from "react";
|
||
import Link from "next/link";
|
||
import { usePathname } from "next/navigation";
|
||
import { TenantSwitcher } from "@/components/TenantSwitcher";
|
||
import { Button } from "@/components/ui";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { useMe } from "@/hooks/useMe";
|
||
import { loadCommercialProducts } from "../adapters";
|
||
import type { CommercialProduct } from "../types";
|
||
import { resolveProductLaunchUrl } from "../types";
|
||
|
||
const CORE_NAV = [
|
||
{ href: "/dashboard", label: "داشبورد" },
|
||
{ href: "/apps", label: "اپها" },
|
||
{ href: "/billing", label: "اشتراک و پرداخت" },
|
||
{ href: "/domains", label: "دامنه" },
|
||
{ href: "/notifications", label: "اعلانها" },
|
||
{ href: "/help", label: "راهنما" },
|
||
{ href: "/dashboard/settings", label: "حساب" },
|
||
];
|
||
|
||
/** Premium authenticated commercial OS shell — dynamic product links from registry. */
|
||
export function PlatformShell({
|
||
children,
|
||
title,
|
||
subtitle,
|
||
}: {
|
||
children: ReactNode;
|
||
title?: string;
|
||
subtitle?: string;
|
||
}) {
|
||
const pathname = usePathname();
|
||
const { signOut } = useAuth();
|
||
const { me, reload: reloadMe } = useMe();
|
||
const [products, setProducts] = useState<CommercialProduct[]>([]);
|
||
const [query, setQuery] = useState("");
|
||
const [mobileOpen, setMobileOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
void loadCommercialProducts().then((r) => {
|
||
if (r.availability === "ready") {
|
||
setProducts(r.data.filter((p) => p.visibility !== "hidden"));
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
const filtered = products.filter((p) => {
|
||
if (!query.trim()) return true;
|
||
const q = query.trim().toLowerCase();
|
||
return (
|
||
p.display_name.toLowerCase().includes(q) ||
|
||
p.product_code.toLowerCase().includes(q) ||
|
||
(p.category || "").toLowerCase().includes(q)
|
||
);
|
||
});
|
||
|
||
const navLink = (href: string, label: string) => {
|
||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||
return (
|
||
<Link
|
||
key={href}
|
||
href={href}
|
||
onClick={() => setMobileOpen(false)}
|
||
className={`rounded-xl px-3 py-2 text-sm transition ${
|
||
active
|
||
? "bg-primary text-white shadow-sm"
|
||
: "text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||
}`}
|
||
>
|
||
{label}
|
||
</Link>
|
||
);
|
||
};
|
||
|
||
const sidebar = (
|
||
<div className="flex h-full flex-col">
|
||
<p className="px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||
فضای کاری
|
||
</p>
|
||
<nav className="mt-2 flex flex-col gap-0.5">{CORE_NAV.map((n) => navLink(n.href, n.label))}</nav>
|
||
|
||
<div className="my-4 border-t border-gray-100 dark:border-gray-800" />
|
||
<p className="px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||
جستجو
|
||
</p>
|
||
<input
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="محصول / دسته…"
|
||
className="mx-2 mt-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900"
|
||
/>
|
||
|
||
<p className="mt-4 px-3 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||
محصولات
|
||
</p>
|
||
<nav className="mt-2 flex max-h-64 flex-col gap-0.5 overflow-y-auto">
|
||
{filtered.length ? (
|
||
filtered.map((p) => {
|
||
const href = resolveProductLaunchUrl(p);
|
||
if (!href) {
|
||
return (
|
||
<span
|
||
key={p.product_code}
|
||
className="rounded-xl px-3 py-2 text-sm text-gray-400"
|
||
title="این محصول هنوز آماده راهاندازی نیست"
|
||
>
|
||
{p.display_name}
|
||
</span>
|
||
);
|
||
}
|
||
return navLink(href, p.display_name);
|
||
})
|
||
) : (
|
||
<p className="px-3 text-xs text-gray-400">محصولی کشف نشد</p>
|
||
)}
|
||
</nav>
|
||
|
||
<div className="mt-auto space-y-2 border-t border-gray-100 pt-4 dark:border-gray-800">
|
||
<Link href="/discover" className="block px-3 text-xs text-primary">
|
||
ارزیابی مجدد
|
||
</Link>
|
||
<Button variant="ghost" size="sm" className="w-full" onClick={() => signOut()}>
|
||
خروج
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="min-h-[calc(100vh-4rem)] bg-[var(--canvas)] dark:bg-gray-950">
|
||
<div className="border-b border-gray-100 bg-white/90 backdrop-blur dark:border-gray-800 dark:bg-gray-950/90 lg:hidden">
|
||
<div className="flex items-center justify-between px-4 py-3">
|
||
<button
|
||
type="button"
|
||
className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm dark:border-gray-700"
|
||
onClick={() => setMobileOpen((v) => !v)}
|
||
>
|
||
منو
|
||
</button>
|
||
{title ? <p className="text-sm font-semibold text-secondary dark:text-gray-100">{title}</p> : null}
|
||
</div>
|
||
{mobileOpen ? (
|
||
<div className="border-t border-gray-100 px-2 py-3 dark:border-gray-800">{sidebar}</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="mx-auto grid max-w-7xl gap-0 lg:grid-cols-[240px_1fr]">
|
||
<aside className="hidden border-l border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-950 lg:block">
|
||
{sidebar}
|
||
</aside>
|
||
<div className="px-4 py-8 sm:px-6">
|
||
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
{title ? (
|
||
<h1 className="text-2xl font-bold text-secondary dark:text-gray-100">{title}</h1>
|
||
) : null}
|
||
{subtitle ? <p className="mt-1 text-sm text-gray-600 dark:text-gray-400">{subtitle}</p> : null}
|
||
</div>
|
||
{me ? (
|
||
<TenantSwitcher
|
||
memberships={me.memberships}
|
||
currentTenantId={me.current_tenant_id}
|
||
onSwitched={() => void reloadMe()}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|