Files
pn-new-crm/src/components/website-analytics/website-analytics-shell.tsx

233 lines
8.0 KiB
TypeScript
Raw Normal View History

'use client';
/**
* /website-analytics page shell. Mirrors the dashboard shell's layout:
* - PageHeader with date-range picker (presets + custom)
* - KPI tiles (pageviews / visitors / visits / bounces)
* - Two-column grid: pageviews trend + active visitors badge
* - Top-N tables: pages, referrers, countries
*
* Gracefully renders an empty state when Umami isn't configured for the
* port - points the operator at /admin/website-analytics to set creds.
*/
import { useState } from 'react';
import Link from 'next/link';
import { Globe, Settings, ExternalLink } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { KPITile } from '@/components/ui/kpi-tile';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
import { type DateRange } from '@/lib/analytics/range';
import { useUIStore } from '@/stores/ui-store';
import {
useUmamiActive,
useUmamiPageviews,
useUmamiStats,
useUmamiTopCountries,
useUmamiTopPages,
useUmamiTopReferrers,
} from './use-website-analytics';
import { PageviewsChart } from './pageviews-chart';
import { TopList } from './top-list';
export function WebsiteAnalyticsShell() {
const [range, setRange] = useState<DateRange>('30d');
const portSlug = useUIStore((s) => s.currentPortSlug);
const stats = useUmamiStats(range);
const pageviews = useUmamiPageviews(range);
const active = useUmamiActive(range);
const topPages = useUmamiTopPages(range);
const topReferrers = useUmamiTopReferrers(range);
const topCountries = useUmamiTopCountries(range);
// API surfaces `notConfigured: true` on a 200 response (not 4xx) so
// React Query doesn't infinite-retry — that retry loop saturated the
// postgres pool and stalled the dev server. Single empty state covers
// all widgets so the page doesn't show six loading spinners forever.
const notConfigured = stats.data?.notConfigured === true;
return (
<div className="space-y-6">
<PageHeader
title="Website analytics"
eyebrow="Marketing"
description="Live data from Umami - site traffic, top pages, referrers, and audience geography."
variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />}
/>
{notConfigured ? (
<NotConfiguredEmptyState portSlug={portSlug} />
) : (
<>
{/* Live indicator + KPI tiles */}
<div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-5">
<ActiveVisitorsBadge value={active.data?.data?.visitors} loading={active.isLoading} />
<KpiPair
label="Pageviews"
loading={stats.isLoading}
value={stats.data?.data?.pageviews?.value}
prev={stats.data?.data?.pageviews?.prev}
accent="brand"
/>
<KpiPair
label="Visitors"
loading={stats.isLoading}
value={stats.data?.data?.visitors?.value}
prev={stats.data?.data?.visitors?.prev}
accent="teal"
/>
<KpiPair
label="Visits"
loading={stats.isLoading}
value={stats.data?.data?.visits?.value}
prev={stats.data?.data?.visits?.prev}
accent="success"
/>
<KpiPair
label="Bounces"
loading={stats.isLoading}
value={stats.data?.data?.bounces?.value}
prev={stats.data?.data?.bounces?.prev}
accent="purple"
invertDelta
/>
</div>
{/* Pageviews trend */}
<Card>
<CardHeader>
<CardTitle className="text-base">Pageviews trend</CardTitle>
</CardHeader>
<CardContent>
{pageviews.isLoading ? (
<Skeleton className="h-[260px] w-full" />
) : (
<PageviewsChart data={pageviews.data?.data ?? null} />
)}
</CardContent>
</Card>
{/* Top-N tables */}
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
<TopList
title="Top pages"
loading={topPages.isLoading}
rows={topPages.data?.data ?? null}
/>
<TopList
title="Top referrers"
loading={topReferrers.isLoading}
rows={topReferrers.data?.data ?? null}
defaultLabel="(direct)"
/>
<TopList
title="Top countries"
loading={topCountries.isLoading}
rows={topCountries.data?.data ?? null}
/>
</div>
</>
)}
</div>
);
}
function ActiveVisitorsBadge({ value, loading }: { value?: number; loading: boolean }) {
return (
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm relative overflow-hidden">
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
Active right now
</div>
<div className="mt-1 flex items-center gap-2">
{loading ? (
<Skeleton className="h-7 w-12" />
) : (
<>
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
<span className="text-lg font-semibold tabular-nums sm:text-2xl">{value ?? 0}</span>
</>
)}
</div>
</div>
);
}
function KpiPair({
label,
value,
prev,
accent,
loading,
invertDelta = false,
}: {
label: string;
value: number | undefined;
prev: number | undefined;
accent: 'brand' | 'success' | 'warning' | 'mint' | 'teal' | 'purple';
loading: boolean;
/** For metrics where lower is better (bounces). Flip the sign so green
* still means "good" in the UI. */
invertDelta?: boolean;
}) {
if (loading) {
return (
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm">
<Skeleton className="h-3 w-20" />
<Skeleton className="mt-2 h-7 w-16" />
</div>
);
}
const v = value ?? 0;
const p = prev ?? 0;
let delta: number | undefined;
if (p > 0) {
delta = Math.round(((v - p) / p) * 100);
if (invertDelta) delta = -delta;
}
return <KPITile title={label} value={v.toLocaleString()} accent={accent} delta={delta} />;
}
function NotConfiguredEmptyState({ portSlug }: { portSlug: string | null }) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-4 py-16 text-center">
<Globe className="h-12 w-12 text-muted-foreground/40" aria-hidden />
<div>
<h3 className="text-base font-semibold">Connect Umami to see your website analytics</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-md">
Add your Umami URL, API token (or username/password), and Website ID for this port to
unlock pageview trends, top pages, referrers, and audience geography.
</p>
</div>
<div className="flex gap-2">
<Button asChild>
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/admin/website-analytics` as any}
>
<Settings className="mr-2 h-4 w-4" />
Configure
</Link>
</Button>
<Button asChild variant="outline">
<a href="https://docs.umami.is/docs/api" target="_blank" rel="noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
Umami API docs
</a>
</Button>
</div>
</CardContent>
</Card>
);
}