feat(analytics): Umami website-analytics suite — world map, realtime, sessions, heatmap, pixel tracking, tracked links
Adds the read-side Umami integration queued in last week's website-analytics plan (Phases 1–6 of `docs/website-analytics-flesh-out-plan.md`): - Realtime panel polls Umami at 5s intervals; world map renders visitor origins via echarts + `public/world-map/echarts-world.json` topo. - Sessions list + session-detail-sheet drill-down (per-session event timeline pulled from `/api/v1/website-analytics`). - Weekly heatmap (day-of-week × hour-of-day) for engagement timing. - Metric-detail pages under `/[portSlug]/website-analytics/[metric]` for pageviews / referrers / events deep-dives. - Email-pixel write path: `/api/public/email-pixel/[sendId]` 1×1 GIF beacon backed by `email_open_tracking` (migration 0076); resolves inline on render in inbox. - Tracked-link redirect: `/q/[slug]` routes through `tracked_links` (migration 0077) and forwards to the canonical destination after logging the click. - Dashboard `website-glance-tile` now reads from the live Umami service instead of placeholder data. Deps: `@umami/node`, `echarts`, `echarts-for-react`, `@types/geojson`, `@types/topojson-client`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,17 @@
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* pageviews for the dashboard's current range + 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 — the
|
||||
* configure-prompt lives on the dedicated page, not the dashboard.
|
||||
*
|
||||
* When an Umami call fails (auth, network, shape) the tile renders a
|
||||
* dash "—" instead of "0" so the rep can tell error from no-data.
|
||||
*/
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Globe, ArrowRight } from 'lucide-react';
|
||||
import { Globe, ArrowRight, AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { Card } from '@/components/ui/card';
|
||||
@@ -19,23 +21,42 @@ import {
|
||||
useUmamiActive,
|
||||
useUmamiStats,
|
||||
} from '@/components/website-analytics/use-website-analytics';
|
||||
import type { DateRange } from '@/lib/analytics/range';
|
||||
import { isCustomRange } from '@/lib/analytics/range';
|
||||
|
||||
export function WebsiteGlanceTile() {
|
||||
interface Props {
|
||||
range?: DateRange;
|
||||
}
|
||||
|
||||
const RANGE_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
|
||||
today: 'Today',
|
||||
'7d': '7 days',
|
||||
'30d': '30 days',
|
||||
'90d': '90 days',
|
||||
};
|
||||
|
||||
function shortRangeLabel(range: DateRange): string {
|
||||
if (isCustomRange(range)) return 'Custom range';
|
||||
return RANGE_LABELS[range];
|
||||
}
|
||||
|
||||
export function WebsiteGlanceTile({ range = '30d' }: Props) {
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const stats = useUmamiStats('today');
|
||||
const active = useUmamiActive('today');
|
||||
const stats = useUmamiStats(range);
|
||||
const active = useUmamiActive(range);
|
||||
|
||||
// Hide the tile entirely if Umami isn't configured - this dashboard is
|
||||
// for sales, not for prompting the operator into integration setup.
|
||||
// The API surfaces `notConfigured: true` on a 200 response so React
|
||||
// Query doesn't retry-loop (a prior 409-throw caused server hangs).
|
||||
if (stats.data?.notConfigured || active.data?.notConfigured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const today = stats.data?.data?.pageviews?.value ?? 0;
|
||||
const activeNow = active.data?.data?.visitors ?? 0;
|
||||
// Umami v3 returns flat numbers — `data?.data?.pageviews` is a number,
|
||||
// not `{value, prev}`. The previous nested shape was Umami v1; v3 moved
|
||||
// comparison values into a sibling `comparison` block.
|
||||
const pageviews = stats.data?.data?.pageviews;
|
||||
const activeNow = active.data?.data?.visitors;
|
||||
const loading = stats.isLoading || active.isLoading;
|
||||
const statsErrored = stats.isError;
|
||||
const activeErrored = active.isError;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -49,22 +70,36 @@ export function WebsiteGlanceTile() {
|
||||
<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
|
||||
Website · {shortRangeLabel(range)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className="mt-2 h-7 w-20" aria-hidden />
|
||||
) : statsErrored || pageviews === undefined ? (
|
||||
<div
|
||||
className="mt-1 flex items-center gap-1.5 text-sm text-warning sm:mt-2"
|
||||
title={stats.error instanceof Error ? stats.error.message : 'Umami unavailable'}
|
||||
>
|
||||
<AlertTriangle className="size-3.5" aria-hidden />
|
||||
Umami unavailable
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 flex items-baseline gap-2 text-lg font-semibold tabular-nums sm:mt-2 sm:text-2xl">
|
||||
{today.toLocaleString()}
|
||||
{pageviews.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
|
||||
{activeErrored ? (
|
||||
<span className="text-warning">live count unavailable</span>
|
||||
) : (
|
||||
<>
|
||||
<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 ?? 0} active right now
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight
|
||||
|
||||
206
src/components/website-analytics/metric-detail-shell.tsx
Normal file
206
src/components/website-analytics/metric-detail-shell.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Detail page shell rendered at /{portSlug}/website-analytics/{metric}.
|
||||
* Shows the full ranked list (no top-10 cap) for one Umami metric, plus
|
||||
* a back-link and a date range picker that mirrors the parent page.
|
||||
*
|
||||
* The metric slug in the URL maps to a Umami metric type. Country rows
|
||||
* are rebadged to full English names; page paths get the same Homepage
|
||||
* substitution the dashboard does.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
|
||||
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
||||
|
||||
const METRIC_CONFIG: Record<
|
||||
string,
|
||||
{ umamiMetric: string; title: string; emptyLabel: string; transform?: (x: string) => string }
|
||||
> = {
|
||||
pages: {
|
||||
umamiMetric: 'top-path',
|
||||
title: 'All pages',
|
||||
emptyLabel: '(unknown)',
|
||||
transform: (x) => (x === '/' ? 'Homepage' : x),
|
||||
},
|
||||
referrers: {
|
||||
umamiMetric: 'top-referrer',
|
||||
title: 'All referrers',
|
||||
emptyLabel: '(direct)',
|
||||
},
|
||||
countries: {
|
||||
umamiMetric: 'top-country',
|
||||
title: 'All countries',
|
||||
emptyLabel: '(unknown)',
|
||||
transform: (x) => getCountryName(x, 'en'),
|
||||
},
|
||||
browsers: {
|
||||
umamiMetric: 'top-browser',
|
||||
title: 'All browsers',
|
||||
emptyLabel: '(unknown)',
|
||||
},
|
||||
os: {
|
||||
umamiMetric: 'top-os',
|
||||
title: 'All operating systems',
|
||||
emptyLabel: '(unknown)',
|
||||
},
|
||||
devices: {
|
||||
umamiMetric: 'top-device',
|
||||
title: 'All devices',
|
||||
emptyLabel: '(unknown)',
|
||||
transform: (x) => (x === '' ? 'Unknown' : x.charAt(0).toUpperCase() + x.slice(1)),
|
||||
},
|
||||
};
|
||||
|
||||
interface MetricResponse {
|
||||
metric: string;
|
||||
range: DateRange;
|
||||
data: UmamiMetricRow[] | null;
|
||||
notConfigured?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
metric: string;
|
||||
initialRange: string;
|
||||
initialFrom?: string;
|
||||
initialTo?: string;
|
||||
}
|
||||
|
||||
export function MetricDetailShell({ metric, initialRange, initialFrom, initialTo }: Props) {
|
||||
const cfg = METRIC_CONFIG[metric];
|
||||
const router = useRouter();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const portId = useUIStore((s) => s.currentPortId);
|
||||
|
||||
const [range, setRange] = useState<DateRange>(() =>
|
||||
parseInitialRange(initialRange, initialFrom, initialTo),
|
||||
);
|
||||
|
||||
function handleRangeChange(next: DateRange) {
|
||||
setRange(next);
|
||||
// Mirror the picker choice back into the URL so refresh / share / back
|
||||
// all preserve the time window the user picked.
|
||||
const sp = new URLSearchParams();
|
||||
if (isCustomRange(next)) {
|
||||
sp.set('range', 'custom');
|
||||
sp.set('from', next.from);
|
||||
sp.set('to', next.to);
|
||||
} else {
|
||||
sp.set('range', next);
|
||||
}
|
||||
router.replace(`/${portSlug}/website-analytics/${metric}?${sp.toString()}` as never);
|
||||
}
|
||||
|
||||
const query = useQuery<MetricResponse>({
|
||||
queryKey: ['website-analytics', cfg?.umamiMetric, range, portId, 'detail'],
|
||||
queryFn: () =>
|
||||
apiFetch<MetricResponse>(
|
||||
`/api/v1/website-analytics?metric=${cfg!.umamiMetric}&${rangeToQuery(range)}&limit=500`,
|
||||
),
|
||||
enabled: !!portId && !!cfg,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (!cfg) {
|
||||
return <div className="p-8 text-sm text-muted-foreground">Unknown metric.</div>;
|
||||
}
|
||||
|
||||
const rows = query.data?.data ?? null;
|
||||
const max = rows && rows.length > 0 ? rows[0]!.y : 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-start">
|
||||
<Link
|
||||
href={`/${portSlug}/website-analytics?${rangeToQuery(range)}` as never}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" aria-hidden />
|
||||
Back to website analytics
|
||||
</Link>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={cfg.title}
|
||||
eyebrow="Website analytics"
|
||||
variant="gradient"
|
||||
actions={<DateRangePicker value={range} onChange={handleRangeChange} />}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{query.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !rows || rows.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
No data in this range.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{rows.map((row, i) => {
|
||||
const pct = (row.y / max) * 100;
|
||||
const raw = row.x?.trim() || cfg.emptyLabel;
|
||||
const label = cfg.transform ? cfg.transform(raw) : raw;
|
||||
return (
|
||||
<li key={`${row.x}-${i}`} className="text-sm">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
<span className="mr-2 inline-block w-6 tabular-nums text-muted-foreground">
|
||||
{i + 1}.
|
||||
</span>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseInitialRange(rawRange: string, from?: string, to?: string): DateRange {
|
||||
if (rawRange === 'custom' && from && to) {
|
||||
return { kind: 'custom', from, to };
|
||||
}
|
||||
if (rawRange === 'today' || rawRange === '7d' || rawRange === '30d' || rawRange === '90d') {
|
||||
return rawRange;
|
||||
}
|
||||
return '30d';
|
||||
}
|
||||
|
||||
function rangeToQuery(range: DateRange): string {
|
||||
if (isCustomRange(range)) {
|
||||
return `range=custom&from=${range.from}&to=${range.to}`;
|
||||
}
|
||||
return `range=${range}`;
|
||||
}
|
||||
@@ -33,13 +33,16 @@ export function PageviewsChart({ data }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Merge the two series (Umami returns them separately) into one row per
|
||||
// bucket so we can drive a single chart.
|
||||
// Merge the two series (Umami returns them separately when `compare` is
|
||||
// requested) into one row per bucket so we can drive a single chart.
|
||||
// `sessions` is optional on Umami v3 — only present when the request
|
||||
// included a comparison directive. Guard the read so an undefined
|
||||
// array doesn't crash the 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) {
|
||||
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 });
|
||||
@@ -78,6 +81,7 @@ export function PageviewsChart({ data }: Props) {
|
||||
borderRadius: '6px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelFormatter={formatTooltipLabel}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area
|
||||
@@ -101,11 +105,24 @@ export function PageviewsChart({ data }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact tick labels: full datetime → just MM-DD or MM-DD HH:00. */
|
||||
/** Compact tick labels: drop the timestamp entirely — for multi-day ranges
|
||||
* the hour component is meaningless (a "day" bucket aggregates the whole
|
||||
* day) and just causes visual crowding. Keep MM-DD. */
|
||||
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"
|
||||
return value.slice(5, 10); // "MM-DD"
|
||||
}
|
||||
|
||||
/** Tooltip header: format "2026-03-30 00:00:00" → "Mar 30, 2026" so the
|
||||
* meaningless 00:00:00 timestamp doesn't show. */
|
||||
function formatTooltipLabel(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const datePart = value.slice(0, 10); // "YYYY-MM-DD"
|
||||
const d = new Date(`${datePart}T00:00:00Z`);
|
||||
if (isNaN(d.getTime())) return datePart;
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
245
src/components/website-analytics/realtime-panel.tsx
Normal file
245
src/components/website-analytics/realtime-panel.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Realtime panel — Umami's "what's happening RIGHT NOW" view, surfaced
|
||||
* as a collapsible card at the top of the website-analytics page.
|
||||
*
|
||||
* Folds in five things from Umami's /api/realtime/<id> endpoint:
|
||||
* - Totals strip (visitors / views / events / countries in the last 30m)
|
||||
* - Top URLs being viewed
|
||||
* - Top countries
|
||||
* - Top referrers
|
||||
* - Recent event stream (pageviews + named events as they arrive)
|
||||
*
|
||||
* Polling pauses when the card is collapsed so we're not hammering
|
||||
* Umami at 5 s intervals while no one is looking.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Globe, Activity, MapPin, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { useUmamiRealtime } from './use-website-analytics';
|
||||
|
||||
export function RealtimePanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const query = useUmamiRealtime(open);
|
||||
const data = query.data?.data ?? null;
|
||||
|
||||
// Hide the entire bar when Umami reports a quiet 30-minute window —
|
||||
// a "Live activity (0 visitors)" header is just noise. We still poll
|
||||
// every 60 s while hidden so the bar reappears the moment traffic
|
||||
// arrives.
|
||||
const isQuiet = !!data && data.totals.visitors === 0 && data.events.length === 0;
|
||||
if (isQuiet && !open) return null;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-muted/40 sm:px-5"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="relative flex h-2.5 w-2.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-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Live activity</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{open
|
||||
? 'Auto-refreshing every 5s · last 30 minutes'
|
||||
: 'Click to expand — top pages, countries, and a live event stream'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{open ? (
|
||||
<ChevronUp className="size-4 text-muted-foreground" aria-hidden />
|
||||
) : (
|
||||
<ChevronDown className="size-4 text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<CardContent className="border-t border-border pt-4 sm:pt-6">
|
||||
{query.isLoading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : !data ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No realtime data available.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Totals strip */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label="Visitors" value={data.totals.visitors} />
|
||||
<Stat label="Pageviews" value={data.totals.views} />
|
||||
<Stat label="Events" value={data.totals.events} />
|
||||
<Stat label="Countries" value={data.totals.countries} />
|
||||
</div>
|
||||
|
||||
{/* Three-column ranked-list strip */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<RankList
|
||||
icon={<ExternalLink className="size-3.5" aria-hidden />}
|
||||
title="Top pages right now"
|
||||
rows={recordToRows(data.urls).map((r) => ({
|
||||
...r,
|
||||
label: r.label === '/' ? 'Homepage' : r.label,
|
||||
}))}
|
||||
emptyLabel="No pageviews yet"
|
||||
/>
|
||||
<RankList
|
||||
icon={<MapPin className="size-3.5" aria-hidden />}
|
||||
title="Top countries"
|
||||
rows={recordToRows(data.countries).map((r) => ({
|
||||
...r,
|
||||
label: getCountryName(r.label, 'en') || r.label || 'Unknown',
|
||||
}))}
|
||||
emptyLabel="No country data yet"
|
||||
/>
|
||||
<RankList
|
||||
icon={<Globe className="size-3.5" aria-hidden />}
|
||||
title="Top referrers"
|
||||
rows={recordToRows(data.referrers).map((r) => ({
|
||||
...r,
|
||||
label: r.label || '(direct)',
|
||||
}))}
|
||||
emptyLabel="No referrers yet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent event stream */}
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-medium">
|
||||
<Activity className="size-3.5 text-muted-foreground" aria-hidden />
|
||||
Recent activity
|
||||
</h3>
|
||||
{data.events.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border py-6 text-center text-xs text-muted-foreground">
|
||||
No events in the last 30 minutes.
|
||||
</div>
|
||||
) : (
|
||||
<ol className="space-y-1.5 text-xs">
|
||||
{data.events.slice(0, 20).map((ev, i) => (
|
||||
<li
|
||||
key={`${ev.createdAt}-${i}`}
|
||||
className="flex items-baseline justify-between gap-2 rounded-md bg-muted/40 px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
<span className="font-medium">
|
||||
{ev.eventName
|
||||
? `Event: ${ev.eventName}`
|
||||
: !ev.urlPath || ev.urlPath === '/'
|
||||
? 'Homepage'
|
||||
: ev.urlPath}
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{[ev.country && getCountryName(ev.country, 'en'), ev.browser, ev.device]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{fmtAgo(ev.createdAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xl font-semibold tabular-nums">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RankRow {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function RankList({
|
||||
icon,
|
||||
title,
|
||||
rows,
|
||||
emptyLabel,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
rows: RankRow[];
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const max = rows[0]?.value ?? 1;
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{icon}
|
||||
{title}
|
||||
</h3>
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border py-3 text-center text-xs text-muted-foreground">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1 text-xs">
|
||||
{rows.slice(0, 5).map((r) => {
|
||||
const pct = (r.value / max) * 100;
|
||||
return (
|
||||
<li key={r.label}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{r.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{r.value.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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function recordToRows(rec: Record<string, number>): RankRow[] {
|
||||
return Object.entries(rec)
|
||||
.map(([label, value]) => ({ label, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
|
||||
function fmtAgo(iso: string): string {
|
||||
const t = new Date(iso).getTime();
|
||||
if (isNaN(t)) return iso;
|
||||
const diff = Date.now() - t;
|
||||
const seconds = Math.max(1, Math.round(diff / 1000));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
106
src/components/website-analytics/session-detail-sheet.tsx
Normal file
106
src/components/website-analytics/session-detail-sheet.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Right-side Sheet with the full activity stream for one session.
|
||||
* Driven by /api/v1/website-analytics?metric=session-activity. Each row
|
||||
* is a pageview or custom event in chronological order.
|
||||
*/
|
||||
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useUmamiSessionActivity } from './use-website-analytics';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import type { DateRange } from '@/lib/analytics/range';
|
||||
import type { UmamiSession } from '@/lib/services/umami.service';
|
||||
|
||||
interface Props {
|
||||
session: UmamiSession | null;
|
||||
range: DateRange;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SessionDetailSheet({ session, range, onClose }: Props) {
|
||||
const activityQuery = useUmamiSessionActivity(range, session?.id ?? null);
|
||||
const activity = activityQuery.data?.data ?? [];
|
||||
|
||||
return (
|
||||
<Sheet open={!!session} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Session detail</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{session ? (
|
||||
<div className="mt-4 space-y-5">
|
||||
{/* Top facts */}
|
||||
<dl className="grid grid-cols-2 gap-y-2 text-sm">
|
||||
<DtDd label="Location">
|
||||
{getCountryName(session.country, 'en') || 'Unknown'}
|
||||
{session.city ? ` · ${session.city}` : ''}
|
||||
</DtDd>
|
||||
<DtDd label="Device">{session.device}</DtDd>
|
||||
<DtDd label="Browser">{session.browser}</DtDd>
|
||||
<DtDd label="OS">{session.os}</DtDd>
|
||||
<DtDd label="Screen">{session.screen || '—'}</DtDd>
|
||||
<DtDd label="Language">{session.language || '—'}</DtDd>
|
||||
<DtDd label="First visit">{fmtTime(session.firstAt)}</DtDd>
|
||||
<DtDd label="Last visit">{fmtTime(session.lastAt)}</DtDd>
|
||||
<DtDd label="Visits">{session.visits.toLocaleString()}</DtDd>
|
||||
<DtDd label="Pageviews">{session.views.toLocaleString()}</DtDd>
|
||||
</dl>
|
||||
|
||||
{/* Activity stream */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Activity</h3>
|
||||
{activityQuery.isLoading ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : activity.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
No activity recorded for this session.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="mt-2 space-y-1 border-l border-border pl-3">
|
||||
{activity.map((row) => (
|
||||
<li key={row.eventId} className="relative text-xs">
|
||||
<span className="absolute -left-[15px] top-1 inline-block size-2 rounded-full bg-brand" />
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{row.eventName ? `Event: ${row.eventName}` : row.urlPath || '/'}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{fmtTime(row.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{row.eventName && row.urlPath ? (
|
||||
<div className="text-muted-foreground">{row.urlPath}</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DtDd({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||
<dd className="text-foreground">{children}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
150
src/components/website-analytics/sessions-list.tsx
Normal file
150
src/components/website-analytics/sessions-list.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Recent-sessions card for the website-analytics page. Paginated list
|
||||
* of visitor sessions (one row per unique session) with click-through to
|
||||
* a detail sheet showing the full activity stream.
|
||||
*
|
||||
* Umami's session model: one row per anonymous-device-fingerprint+IP+UA
|
||||
* combination, with first/last visit timestamps + visit/view counts +
|
||||
* geo + browser/os/device. The detail page shows the per-event stream
|
||||
* (pageviews + custom events) within that session.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Globe, Smartphone, Monitor, Tablet, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { useUmamiSessions } from './use-website-analytics';
|
||||
import { SessionDetailSheet } from './session-detail-sheet';
|
||||
import { type DateRange } from '@/lib/analytics/range';
|
||||
import type { UmamiSession } from '@/lib/services/umami.service';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
export function SessionsList({ range }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState<UmamiSession | null>(null);
|
||||
const pageSize = 15;
|
||||
const query = useUmamiSessions(range, { page, pageSize });
|
||||
|
||||
const sessions = query.data?.data?.data ?? [];
|
||||
const total = query.data?.data?.count ?? 0;
|
||||
const hasMore = page * pageSize < total;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent sessions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{query.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No sessions in this range.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="divide-y divide-border">
|
||||
{sessions.map((s, i) => (
|
||||
// Umami's sessions endpoint can return rows with the
|
||||
// same session id within a page when activity straddles
|
||||
// a bucket boundary. Compose the key to dedupe.
|
||||
<li key={`${s.id}-${i}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(s)}
|
||||
className="group flex w-full items-center justify-between gap-3 py-3 text-left transition hover:bg-muted/40 -mx-2 px-2 rounded"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<DeviceIcon device={s.device} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
|
||||
<span className="font-medium">
|
||||
{getCountryName(s.country, 'en') || 'Unknown'}
|
||||
</span>
|
||||
{s.city ? (
|
||||
<span className="text-muted-foreground">{s.city}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{s.browser} · {s.os} · {fmtTime(s.firstAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">{s.views.toLocaleString()} views</span>
|
||||
<ChevronRight
|
||||
className="size-4 opacity-0 transition group-hover:opacity-100"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of{' '}
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasMore}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SessionDetailSheet session={selected} range={range} onClose={() => setSelected(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceIcon({ device }: { device: string }) {
|
||||
const cls = 'size-5 shrink-0 text-muted-foreground';
|
||||
switch (device.toLowerCase()) {
|
||||
case 'mobile':
|
||||
return <Smartphone className={cls} aria-hidden />;
|
||||
case 'tablet':
|
||||
return <Tablet className={cls} aria-hidden />;
|
||||
case 'desktop':
|
||||
case 'laptop':
|
||||
return <Monitor className={cls} aria-hidden />;
|
||||
default:
|
||||
return <Globe className={cls} aria-hidden />;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
||||
@@ -10,6 +13,11 @@ interface Props {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,11 +26,31 @@ interface Props {
|
||||
* 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) {
|
||||
export function TopList({
|
||||
title,
|
||||
rows,
|
||||
loading,
|
||||
defaultLabel = '-',
|
||||
viewAllHref,
|
||||
limit = 10,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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 ? (
|
||||
@@ -36,7 +64,7 @@ export function TopList({ title, rows, loading, defaultLabel = '-' }: Props) {
|
||||
<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) => {
|
||||
{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;
|
||||
|
||||
@@ -19,7 +19,12 @@ import type {
|
||||
UmamiActiveVisitors,
|
||||
UmamiMetricRow,
|
||||
UmamiPageviewsSeries,
|
||||
UmamiRealtime,
|
||||
UmamiSession,
|
||||
UmamiSessionActivity,
|
||||
UmamiSessionsPage,
|
||||
UmamiStats,
|
||||
UmamiWebsiteInfo,
|
||||
} from '@/lib/services/umami.service';
|
||||
|
||||
interface MetricResponse<T> {
|
||||
@@ -49,6 +54,12 @@ function useUmamiQuery<T>(
|
||||
* 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,
|
||||
/** Optional auto-refresh interval. Used for the live active-visitors
|
||||
* badge so the count ticks without a page reload. */
|
||||
refetchInterval?: number,
|
||||
/** Additional enabled gate (e.g. for session-activity which needs a
|
||||
* selected sessionId before firing). ANDed with the port-id check. */
|
||||
enabledGate = true,
|
||||
) {
|
||||
const portId = useUIStore((s) => s.currentPortId);
|
||||
return useQuery<MetricResponse<T>>({
|
||||
@@ -59,7 +70,8 @@ function useUmamiQuery<T>(
|
||||
),
|
||||
staleTime: 30_000, // umami data refreshes constantly; short stale time
|
||||
retry: 1,
|
||||
enabled: !!portId,
|
||||
enabled: !!portId && enabledGate,
|
||||
...(refetchInterval ? { refetchInterval } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,11 +85,84 @@ export const useUmamiPageviews = (range: DateRange) =>
|
||||
// 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');
|
||||
useUmamiQuery<UmamiActiveVisitors>('active', range, '', 'fixed', 30_000);
|
||||
|
||||
export const useUmamiTopPages = (range: DateRange, limit = 10) =>
|
||||
useUmamiQuery<UmamiMetricRow[]>('top-url', range, `&limit=${limit}`);
|
||||
useUmamiQuery<UmamiMetricRow[]>('top-path', 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}`);
|
||||
|
||||
// World map needs ALL countries with traffic, not just the top 10 the list
|
||||
// uses. Umami caps `limit` server-side around 500; 250 covers every ISO
|
||||
// country we'll ever see in one request.
|
||||
export const useUmamiAllCountries = (range: DateRange) =>
|
||||
useUmamiQuery<UmamiMetricRow[]>('top-country', range, `&limit=250`, `all-${range}`);
|
||||
export const useUmamiTopBrowsers = (range: DateRange, limit = 10) =>
|
||||
useUmamiQuery<UmamiMetricRow[]>('top-browser', range, `&limit=${limit}`);
|
||||
export const useUmamiTopOS = (range: DateRange, limit = 10) =>
|
||||
useUmamiQuery<UmamiMetricRow[]>('top-os', range, `&limit=${limit}`);
|
||||
export const useUmamiTopDevices = (range: DateRange, limit = 10) =>
|
||||
useUmamiQuery<UmamiMetricRow[]>('top-device', range, `&limit=${limit}`);
|
||||
|
||||
// Website metadata (name + domain). Range-independent; long stale time
|
||||
// since the domain rarely changes.
|
||||
export const useUmamiWebsiteInfo = (range: DateRange) =>
|
||||
useUmamiQuery<UmamiWebsiteInfo>('website', range, '', 'website-info');
|
||||
|
||||
// Phase 2 — sessions surface. Paginated list of recent sessions plus
|
||||
// per-session detail + activity stream + weekly engagement heatmap.
|
||||
export const useUmamiSessions = (
|
||||
range: DateRange,
|
||||
opts: { page?: number; pageSize?: number; query?: string } = {},
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.page) params.set('page', String(opts.page));
|
||||
if (opts.pageSize) params.set('pageSize', String(opts.pageSize));
|
||||
if (opts.query) params.set('query', opts.query);
|
||||
const suffix = params.toString() ? `&${params.toString()}` : '';
|
||||
const cacheKey = `${range}-${opts.page ?? 1}-${opts.pageSize ?? 25}-${opts.query ?? ''}`;
|
||||
return useUmamiQuery<UmamiSessionsPage>('sessions', range, suffix, cacheKey);
|
||||
};
|
||||
|
||||
export const useUmamiSession = (range: DateRange, sessionId: string | null) => {
|
||||
const suffix = sessionId ? `&sessionId=${sessionId}` : '';
|
||||
return useUmamiQuery<UmamiSession>(
|
||||
'session',
|
||||
range,
|
||||
suffix,
|
||||
`session-${sessionId ?? 'none'}`,
|
||||
undefined,
|
||||
!!sessionId,
|
||||
);
|
||||
};
|
||||
|
||||
export const useUmamiSessionActivity = (range: DateRange, sessionId: string | null) => {
|
||||
const suffix = sessionId ? `&sessionId=${sessionId}` : '';
|
||||
return useUmamiQuery<UmamiSessionActivity[]>(
|
||||
'session-activity',
|
||||
range,
|
||||
suffix,
|
||||
`session-activity-${sessionId ?? 'none'}`,
|
||||
undefined,
|
||||
!!sessionId,
|
||||
);
|
||||
};
|
||||
|
||||
export const useUmamiSessionsWeekly = (range: DateRange) =>
|
||||
useUmamiQuery<number[][]>('sessions-weekly', range);
|
||||
|
||||
// Realtime panel — Umami's /api/realtime endpoint returns last-30-min
|
||||
// activity. Two cadences: 5 s when the panel is expanded (so it feels
|
||||
// live) and 60 s when collapsed (so we still know whether to show the
|
||||
// "Live activity" bar at all — the bar is hidden entirely when there
|
||||
// are zero visitors and zero events in the last 30 minutes).
|
||||
export const useUmamiRealtime = (expanded: boolean) =>
|
||||
useUmamiQuery<UmamiRealtime>(
|
||||
'realtime',
|
||||
'today',
|
||||
'',
|
||||
'realtime-fixed',
|
||||
expanded ? 5_000 : 60_000,
|
||||
);
|
||||
|
||||
202
src/components/website-analytics/visitor-world-map.tsx
Normal file
202
src/components/website-analytics/visitor-world-map.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Choropleth world map of visitor counts per country. Powers a single card
|
||||
* on the website-analytics page; hover any country for the visitor count,
|
||||
* click to filter the rest of the page to that country.
|
||||
*
|
||||
* Uses ECharts' own world.json (the GeoJSON shipped with their public
|
||||
* examples) — pre-cleaned, no antimeridian artifacts. Country features
|
||||
* are matched on `properties.name` (English country name from the source).
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { MapChart } from 'echarts/charts';
|
||||
import {
|
||||
GeoComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
TitleComponent,
|
||||
} from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import type { FeatureCollection } from 'geojson';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
||||
|
||||
echarts.use([
|
||||
MapChart,
|
||||
GeoComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
TitleComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
const ReactEChartsCore = dynamic(() => import('echarts-for-react/lib/core'), { ssr: false });
|
||||
|
||||
let registrationPromise: Promise<void> | null = null;
|
||||
async function ensureWorldMapRegistered(): Promise<void> {
|
||||
if (registrationPromise) return registrationPromise;
|
||||
registrationPromise = (async () => {
|
||||
const res = await fetch('/world-map/echarts-world.json');
|
||||
const geo = (await res.json()) as FeatureCollection;
|
||||
echarts.registerMap('world', { geoJSON: geo as unknown as object } as never);
|
||||
})();
|
||||
return registrationPromise;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: UmamiMetricRow[] | null;
|
||||
loading: boolean;
|
||||
onCountryClick?: (iso2: string) => void;
|
||||
}
|
||||
|
||||
export function VisitorWorldMap({ rows, loading, onCountryClick }: Props) {
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
ensureWorldMapRegistered().then(() => {
|
||||
if (!cancelled) setMapReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!rows) return [];
|
||||
return rows.map((r) => ({
|
||||
name: getCountryName(r.x, 'en'),
|
||||
value: r.y,
|
||||
iso2: r.x,
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
const maxValue = useMemo(
|
||||
() => (data.length > 0 ? Math.max(...data.map((d) => d.value)) : 0),
|
||||
[data],
|
||||
);
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: { name: string; value?: number }) =>
|
||||
params.value === undefined || isNaN(params.value)
|
||||
? `${params.name}<br/><span style="color:#94a3b8">No visitors</span>`
|
||||
: `${params.name}<br/><strong>${params.value.toLocaleString()}</strong> visitor${params.value === 1 ? '' : 's'}`,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.95)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
textStyle: { color: '#f1f5f9', fontSize: 12 },
|
||||
},
|
||||
visualMap: {
|
||||
type: 'piecewise',
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
left: 16,
|
||||
bottom: 12,
|
||||
orient: 'horizontal',
|
||||
textStyle: { color: '#64748b', fontSize: 10 },
|
||||
inRange: {
|
||||
color: ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1d4ed8', '#1e3a8a'],
|
||||
},
|
||||
itemWidth: 18,
|
||||
itemHeight: 10,
|
||||
itemGap: 2,
|
||||
showLabel: true,
|
||||
// Bucket counts into 5 piecewise segments so the legend reads
|
||||
// like a discrete heat-scale rather than a hard-to-eyeball
|
||||
// gradient bar.
|
||||
pieces: bucketizeMax(maxValue),
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'map',
|
||||
map: 'world',
|
||||
roam: true,
|
||||
scaleLimit: { min: 0.8, max: 8 },
|
||||
aspectScale: 0.85,
|
||||
itemStyle: {
|
||||
areaColor: '#f1f5f9',
|
||||
borderColor: '#cbd5e1',
|
||||
borderWidth: 0.4,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: { areaColor: '#fbbf24', borderColor: '#92400e' },
|
||||
label: { show: false },
|
||||
},
|
||||
select: {
|
||||
itemStyle: { areaColor: '#f97316' },
|
||||
label: { show: false },
|
||||
},
|
||||
data,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data, maxValue],
|
||||
);
|
||||
|
||||
const onEvents = useMemo(
|
||||
() => ({
|
||||
click: (params: { data?: { iso2?: string } }) => {
|
||||
const iso2 = params?.data?.iso2;
|
||||
if (iso2 && onCountryClick) onCountryClick(iso2);
|
||||
},
|
||||
}),
|
||||
[onCountryClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Visitors by country</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading || !mapReady ? (
|
||||
<Skeleton className="h-[560px] w-full" />
|
||||
) : (
|
||||
<ReactEChartsCore
|
||||
echarts={echarts}
|
||||
option={option}
|
||||
onEvents={onEvents}
|
||||
style={{ height: 560, width: '100%' }}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket the visitor-count scale into 5 readable bins. Umami country data
|
||||
* is heavily skewed (top country may have 900+, most have 0–5), so a
|
||||
* linear gradient looks visually flat. The buckets are derived from the
|
||||
* observed max so the highest bin is always saturated.
|
||||
*/
|
||||
function bucketizeMax(max: number): Array<{ min?: number; max?: number; label: string }> {
|
||||
if (max <= 0) return [{ min: 0, max: 0, label: '0' }];
|
||||
const step = Math.max(1, Math.ceil(max / 5));
|
||||
return [
|
||||
{ min: 0, max: 0, label: '0' },
|
||||
{ min: 1, max: step, label: `1–${step.toLocaleString()}` },
|
||||
{
|
||||
min: step + 1,
|
||||
max: step * 2,
|
||||
label: `${(step + 1).toLocaleString()}–${(step * 2).toLocaleString()}`,
|
||||
},
|
||||
{
|
||||
min: step * 2 + 1,
|
||||
max: step * 3,
|
||||
label: `${(step * 2 + 1).toLocaleString()}–${(step * 3).toLocaleString()}`,
|
||||
},
|
||||
{ min: step * 3 + 1, label: `${(step * 3 + 1).toLocaleString()}+` },
|
||||
];
|
||||
}
|
||||
@@ -11,9 +11,12 @@
|
||||
* port - points the operator at /admin/website-analytics to set creds.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Globe, Settings, ExternalLink } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Globe, Info, Settings, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -21,22 +24,50 @@ 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 { isCustomRange, type DateRange } from '@/lib/analytics/range';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import {
|
||||
useUmamiActive,
|
||||
useUmamiAllCountries,
|
||||
useUmamiPageviews,
|
||||
useUmamiStats,
|
||||
useUmamiTopBrowsers,
|
||||
useUmamiTopCountries,
|
||||
useUmamiTopDevices,
|
||||
useUmamiTopOS,
|
||||
useUmamiTopPages,
|
||||
useUmamiTopReferrers,
|
||||
useUmamiWebsiteInfo,
|
||||
} from './use-website-analytics';
|
||||
import { PageviewsChart } from './pageviews-chart';
|
||||
import { RealtimePanel } from './realtime-panel';
|
||||
import { SessionsList } from './sessions-list';
|
||||
import { TopList } from './top-list';
|
||||
import { VisitorWorldMap } from './visitor-world-map';
|
||||
import { WeeklyHeatmap } from './weekly-heatmap';
|
||||
|
||||
export function WebsiteAnalyticsShell() {
|
||||
const [range, setRange] = useState<DateRange>('30d');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const portSlug = useUIStore((s) => s.currentPortSlug);
|
||||
// Hydrate range from URL so /website-analytics?range=90d (or
|
||||
// ?range=custom&from=…&to=…) survives refreshes and round-trips from
|
||||
// the "View all" detail pages.
|
||||
const [range, setRange] = useState<DateRange>(() => parseRangeFromQuery(searchParams));
|
||||
|
||||
function handleRangeChange(next: DateRange) {
|
||||
setRange(next);
|
||||
const sp = new URLSearchParams();
|
||||
if (isCustomRange(next)) {
|
||||
sp.set('range', 'custom');
|
||||
sp.set('from', next.from);
|
||||
sp.set('to', next.to);
|
||||
} else {
|
||||
sp.set('range', next);
|
||||
}
|
||||
router.replace(`/${portSlug}/website-analytics?${sp.toString()}` as never);
|
||||
}
|
||||
|
||||
const stats = useUmamiStats(range);
|
||||
const pageviews = useUmamiPageviews(range);
|
||||
@@ -44,6 +75,14 @@ export function WebsiteAnalyticsShell() {
|
||||
const topPages = useUmamiTopPages(range);
|
||||
const topReferrers = useUmamiTopReferrers(range);
|
||||
const topCountries = useUmamiTopCountries(range);
|
||||
const allCountries = useUmamiAllCountries(range);
|
||||
const topBrowsers = useUmamiTopBrowsers(range);
|
||||
const topOS = useUmamiTopOS(range);
|
||||
const topDevices = useUmamiTopDevices(range);
|
||||
const websiteInfo = useUmamiWebsiteInfo(range);
|
||||
// Prefer the live domain from the connected website; fall back to a
|
||||
// generic eyebrow until the metadata request resolves.
|
||||
const eyebrow = websiteInfo.data?.data?.domain || websiteInfo.data?.data?.name || 'Marketing';
|
||||
|
||||
// API surfaces `notConfigured: true` on a 200 response (not 4xx) so
|
||||
// React Query doesn't infinite-retry — that retry loop saturated the
|
||||
@@ -55,54 +94,93 @@ export function WebsiteAnalyticsShell() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Website analytics"
|
||||
eyebrow="Marketing"
|
||||
description="Live data from Umami - site traffic, top pages, referrers, and audience geography."
|
||||
eyebrow={eyebrow}
|
||||
description="Site traffic, top pages, referrers, and audience geography."
|
||||
variant="gradient"
|
||||
actions={<DateRangePicker value={range} onChange={setRange} />}
|
||||
actions={<DateRangePicker value={range} onChange={handleRangeChange} />}
|
||||
/>
|
||||
|
||||
{notConfigured ? (
|
||||
<NotConfiguredEmptyState portSlug={portSlug} />
|
||||
) : (
|
||||
<>
|
||||
{/* Live indicator + KPI tiles */}
|
||||
<div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-5">
|
||||
{/* Realtime panel — collapsible "what's happening RIGHT NOW"
|
||||
strip at the very top. Polling only fires while expanded. */}
|
||||
<RealtimePanel />
|
||||
|
||||
{/* Live indicator + KPI tiles — mirrors Umami's overview row. */}
|
||||
<div className="grid gap-3 grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<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}
|
||||
value={stats.data?.data?.visitors}
|
||||
prev={stats.data?.data?.comparison?.visitors}
|
||||
accent="teal"
|
||||
tooltip="Unique people who visited the site at least once. Counted by anonymous device fingerprint — one person across two devices counts as two."
|
||||
/>
|
||||
<KpiPair
|
||||
label="Visits"
|
||||
loading={stats.isLoading}
|
||||
value={stats.data?.data?.visits?.value}
|
||||
prev={stats.data?.data?.visits?.prev}
|
||||
value={stats.data?.data?.visits}
|
||||
prev={stats.data?.data?.comparison?.visits}
|
||||
accent="success"
|
||||
tooltip="Distinct browsing sessions. A new visit starts when a visitor returns after 30 minutes of inactivity. One person can rack up multiple visits."
|
||||
/>
|
||||
<KpiPair
|
||||
label="Bounces"
|
||||
label="Pageviews"
|
||||
loading={stats.isLoading}
|
||||
value={stats.data?.data?.bounces?.value}
|
||||
prev={stats.data?.data?.bounces?.prev}
|
||||
accent="purple"
|
||||
invertDelta
|
||||
value={stats.data?.data?.pageviews}
|
||||
prev={stats.data?.data?.comparison?.pageviews}
|
||||
accent="brand"
|
||||
tooltip="Total page loads, including refreshes and back-navigation. One visit browsing five pages = 5 pageviews."
|
||||
/>
|
||||
<BounceRateTile
|
||||
loading={stats.isLoading}
|
||||
bounces={stats.data?.data?.bounces}
|
||||
visits={stats.data?.data?.visits}
|
||||
prevBounces={stats.data?.data?.comparison?.bounces}
|
||||
prevVisits={stats.data?.data?.comparison?.visits}
|
||||
/>
|
||||
<VisitDurationTile
|
||||
loading={stats.isLoading}
|
||||
totaltime={stats.data?.data?.totaltime}
|
||||
visits={stats.data?.data?.visits}
|
||||
prevTotaltime={stats.data?.data?.comparison?.totaltime}
|
||||
prevVisits={stats.data?.data?.comparison?.visits}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pageviews trend */}
|
||||
{/* Views (pageviews + sessions) trend */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Pageviews trend</CardTitle>
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
Views
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label="What's the difference between pageviews and sessions?"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||
<p className="font-semibold text-foreground">Pageviews vs Sessions</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
<strong>Pageviews</strong> = total page hits, including refreshes and
|
||||
back-button navigation. One visitor browsing five pages = 5 pageviews.
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
<strong>Sessions</strong> = distinct visitor sessions. The same person
|
||||
browsing five pages in one sitting still counts as 1 session.
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Pages-per-session (pageviews ÷ sessions) is a rough engagement signal — higher
|
||||
means people are exploring deeper.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pageviews.isLoading ? (
|
||||
@@ -113,25 +191,84 @@ export function WebsiteAnalyticsShell() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top-N tables */}
|
||||
{/* Audience: pages / referrers / countries */}
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
|
||||
<TopList
|
||||
title="Top pages"
|
||||
loading={topPages.isLoading}
|
||||
rows={topPages.data?.data ?? null}
|
||||
viewAllHref={detailHref(portSlug, 'pages', range)}
|
||||
rows={
|
||||
topPages.data?.data
|
||||
? topPages.data.data.map((row) => ({
|
||||
...row,
|
||||
x: row.x === '/' ? 'Homepage' : row.x,
|
||||
}))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<TopList
|
||||
title="Top referrers"
|
||||
loading={topReferrers.isLoading}
|
||||
viewAllHref={detailHref(portSlug, 'referrers', range)}
|
||||
rows={topReferrers.data?.data ?? null}
|
||||
defaultLabel="(direct)"
|
||||
/>
|
||||
<TopList
|
||||
title="Top countries"
|
||||
loading={topCountries.isLoading}
|
||||
rows={topCountries.data?.data ?? null}
|
||||
viewAllHref={detailHref(portSlug, 'countries', range)}
|
||||
rows={
|
||||
topCountries.data?.data
|
||||
? topCountries.data.data.map((row) => ({
|
||||
...row,
|
||||
x: getCountryName(row.x, 'en'),
|
||||
}))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tech breakdown: browsers / OS / devices */}
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
|
||||
<TopList
|
||||
title="Top browsers"
|
||||
loading={topBrowsers.isLoading}
|
||||
viewAllHref={detailHref(portSlug, 'browsers', range)}
|
||||
rows={topBrowsers.data?.data ?? null}
|
||||
/>
|
||||
<TopList
|
||||
title="Top operating systems"
|
||||
loading={topOS.isLoading}
|
||||
viewAllHref={detailHref(portSlug, 'os', range)}
|
||||
rows={topOS.data?.data ?? null}
|
||||
/>
|
||||
<TopList
|
||||
title="Top devices"
|
||||
loading={topDevices.isLoading}
|
||||
viewAllHref={detailHref(portSlug, 'devices', range)}
|
||||
rows={
|
||||
topDevices.data?.data
|
||||
? topDevices.data.data.map((row) => ({
|
||||
...row,
|
||||
x: row.x === '' ? 'Unknown' : row.x.charAt(0).toUpperCase() + row.x.slice(1),
|
||||
}))
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Engagement heatmap — full-width so the 7×24 grid has room
|
||||
to breathe and cells are large enough to hover comfortably. */}
|
||||
<WeeklyHeatmap range={range} />
|
||||
|
||||
{/* Recent sessions */}
|
||||
<SessionsList range={range} />
|
||||
|
||||
{/* World heatmap — visitor counts per country (full-width, bottom of page) */}
|
||||
<VisitorWorldMap
|
||||
rows={allCountries.data?.data ?? null}
|
||||
loading={allCountries.isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,8 +279,26 @@ function ActiveVisitorsBadge({ value, loading }: { value?: number; loading: bool
|
||||
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 className="flex items-center gap-1.5">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
|
||||
Active right now
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label="How is this counted?"
|
||||
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||
>
|
||||
<Info className="size-3" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 text-xs leading-relaxed">
|
||||
<p className="font-semibold text-foreground">Live visitor count</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Visitors active in the last <strong>5 minutes</strong>, as reported by your analytics
|
||||
backend. This card auto-refreshes every <strong>30 seconds</strong>.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{loading ? (
|
||||
@@ -169,6 +324,7 @@ function KpiPair({
|
||||
accent,
|
||||
loading,
|
||||
invertDelta = false,
|
||||
tooltip,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | undefined;
|
||||
@@ -178,6 +334,7 @@ function KpiPair({
|
||||
/** For metrics where lower is better (bounces). Flip the sign so green
|
||||
* still means "good" in the UI. */
|
||||
invertDelta?: boolean;
|
||||
tooltip?: ReactNode;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -194,7 +351,141 @@ function KpiPair({
|
||||
delta = Math.round(((v - p) / p) * 100);
|
||||
if (invertDelta) delta = -delta;
|
||||
}
|
||||
return <KPITile title={label} value={v.toLocaleString()} accent={accent} delta={delta} />;
|
||||
return (
|
||||
<KPITile
|
||||
title={label}
|
||||
value={v.toLocaleString()}
|
||||
accent={accent}
|
||||
delta={delta}
|
||||
deltaSuffix="%"
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounce rate = bounces / visits × 100. Lower is better, so the delta is
|
||||
* inverted relative to the raw bounce count (a drop in bounce rate is
|
||||
* "good" → green up-arrow).
|
||||
*/
|
||||
function BounceRateTile({
|
||||
bounces,
|
||||
visits,
|
||||
prevBounces,
|
||||
prevVisits,
|
||||
loading,
|
||||
}: {
|
||||
bounces: number | undefined;
|
||||
visits: number | undefined;
|
||||
prevBounces: number | undefined;
|
||||
prevVisits: number | undefined;
|
||||
loading: 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 rate = (visits ?? 0) > 0 ? ((bounces ?? 0) / (visits ?? 1)) * 100 : 0;
|
||||
const prevRate = (prevVisits ?? 0) > 0 ? ((prevBounces ?? 0) / (prevVisits ?? 1)) * 100 : 0;
|
||||
let delta: number | undefined;
|
||||
if (prevRate > 0) {
|
||||
// Show the raw percentage-point change (positive = bounce went up).
|
||||
// KPITile's lowerIsBetter flips the colour so a drop renders green.
|
||||
delta = Math.round(rate - prevRate);
|
||||
}
|
||||
return (
|
||||
<KPITile
|
||||
title="Bounce rate"
|
||||
value={`${rate.toFixed(1)}%`}
|
||||
accent="purple"
|
||||
delta={delta}
|
||||
deltaSuffix="%"
|
||||
lowerIsBetter
|
||||
tooltip="Share of visits that ended without a second pageview — i.e. someone landed, didn't click anything, and left. Lower is generally better."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Avg visit duration = totaltime / visits (Umami returns totaltime in
|
||||
* seconds across all visits in the range). Formatted as `Xm Ys`.
|
||||
*/
|
||||
function VisitDurationTile({
|
||||
totaltime,
|
||||
visits,
|
||||
prevTotaltime,
|
||||
prevVisits,
|
||||
loading,
|
||||
}: {
|
||||
totaltime: number | undefined;
|
||||
visits: number | undefined;
|
||||
prevTotaltime: number | undefined;
|
||||
prevVisits: number | undefined;
|
||||
loading: 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 secs = (visits ?? 0) > 0 ? (totaltime ?? 0) / (visits ?? 1) : 0;
|
||||
const prevSecs = (prevVisits ?? 0) > 0 ? (prevTotaltime ?? 0) / (prevVisits ?? 1) : 0;
|
||||
let delta: number | undefined;
|
||||
if (prevSecs > 0) {
|
||||
delta = Math.round(((secs - prevSecs) / prevSecs) * 100);
|
||||
}
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = Math.round(secs % 60);
|
||||
const display = m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
return (
|
||||
<KPITile
|
||||
title="Visit duration"
|
||||
value={display}
|
||||
accent="mint"
|
||||
delta={delta}
|
||||
deltaSuffix="%"
|
||||
tooltip="Average time a visitor spent on the site per visit. Longer usually means deeper engagement, though it can also mean people got stuck."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the range from the current URL search params, defaulting to 30d. */
|
||||
function parseRangeFromQuery(searchParams: URLSearchParams | null): DateRange {
|
||||
const rawRange = searchParams?.get('range') ?? '30d';
|
||||
if (rawRange === 'custom') {
|
||||
const from = searchParams?.get('from');
|
||||
const to = searchParams?.get('to');
|
||||
if (from && to) return { kind: 'custom', from, to };
|
||||
return '30d';
|
||||
}
|
||||
if (rawRange === 'today' || rawRange === '7d' || rawRange === '30d' || rawRange === '90d') {
|
||||
return rawRange;
|
||||
}
|
||||
return '30d';
|
||||
}
|
||||
|
||||
/** Build the "View all" href for a metric, carrying the current range. */
|
||||
function detailHref(
|
||||
portSlug: string | null,
|
||||
metric: 'pages' | 'referrers' | 'countries' | 'browsers' | 'os' | 'devices',
|
||||
range: DateRange,
|
||||
): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (isCustomRange(range)) {
|
||||
sp.set('range', 'custom');
|
||||
sp.set('from', range.from);
|
||||
sp.set('to', range.to);
|
||||
} else {
|
||||
sp.set('range', range);
|
||||
}
|
||||
return `/${portSlug}/website-analytics/${metric}?${sp.toString()}`;
|
||||
}
|
||||
|
||||
function NotConfiguredEmptyState({ portSlug }: { portSlug: string | null }) {
|
||||
|
||||
168
src/components/website-analytics/weekly-heatmap.tsx
Normal file
168
src/components/website-analytics/weekly-heatmap.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Hour-of-week engagement heatmap. Driven by Umami's
|
||||
* `/api/websites/:id/sessions/weekly` endpoint which returns a 7×24
|
||||
* nested array of session counts (rows Sun…Sat, cols 0…23 UTC).
|
||||
*
|
||||
* Visual: a 7-row × 24-col grid of cells, with cell colour intensity
|
||||
* scaled to the max value across the whole grid. Hover any cell for a
|
||||
* floating tooltip showing the exact day/hour/count.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useUmamiSessionsWeekly } from './use-website-analytics';
|
||||
import { type DateRange } from '@/lib/analytics/range';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
const DAYS_FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const DAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
interface HoverInfo {
|
||||
dayIdx: number;
|
||||
hour: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function WeeklyHeatmap({ range }: Props) {
|
||||
const query = useUmamiSessionsWeekly(range);
|
||||
const [hover, setHover] = useState<HoverInfo | null>(null);
|
||||
const grid = query.data?.data ?? null;
|
||||
const max = useMemo(() => {
|
||||
if (!grid) return 0;
|
||||
let m = 0;
|
||||
for (const row of grid) for (const v of row) if (v > m) m = v;
|
||||
return m;
|
||||
}, [grid]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-1.5 text-base">
|
||||
Engagement heatmap
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
type="button"
|
||||
aria-label="How to read the engagement heatmap"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
|
||||
>
|
||||
<Info className="size-3.5" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
|
||||
<p className="font-semibold text-foreground">When is your audience active?</p>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Each cell is one hour of one day — the columns are{' '}
|
||||
<strong>hours of the day in UTC</strong> (0 = midnight, 23 = 11 PM) and the rows are
|
||||
days of the week. Darker blue means more sessions started during that hour across
|
||||
the whole selected period. Hover any cell for the exact session count.
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{query.isLoading ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : !grid || max === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
No session activity in this range.
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative" onMouseLeave={() => setHover(null)}>
|
||||
{/* Hour-axis header */}
|
||||
<div className="flex pl-12 text-[10px] text-muted-foreground">
|
||||
{Array.from({ length: 24 }).map((_, h) => (
|
||||
<div key={h} className="flex-1 text-center">
|
||||
{h % 2 === 0 ? h : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Grid */}
|
||||
<div className="mt-1">
|
||||
{grid.map((row, dIdx) => (
|
||||
<div key={dIdx} className="flex items-center">
|
||||
<div className="w-12 pr-2 text-right text-[11px] text-muted-foreground">
|
||||
{DAYS_SHORT[dIdx]}
|
||||
</div>
|
||||
<div className="flex flex-1 gap-px">
|
||||
{row.map((v, h) => {
|
||||
const intensity = v / max;
|
||||
const isHovered = hover?.dayIdx === dIdx && hover?.hour === h;
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
onMouseEnter={() => setHover({ dayIdx: dIdx, hour: h, count: v })}
|
||||
className="aspect-square min-w-0 flex-1 rounded-sm transition-all cursor-pointer"
|
||||
style={{
|
||||
backgroundColor:
|
||||
v === 0 ? '#f1f5f9' : `rgba(37, 99, 235, ${0.15 + 0.85 * intensity})`,
|
||||
outline: isHovered ? '2px solid #f59e0b' : 'none',
|
||||
outlineOffset: '-1px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Hour-axis footer (mirrors header so wide cards read symmetrically) */}
|
||||
<div className="mt-1 flex pl-12 text-[10px] text-muted-foreground">
|
||||
{Array.from({ length: 24 }).map((_, h) => (
|
||||
<div key={h} className="flex-1 text-center">
|
||||
{h % 2 === 0 ? `${h}:00` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend + floating value indicator */}
|
||||
<div className="mt-4 flex items-center justify-between gap-4 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
Hour of day (UTC) — colour intensity scaled to peak ({max.toLocaleString()}{' '}
|
||||
sessions)
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Less</span>
|
||||
<div className="flex gap-px">
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="size-3 rounded-sm"
|
||||
style={{
|
||||
backgroundColor:
|
||||
i === 0 ? '#f1f5f9' : `rgba(37, 99, 235, ${0.15 + 0.85 * i})`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover tooltip — single element re-positioned via the
|
||||
hovered cell's data, much cheaper than mounting 168
|
||||
Radix Tooltips. */}
|
||||
{hover ? (
|
||||
<div className="pointer-events-none absolute top-0 right-0 -mt-2 rounded-md border border-border bg-popover px-3 py-1.5 text-xs shadow-md">
|
||||
<div className="font-medium text-foreground">
|
||||
{DAYS_FULL[hover.dayIdx]} {hover.hour}:00–{hover.hour}:59 UTC
|
||||
</div>
|
||||
<div className="tabular-nums text-muted-foreground">
|
||||
{hover.count.toLocaleString()} session{hover.count === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user