TorbatYar/frontend/modules/communication/components/CommunicationPortalShell.tsx
Mortezakoohjani 10c3c43a75 feat(communication): add SMS MVP frontend with governance artifacts.
Ship the full Communication portal (real SMS API, feature-locked future channels), BFF proxy, docs, snapshot, and phase handover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:11:25 +03:30

176 lines
7.0 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Moon, Sun, Menu, X, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { useColorMode } from "@/components/providers/ColorModeProvider";
import { Button } from "@/components/ds";
import {
COMMUNICATION_PORTAL,
navForCommunication,
filterNavForDisplay,
} from "@/modules/communication/constants/portals";
import { useCommunicationCapabilities } from "@/modules/communication/hooks/useCommunicationCapabilities";
function isActive(pathname: string, href: string) {
const base = href.split("?")[0];
if (base === "/communication") return pathname === "/communication";
return pathname === base || pathname.startsWith(`${base}/`);
}
export function CommunicationPortalShell({
children,
title,
}: {
children: React.ReactNode;
title?: string;
}) {
const pathname = usePathname();
const { mode, toggle } = useColorMode();
const caps = useCommunicationCapabilities();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const [mobileOpen, setMobileOpen] = useState(false);
const nav = useMemo(
() => filterNavForDisplay(navForCommunication(), caps.data?.features),
[caps.data?.features]
);
const autoOpen = useMemo(() => {
const map: Record<string, boolean> = {};
for (const g of nav) {
if (g.href && isActive(pathname, g.href)) map[g.id] = true;
if (g.items?.some((i) => isActive(pathname, i.href))) map[g.id] = true;
}
return map;
}, [pathname, nav]);
const expanded = { ...autoOpen, ...openGroups };
const sidebar = (
<>
<div className="mb-4 px-2">
<p className="text-xs text-[var(--muted)]">Torbat Communication</p>
<p className="font-semibold text-secondary">{title ?? COMMUNICATION_PORTAL.label}</p>
<p className="mt-1 text-[10px] text-[var(--communication-success)]">SMS Production Ready</p>
</div>
<nav className="space-y-0.5" aria-label="ناوبری ارتباطات">
{nav.map((group) => {
const Icon = group.icon;
const hasChildren = !!group.items?.length;
const groupActive = group.href
? isActive(pathname, group.href)
: group.items?.some((i) => isActive(pathname, i.href));
const isOpen = expanded[group.id] ?? false;
if (!hasChildren && group.href) {
return (
<Link
key={group.id}
href={group.href}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all",
groupActive
? "bg-[var(--communication-accent)] text-white shadow-[var(--shadow-sm)]"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)] hover:text-secondary"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{group.label}
</Link>
);
}
return (
<div key={group.id}>
<button
type="button"
onClick={() =>
setOpenGroups((prev) => ({ ...prev, [group.id]: !(expanded[group.id] ?? false) }))
}
className={cn(
"flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors",
groupActive
? "bg-[var(--surface-muted)] text-secondary"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)]"
)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 text-right">{group.label}</span>
<ChevronDown className={cn("h-4 w-4 transition-transform", isOpen && "rotate-180")} />
</button>
{isOpen ? (
<div className="mb-1 mr-3 space-y-0.5 border-r border-[var(--border)] pr-2">
{group.items?.map((item) => (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center justify-between gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
isActive(pathname, item.href)
? "bg-[var(--communication-accent)] text-white"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)]",
item.locked && !isActive(pathname, item.href) && "opacity-75"
)}
>
<span>{item.label}</span>
{item.locked ? <Lock className="h-3 w-3 shrink-0 opacity-70" /> : null}
</Link>
))}
</div>
) : null}
</div>
);
})}
</nav>
</>
);
return (
<div className="flex min-h-screen bg-[var(--canvas)]" dir="rtl">
<aside className="hidden w-64 shrink-0 border-l border-[var(--border)] bg-[var(--surface)] p-4 lg:block">
{sidebar}
</aside>
{mobileOpen ? (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} />
<aside className="absolute right-0 top-0 h-full w-72 overflow-y-auto border-l border-[var(--border)] bg-[var(--surface)] p-4 shadow-xl">
<div className="mb-4 flex justify-end">
<Button variant="ghost" size="sm" onClick={() => setMobileOpen(false)} aria-label="بستن">
<X className="h-5 w-5" />
</Button>
</div>
{sidebar}
</aside>
</div>
) : null}
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-40 flex items-center justify-between border-b border-[var(--border)] bg-[var(--surface)]/95 px-4 py-3 backdrop-blur">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="lg:hidden"
onClick={() => setMobileOpen(true)}
aria-label="منو"
>
<Menu className="h-5 w-5" />
</Button>
<span className="text-sm font-semibold text-secondary lg:hidden">
{COMMUNICATION_PORTAL.label}
</span>
</div>
<Button variant="ghost" size="sm" onClick={toggle} aria-label="حالت تاریک">
{mode === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</header>
<main className="flex-1 p-4 md:p-6">{children}</main>
</div>
</div>
);
}