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:
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()}+` },
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user