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:
2026-05-20 15:53:41 +02:00
parent 292800b643
commit bac253b360
28 changed files with 35334 additions and 96 deletions

View File

@@ -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