Add payment module, BFF proxy, hub/dashboard/ops/PSP/merchant screens wired to real APIs, and mark payment-frontend complete in project status. Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
6.9 KiB
TypeScript
185 lines
6.9 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 } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
import { useColorMode } from "@/components/providers/ColorModeProvider";
|
||
import { Button } from "@/components/ds";
|
||
import {
|
||
PAYMENT_PORTAL,
|
||
navForPayment,
|
||
filterNavByBundles,
|
||
} from "@/modules/payment/constants/portals";
|
||
import { usePaymentCapabilities } from "@/modules/payment/hooks/usePaymentCapabilities";
|
||
|
||
function isActive(pathname: string, href: string) {
|
||
const base = href.split("?")[0];
|
||
if (base === "/payment") return pathname === "/payment";
|
||
return pathname === base || pathname.startsWith(`${base}/`);
|
||
}
|
||
|
||
export function PaymentPortalShell({
|
||
children,
|
||
title,
|
||
}: {
|
||
children: React.ReactNode;
|
||
title?: string;
|
||
}) {
|
||
const pathname = usePathname();
|
||
const { mode, toggle } = useColorMode();
|
||
const caps = usePaymentCapabilities();
|
||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||
const [mobileOpen, setMobileOpen] = useState(false);
|
||
|
||
const nav = useMemo(
|
||
() => filterNavByBundles(navForPayment(), caps.data?.active_bundles),
|
||
[caps.data?.active_bundles]
|
||
);
|
||
|
||
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 ?? PAYMENT_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-teal-600 text-white 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-teal-600 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="/payment/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">
|
||
<button
|
||
type="button"
|
||
className="mb-2 self-start rounded-lg p-2"
|
||
onClick={() => setMobileOpen(false)}
|
||
aria-label="بستن منو"
|
||
>
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
{sidebar}
|
||
</aside>
|
||
</div>
|
||
) : null}
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
<header className="flex items-center gap-2 border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 lg:hidden">
|
||
<Button variant="ghost" size="sm" onClick={() => setMobileOpen(true)} aria-label="منو">
|
||
<Menu className="h-5 w-5" />
|
||
</Button>
|
||
<span className="font-medium text-secondary">{PAYMENT_PORTAL.label}</span>
|
||
</header>
|
||
<main className="flex-1 p-4 md:p-6">{children}</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|