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>
151 lines
5.8 KiB
TypeScript
151 lines
5.8 KiB
TypeScript
'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' });
|
||
}
|