Files
pn-new-crm/src/components/website-analytics/visitor-world-map.tsx
Matt bac253b360 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>
2026-05-20 15:53:41 +02:00

203 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 05), 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()}+` },
];
}