feat(analytics): Umami integration with per-port admin settings
Adds /[portSlug]/website-analytics dashboard page (pageviews, top pages, top referrers) and a per-port admin config UI for the Umami URL / website-ID / API token. Settings live in system_settings keyed per-port so a future second port has its own Umami account. Adds a website glance tile to the main dashboard, a server-side test-credentials endpoint, and a stable cache key for the active- visitor poll so React Query doesn't fragment the cache per range. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
232
src/components/website-analytics/website-analytics-shell.tsx
Normal file
232
src/components/website-analytics/website-analytics-shell.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'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);
|
||||
|
||||
// Any of the queries returning `error: 'umami_not_configured'` means we
|
||||
// need to prompt the operator to set credentials. Single empty state
|
||||
// covers all widgets so the page doesn't show six loading spinners that
|
||||
// never resolve.
|
||||
const notConfigured = stats.data?.error === 'umami_not_configured';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user