174 lines
5.5 KiB
TypeScript
174 lines
5.5 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
import { Link } from "wouter";
|
|
|
|
export function PortalDashboardHeader({
|
|
title,
|
|
description,
|
|
actions,
|
|
backHref = "/dashboard",
|
|
className,
|
|
contentClassName,
|
|
tourId,
|
|
}: {
|
|
title: string;
|
|
description?: string;
|
|
actions?: ReactNode;
|
|
backHref?: string;
|
|
className?: string;
|
|
contentClassName?: string;
|
|
tourId?: string;
|
|
}) {
|
|
return (
|
|
<header className={cn("sticky top-0 z-10 border-b border-border bg-white", className)}>
|
|
<div className="container py-3 sm:py-4">
|
|
<div className="flex items-start gap-3 sm:items-center sm:gap-4">
|
|
<Link href={backHref}>
|
|
<Button variant="ghost" size="icon" className="mt-0.5 shrink-0 sm:mt-0">
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
</Link>
|
|
<div data-tour-id={tourId} className={cn("min-w-0 flex-1", contentClassName)}>
|
|
<PortalPageHeader title={title} description={description} actions={actions} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
export function PortalPageHeader({
|
|
title,
|
|
description,
|
|
actions,
|
|
}: {
|
|
title: string;
|
|
description?: string;
|
|
actions?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-3 md:gap-3.5 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="min-w-0 space-y-1.5">
|
|
<h1 className="text-[1.65rem] font-bold tracking-tight leading-tight text-slate-950 sm:text-[1.8rem]">{title}</h1>
|
|
{description ? <p className="max-w-3xl text-sm leading-6 text-slate-600 sm:text-[0.95rem]">{description}</p> : null}
|
|
</div>
|
|
{actions ? <div className="flex w-full flex-col gap-2.5 pt-0.5 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-end">{actions}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PortalSectionIntro({
|
|
eyebrow,
|
|
title,
|
|
description,
|
|
aside,
|
|
className,
|
|
eyebrowClassName,
|
|
titleClassName,
|
|
descriptionClassName,
|
|
}: {
|
|
eyebrow?: string;
|
|
title: string;
|
|
description?: string;
|
|
aside?: ReactNode;
|
|
className?: string;
|
|
eyebrowClassName?: string;
|
|
titleClassName?: string;
|
|
descriptionClassName?: string;
|
|
}) {
|
|
return (
|
|
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between", className)}>
|
|
<div className="max-w-2xl space-y-1.5">
|
|
{eyebrow ? (
|
|
<p className={cn("text-xs font-semibold uppercase tracking-[0.16em] text-primary", eyebrowClassName)}>{eyebrow}</p>
|
|
) : null}
|
|
<h2 className={cn("text-lg font-semibold tracking-tight text-slate-950 sm:text-[1.45rem]", titleClassName)}>{title}</h2>
|
|
{description ? <p className={cn("text-sm leading-6 text-slate-600", descriptionClassName)}>{description}</p> : null}
|
|
</div>
|
|
{aside ? (
|
|
<div className="rounded-xl border border-slate-200 bg-white/92 px-4 py-2.5 text-sm text-slate-600 shadow-sm">
|
|
{aside}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PortalSurface({
|
|
title,
|
|
description,
|
|
badge,
|
|
children,
|
|
className,
|
|
bodyClassName,
|
|
}: {
|
|
title?: string;
|
|
description?: string;
|
|
badge?: ReactNode;
|
|
children: ReactNode;
|
|
className?: string;
|
|
bodyClassName?: string;
|
|
}) {
|
|
return (
|
|
<section
|
|
className={cn(
|
|
"overflow-hidden rounded-2xl border border-slate-200/90 bg-white/96 shadow-[var(--portal-panel-shadow)] transition-[transform,box-shadow,border-color,background-color] duration-[var(--portal-motion-base)] hover:shadow-[var(--portal-panel-hover-shadow)]",
|
|
className
|
|
)}
|
|
>
|
|
{title || description || badge ? (
|
|
<div className="border-b border-slate-100 px-4 py-4 sm:px-5">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
<div className="space-y-1">
|
|
{title ? <h3 className="text-base font-semibold text-slate-950">{title}</h3> : null}
|
|
{description ? <p className="text-sm leading-6 text-slate-600">{description}</p> : null}
|
|
</div>
|
|
{badge ? <div className="shrink-0">{badge}</div> : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className={cn("px-4 py-4 sm:px-5", bodyClassName)}>{children}</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function PortalMetricTile({
|
|
label,
|
|
value,
|
|
detail,
|
|
tone = "default",
|
|
}: {
|
|
label: string;
|
|
value: ReactNode;
|
|
detail?: ReactNode;
|
|
tone?: "default" | "success" | "info" | "warning";
|
|
}) {
|
|
const toneClassName =
|
|
tone === "success"
|
|
? "border-emerald-200 bg-emerald-50"
|
|
: tone === "info"
|
|
? "border-blue-200 bg-blue-50"
|
|
: tone === "warning"
|
|
? "border-amber-200 bg-amber-50"
|
|
: "border-slate-200 bg-white";
|
|
|
|
return (
|
|
<div className={cn("rounded-xl border px-4 py-3.5 shadow-[var(--portal-card-shadow)] transition-[transform,box-shadow,border-color,background-color] duration-[var(--portal-motion-base)] hover:-translate-y-0.5 hover:shadow-[var(--portal-card-hover-shadow)]", toneClassName)}>
|
|
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">{label}</p>
|
|
<div className="mt-1.5 text-[1.65rem] font-semibold tracking-tight text-slate-950">{value}</div>
|
|
{detail ? <div className="mt-1.5 text-xs leading-5 text-slate-500">{detail}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PortalActionGroup({
|
|
children,
|
|
className,
|
|
}: {
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return <div className={cn("flex flex-col gap-2 sm:flex-row sm:flex-wrap", className)}>{children}</div>;
|
|
}
|