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:
67
src/components/admin/website-analytics/umami-test-button.tsx
Normal file
67
src/components/admin/website-analytics/umami-test-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/dashboard/website-glance-tile.tsx
Normal file
79
src/components/dashboard/website-glance-tile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/components/website-analytics/pageviews-chart.tsx
Normal file
111
src/components/website-analytics/pageviews-chart.tsx
Normal 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"
|
||||
}
|
||||
66
src/components/website-analytics/top-list.tsx
Normal file
66
src/components/website-analytics/top-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/website-analytics/use-website-analytics.ts
Normal file
80
src/components/website-analytics/use-website-analytics.ts
Normal 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}`);
|
||||
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