Files
pn-new-crm/src/components/website-analytics/sessions-list.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

151 lines
5.8 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';
/**
* 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' });
}