Initial commit: thbd-portal, duplicated from elmi-portal

Rebranded for Thunderbird Portal (THBD): numbering prefix EL->TH, all
"ELMI"/"ЭЛМИ" UI strings genericized, ELMI's real contract templates and
company legal/bank details removed (not applicable to this company), fresh
VAPID keypair and encryption key, fresh single-migration schema history.
Same architecture as elmi-portal: Web Push notifications, unread-chat app
badge, cookie-session auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-31 16:33:30 +00:00
commit 579528edf7
197 files changed
+25022

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Inbox, Truck, MessageCircle, ListTodo, MoreHorizontal, X } from "lucide-react";
import { links, type Role } from "./nav-data";
// The 4 most-used sections get their own bottom-bar slot (bar's too narrow
// for all 8, and cramming more in defeats the point of a thumb-reachable
// tab bar); everything else lives behind "Ещё".
const PRIMARY_HREFS = ["/chat", "/orders", "/tasks", "/inquiries"];
const PRIMARY_ICONS: Record<string, typeof Inbox> = {
"/chat": MessageCircle,
"/orders": Truck,
"/tasks": ListTodo,
"/inquiries": Inbox,
};
export function MobileNav({ role, unread }: { role: Role; unread: number }) {
const pathname = usePathname();
const [moreOpen, setMoreOpen] = useState(false);
const visibleLinks = links.filter((link) => link.roles.includes(role));
const primary = PRIMARY_HREFS.map((href) => visibleLinks.find((l) => l.href === href)).filter((l) => l !== undefined);
const overflow = visibleLinks.filter((l) => !PRIMARY_HREFS.includes(l.href));
const overflowActive = overflow.some((l) => pathname.startsWith(l.href));
return (
<>
{moreOpen && (
<div className="fixed inset-0 z-40 bg-black/40 lg:hidden" onClick={() => setMoreOpen(false)}>
<div
className="absolute inset-x-0 bottom-0 rounded-t-xl border-t border-border bg-surface p-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))]"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-1 flex items-center justify-between px-2 py-1.5">
<span className="text-sm font-semibold">Ещё</span>
<button onClick={() => setMoreOpen(false)} className="rounded-md p-1 text-text-faint hover:bg-surface-hover">
<X size={16} />
</button>
</div>
{overflow.map(({ href, label, icon: Icon }) => {
const active = pathname.startsWith(href);
return (
<Link
key={href}
href={href}
onClick={() => setMoreOpen(false)}
className={`flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium ${
active ? "bg-accent-soft text-accent-soft-text" : "text-text-muted hover:bg-surface-hover hover:text-text"
}`}
>
<Icon size={17} />
{label}
</Link>
);
})}
</div>
</div>
)}
<nav
className="app-mobile-nav fixed inset-x-0 bottom-0 z-30 flex border-t border-border bg-surface pb-[env(safe-area-inset-bottom)] lg:hidden"
aria-label="Основная навигация"
>
{primary.map(({ href, label, icon: FallbackIcon }) => {
const active = pathname.startsWith(href);
const Icon = PRIMARY_ICONS[href] ?? FallbackIcon;
return (
<Link
key={href}
href={href}
className={`relative flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium ${
active ? "text-accent" : "text-text-faint"
}`}
>
<Icon size={20} />
{label}
{href === "/chat" && unread > 0 && (
<span className="absolute right-[22%] top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[10px] font-semibold text-white">
{unread}
</span>
)}
</Link>
);
})}
<button
onClick={() => setMoreOpen(true)}
className={`flex flex-1 flex-col items-center gap-0.5 py-2 text-[11px] font-medium ${
overflowActive ? "text-accent" : "text-text-faint"
}`}
>
<MoreHorizontal size={20} />
Ещё
</button>
</nav>
</>
);
}