TorbatYar/frontend/modules/delivery/components/DeliveryPortalShell.tsx
Mortezakoohjani 7978970783 feat(delivery): add Torbat Driver frontend module with real API wiring.
Ship the delivery platform UI under modules/delivery with thin app routes, BFF proxy, CRUD for phase 10.0-10.1 APIs, and capability-gated future screens.

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

172 lines
6.8 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 {
DELIVERY_PORTAL,
navForDelivery,
filterNavByCapabilities,
} from "@/modules/delivery/constants/portals";
import { useDeliveryCapabilities } from "@/modules/delivery/hooks/useDeliveryCapabilities";
function isActive(pathname: string, href: string) {
const base = href.split("?")[0];
if (base === "/delivery") return pathname === "/delivery";
return pathname === base || pathname.startsWith(`${base}/`);
}
export function DeliveryPortalShell({
children,
title,
}: {
children: React.ReactNode;
title?: string;
}) {
const pathname = usePathname();
const { mode, toggle } = useColorMode();
const caps = useDeliveryCapabilities();
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const [mobileOpen, setMobileOpen] = useState(false);
const nav = useMemo(
() => filterNavByCapabilities(navForDelivery(), 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 ?? DELIVERY_PORTAL.label}</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(--delivery-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(--delivery-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="/delivery/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">{DELIVERY_PORTAL.label}</span>
</header>
<main className="flex-1 p-4 lg:p-6">{children}</main>
</div>
</div>
);
}