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
|
||||
|
||||
Reference in New Issue
Block a user