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:
Matt Ciaccio
2026-05-04 22:53:06 +02:00
parent 49d34e00c8
commit f5772ce318
13 changed files with 1198 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface TestResponse {
ok: boolean;
visitors?: number;
error?: string;
}
/**
* Hits POST /api/v1/admin/umami/test which calls Umami's `/api/websites/:id/
* active` to verify auth + websiteId in one request. On success, shows the
* live visitor count as proof we got real data back.
*/
export function UmamiTestButton() {
const [pending, setPending] = useState(false);
const [result, setResult] = useState<TestResponse | null>(null);
async function runTest() {
setPending(true);
setResult(null);
try {
const res = await apiFetch<{ data: TestResponse }>('/api/v1/admin/umami/test', {
method: 'POST',
});
setResult(res.data);
if (res.data.ok) {
toast.success(`Umami reachable - ${res.data.visitors ?? 0} active visitor(s) right now`);
} else {
toast.error(res.data.error ?? 'Umami test failed');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Test failed';
setResult({ ok: false, error: message });
toast.error(message);
} finally {
setPending(false);
}
}
return (
<div className="flex items-center gap-3">
{result &&
(result.ok ? (
<span className="flex items-center text-xs text-green-600">
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Connected ({result.visitors ?? 0} active)
</span>
) : (
<span className="flex items-center text-xs text-destructive">
<XCircle className="mr-1 h-3.5 w-3.5" />
{result.error ?? 'Failed'}
</span>
))}
<Button type="button" size="sm" variant="outline" onClick={runTest} disabled={pending}>
{pending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Test connection
</Button>
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
/**
* Compact "Website at a glance" tile for the main sales dashboard. Shows
* pageviews today + active visitors right now + a deep-link to the full
* /website-analytics page. Soft-fails (renders nothing) when Umami isn't
* configured for this port - so the dashboard doesn't get cluttered with
* a "configure Umami" prompt that the user already saw on the dedicated
* page.
*/
import Link from 'next/link';
import { Globe, ArrowRight } from 'lucide-react';
import { useUIStore } from '@/stores/ui-store';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
useUmamiActive,
useUmamiStats,
} from '@/components/website-analytics/use-website-analytics';
export function WebsiteGlanceTile() {
const portSlug = useUIStore((s) => s.currentPortSlug);
const stats = useUmamiStats('today');
const active = useUmamiActive('today');
// Hide the tile entirely if Umami isn't configured - this dashboard is
// for sales, not for prompting the operator into integration setup.
if (
stats.data?.error === 'umami_not_configured' ||
active.data?.error === 'umami_not_configured'
) {
return null;
}
const today = stats.data?.data?.pageviews?.value ?? 0;
const activeNow = active.data?.data?.visitors ?? 0;
const loading = stats.isLoading || active.isLoading;
return (
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/website-analytics` as any) : ('/' as any)}
className="block group"
>
<Card className="relative overflow-hidden p-3 sm:p-5 transition-shadow hover:shadow-md">
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
<Globe className="h-3 w-3" aria-hidden />
Website today
</div>
{loading ? (
<Skeleton className="mt-2 h-7 w-20" />
) : (
<div className="mt-1 flex items-baseline gap-2 text-lg font-semibold tabular-nums sm:mt-2 sm:text-2xl">
{today.toLocaleString()}
<span className="text-xs font-normal text-muted-foreground">pageviews</span>
</div>
)}
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{activeNow} active right now
</div>
</div>
<ArrowRight
className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
aria-hidden
/>
</div>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { UmamiPageviewsSeries } from '@/lib/services/umami.service';
interface Props {
data: UmamiPageviewsSeries | null;
}
/**
* Stacks pageviews on top of sessions in a simple area chart. Umami's
* timeseries comes pre-bucketed (the service picks bucket size based on
* range span - minute/hour/day/month).
*
* X-axis labels are kept short to avoid overflow on dense ranges.
*/
export function PageviewsChart({ data }: Props) {
if (!data || data.pageviews.length === 0) {
return (
<div className="flex h-[260px] items-center justify-center text-sm text-muted-foreground">
No data in this range
</div>
);
}
// Merge the two series (Umami returns them separately) into one row per
// bucket so we can drive a single chart.
const byX = new Map<string, { x: string; pageviews: number; sessions: number }>();
for (const p of data.pageviews) {
byX.set(p.x, { x: p.x, pageviews: p.y, sessions: 0 });
}
for (const s of data.sessions) {
const row = byX.get(s.x);
if (row) row.sessions = s.y;
else byX.set(s.x, { x: s.x, pageviews: 0, sessions: s.y });
}
const merged = Array.from(byX.values()).sort((a, b) => a.x.localeCompare(b.x));
return (
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={merged} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
<defs>
<linearGradient id="pvFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-1))" stopOpacity={0.6} />
<stop offset="100%" stopColor="hsl(var(--chart-1))" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="sessFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(var(--chart-2))" stopOpacity={0.5} />
<stop offset="100%" stopColor="hsl(var(--chart-2))" stopOpacity={0.04} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="x"
fontSize={11}
tick={{ fill: 'hsl(var(--muted-foreground))' }}
tickFormatter={formatXTick}
/>
<YAxis
fontSize={11}
tick={{ fill: 'hsl(var(--muted-foreground))' }}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="pageviews"
stroke="hsl(var(--chart-1))"
fill="url(#pvFill)"
strokeWidth={2}
name="Pageviews"
/>
<Area
type="monotone"
dataKey="sessions"
stroke="hsl(var(--chart-2))"
fill="url(#sessFill)"
strokeWidth={2}
name="Sessions"
/>
</AreaChart>
</ResponsiveContainer>
);
}
/** Compact tick labels: full datetime → just MM-DD or MM-DD HH:00. */
function formatXTick(value: string): string {
// Umami can return either "YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD".
if (value.length >= 16) {
return value.slice(5, 16); // "MM-DD HH:mm"
}
return value.slice(5); // "MM-DD"
}

View File

@@ -0,0 +1,66 @@
'use client';
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;
}
/**
* 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 = '-' }: Props) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</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="py-6 text-center text-sm text-muted-foreground">No data</div>
) : (
<ul className="space-y-1.5">
{rows.slice(0, 10).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>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
/**
* React Query hooks for the Website Analytics page. Mirrors the structure
* of `src/components/dashboard/use-analytics.ts` but talks to the Umami
* proxy at /api/v1/website-analytics.
*
* All hooks are gated on `currentPortId` so they don't fire before the
* port is resolved (which would cause a 401 to land in the React Query
* cache and persist as `isError: true` until staleTime expires).
*/
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { useUIStore } from '@/stores/ui-store';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
import type {
UmamiActiveVisitors,
UmamiMetricRow,
UmamiPageviewsSeries,
UmamiStats,
} from '@/lib/services/umami.service';
interface MetricResponse<T> {
metric: string;
range: DateRange;
data: T;
/** Surfaced when Umami isn't configured for the port - UI can render a
* "set up Umami" empty state instead of a generic loading spinner. */
error?: string;
}
function rangeToQuery(range: DateRange): string {
if (isCustomRange(range)) {
return `range=custom&from=${range.from}&to=${range.to}`;
}
return `range=${range}`;
}
function useUmamiQuery<T>(
metric: string,
range: DateRange,
extraParams = '',
/** Override the cache key segment that defaults to `range`. Use for
* metrics whose response is range-independent (e.g. active visitors)
* so the cache isn't fragmented across each date the user has picked. */
cacheKeySegment?: unknown,
) {
const portId = useUIStore((s) => s.currentPortId);
return useQuery<MetricResponse<T>>({
queryKey: ['website-analytics', metric, cacheKeySegment ?? range, portId],
queryFn: () =>
apiFetch<MetricResponse<T>>(
`/api/v1/website-analytics?metric=${metric}&${rangeToQuery(range)}${extraParams}`,
),
staleTime: 30_000, // umami data refreshes constantly; short stale time
retry: 1,
enabled: !!portId,
});
}
export const useUmamiStats = (range: DateRange) => useUmamiQuery<UmamiStats>('stats', range);
export const useUmamiPageviews = (range: DateRange) =>
useUmamiQuery<UmamiPageviewsSeries>('pageviews', range);
// Active visitors are server-side range-independent (Umami's /active endpoint
// returns the last-5-minute count regardless of what range we pass). Use a
// fixed cache-key segment so the dashboard tile (range='today') and the
// website-analytics shell (any selected range) share one cache entry instead
// of fragmenting it across every range the user picks.
export const useUmamiActive = (range: DateRange) =>
useUmamiQuery<UmamiActiveVisitors>('active', range, '', 'fixed');
export const useUmamiTopPages = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-url', range, `&limit=${limit}`);
export const useUmamiTopReferrers = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-referrer', range, `&limit=${limit}`);
export const useUmamiTopCountries = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-country', range, `&limit=${limit}`);

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