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:
150
src/components/website-analytics/sessions-list.tsx
Normal file
150
src/components/website-analytics/sessions-list.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Recent-sessions card for the website-analytics page. Paginated list
|
||||
* of visitor sessions (one row per unique session) with click-through to
|
||||
* a detail sheet showing the full activity stream.
|
||||
*
|
||||
* Umami's session model: one row per anonymous-device-fingerprint+IP+UA
|
||||
* combination, with first/last visit timestamps + visit/view counts +
|
||||
* geo + browser/os/device. The detail page shows the per-event stream
|
||||
* (pageviews + custom events) within that session.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Globe, Smartphone, Monitor, Tablet, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getCountryName } from '@/lib/i18n/countries';
|
||||
import { useUmamiSessions } from './use-website-analytics';
|
||||
import { SessionDetailSheet } from './session-detail-sheet';
|
||||
import { type DateRange } from '@/lib/analytics/range';
|
||||
import type { UmamiSession } from '@/lib/services/umami.service';
|
||||
|
||||
interface Props {
|
||||
range: DateRange;
|
||||
}
|
||||
|
||||
export function SessionsList({ range }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState<UmamiSession | null>(null);
|
||||
const pageSize = 15;
|
||||
const query = useUmamiSessions(range, { page, pageSize });
|
||||
|
||||
const sessions = query.data?.data?.data ?? [];
|
||||
const total = query.data?.data?.count ?? 0;
|
||||
const hasMore = page * pageSize < total;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Recent sessions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{query.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No sessions in this range.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="divide-y divide-border">
|
||||
{sessions.map((s, i) => (
|
||||
// Umami's sessions endpoint can return rows with the
|
||||
// same session id within a page when activity straddles
|
||||
// a bucket boundary. Compose the key to dedupe.
|
||||
<li key={`${s.id}-${i}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(s)}
|
||||
className="group flex w-full items-center justify-between gap-3 py-3 text-left transition hover:bg-muted/40 -mx-2 px-2 rounded"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<DeviceIcon device={s.device} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-sm">
|
||||
<span className="font-medium">
|
||||
{getCountryName(s.country, 'en') || 'Unknown'}
|
||||
</span>
|
||||
{s.city ? (
|
||||
<span className="text-muted-foreground">{s.city}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{s.browser} · {s.os} · {fmtTime(s.firstAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">{s.views.toLocaleString()} views</span>
|
||||
<ChevronRight
|
||||
className="size-4 opacity-0 transition group-hover:opacity-100"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of{' '}
|
||||
{total.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={!hasMore}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SessionDetailSheet session={selected} range={range} onClose={() => setSelected(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceIcon({ device }: { device: string }) {
|
||||
const cls = 'size-5 shrink-0 text-muted-foreground';
|
||||
switch (device.toLowerCase()) {
|
||||
case 'mobile':
|
||||
return <Smartphone className={cls} aria-hidden />;
|
||||
case 'tablet':
|
||||
return <Tablet className={cls} aria-hidden />;
|
||||
case 'desktop':
|
||||
case 'laptop':
|
||||
return <Monitor className={cls} aria-hidden />;
|
||||
default:
|
||||
return <Globe className={cls} aria-hidden />;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
Reference in New Issue
Block a user