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' });
|
|||
|
|
}
|