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