portail-associations/client/src/components/portal/PortalSurface.tsx

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>;
}