TorbatYar/frontend/modules/commercial/components/PlatformShell.tsx
2026-07-28 20:39:10 +03:30

175 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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: "Billing" },
{ 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">
Workspace
</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">
Apps
</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="default_route در رجیستری نیست"
>
{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>
);
}