TorbatYar/frontend/modules/experience/components/ExperiencePortalShell.tsx
Mortezakoohjani 0eec9f729b feat(experience): deploy Experience frontend FE-11.0-11.5 portal
Commit completed Torbat Pages admin frontend module, BFF proxy, routes, and validation artifacts for official platform deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:01:50 +03:30

176 lines
7.0 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 { useMemo, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Moon, Sun, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useColorMode } from "@/components/providers/ColorModeProvider";
import { Button } from "@/components/ds";
import {
EXPERIENCE_PORTAL,
navForExperience,
filterNavByCapabilities,
} from "@/modules/experience/constants/portals";
import { useExperienceCapabilities } from "@/modules/experience/hooks/useExperienceCapabilities";
import { WorkspaceSwitcher } from "@/modules/experience/components/WorkspaceSwitcher";
function isActive(pathname: string, href: string) {
const base = href.split("?")[0];
if (base === "/experience") return pathname === "/experience";
return pathname === base || pathname.startsWith(`${base}/`);
}
export function ExperiencePortalShell({
children,
title,
}: {
children: React.ReactNode;
title?: string;
}) {
const pathname = usePathname();
const { mode, toggle } = useColorMode();
const caps = useExperienceCapabilities();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const [mobileOpen, setMobileOpen] = useState(false);
const nav = useMemo(
() => filterNavByCapabilities(navForExperience(), 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)]">تربت پیجز</p>
<p className="font-semibold text-secondary">{title ?? EXPERIENCE_PORTAL.label}</p>
</div>
<div className="mb-4 px-2">
<WorkspaceSwitcher />
</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(--experience-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(
"block rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
isActive(pathname, item.href)
? "bg-[var(--experience-accent)] text-white"
: "text-[var(--muted)] hover:bg-[var(--surface-muted)]"
)}
>
{item.label}
</Link>
))}
</div>
) : null}
</div>
);
})}
</nav>
<div className="mt-auto space-y-2 border-t border-[var(--border)] pt-4">
<Link
href="/experience/hub"
className="block rounded-lg px-3 py-2 text-xs text-[var(--muted)] hover:text-secondary"
>
تغییر پورتال
</Link>
<Button variant="ghost" size="sm" className="w-full justify-start gap-2" onClick={toggle}>
{mode === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{mode === "dark" ? "حالت روشن" : "حالت تاریک"}
</Button>
</div>
</>
);
return (
<div className="flex min-h-screen bg-[var(--canvas)]">
<aside className="hidden w-64 shrink-0 flex-col border-l border-[var(--border)] bg-[var(--surface)] p-4 lg:flex">
{sidebar}
</aside>
{mobileOpen ? (
<div className="fixed inset-0 z-50 lg:hidden">
<button type="button" className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} aria-label="بستن" />
<aside className="absolute right-0 top-0 flex h-full w-72 flex-col border-l border-[var(--border)] bg-[var(--surface)] p-4 shadow-lg">
<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 gap-3 border-b border-[var(--border)] bg-[var(--surface)]/95 px-4 py-3 backdrop-blur lg:hidden">
<Button variant="ghost" size="sm" onClick={() => setMobileOpen(true)} aria-label="باز کردن منو">
<Menu className="h-5 w-5" />
</Button>
<span className="font-semibold text-secondary">{EXPERIENCE_PORTAL.label}</span>
</header>
<main className="flex-1 p-4 lg:p-6">{children}</main>
</div>
</div>
);
}