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>
111 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|