Files
pn-new-crm/src/components/website-analytics/top-list.tsx
Matt 777b711548 feat(uat-b2): visual breakpoint fixes + form-error UX rollout
B2 Wave A (visual breakpoints):
- Documents Hub folder rail: widen ResizablePanel default 20→22%,
  min 14→18%, add min-w-[180px] CSS floor so names don't truncate
  at tablet 768
- Website analytics KPI tiles: switch lg grid 6→3 cols, restore 6
  at xl so "Visit duration" stops truncating in the 1024+sidebar
  layout
- Pipeline Value tile per-stage rows: compact $3.5M format on
  sm- breakpoint (responsive sm:hidden / hidden sm:inline pair)

B2 Wave D (form-error UX rollout):
- useFormScrollToError + FormErrorSummary wired into 5 high-impact
  forms: client-form, interest-form, yacht-form, company-form,
  berth-form. Validation failures now scroll the first errored
  field into view + render a top-of-form summary banner when ≥2
  errors exist. Remaining ~23 form surfaces queued for follow-up.

B2 Wave B (Umami follow-ups):
- TopList primitive: add onExpandRange + expandRangeLabel props
  for the empty-state nudge ("Try last 30 days" button). Callers
  can opt in to drive the page-level DateRange.

B2 Wave C (FieldLabel + admin tooltip audit):
- Verified FieldLabel primitive already exists + is adopted in
  custom-field-form. Registry-driven-form renders entry.description
  inline below labels for every entry — the broad sweep across
  15-20 admin pages is deferred to a focused polish session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:50:46 +02:00

111 lines
4.0 KiB
TypeScript

'use client';
import Link from 'next/link';
import { ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import type { UmamiMetricRow } from '@/lib/services/umami.service';
interface Props {
title: string;
rows: UmamiMetricRow[] | null;
loading: boolean;
/** Label substituted when `x` is empty (e.g. direct traffic referrers). */
defaultLabel?: string;
/** Optional "View all" link target. When set, renders a link in the
* card header that opens a full ranked-list page for this metric. */
viewAllHref?: string;
/** Cap for the inline list (default 10). The full page uses no cap. */
limit?: number;
/** Optional callback invoked when the empty-state "expand range" CTA is
* clicked. When supplied, the empty state shows a nudge button suggesting
* the rep try a wider range. */
onExpandRange?: () => void;
/** Label for the expand-range button when `onExpandRange` is supplied. */
expandRangeLabel?: string;
}
/**
* Compact "top N" list used for top pages / referrers / countries.
* Renders each row as label + numeric count, with a thin progress bar
* scaled to the largest count in the set so the visual density tells
* the same story at a glance as the numbers.
*/
export function TopList({
title,
rows,
loading,
defaultLabel = '-',
viewAllHref,
limit = 10,
onExpandRange,
expandRangeLabel = 'Try last 30 days',
}: Props) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-base">{title}</CardTitle>
{viewAllHref ? (
<Link
// typedRoutes is enabled - viewAllHref is constructed at the
// call site from string interpolation, so opt out of the
// literal-string check here.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={viewAllHref as any}
className="inline-flex items-center gap-0.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
View all
<ArrowRight className="size-3" aria-hidden />
</Link>
) : null}
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-3/6" />
</div>
) : !rows || rows.length === 0 ? (
<div className="space-y-2 py-6 text-center text-sm text-muted-foreground">
<p>No data in this range.</p>
{onExpandRange ? (
<Button size="sm" variant="outline" onClick={onExpandRange}>
{expandRangeLabel}
</Button>
) : null}
</div>
) : (
<ul className="space-y-1.5">
{rows.slice(0, limit).map((row, i) => {
const max = rows[0]?.y ?? 1;
const pct = (row.y / max) * 100;
const label = row.x?.trim() || defaultLabel;
return (
<li key={`${row.x}-${i}`} className="text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-medium">{label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{row.y.toLocaleString()}
</span>
</div>
<div className="mt-0.5 h-1 w-full rounded-full bg-muted">
<div
className="h-1 rounded-full bg-brand"
style={{ width: `${Math.max(2, pct)}%` }}
aria-hidden
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}