Workflow-App/Workflow/components/NavBar.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

485 lines
15 KiB
TypeScript

// components/NavBar.tsx
// Top-of-page navigation. Logo lockup on the left, primary nav
// center, secondary status on the right. Sticky on scroll with the
// signature hairline border-bottom.
//
// Mobile (<md): a fixed bottom tab bar with inline-SVG icons (Overview
// · Directors · Companies · Onboarding). The second-row horizontal
// scroller from earlier turns has been replaced because the tab bar
// is the standard mobile-app pattern and reclaims vertical screen
// real estate.
//
// Icons use stroke-currentColor at 1.6px per the design system
// (anti-slop rule: emoji and gradient icons are forbidden). Brand
// mark "Y" pill in the top-left stays on all viewports.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
const LINKS = [
{ href: "/", label: "Overview", icon: IconHome },
{ href: "/notes", label: "Notes", icon: IconNotepad },
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
{ href: "/bizportal-2", label: "BizPortal 2.0", icon: IconSearch, badge: "test" },
{ href: "/cipc", label: "CIPC", icon: IconSearch },
{ href: "/cipc-2", label: "CIPC2.0", icon: IconSearch, badge: "test" },
{ href: "/sars-returns", label: "SARS Returns", icon: IconReceipt },
{ href: "/sars-registrations", label: "SARS Registrations", icon: IconRegistry },
{ href: "/tasks", label: "Tasks", icon: IconClipboard },
{ href: "/directors", label: "Directors", icon: IconUsers },
{ href: "/companies", label: "Companies", icon: IconBuilding },
{ href: "/groups", label: "Groups", icon: IconTag },
{ href: "/compliance", label: "Compliance", icon: IconCheckSquare },
{ href: "/resolutions", label: "Resolutions", icon: IconDocument },
] as const;
export default function NavBar() {
const pathname = usePathname() ?? "/";
return (
<>
<header
data-od-id="navbar"
className="sticky top-0 z-30 bg-surface border-b border-border"
>
<div className="container-x flex items-center justify-between h-20">
<Link
href="/"
data-od-id="brand-mark"
className="flex items-center gap-2 no-underline"
>
<span
aria-hidden="true"
className="inline-flex items-center justify-center w-8 h-8 rounded-pill bg-accent text-accent-on font-bold text-sm"
>
Y
</span>
<span className="text-lg font-bold text-fg tracking-tight">
Workflow
</span>
</Link>
<nav
className="hidden md:flex items-center gap-2"
aria-label="Primary"
>
{LINKS.map((link) => {
const isActive = isActiveLink(pathname, link.href);
return (
<Link
key={link.href}
href={link.href}
data-od-id={`nav-${link.label.toLowerCase().replace(/\s+/g, "-")}`}
className={
"px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors inline-flex items-center gap-2 " +
(isActive
? "bg-surface-warm text-fg"
: "text-fg hover:bg-surface-warm")
}
>
<span>{link.label}</span>
{"badge" in link && link.badge ? (
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
{link.badge}
</span>
) : null}
</Link>
);
})}
</nav>
{/* Right-side desktop cluster: label + theme toggle. Hidden
on mobile — the bottom tab bar carries the brand context. */}
<div className="hidden md:flex items-center gap-3">
<span className="text-xs text-muted">Registry Browser</span>
<ThemeToggle />
</div>
{/* Spacer for mobile so the brand lockup stays left-aligned
and the bottom-bar height is the only vertical change. */}
<div className="md:hidden" aria-hidden="true" />
</div>
</header>
{/* Mobile bottom tab bar */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 bg-surface border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Primary"
>
{/*
Layout notes (phone, full-bleed):
- No `container-x` here — the bottom nav should sit edge-to-edge.
- Each tab uses `flex-1 min-w-0` so the labels can truncate cleanly
instead of pushing siblings or wrapping onto a second line.
- `gap-2` between tabs is decorative breathing room; `flex-1` does
the real even spacing.
- `text-[10px] tracking-tight` keeps 8-tab labels inside ~46px slots
on a 390px viewport without truncating every label into dots.
*/}
<ul className="flex items-stretch justify-between h-16 gap-2 px-2">
{LINKS.map((link) => {
const isActive = isActiveLink(pathname, link.href);
const Icon = link.icon;
return (
<li
key={link.href}
className="flex-1 min-w-0"
>
<Link
href={link.href}
aria-current={isActive ? "page" : undefined}
aria-label={link.label}
className={
"group flex flex-col items-center justify-center gap-0.5 h-full w-full px-1 no-underline transition-colors " +
(isActive
? "text-accent"
: "text-muted hover:text-fg")
}
>
<Icon
className={
"w-5 h-5 shrink-0 transition-transform " +
(isActive ? "scale-110" : "scale-100")
}
active={isActive}
/>
<span className="text-[10px] font-semibold tracking-tight truncate w-full text-center">
{link.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
</>
);
}
function isActiveLink(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(href + "/");
}
/* ─── Icon set (inline SVG, stroke-currentColor, 1.6px stroke) ──── */
interface IconProps {
className?: string;
/** Active state hints — used to swap fill behavior on the home icon
* and to thicken the stroke on outlined icons. Always defaults to
* the brand "muted" rendering since the active state is owned by
* the parent (text color + scale already encodes it). */
active?: boolean;
}
function IconHome({ className, active }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill={active ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M3 11.5L12 4l9 7.5" />
<path d="M5 10.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1v-9.5" />
</svg>
);
}
function IconUsers({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx="9" cy="9" r="3.2" />
<path d="M3.5 19c.7-3 3-4.6 5.5-4.6S13.8 16 14.5 19" />
<circle cx="16.5" cy="9" r="2.6" />
<path d="M15.2 14.4c2 0 4 1.4 4.6 4" />
</svg>
);
}
function IconBuilding({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<rect x="4" y="3" width="16" height="18" rx="1.5" />
<path d="M8 7h2M8 11h2M8 15h2M14 7h2M14 11h2M14 15h2" />
<path d="M10 21v-3h4v3" />
</svg>
);
}
function IconDocument({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
<line x1="9" y1="13" x2="15" y2="13" />
<line x1="9" y1="17" x2="13" y2="17" />
</svg>
);
}
function IconClipboard({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<line x1="9" y1="13" x2="15" y2="13" />
<line x1="9" y1="17" x2="12" y2="17" />
</svg>
);
}
function IconSearch({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
}
function IconTag({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
);
}
function IconCheckSquare({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 12l2 2 4-4" />
</svg>
);
}
/**
* Tax-receipt glyph for the SARS Returns tab. Deliberately a torn-
* edge receipt (zig-zag bottom) to distinguish it from IconDocument
* (which is a sealed page) and IconClipboard (which has a clip).
*/
function IconReceipt({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M5 3h14v18l-2-1.6L15 21l-2-1.6L11 21l-2-1.6L7 21l-2-1.6V3z" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="9" y1="12" x2="15" y2="12" />
<line x1="9" y1="16" x2="13" y2="16" />
</svg>
);
}
/**
* Registry / ID-badge glyph for the SARS Registrations tab. A card with
* two horizontal ruled lines and a small "stamp" circle in the corner —
* visually distinct from IconReceipt (torn-edge paper), IconNotepad
* (folded top-right page), IconDocument (sealed page), and
* IconClipboard (task list with a clip).
*/
function IconRegistry({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<rect x="3.5" y="4.5" width="14" height="13" rx="1.6" />
<line x1="6.5" y1="8.5" x2="14.5" y2="8.5" />
<line x1="6.5" y1="11.5" x2="14.5" y2="11.5" />
<line x1="6.5" y1="14" x2="11.5" y2="14" />
<circle cx="17.5" cy="17" r="3" />
<path d="M16.2 17l1 1 1.6-1.8" />
</svg>
);
}
/**
* Notepad glyph for the Notes tab. Rounded rectangle with a horizontal
* ruled line and a folded top-right corner — distinguishes it from
* IconClipboard (task list with a clip) and IconDocument (sealed page).
*/
function IconNotepad({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8z" />
<polyline points="14,2 14,8 19,8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="14" y2="17" />
</svg>
);
}
/* ─── Theme toggle ────────────────────────────────────────────────── */
/**
* Sun/moon button. Uses next-themes' `resolvedTheme` so system
* preference is honoured. The icon shown represents the mode you'd
* switch TO (moon in light, sun in dark).
*
* `mounted` guard avoids SSR/CSR mismatch — `useTheme` returns
* undefined on the first render. Until mounted, we render a neutral
* placeholder so the button's box doesn't pop in late.
*/
function ThemeToggle() {
const [mounted, setMounted] = useState(false);
const { resolvedTheme, setTheme } = useTheme();
useEffect(() => setMounted(true), []);
const isDark = mounted && resolvedTheme === "dark";
const next = isDark ? "light" : "dark";
return (
<button
type="button"
onClick={() => setTheme(next)}
aria-label={mounted ? `Switch to ${next} mode` : "Toggle theme"}
title={`Switch to ${next} mode`}
data-od-id="theme-toggle"
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-fg hover:bg-surface-warm transition-colors"
>
{mounted ? (
isDark ? (
<IconSun className="w-5 h-5" />
) : (
<IconMoon className="w-5 h-5" />
)
) : (
// Neutral placeholder slot — same dimensions so layout doesn't shift.
<span className="w-5 h-5 block" aria-hidden="true" />
)}
</button>
);
}
function IconSun({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
}
function IconMoon({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
);
}