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.
70 lines
2.4 KiB
TypeScript
70 lines
2.4 KiB
TypeScript
// components/PageHeader.tsx
|
|
// Page-level heading strip. Used on top of every screen — title +
|
|
// short subtitle, with an optional right-aligned action.
|
|
|
|
import { ReactNode } from "react";
|
|
|
|
interface PageHeaderProps {
|
|
eyebrow?: string;
|
|
title: ReactNode;
|
|
subtitle?: ReactNode;
|
|
action?: ReactNode;
|
|
}
|
|
|
|
export default function PageHeader({ eyebrow, title, subtitle, action }: PageHeaderProps) {
|
|
// Subtitle is often a fragment of multiple <span> children. Flatten
|
|
// to a single array (Children.toArray on its own does NOT flatten
|
|
// nested fragments — that bites the common
|
|
// `<><span>...</span><span>...</span></>` pattern). Drop empty
|
|
// entries, and lay them out as a flex row with separator dots so
|
|
// they don't render flush against each other.
|
|
const flatten = (node: ReactNode): ReactNode[] => {
|
|
if (node === null || node === undefined || node === false) return [];
|
|
if (Array.isArray(node)) {
|
|
return node.flatMap((n) => flatten(n as ReactNode));
|
|
}
|
|
return [node];
|
|
};
|
|
const items = flatten(subtitle);
|
|
let subtitleNode: ReactNode = null;
|
|
if (items.length === 0) {
|
|
subtitleNode = null;
|
|
} else if (items.length === 1) {
|
|
subtitleNode = <span>{items[0]}</span>;
|
|
} else {
|
|
subtitleNode = (
|
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-muted">
|
|
{items.map((child, i) => (
|
|
<span key={i} className="inline-flex items-center gap-x-2">
|
|
{i > 0 ? <span aria-hidden="true" className="text-meta">·</span> : null}
|
|
<span>{child}</span>
|
|
</span>
|
|
))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="border-b border-border bg-surface">
|
|
<div className="container-x py-10 md:py-14 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
|
<div className="max-w-2xl">
|
|
{eyebrow ? (
|
|
<p className="text-xs uppercase tracking-[0.08em] text-muted font-semibold mb-3">
|
|
{eyebrow}
|
|
</p>
|
|
) : null}
|
|
<h1 className="text-2xl md:text-3xl font-bold text-fg leading-tight tracking-tight">
|
|
{title}
|
|
</h1>
|
|
{subtitleNode ? (
|
|
<div className="text-base text-muted mt-3 leading-relaxed">
|
|
{subtitleNode}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{action ? <div className="flex-shrink-0">{action}</div> : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|