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:
2026-05-20 15:53:41 +02:00
parent 292800b643
commit bac253b360
28 changed files with 35334 additions and 96 deletions

View File

@@ -6,33 +6,27 @@ import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test
import { PageHeader } from '@/components/shared/page-header';
/**
* Per-port Umami credentials. We deliberately keep all three values
* port-scoped (per the operator decision) so different ports can point at
* different Umami instances if needed. The /website-analytics dashboard
* page reads these settings via the umami.service layer at request time.
* Per-port Umami credentials. Self-hosted Umami uses username + password →
* JWT bearer token (https://docs.umami.is/docs/api/authentication); the
* service POSTs to /api/auth/login and caches the JWT in-memory. Umami
* Cloud installations use a long-lived API key instead; the optional field
* below covers that case. All credentials are port-scoped so different
* ports can point at different Umami instances.
*/
const FIELDS: SettingFieldDef[] = [
{
key: 'umami_api_url',
label: 'Umami API URL',
label: 'Umami URL',
description:
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
type: 'string',
placeholder: 'https://analytics.portnimara.com',
defaultValue: '',
},
{
key: 'umami_api_token',
label: 'API token',
description:
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_username',
label: 'Username',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
description: 'Umami login username (self-hosted).',
type: 'string',
placeholder: 'admin',
defaultValue: '',
@@ -40,7 +34,8 @@ const FIELDS: SettingFieldDef[] = [
{
key: 'umami_password',
label: 'Password',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
description:
'Umami login password (self-hosted). Exchanged for a JWT via /api/auth/login on each port; the JWT is cached for 55 minutes. Stored AES-256-GCM at rest.',
type: 'password',
defaultValue: '',
},
@@ -53,6 +48,28 @@ const FIELDS: SettingFieldDef[] = [
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
{
key: 'umami_api_token',
label: 'API key (Umami Cloud only — optional)',
description:
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs — the username + password above are used instead. Stored AES-256-GCM at rest.',
type: 'password',
defaultValue: '',
},
];
// Tracking-pixel kill switch — opt-in per port. When enabled, outbound
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
// records opens to `document_send_opens` and cross-posts to Umami.
const TRACKING_FIELDS: SettingFieldDef[] = [
{
key: 'email_open_tracking_enabled',
label: 'Track email opens',
description:
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count — standard email-tracking caveats apply.',
type: 'boolean',
defaultValue: false,
},
];
export default function WebsiteAnalyticsSettingsPage() {
@@ -65,10 +82,16 @@ export default function WebsiteAnalyticsSettingsPage() {
<SettingsFormCard
title="Umami connection"
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
description="Self-hosted Umami: enter URL + username + password + website ID. Umami Cloud: enter URL + API key (Cloud field at the bottom) + website ID. Each port can point at its own Umami instance, or share one instance with different website IDs."
fields={FIELDS}
extra={<UmamiTestButton />}
/>
<SettingsFormCard
title="Email open tracking"
description="Opt-in tracking for outbound sales emails. Disabled by default."
fields={TRACKING_FIELDS}
/>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { notFound } from 'next/navigation';
import { MetricDetailShell } from '@/components/website-analytics/metric-detail-shell';
/**
* Full ranked-list view for one analytics metric (pages / referrers /
* countries / browsers / os / devices). Reached via the "View all" link
* on each top-N card. Honours the `range` (and optional `from`/`to`)
* query params so the detail page mirrors the time window the operator
* had selected on the parent page.
*/
const VALID_METRICS = ['pages', 'referrers', 'countries', 'browsers', 'os', 'devices'] as const;
type ValidMetric = (typeof VALID_METRICS)[number];
interface PageProps {
params: Promise<{ portSlug: string; metric: string }>;
searchParams: Promise<{ range?: string; from?: string; to?: string }>;
}
export default async function Page({ params, searchParams }: PageProps) {
const { metric } = await params;
const { range, from, to } = await searchParams;
if (!VALID_METRICS.includes(metric as ValidMetric)) notFound();
return (
<MetricDetailShell
metric={metric as ValidMetric}
initialRange={range ?? '30d'}
initialFrom={from}
initialTo={to}
/>
);
}

View File

@@ -0,0 +1,106 @@
import { NextResponse, type NextRequest } from 'next/server';
import { and, eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentSendOpens, documentSends } from '@/lib/db/schema/brochures';
import { logger } from '@/lib/logger';
import { trackEvent } from '@/lib/services/umami.service';
/**
* GET /api/public/email-pixel/[sendId]
*
* Returns a 1×1 transparent GIF and records an open event in
* `document_send_opens` + bumps the cached aggregates on `document_sends`.
*
* Lookups are gated by `track_opens=true` on the send row, so a leaked
* sendId for an untracked email is a no-op (the pixel still returns
* 200/GIF so email clients don't surface a broken-image icon).
*
* Privacy: we deliberately don't store IP addresses or any data beyond
* user-agent + referer. Apple Mail privacy proxy pre-fetches images, so
* opens from iOS users are over-counted; image-blocking clients
* (Outlook with images disabled) under-count. Standard email-tracking
* caveats apply.
*/
// 1×1 transparent GIF, base64-encoded. Generated once at module-load so
// every request returns the same buffer without re-allocating.
const TRANSPARENT_GIF = Buffer.from(
'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',
'base64',
);
function gifResponse(): NextResponse {
return new NextResponse(TRANSPARENT_GIF as unknown as BodyInit, {
status: 200,
headers: {
'Content-Type': 'image/gif',
'Content-Length': String(TRANSPARENT_GIF.length),
// Tell every upstream cache to keep its hands off — we count opens
// on the FETCH itself, so any cached response is a missed open.
'Cache-Control': 'no-store, no-cache, must-revalidate, private',
Pragma: 'no-cache',
Expires: '0',
},
});
}
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ sendId: string }> },
): Promise<NextResponse> {
const { sendId } = await ctx.params;
try {
// Look up the send row; ignore unknown / un-tracked sends silently.
const sendRow = await db.query.documentSends.findFirst({
where: and(eq(documentSends.id, sendId), eq(documentSends.trackOpens, true)),
columns: { id: true, portId: true, recipientEmail: true, documentKind: true },
});
if (!sendRow) return gifResponse();
const userAgent = req.headers.get('user-agent');
const referer = req.headers.get('referer');
// Best-effort write — never block the pixel response on a slow DB.
// The pixel must return promptly so email clients render normally.
db.insert(documentSendOpens)
.values({
portId: sendRow.portId,
sendId: sendRow.id,
userAgent: userAgent ?? null,
referer: referer ?? null,
})
.then(() =>
db
.update(documentSends)
.set({
openCount: sql`${documentSends.openCount} + 1`,
firstOpenedAt: sql`COALESCE(${documentSends.firstOpenedAt}, NOW())`,
})
.where(eq(documentSends.id, sendRow.id)),
)
.catch((err) => {
logger.warn({ err, sendId: sendRow.id }, 'email-pixel: failed to record open');
});
// Cross-post to Umami so the marketing funnel includes opens. Don't
// await — fire-and-forget so the pixel response stays fast.
trackEvent(
sendRow.portId,
'email-opened',
{
sendId: sendRow.id,
documentKind: sendRow.documentKind,
},
'email://pixel',
).catch((err) => {
logger.debug({ err, sendId: sendRow.id }, 'email-pixel: umami cross-post failed');
});
return gifResponse();
} catch (err) {
logger.warn({ err, sendId }, 'email-pixel: unexpected error');
return gifResponse();
}
}

View File

@@ -7,7 +7,13 @@ import {
getActiveVisitors,
getMetric,
getPageviewsSeries,
getRealtime,
getSession,
getSessionActivity,
getSessions,
getSessionsWeekly,
getStats,
getWebsiteInfo,
type UmamiMetricType,
} from '@/lib/services/umami.service';
@@ -31,7 +37,11 @@ import {
*/
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
const TOP_METRIC_RX = /^top-(url|referrer|country|browser|os|device|event)$/;
// Umami v2/v3 metric `type` values surfaced by the CRM. `path` is the
// current name for what older versions called `url` — accept both as
// inbound metric names (old clients won't break) but `path` is what the
// service forwards to Umami.
const TOP_METRIC_RX = /^top-(path|url|referrer|country|browser|os|device|event)$/;
function parseRange(req: NextRequest): DateRange | { error: string } {
const url = new URL(req.url);
@@ -88,8 +98,30 @@ export const GET = withAuth(
data = await getPageviewsSeries(ctx.portId, range);
} else if (metric === 'active') {
data = await getActiveVisitors(ctx.portId);
} else if (metric === 'realtime') {
data = await getRealtime(ctx.portId);
} else if (metric === 'website') {
data = await getWebsiteInfo(ctx.portId);
} else if (metric === 'sessions') {
const page = Number(url.searchParams.get('page') ?? 1);
const pageSize = Number(url.searchParams.get('pageSize') ?? 25);
const query = url.searchParams.get('query') ?? undefined;
data = await getSessions(ctx.portId, range, { page, pageSize, query });
} else if (metric === 'session') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) throw new ValidationError('Missing sessionId');
data = await getSession(ctx.portId, sessionId);
} else if (metric === 'session-activity') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) throw new ValidationError('Missing sessionId');
data = await getSessionActivity(ctx.portId, sessionId, range);
} else if (metric === 'sessions-weekly') {
data = await getSessionsWeekly(ctx.portId, range);
} else if (TOP_METRIC_RX.test(metric)) {
const type = metric.replace(/^top-/, '') as UmamiMetricType;
const raw = metric.replace(/^top-/, '');
// Legacy alias — older callers still send `top-url`; map to the
// Umami v3 enum name to keep them working post-rewrite.
const type = (raw === 'url' ? 'path' : raw) as UmamiMetricType;
const limit = Number(url.searchParams.get('limit') ?? 10);
data = await getMetric(ctx.portId, range, type, limit);
} else {

79
src/app/q/[slug]/route.ts Normal file
View File

@@ -0,0 +1,79 @@
import { NextResponse, type NextRequest } from 'next/server';
import { eq, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { trackedLinkClicks, trackedLinks } from '@/lib/db/schema/tracked-links';
import { logger } from '@/lib/logger';
import { trackEvent } from '@/lib/services/umami.service';
/**
* GET /q/[slug]
*
* Phase 4c — tracked redirect link. Looks up the slug, records the
* click (fire-and-forget so the redirect stays fast), and 302s the
* recipient to the target URL. Unknown slugs 404 — we deliberately do
* NOT redirect anonymous traffic to a default home page since that
* would be an open-redirect risk (although `targetUrl` is admin-stored
* not user-supplied, this keeps the endpoint surface small).
*
* Cross-posts to Umami as a `link-clicked` event so marketing can see
* email click-throughs alongside their normal pageview funnel.
*/
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ slug: string }> },
): Promise<NextResponse> {
const { slug } = await ctx.params;
// Slug format gate — reject obvious noise without hitting the DB.
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(slug)) {
return new NextResponse('Not found', { status: 404 });
}
const link = await db.query.trackedLinks.findFirst({
where: eq(trackedLinks.slug, slug),
columns: { id: true, portId: true, targetUrl: true, sendId: true },
});
if (!link) return new NextResponse('Not found', { status: 404 });
const userAgent = req.headers.get('user-agent');
const referer = req.headers.get('referer');
// Fire-and-forget click recording; the redirect doesn't wait on DB.
db.insert(trackedLinkClicks)
.values({
trackedLinkId: link.id,
portId: link.portId,
userAgent: userAgent ?? null,
referer: referer ?? null,
})
.then(() =>
db
.update(trackedLinks)
.set({
clickCount: sql`${trackedLinks.clickCount} + 1`,
firstClickedAt: sql`COALESCE(${trackedLinks.firstClickedAt}, NOW())`,
lastClickedAt: sql`NOW()`,
})
.where(eq(trackedLinks.id, link.id)),
)
.catch((err) => {
logger.warn({ err, slug }, '/q: failed to record click');
});
// Umami cross-post for funnel analysis. Soft-fails.
trackEvent(
link.portId,
'link-clicked',
{
slug,
sendId: link.sendId ?? null,
},
`/q/${slug}`,
).catch((err) => {
logger.debug({ err, slug }, '/q: umami cross-post failed');
});
return NextResponse.redirect(link.targetUrl, 302);
}

View File

@@ -2,15 +2,17 @@
/**
* Compact "Website at a glance" tile for the main sales dashboard. Shows
* pageviews today + active visitors right now + a deep-link to the full
* /website-analytics page. Soft-fails (renders nothing) when Umami isn't
* configured for this port - so the dashboard doesn't get cluttered with
* a "configure Umami" prompt that the user already saw on the dedicated
* page.
* pageviews for the dashboard's current range + active visitors right
* now + a deep-link to the full /website-analytics page. Soft-fails
* (renders nothing) when Umami isn't configured for this port — the
* configure-prompt lives on the dedicated page, not the dashboard.
*
* When an Umami call fails (auth, network, shape) the tile renders a
* dash "—" instead of "0" so the rep can tell error from no-data.
*/
import Link from 'next/link';
import { Globe, ArrowRight } from 'lucide-react';
import { Globe, ArrowRight, AlertTriangle } from 'lucide-react';
import { useUIStore } from '@/stores/ui-store';
import { Card } from '@/components/ui/card';
@@ -19,23 +21,42 @@ import {
useUmamiActive,
useUmamiStats,
} from '@/components/website-analytics/use-website-analytics';
import type { DateRange } from '@/lib/analytics/range';
import { isCustomRange } from '@/lib/analytics/range';
export function WebsiteGlanceTile() {
interface Props {
range?: DateRange;
}
const RANGE_LABELS: Record<'today' | '7d' | '30d' | '90d', string> = {
today: 'Today',
'7d': '7 days',
'30d': '30 days',
'90d': '90 days',
};
function shortRangeLabel(range: DateRange): string {
if (isCustomRange(range)) return 'Custom range';
return RANGE_LABELS[range];
}
export function WebsiteGlanceTile({ range = '30d' }: Props) {
const portSlug = useUIStore((s) => s.currentPortSlug);
const stats = useUmamiStats('today');
const active = useUmamiActive('today');
const stats = useUmamiStats(range);
const active = useUmamiActive(range);
// Hide the tile entirely if Umami isn't configured - this dashboard is
// for sales, not for prompting the operator into integration setup.
// The API surfaces `notConfigured: true` on a 200 response so React
// Query doesn't retry-loop (a prior 409-throw caused server hangs).
if (stats.data?.notConfigured || active.data?.notConfigured) {
return null;
}
const today = stats.data?.data?.pageviews?.value ?? 0;
const activeNow = active.data?.data?.visitors ?? 0;
// Umami v3 returns flat numbers — `data?.data?.pageviews` is a number,
// not `{value, prev}`. The previous nested shape was Umami v1; v3 moved
// comparison values into a sibling `comparison` block.
const pageviews = stats.data?.data?.pageviews;
const activeNow = active.data?.data?.visitors;
const loading = stats.isLoading || active.isLoading;
const statsErrored = stats.isError;
const activeErrored = active.isError;
return (
<Link
@@ -49,22 +70,36 @@ export function WebsiteGlanceTile() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
<Globe className="h-3 w-3" aria-hidden />
Website today
Website · {shortRangeLabel(range)}
</div>
{loading ? (
<Skeleton className="mt-2 h-7 w-20" aria-hidden />
) : statsErrored || pageviews === undefined ? (
<div
className="mt-1 flex items-center gap-1.5 text-sm text-warning sm:mt-2"
title={stats.error instanceof Error ? stats.error.message : 'Umami unavailable'}
>
<AlertTriangle className="size-3.5" aria-hidden />
Umami unavailable
</div>
) : (
<div className="mt-1 flex items-baseline gap-2 text-lg font-semibold tabular-nums sm:mt-2 sm:text-2xl">
{today.toLocaleString()}
{pageviews.toLocaleString()}
<span className="text-xs font-normal text-muted-foreground">pageviews</span>
</div>
)}
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{activeNow} active right now
{activeErrored ? (
<span className="text-warning">live count unavailable</span>
) : (
<>
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{activeNow ?? 0} active right now
</>
)}
</div>
</div>
<ArrowRight

View File

@@ -0,0 +1,206 @@
'use client';
/**
* Detail page shell rendered at /{portSlug}/website-analytics/{metric}.
* Shows the full ranked list (no top-10 cap) for one Umami metric, plus
* a back-link and a date range picker that mirrors the parent page.
*
* The metric slug in the URL maps to a Umami metric type. Country rows
* are rebadged to full English names; page paths get the same Homepage
* substitution the dashboard does.
*/
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
import { useUIStore } from '@/stores/ui-store';
import { getCountryName } from '@/lib/i18n/countries';
import { apiFetch } from '@/lib/api/client';
import { useQuery } from '@tanstack/react-query';
import type { UmamiMetricRow } from '@/lib/services/umami.service';
const METRIC_CONFIG: Record<
string,
{ umamiMetric: string; title: string; emptyLabel: string; transform?: (x: string) => string }
> = {
pages: {
umamiMetric: 'top-path',
title: 'All pages',
emptyLabel: '(unknown)',
transform: (x) => (x === '/' ? 'Homepage' : x),
},
referrers: {
umamiMetric: 'top-referrer',
title: 'All referrers',
emptyLabel: '(direct)',
},
countries: {
umamiMetric: 'top-country',
title: 'All countries',
emptyLabel: '(unknown)',
transform: (x) => getCountryName(x, 'en'),
},
browsers: {
umamiMetric: 'top-browser',
title: 'All browsers',
emptyLabel: '(unknown)',
},
os: {
umamiMetric: 'top-os',
title: 'All operating systems',
emptyLabel: '(unknown)',
},
devices: {
umamiMetric: 'top-device',
title: 'All devices',
emptyLabel: '(unknown)',
transform: (x) => (x === '' ? 'Unknown' : x.charAt(0).toUpperCase() + x.slice(1)),
},
};
interface MetricResponse {
metric: string;
range: DateRange;
data: UmamiMetricRow[] | null;
notConfigured?: boolean;
}
interface Props {
metric: string;
initialRange: string;
initialFrom?: string;
initialTo?: string;
}
export function MetricDetailShell({ metric, initialRange, initialFrom, initialTo }: Props) {
const cfg = METRIC_CONFIG[metric];
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const portId = useUIStore((s) => s.currentPortId);
const [range, setRange] = useState<DateRange>(() =>
parseInitialRange(initialRange, initialFrom, initialTo),
);
function handleRangeChange(next: DateRange) {
setRange(next);
// Mirror the picker choice back into the URL so refresh / share / back
// all preserve the time window the user picked.
const sp = new URLSearchParams();
if (isCustomRange(next)) {
sp.set('range', 'custom');
sp.set('from', next.from);
sp.set('to', next.to);
} else {
sp.set('range', next);
}
router.replace(`/${portSlug}/website-analytics/${metric}?${sp.toString()}` as never);
}
const query = useQuery<MetricResponse>({
queryKey: ['website-analytics', cfg?.umamiMetric, range, portId, 'detail'],
queryFn: () =>
apiFetch<MetricResponse>(
`/api/v1/website-analytics?metric=${cfg!.umamiMetric}&${rangeToQuery(range)}&limit=500`,
),
enabled: !!portId && !!cfg,
staleTime: 30_000,
});
if (!cfg) {
return <div className="p-8 text-sm text-muted-foreground">Unknown metric.</div>;
}
const rows = query.data?.data ?? null;
const max = rows && rows.length > 0 ? rows[0]!.y : 1;
return (
<div className="space-y-6">
<div className="flex items-center justify-start">
<Link
href={`/${portSlug}/website-analytics?${rangeToQuery(range)}` as never}
className="inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3" aria-hidden />
Back to website analytics
</Link>
</div>
<PageHeader
title={cfg.title}
eyebrow="Website analytics"
variant="gradient"
actions={<DateRangePicker value={range} onChange={handleRangeChange} />}
/>
<Card>
<CardContent className="pt-6">
{query.isLoading ? (
<div className="space-y-2">
{Array.from({ length: 12 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</div>
) : !rows || rows.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
No data in this range.
</div>
) : (
<ul className="space-y-1.5">
{rows.map((row, i) => {
const pct = (row.y / max) * 100;
const raw = row.x?.trim() || cfg.emptyLabel;
const label = cfg.transform ? cfg.transform(raw) : raw;
return (
<li key={`${row.x}-${i}`} className="text-sm">
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0 flex-1 truncate font-medium">
<span className="mr-2 inline-block w-6 tabular-nums text-muted-foreground">
{i + 1}.
</span>
{label}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{row.y.toLocaleString()}
</span>
</div>
<div className="mt-0.5 h-1 w-full rounded-full bg-muted">
<div
className="h-1 rounded-full bg-brand"
style={{ width: `${Math.max(2, pct)}%` }}
aria-hidden
/>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
function parseInitialRange(rawRange: string, from?: string, to?: string): DateRange {
if (rawRange === 'custom' && from && to) {
return { kind: 'custom', from, to };
}
if (rawRange === 'today' || rawRange === '7d' || rawRange === '30d' || rawRange === '90d') {
return rawRange;
}
return '30d';
}
function rangeToQuery(range: DateRange): string {
if (isCustomRange(range)) {
return `range=custom&from=${range.from}&to=${range.to}`;
}
return `range=${range}`;
}

View File

@@ -33,13 +33,16 @@ export function PageviewsChart({ data }: Props) {
);
}
// Merge the two series (Umami returns them separately) into one row per
// bucket so we can drive a single chart.
// Merge the two series (Umami returns them separately when `compare` is
// requested) into one row per bucket so we can drive a single chart.
// `sessions` is optional on Umami v3 — only present when the request
// included a comparison directive. Guard the read so an undefined
// array doesn't crash the chart.
const byX = new Map<string, { x: string; pageviews: number; sessions: number }>();
for (const p of data.pageviews) {
byX.set(p.x, { x: p.x, pageviews: p.y, sessions: 0 });
}
for (const s of data.sessions) {
for (const s of data.sessions ?? []) {
const row = byX.get(s.x);
if (row) row.sessions = s.y;
else byX.set(s.x, { x: s.x, pageviews: 0, sessions: s.y });
@@ -78,6 +81,7 @@ export function PageviewsChart({ data }: Props) {
borderRadius: '6px',
fontSize: 12,
}}
labelFormatter={formatTooltipLabel}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
@@ -101,11 +105,24 @@ export function PageviewsChart({ data }: Props) {
);
}
/** Compact tick labels: full datetime → just MM-DD or MM-DD HH:00. */
/** Compact tick labels: drop the timestamp entirely — for multi-day ranges
* the hour component is meaningless (a "day" bucket aggregates the whole
* day) and just causes visual crowding. Keep MM-DD. */
function formatXTick(value: string): string {
// Umami can return either "YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD".
if (value.length >= 16) {
return value.slice(5, 16); // "MM-DD HH:mm"
}
return value.slice(5); // "MM-DD"
return value.slice(5, 10); // "MM-DD"
}
/** Tooltip header: format "2026-03-30 00:00:00" → "Mar 30, 2026" so the
* meaningless 00:00:00 timestamp doesn't show. */
function formatTooltipLabel(value: unknown): string {
if (typeof value !== 'string') return '';
const datePart = value.slice(0, 10); // "YYYY-MM-DD"
const d = new Date(`${datePart}T00:00:00Z`);
if (isNaN(d.getTime())) return datePart;
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
}

View File

@@ -0,0 +1,245 @@
'use client';
/**
* Realtime panel — Umami's "what's happening RIGHT NOW" view, surfaced
* as a collapsible card at the top of the website-analytics page.
*
* Folds in five things from Umami's /api/realtime/<id> endpoint:
* - Totals strip (visitors / views / events / countries in the last 30m)
* - Top URLs being viewed
* - Top countries
* - Top referrers
* - Recent event stream (pageviews + named events as they arrive)
*
* Polling pauses when the card is collapsed so we're not hammering
* Umami at 5 s intervals while no one is looking.
*/
import { useState } from 'react';
import { ChevronDown, ChevronUp, Globe, Activity, MapPin, ExternalLink } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { getCountryName } from '@/lib/i18n/countries';
import { useUmamiRealtime } from './use-website-analytics';
export function RealtimePanel() {
const [open, setOpen] = useState(false);
const query = useUmamiRealtime(open);
const data = query.data?.data ?? null;
// Hide the entire bar when Umami reports a quiet 30-minute window —
// a "Live activity (0 visitors)" header is just noise. We still poll
// every 60 s while hidden so the bar reappears the moment traffic
// arrives.
const isQuiet = !!data && data.totals.visitors === 0 && data.events.length === 0;
if (isQuiet && !open) return null;
return (
<Card className="overflow-hidden">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-muted/40 sm:px-5"
aria-expanded={open}
>
<div className="flex items-center gap-3">
<span className="relative flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
</span>
<div>
<div className="text-sm font-semibold">Live activity</div>
<div className="text-xs text-muted-foreground">
{open
? 'Auto-refreshing every 5s · last 30 minutes'
: 'Click to expand — top pages, countries, and a live event stream'}
</div>
</div>
</div>
{open ? (
<ChevronUp className="size-4 text-muted-foreground" aria-hidden />
) : (
<ChevronDown className="size-4 text-muted-foreground" aria-hidden />
)}
</button>
{open ? (
<CardContent className="border-t border-border pt-4 sm:pt-6">
{query.isLoading ? (
<Skeleton className="h-[300px] w-full" />
) : !data ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No realtime data available.
</div>
) : (
<div className="space-y-4">
{/* Totals strip */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Visitors" value={data.totals.visitors} />
<Stat label="Pageviews" value={data.totals.views} />
<Stat label="Events" value={data.totals.events} />
<Stat label="Countries" value={data.totals.countries} />
</div>
{/* Three-column ranked-list strip */}
<div className="grid gap-4 sm:grid-cols-3">
<RankList
icon={<ExternalLink className="size-3.5" aria-hidden />}
title="Top pages right now"
rows={recordToRows(data.urls).map((r) => ({
...r,
label: r.label === '/' ? 'Homepage' : r.label,
}))}
emptyLabel="No pageviews yet"
/>
<RankList
icon={<MapPin className="size-3.5" aria-hidden />}
title="Top countries"
rows={recordToRows(data.countries).map((r) => ({
...r,
label: getCountryName(r.label, 'en') || r.label || 'Unknown',
}))}
emptyLabel="No country data yet"
/>
<RankList
icon={<Globe className="size-3.5" aria-hidden />}
title="Top referrers"
rows={recordToRows(data.referrers).map((r) => ({
...r,
label: r.label || '(direct)',
}))}
emptyLabel="No referrers yet"
/>
</div>
{/* Recent event stream */}
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-medium">
<Activity className="size-3.5 text-muted-foreground" aria-hidden />
Recent activity
</h3>
{data.events.length === 0 ? (
<div className="rounded-md border border-dashed border-border py-6 text-center text-xs text-muted-foreground">
No events in the last 30 minutes.
</div>
) : (
<ol className="space-y-1.5 text-xs">
{data.events.slice(0, 20).map((ev, i) => (
<li
key={`${ev.createdAt}-${i}`}
className="flex items-baseline justify-between gap-2 rounded-md bg-muted/40 px-2 py-1.5"
>
<div className="min-w-0 flex-1 truncate">
<span className="font-medium">
{ev.eventName
? `Event: ${ev.eventName}`
: !ev.urlPath || ev.urlPath === '/'
? 'Homepage'
: ev.urlPath}
</span>
<span className="ml-2 text-muted-foreground">
{[ev.country && getCountryName(ev.country, 'en'), ev.browser, ev.device]
.filter(Boolean)
.join(' · ')}
</span>
</div>
<span className="shrink-0 tabular-nums text-muted-foreground">
{fmtAgo(ev.createdAt)}
</span>
</li>
))}
</ol>
)}
</div>
</div>
)}
</CardContent>
) : null}
</Card>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-border bg-card px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="mt-0.5 text-xl font-semibold tabular-nums">{value.toLocaleString()}</div>
</div>
);
}
interface RankRow {
label: string;
value: number;
}
function RankList({
icon,
title,
rows,
emptyLabel,
}: {
icon: React.ReactNode;
title: string;
rows: RankRow[];
emptyLabel: string;
}) {
const max = rows[0]?.value ?? 1;
return (
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{icon}
{title}
</h3>
{rows.length === 0 ? (
<div className="rounded-md border border-dashed border-border py-3 text-center text-xs text-muted-foreground">
{emptyLabel}
</div>
) : (
<ul className="space-y-1 text-xs">
{rows.slice(0, 5).map((r) => {
const pct = (r.value / max) * 100;
return (
<li key={r.label}>
<div className="flex items-baseline justify-between gap-2">
<span className="min-w-0 flex-1 truncate font-medium">{r.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{r.value.toLocaleString()}
</span>
</div>
<div className="mt-0.5 h-1 w-full rounded-full bg-muted">
<div
className="h-1 rounded-full bg-brand"
style={{ width: `${Math.max(2, pct)}%` }}
aria-hidden
/>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
function recordToRows(rec: Record<string, number>): RankRow[] {
return Object.entries(rec)
.map(([label, value]) => ({ label, value }))
.sort((a, b) => b.value - a.value);
}
function fmtAgo(iso: string): string {
const t = new Date(iso).getTime();
if (isNaN(t)) return iso;
const diff = Date.now() - t;
const seconds = Math.max(1, Math.round(diff / 1000));
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}

View File

@@ -0,0 +1,106 @@
'use client';
/**
* Right-side Sheet with the full activity stream for one session.
* Driven by /api/v1/website-analytics?metric=session-activity. Each row
* is a pageview or custom event in chronological order.
*/
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import { useUmamiSessionActivity } from './use-website-analytics';
import { getCountryName } from '@/lib/i18n/countries';
import type { DateRange } from '@/lib/analytics/range';
import type { UmamiSession } from '@/lib/services/umami.service';
interface Props {
session: UmamiSession | null;
range: DateRange;
onClose: () => void;
}
export function SessionDetailSheet({ session, range, onClose }: Props) {
const activityQuery = useUmamiSessionActivity(range, session?.id ?? null);
const activity = activityQuery.data?.data ?? [];
return (
<Sheet open={!!session} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>Session detail</SheetTitle>
</SheetHeader>
{session ? (
<div className="mt-4 space-y-5">
{/* Top facts */}
<dl className="grid grid-cols-2 gap-y-2 text-sm">
<DtDd label="Location">
{getCountryName(session.country, 'en') || 'Unknown'}
{session.city ? ` · ${session.city}` : ''}
</DtDd>
<DtDd label="Device">{session.device}</DtDd>
<DtDd label="Browser">{session.browser}</DtDd>
<DtDd label="OS">{session.os}</DtDd>
<DtDd label="Screen">{session.screen || '—'}</DtDd>
<DtDd label="Language">{session.language || '—'}</DtDd>
<DtDd label="First visit">{fmtTime(session.firstAt)}</DtDd>
<DtDd label="Last visit">{fmtTime(session.lastAt)}</DtDd>
<DtDd label="Visits">{session.visits.toLocaleString()}</DtDd>
<DtDd label="Pageviews">{session.views.toLocaleString()}</DtDd>
</dl>
{/* Activity stream */}
<div>
<h3 className="text-sm font-medium">Activity</h3>
{activityQuery.isLoading ? (
<div className="mt-2 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : activity.length === 0 ? (
<p className="mt-2 text-xs text-muted-foreground">
No activity recorded for this session.
</p>
) : (
<ol className="mt-2 space-y-1 border-l border-border pl-3">
{activity.map((row) => (
<li key={row.eventId} className="relative text-xs">
<span className="absolute -left-[15px] top-1 inline-block size-2 rounded-full bg-brand" />
<div className="flex items-baseline justify-between gap-2">
<span className="truncate font-medium">
{row.eventName ? `Event: ${row.eventName}` : row.urlPath || '/'}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{fmtTime(row.createdAt)}
</span>
</div>
{row.eventName && row.urlPath ? (
<div className="text-muted-foreground">{row.urlPath}</div>
) : null}
</li>
))}
</ol>
)}
</div>
</div>
) : null}
</SheetContent>
</Sheet>
);
}
function DtDd({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{label}</dt>
<dd className="text-foreground">{children}</dd>
</>
);
}
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' });
}

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

View File

@@ -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;

View File

@@ -19,7 +19,12 @@ import type {
UmamiActiveVisitors,
UmamiMetricRow,
UmamiPageviewsSeries,
UmamiRealtime,
UmamiSession,
UmamiSessionActivity,
UmamiSessionsPage,
UmamiStats,
UmamiWebsiteInfo,
} from '@/lib/services/umami.service';
interface MetricResponse<T> {
@@ -49,6 +54,12 @@ function useUmamiQuery<T>(
* metrics whose response is range-independent (e.g. active visitors)
* so the cache isn't fragmented across each date the user has picked. */
cacheKeySegment?: unknown,
/** Optional auto-refresh interval. Used for the live active-visitors
* badge so the count ticks without a page reload. */
refetchInterval?: number,
/** Additional enabled gate (e.g. for session-activity which needs a
* selected sessionId before firing). ANDed with the port-id check. */
enabledGate = true,
) {
const portId = useUIStore((s) => s.currentPortId);
return useQuery<MetricResponse<T>>({
@@ -59,7 +70,8 @@ function useUmamiQuery<T>(
),
staleTime: 30_000, // umami data refreshes constantly; short stale time
retry: 1,
enabled: !!portId,
enabled: !!portId && enabledGate,
...(refetchInterval ? { refetchInterval } : {}),
});
}
@@ -73,11 +85,84 @@ export const useUmamiPageviews = (range: DateRange) =>
// website-analytics shell (any selected range) share one cache entry instead
// of fragmenting it across every range the user picks.
export const useUmamiActive = (range: DateRange) =>
useUmamiQuery<UmamiActiveVisitors>('active', range, '', 'fixed');
useUmamiQuery<UmamiActiveVisitors>('active', range, '', 'fixed', 30_000);
export const useUmamiTopPages = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-url', range, `&limit=${limit}`);
useUmamiQuery<UmamiMetricRow[]>('top-path', range, `&limit=${limit}`);
export const useUmamiTopReferrers = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-referrer', range, `&limit=${limit}`);
export const useUmamiTopCountries = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-country', range, `&limit=${limit}`);
// World map needs ALL countries with traffic, not just the top 10 the list
// uses. Umami caps `limit` server-side around 500; 250 covers every ISO
// country we'll ever see in one request.
export const useUmamiAllCountries = (range: DateRange) =>
useUmamiQuery<UmamiMetricRow[]>('top-country', range, `&limit=250`, `all-${range}`);
export const useUmamiTopBrowsers = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-browser', range, `&limit=${limit}`);
export const useUmamiTopOS = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-os', range, `&limit=${limit}`);
export const useUmamiTopDevices = (range: DateRange, limit = 10) =>
useUmamiQuery<UmamiMetricRow[]>('top-device', range, `&limit=${limit}`);
// Website metadata (name + domain). Range-independent; long stale time
// since the domain rarely changes.
export const useUmamiWebsiteInfo = (range: DateRange) =>
useUmamiQuery<UmamiWebsiteInfo>('website', range, '', 'website-info');
// Phase 2 — sessions surface. Paginated list of recent sessions plus
// per-session detail + activity stream + weekly engagement heatmap.
export const useUmamiSessions = (
range: DateRange,
opts: { page?: number; pageSize?: number; query?: string } = {},
) => {
const params = new URLSearchParams();
if (opts.page) params.set('page', String(opts.page));
if (opts.pageSize) params.set('pageSize', String(opts.pageSize));
if (opts.query) params.set('query', opts.query);
const suffix = params.toString() ? `&${params.toString()}` : '';
const cacheKey = `${range}-${opts.page ?? 1}-${opts.pageSize ?? 25}-${opts.query ?? ''}`;
return useUmamiQuery<UmamiSessionsPage>('sessions', range, suffix, cacheKey);
};
export const useUmamiSession = (range: DateRange, sessionId: string | null) => {
const suffix = sessionId ? `&sessionId=${sessionId}` : '';
return useUmamiQuery<UmamiSession>(
'session',
range,
suffix,
`session-${sessionId ?? 'none'}`,
undefined,
!!sessionId,
);
};
export const useUmamiSessionActivity = (range: DateRange, sessionId: string | null) => {
const suffix = sessionId ? `&sessionId=${sessionId}` : '';
return useUmamiQuery<UmamiSessionActivity[]>(
'session-activity',
range,
suffix,
`session-activity-${sessionId ?? 'none'}`,
undefined,
!!sessionId,
);
};
export const useUmamiSessionsWeekly = (range: DateRange) =>
useUmamiQuery<number[][]>('sessions-weekly', range);
// Realtime panel — Umami's /api/realtime endpoint returns last-30-min
// activity. Two cadences: 5 s when the panel is expanded (so it feels
// live) and 60 s when collapsed (so we still know whether to show the
// "Live activity" bar at all — the bar is hidden entirely when there
// are zero visitors and zero events in the last 30 minutes).
export const useUmamiRealtime = (expanded: boolean) =>
useUmamiQuery<UmamiRealtime>(
'realtime',
'today',
'',
'realtime-fixed',
expanded ? 5_000 : 60_000,
);

View File

@@ -0,0 +1,202 @@
'use client';
/**
* Choropleth world map of visitor counts per country. Powers a single card
* on the website-analytics page; hover any country for the visitor count,
* click to filter the rest of the page to that country.
*
* Uses ECharts' own world.json (the GeoJSON shipped with their public
* examples) — pre-cleaned, no antimeridian artifacts. Country features
* are matched on `properties.name` (English country name from the source).
*/
import { useEffect, useMemo, useState } from 'react';
import dynamic from 'next/dynamic';
import * as echarts from 'echarts/core';
import { MapChart } from 'echarts/charts';
import {
GeoComponent,
TooltipComponent,
VisualMapComponent,
TitleComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import type { FeatureCollection } from 'geojson';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { getCountryName } from '@/lib/i18n/countries';
import type { UmamiMetricRow } from '@/lib/services/umami.service';
echarts.use([
MapChart,
GeoComponent,
TooltipComponent,
VisualMapComponent,
TitleComponent,
CanvasRenderer,
]);
const ReactEChartsCore = dynamic(() => import('echarts-for-react/lib/core'), { ssr: false });
let registrationPromise: Promise<void> | null = null;
async function ensureWorldMapRegistered(): Promise<void> {
if (registrationPromise) return registrationPromise;
registrationPromise = (async () => {
const res = await fetch('/world-map/echarts-world.json');
const geo = (await res.json()) as FeatureCollection;
echarts.registerMap('world', { geoJSON: geo as unknown as object } as never);
})();
return registrationPromise;
}
interface Props {
rows: UmamiMetricRow[] | null;
loading: boolean;
onCountryClick?: (iso2: string) => void;
}
export function VisitorWorldMap({ rows, loading, onCountryClick }: Props) {
const [mapReady, setMapReady] = useState(false);
useEffect(() => {
let cancelled = false;
ensureWorldMapRegistered().then(() => {
if (!cancelled) setMapReady(true);
});
return () => {
cancelled = true;
};
}, []);
const data = useMemo(() => {
if (!rows) return [];
return rows.map((r) => ({
name: getCountryName(r.x, 'en'),
value: r.y,
iso2: r.x,
}));
}, [rows]);
const maxValue = useMemo(
() => (data.length > 0 ? Math.max(...data.map((d) => d.value)) : 0),
[data],
);
const option = useMemo(
() => ({
tooltip: {
trigger: 'item',
formatter: (params: { name: string; value?: number }) =>
params.value === undefined || isNaN(params.value)
? `${params.name}<br/><span style="color:#94a3b8">No visitors</span>`
: `${params.name}<br/><strong>${params.value.toLocaleString()}</strong> visitor${params.value === 1 ? '' : 's'}`,
backgroundColor: 'rgba(15, 23, 42, 0.95)',
borderColor: 'rgba(255, 255, 255, 0.1)',
textStyle: { color: '#f1f5f9', fontSize: 12 },
},
visualMap: {
type: 'piecewise',
min: 0,
max: maxValue,
left: 16,
bottom: 12,
orient: 'horizontal',
textStyle: { color: '#64748b', fontSize: 10 },
inRange: {
color: ['#eff6ff', '#bfdbfe', '#60a5fa', '#2563eb', '#1d4ed8', '#1e3a8a'],
},
itemWidth: 18,
itemHeight: 10,
itemGap: 2,
showLabel: true,
// Bucket counts into 5 piecewise segments so the legend reads
// like a discrete heat-scale rather than a hard-to-eyeball
// gradient bar.
pieces: bucketizeMax(maxValue),
},
series: [
{
type: 'map',
map: 'world',
roam: true,
scaleLimit: { min: 0.8, max: 8 },
aspectScale: 0.85,
itemStyle: {
areaColor: '#f1f5f9',
borderColor: '#cbd5e1',
borderWidth: 0.4,
},
emphasis: {
itemStyle: { areaColor: '#fbbf24', borderColor: '#92400e' },
label: { show: false },
},
select: {
itemStyle: { areaColor: '#f97316' },
label: { show: false },
},
data,
},
],
}),
[data, maxValue],
);
const onEvents = useMemo(
() => ({
click: (params: { data?: { iso2?: string } }) => {
const iso2 = params?.data?.iso2;
if (iso2 && onCountryClick) onCountryClick(iso2);
},
}),
[onCountryClick],
);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Visitors by country</CardTitle>
</CardHeader>
<CardContent>
{loading || !mapReady ? (
<Skeleton className="h-[560px] w-full" />
) : (
<ReactEChartsCore
echarts={echarts}
option={option}
onEvents={onEvents}
style={{ height: 560, width: '100%' }}
notMerge
lazyUpdate
/>
)}
</CardContent>
</Card>
);
}
/**
* Bucket the visitor-count scale into 5 readable bins. Umami country data
* is heavily skewed (top country may have 900+, most have 05), so a
* linear gradient looks visually flat. The buckets are derived from the
* observed max so the highest bin is always saturated.
*/
function bucketizeMax(max: number): Array<{ min?: number; max?: number; label: string }> {
if (max <= 0) return [{ min: 0, max: 0, label: '0' }];
const step = Math.max(1, Math.ceil(max / 5));
return [
{ min: 0, max: 0, label: '0' },
{ min: 1, max: step, label: `1${step.toLocaleString()}` },
{
min: step + 1,
max: step * 2,
label: `${(step + 1).toLocaleString()}${(step * 2).toLocaleString()}`,
},
{
min: step * 2 + 1,
max: step * 3,
label: `${(step * 2 + 1).toLocaleString()}${(step * 3).toLocaleString()}`,
},
{ min: step * 3 + 1, label: `${(step * 3 + 1).toLocaleString()}+` },
];
}

View File

@@ -11,9 +11,12 @@
* port - points the operator at /admin/website-analytics to set creds.
*/
import { useState } from 'react';
import { useState, type ReactNode } from 'react';
import Link from 'next/link';
import { Globe, Settings, ExternalLink } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Globe, Info, Settings, ExternalLink } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -21,22 +24,50 @@ import { KPITile } from '@/components/ui/kpi-tile';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { DateRangePicker } from '@/components/dashboard/date-range-picker';
import { type DateRange } from '@/lib/analytics/range';
import { isCustomRange, type DateRange } from '@/lib/analytics/range';
import { getCountryName } from '@/lib/i18n/countries';
import { useUIStore } from '@/stores/ui-store';
import {
useUmamiActive,
useUmamiAllCountries,
useUmamiPageviews,
useUmamiStats,
useUmamiTopBrowsers,
useUmamiTopCountries,
useUmamiTopDevices,
useUmamiTopOS,
useUmamiTopPages,
useUmamiTopReferrers,
useUmamiWebsiteInfo,
} from './use-website-analytics';
import { PageviewsChart } from './pageviews-chart';
import { RealtimePanel } from './realtime-panel';
import { SessionsList } from './sessions-list';
import { TopList } from './top-list';
import { VisitorWorldMap } from './visitor-world-map';
import { WeeklyHeatmap } from './weekly-heatmap';
export function WebsiteAnalyticsShell() {
const [range, setRange] = useState<DateRange>('30d');
const router = useRouter();
const searchParams = useSearchParams();
const portSlug = useUIStore((s) => s.currentPortSlug);
// Hydrate range from URL so /website-analytics?range=90d (or
// ?range=custom&from=…&to=…) survives refreshes and round-trips from
// the "View all" detail pages.
const [range, setRange] = useState<DateRange>(() => parseRangeFromQuery(searchParams));
function handleRangeChange(next: DateRange) {
setRange(next);
const sp = new URLSearchParams();
if (isCustomRange(next)) {
sp.set('range', 'custom');
sp.set('from', next.from);
sp.set('to', next.to);
} else {
sp.set('range', next);
}
router.replace(`/${portSlug}/website-analytics?${sp.toString()}` as never);
}
const stats = useUmamiStats(range);
const pageviews = useUmamiPageviews(range);
@@ -44,6 +75,14 @@ export function WebsiteAnalyticsShell() {
const topPages = useUmamiTopPages(range);
const topReferrers = useUmamiTopReferrers(range);
const topCountries = useUmamiTopCountries(range);
const allCountries = useUmamiAllCountries(range);
const topBrowsers = useUmamiTopBrowsers(range);
const topOS = useUmamiTopOS(range);
const topDevices = useUmamiTopDevices(range);
const websiteInfo = useUmamiWebsiteInfo(range);
// Prefer the live domain from the connected website; fall back to a
// generic eyebrow until the metadata request resolves.
const eyebrow = websiteInfo.data?.data?.domain || websiteInfo.data?.data?.name || 'Marketing';
// API surfaces `notConfigured: true` on a 200 response (not 4xx) so
// React Query doesn't infinite-retry — that retry loop saturated the
@@ -55,54 +94,93 @@ export function WebsiteAnalyticsShell() {
<div className="space-y-6">
<PageHeader
title="Website analytics"
eyebrow="Marketing"
description="Live data from Umami - site traffic, top pages, referrers, and audience geography."
eyebrow={eyebrow}
description="Site traffic, top pages, referrers, and audience geography."
variant="gradient"
actions={<DateRangePicker value={range} onChange={setRange} />}
actions={<DateRangePicker value={range} onChange={handleRangeChange} />}
/>
{notConfigured ? (
<NotConfiguredEmptyState portSlug={portSlug} />
) : (
<>
{/* Live indicator + KPI tiles */}
<div className="grid gap-3 grid-cols-2 sm:gap-4 lg:grid-cols-5">
{/* Realtime panel — collapsible "what's happening RIGHT NOW"
strip at the very top. Polling only fires while expanded. */}
<RealtimePanel />
{/* Live indicator + KPI tiles — mirrors Umami's overview row. */}
<div className="grid gap-3 grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-6">
<ActiveVisitorsBadge value={active.data?.data?.visitors} loading={active.isLoading} />
<KpiPair
label="Pageviews"
loading={stats.isLoading}
value={stats.data?.data?.pageviews?.value}
prev={stats.data?.data?.pageviews?.prev}
accent="brand"
/>
<KpiPair
label="Visitors"
loading={stats.isLoading}
value={stats.data?.data?.visitors?.value}
prev={stats.data?.data?.visitors?.prev}
value={stats.data?.data?.visitors}
prev={stats.data?.data?.comparison?.visitors}
accent="teal"
tooltip="Unique people who visited the site at least once. Counted by anonymous device fingerprint — one person across two devices counts as two."
/>
<KpiPair
label="Visits"
loading={stats.isLoading}
value={stats.data?.data?.visits?.value}
prev={stats.data?.data?.visits?.prev}
value={stats.data?.data?.visits}
prev={stats.data?.data?.comparison?.visits}
accent="success"
tooltip="Distinct browsing sessions. A new visit starts when a visitor returns after 30 minutes of inactivity. One person can rack up multiple visits."
/>
<KpiPair
label="Bounces"
label="Pageviews"
loading={stats.isLoading}
value={stats.data?.data?.bounces?.value}
prev={stats.data?.data?.bounces?.prev}
accent="purple"
invertDelta
value={stats.data?.data?.pageviews}
prev={stats.data?.data?.comparison?.pageviews}
accent="brand"
tooltip="Total page loads, including refreshes and back-navigation. One visit browsing five pages = 5 pageviews."
/>
<BounceRateTile
loading={stats.isLoading}
bounces={stats.data?.data?.bounces}
visits={stats.data?.data?.visits}
prevBounces={stats.data?.data?.comparison?.bounces}
prevVisits={stats.data?.data?.comparison?.visits}
/>
<VisitDurationTile
loading={stats.isLoading}
totaltime={stats.data?.data?.totaltime}
visits={stats.data?.data?.visits}
prevTotaltime={stats.data?.data?.comparison?.totaltime}
prevVisits={stats.data?.data?.comparison?.visits}
/>
</div>
{/* Pageviews trend */}
{/* Views (pageviews + sessions) trend */}
<Card>
<CardHeader>
<CardTitle className="text-base">Pageviews trend</CardTitle>
<CardTitle className="flex items-center gap-1.5 text-base">
Views
<Popover>
<PopoverTrigger
type="button"
aria-label="What's the difference between pageviews and sessions?"
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
>
<Info className="size-3.5" aria-hidden />
</PopoverTrigger>
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
<p className="font-semibold text-foreground">Pageviews vs Sessions</p>
<p className="mt-2 text-muted-foreground">
<strong>Pageviews</strong> = total page hits, including refreshes and
back-button navigation. One visitor browsing five pages = 5 pageviews.
</p>
<p className="mt-2 text-muted-foreground">
<strong>Sessions</strong> = distinct visitor sessions. The same person
browsing five pages in one sitting still counts as 1 session.
</p>
<p className="mt-2 text-muted-foreground">
Pages-per-session (pageviews ÷ sessions) is a rough engagement signal higher
means people are exploring deeper.
</p>
</PopoverContent>
</Popover>
</CardTitle>
</CardHeader>
<CardContent>
{pageviews.isLoading ? (
@@ -113,25 +191,84 @@ export function WebsiteAnalyticsShell() {
</CardContent>
</Card>
{/* Top-N tables */}
{/* Audience: pages / referrers / countries */}
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
<TopList
title="Top pages"
loading={topPages.isLoading}
rows={topPages.data?.data ?? null}
viewAllHref={detailHref(portSlug, 'pages', range)}
rows={
topPages.data?.data
? topPages.data.data.map((row) => ({
...row,
x: row.x === '/' ? 'Homepage' : row.x,
}))
: null
}
/>
<TopList
title="Top referrers"
loading={topReferrers.isLoading}
viewAllHref={detailHref(portSlug, 'referrers', range)}
rows={topReferrers.data?.data ?? null}
defaultLabel="(direct)"
/>
<TopList
title="Top countries"
loading={topCountries.isLoading}
rows={topCountries.data?.data ?? null}
viewAllHref={detailHref(portSlug, 'countries', range)}
rows={
topCountries.data?.data
? topCountries.data.data.map((row) => ({
...row,
x: getCountryName(row.x, 'en'),
}))
: null
}
/>
</div>
{/* Tech breakdown: browsers / OS / devices */}
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
<TopList
title="Top browsers"
loading={topBrowsers.isLoading}
viewAllHref={detailHref(portSlug, 'browsers', range)}
rows={topBrowsers.data?.data ?? null}
/>
<TopList
title="Top operating systems"
loading={topOS.isLoading}
viewAllHref={detailHref(portSlug, 'os', range)}
rows={topOS.data?.data ?? null}
/>
<TopList
title="Top devices"
loading={topDevices.isLoading}
viewAllHref={detailHref(portSlug, 'devices', range)}
rows={
topDevices.data?.data
? topDevices.data.data.map((row) => ({
...row,
x: row.x === '' ? 'Unknown' : row.x.charAt(0).toUpperCase() + row.x.slice(1),
}))
: null
}
/>
</div>
{/* Engagement heatmap — full-width so the 7×24 grid has room
to breathe and cells are large enough to hover comfortably. */}
<WeeklyHeatmap range={range} />
{/* Recent sessions */}
<SessionsList range={range} />
{/* World heatmap — visitor counts per country (full-width, bottom of page) */}
<VisitorWorldMap
rows={allCountries.data?.data ?? null}
loading={allCountries.isLoading}
/>
</>
)}
</div>
@@ -142,8 +279,26 @@ function ActiveVisitorsBadge({ value, loading }: { value?: number; loading: bool
return (
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm relative overflow-hidden">
<div className="absolute inset-x-0 top-0 h-1 bg-mint" aria-hidden />
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
Active right now
<div className="flex items-center gap-1.5">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
Active right now
</div>
<Popover>
<PopoverTrigger
type="button"
aria-label="How is this counted?"
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
>
<Info className="size-3" aria-hidden />
</PopoverTrigger>
<PopoverContent align="start" className="w-72 text-xs leading-relaxed">
<p className="font-semibold text-foreground">Live visitor count</p>
<p className="mt-2 text-muted-foreground">
Visitors active in the last <strong>5 minutes</strong>, as reported by your analytics
backend. This card auto-refreshes every <strong>30 seconds</strong>.
</p>
</PopoverContent>
</Popover>
</div>
<div className="mt-1 flex items-center gap-2">
{loading ? (
@@ -169,6 +324,7 @@ function KpiPair({
accent,
loading,
invertDelta = false,
tooltip,
}: {
label: string;
value: number | undefined;
@@ -178,6 +334,7 @@ function KpiPair({
/** For metrics where lower is better (bounces). Flip the sign so green
* still means "good" in the UI. */
invertDelta?: boolean;
tooltip?: ReactNode;
}) {
if (loading) {
return (
@@ -194,7 +351,141 @@ function KpiPair({
delta = Math.round(((v - p) / p) * 100);
if (invertDelta) delta = -delta;
}
return <KPITile title={label} value={v.toLocaleString()} accent={accent} delta={delta} />;
return (
<KPITile
title={label}
value={v.toLocaleString()}
accent={accent}
delta={delta}
deltaSuffix="%"
tooltip={tooltip}
/>
);
}
/**
* Bounce rate = bounces / visits × 100. Lower is better, so the delta is
* inverted relative to the raw bounce count (a drop in bounce rate is
* "good" → green up-arrow).
*/
function BounceRateTile({
bounces,
visits,
prevBounces,
prevVisits,
loading,
}: {
bounces: number | undefined;
visits: number | undefined;
prevBounces: number | undefined;
prevVisits: number | undefined;
loading: boolean;
}) {
if (loading) {
return (
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm">
<Skeleton className="h-3 w-20" />
<Skeleton className="mt-2 h-7 w-16" />
</div>
);
}
const rate = (visits ?? 0) > 0 ? ((bounces ?? 0) / (visits ?? 1)) * 100 : 0;
const prevRate = (prevVisits ?? 0) > 0 ? ((prevBounces ?? 0) / (prevVisits ?? 1)) * 100 : 0;
let delta: number | undefined;
if (prevRate > 0) {
// Show the raw percentage-point change (positive = bounce went up).
// KPITile's lowerIsBetter flips the colour so a drop renders green.
delta = Math.round(rate - prevRate);
}
return (
<KPITile
title="Bounce rate"
value={`${rate.toFixed(1)}%`}
accent="purple"
delta={delta}
deltaSuffix="%"
lowerIsBetter
tooltip="Share of visits that ended without a second pageview — i.e. someone landed, didn't click anything, and left. Lower is generally better."
/>
);
}
/**
* Avg visit duration = totaltime / visits (Umami returns totaltime in
* seconds across all visits in the range). Formatted as `Xm Ys`.
*/
function VisitDurationTile({
totaltime,
visits,
prevTotaltime,
prevVisits,
loading,
}: {
totaltime: number | undefined;
visits: number | undefined;
prevTotaltime: number | undefined;
prevVisits: number | undefined;
loading: boolean;
}) {
if (loading) {
return (
<div className="rounded-xl border border-border bg-card p-3 sm:p-5 shadow-sm">
<Skeleton className="h-3 w-20" />
<Skeleton className="mt-2 h-7 w-16" />
</div>
);
}
const secs = (visits ?? 0) > 0 ? (totaltime ?? 0) / (visits ?? 1) : 0;
const prevSecs = (prevVisits ?? 0) > 0 ? (prevTotaltime ?? 0) / (prevVisits ?? 1) : 0;
let delta: number | undefined;
if (prevSecs > 0) {
delta = Math.round(((secs - prevSecs) / prevSecs) * 100);
}
const m = Math.floor(secs / 60);
const s = Math.round(secs % 60);
const display = m > 0 ? `${m}m ${s}s` : `${s}s`;
return (
<KPITile
title="Visit duration"
value={display}
accent="mint"
delta={delta}
deltaSuffix="%"
tooltip="Average time a visitor spent on the site per visit. Longer usually means deeper engagement, though it can also mean people got stuck."
/>
);
}
/** Read the range from the current URL search params, defaulting to 30d. */
function parseRangeFromQuery(searchParams: URLSearchParams | null): DateRange {
const rawRange = searchParams?.get('range') ?? '30d';
if (rawRange === 'custom') {
const from = searchParams?.get('from');
const to = searchParams?.get('to');
if (from && to) return { kind: 'custom', from, to };
return '30d';
}
if (rawRange === 'today' || rawRange === '7d' || rawRange === '30d' || rawRange === '90d') {
return rawRange;
}
return '30d';
}
/** Build the "View all" href for a metric, carrying the current range. */
function detailHref(
portSlug: string | null,
metric: 'pages' | 'referrers' | 'countries' | 'browsers' | 'os' | 'devices',
range: DateRange,
): string {
const sp = new URLSearchParams();
if (isCustomRange(range)) {
sp.set('range', 'custom');
sp.set('from', range.from);
sp.set('to', range.to);
} else {
sp.set('range', range);
}
return `/${portSlug}/website-analytics/${metric}?${sp.toString()}`;
}
function NotConfiguredEmptyState({ portSlug }: { portSlug: string | null }) {

View File

@@ -0,0 +1,168 @@
'use client';
/**
* Hour-of-week engagement heatmap. Driven by Umami's
* `/api/websites/:id/sessions/weekly` endpoint which returns a 7×24
* nested array of session counts (rows Sun…Sat, cols 0…23 UTC).
*
* Visual: a 7-row × 24-col grid of cells, with cell colour intensity
* scaled to the max value across the whole grid. Hover any cell for a
* floating tooltip showing the exact day/hour/count.
*/
import { useMemo, useState } from 'react';
import { Info } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Skeleton } from '@/components/ui/skeleton';
import { useUmamiSessionsWeekly } from './use-website-analytics';
import { type DateRange } from '@/lib/analytics/range';
interface Props {
range: DateRange;
}
const DAYS_FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const DAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
interface HoverInfo {
dayIdx: number;
hour: number;
count: number;
}
export function WeeklyHeatmap({ range }: Props) {
const query = useUmamiSessionsWeekly(range);
const [hover, setHover] = useState<HoverInfo | null>(null);
const grid = query.data?.data ?? null;
const max = useMemo(() => {
if (!grid) return 0;
let m = 0;
for (const row of grid) for (const v of row) if (v > m) m = v;
return m;
}, [grid]);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-1.5 text-base">
Engagement heatmap
<Popover>
<PopoverTrigger
type="button"
aria-label="How to read the engagement heatmap"
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400"
>
<Info className="size-3.5" aria-hidden />
</PopoverTrigger>
<PopoverContent align="start" className="w-80 text-xs leading-relaxed">
<p className="font-semibold text-foreground">When is your audience active?</p>
<p className="mt-2 text-muted-foreground">
Each cell is one hour of one day the columns are{' '}
<strong>hours of the day in UTC</strong> (0 = midnight, 23 = 11 PM) and the rows are
days of the week. Darker blue means more sessions started during that hour across
the whole selected period. Hover any cell for the exact session count.
</p>
</PopoverContent>
</Popover>
</CardTitle>
</CardHeader>
<CardContent>
{query.isLoading ? (
<Skeleton className="h-[220px] w-full" />
) : !grid || max === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">
No session activity in this range.
</div>
) : (
<div className="relative" onMouseLeave={() => setHover(null)}>
{/* Hour-axis header */}
<div className="flex pl-12 text-[10px] text-muted-foreground">
{Array.from({ length: 24 }).map((_, h) => (
<div key={h} className="flex-1 text-center">
{h % 2 === 0 ? h : ''}
</div>
))}
</div>
{/* Grid */}
<div className="mt-1">
{grid.map((row, dIdx) => (
<div key={dIdx} className="flex items-center">
<div className="w-12 pr-2 text-right text-[11px] text-muted-foreground">
{DAYS_SHORT[dIdx]}
</div>
<div className="flex flex-1 gap-px">
{row.map((v, h) => {
const intensity = v / max;
const isHovered = hover?.dayIdx === dIdx && hover?.hour === h;
return (
<div
key={h}
onMouseEnter={() => setHover({ dayIdx: dIdx, hour: h, count: v })}
className="aspect-square min-w-0 flex-1 rounded-sm transition-all cursor-pointer"
style={{
backgroundColor:
v === 0 ? '#f1f5f9' : `rgba(37, 99, 235, ${0.15 + 0.85 * intensity})`,
outline: isHovered ? '2px solid #f59e0b' : 'none',
outlineOffset: '-1px',
}}
/>
);
})}
</div>
</div>
))}
</div>
{/* Hour-axis footer (mirrors header so wide cards read symmetrically) */}
<div className="mt-1 flex pl-12 text-[10px] text-muted-foreground">
{Array.from({ length: 24 }).map((_, h) => (
<div key={h} className="flex-1 text-center">
{h % 2 === 0 ? `${h}:00` : ''}
</div>
))}
</div>
{/* Legend + floating value indicator */}
<div className="mt-4 flex items-center justify-between gap-4 text-[11px] text-muted-foreground">
<span>
Hour of day (UTC) colour intensity scaled to peak ({max.toLocaleString()}{' '}
sessions)
</span>
<div className="flex items-center gap-1">
<span>Less</span>
<div className="flex gap-px">
{[0, 0.25, 0.5, 0.75, 1].map((i) => (
<div
key={i}
className="size-3 rounded-sm"
style={{
backgroundColor:
i === 0 ? '#f1f5f9' : `rgba(37, 99, 235, ${0.15 + 0.85 * i})`,
}}
/>
))}
</div>
<span>More</span>
</div>
</div>
{/* Hover tooltip — single element re-positioned via the
hovered cell's data, much cheaper than mounting 168
Radix Tooltips. */}
{hover ? (
<div className="pointer-events-none absolute top-0 right-0 -mt-2 rounded-md border border-border bg-popover px-3 py-1.5 text-xs shadow-md">
<div className="font-medium text-foreground">
{DAYS_FULL[hover.dayIdx]} {hover.hour}:00{hover.hour}:59 UTC
</div>
<div className="tabular-nums text-muted-foreground">
{hover.count.toLocaleString()} session{hover.count === 1 ? '' : 's'}
</div>
</div>
) : null}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,19 @@
-- Phase 4b — email open tracking via a 1×1 pixel endpoint.
-- Adds a per-send open log + cached aggregates on document_sends.
ALTER TABLE "document_sends"
ADD COLUMN IF NOT EXISTS "track_opens" boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS "first_opened_at" timestamptz,
ADD COLUMN IF NOT EXISTS "open_count" integer NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS "document_send_opens" (
"id" text PRIMARY KEY,
"port_id" text NOT NULL REFERENCES "ports"("id"),
"send_id" text NOT NULL REFERENCES "document_sends"("id") ON DELETE CASCADE,
"opened_at" timestamptz NOT NULL DEFAULT now(),
"user_agent" text,
"referer" text
);
CREATE INDEX IF NOT EXISTS "idx_dso_send" ON "document_send_opens" ("send_id", "opened_at" DESC);
CREATE INDEX IF NOT EXISTS "idx_dso_port" ON "document_send_opens" ("port_id", "opened_at" DESC);

View File

@@ -0,0 +1,36 @@
-- Phase 4c — tracked redirect links for email click-through tracking.
-- A short URL at /q/<slug> redirects to the target and records the
-- click against the originating send. Cross-posted to Umami as a
-- `link-clicked` event.
CREATE TABLE IF NOT EXISTS "tracked_links" (
"id" text PRIMARY KEY,
"port_id" text NOT NULL REFERENCES "ports"("id"),
"slug" text NOT NULL,
"target_url" text NOT NULL,
"send_id" text REFERENCES "document_sends"("id") ON DELETE SET NULL,
"click_count" integer NOT NULL DEFAULT 0,
"first_clicked_at" timestamptz,
"last_clicked_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now(),
"created_by_user_id" text REFERENCES "user"("id") ON DELETE SET NULL
);
-- Slugs are scoped to a port; an admin can rotate them per-port. Global
-- uniqueness isn't required because /q/<slug> is gated by tenancy in
-- the route handler.
CREATE UNIQUE INDEX IF NOT EXISTS "uniq_tracked_links_slug" ON "tracked_links" ("slug");
CREATE INDEX IF NOT EXISTS "idx_tracked_links_send" ON "tracked_links" ("send_id");
CREATE INDEX IF NOT EXISTS "idx_tracked_links_port" ON "tracked_links" ("port_id", "created_at" DESC);
CREATE TABLE IF NOT EXISTS "tracked_link_clicks" (
"id" text PRIMARY KEY,
"tracked_link_id" text NOT NULL REFERENCES "tracked_links"("id") ON DELETE CASCADE,
"port_id" text NOT NULL REFERENCES "ports"("id"),
"clicked_at" timestamptz NOT NULL DEFAULT now(),
"user_agent" text,
"referer" text
);
CREATE INDEX IF NOT EXISTS "idx_tlc_link" ON "tracked_link_clicks" ("tracked_link_id", "clicked_at" DESC);
CREATE INDEX IF NOT EXISTS "idx_tlc_port" ON "tracked_link_clicks" ("port_id", "clicked_at" DESC);

View File

@@ -76,3 +76,4 @@ export * from './pipeline';
// Relations (must come last - references all tables)
export * from './relations';
export * from './tracked-links';

View File

@@ -0,0 +1,71 @@
import { pgTable, text, integer, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { ports } from './ports';
import { user } from './users';
import { documentSends } from './brochures';
/**
* Phase 4c — tracked redirect links. A short URL `/q/<slug>` records a
* click and 302s the recipient on to `targetUrl`. The matching click
* row is fire-and-forget so the redirect stays snappy; an aggregate
* `clickCount` on the parent row keeps "was clicked at all" queries
* cheap.
*
* `sendId` is the optional link back to the originating outbound email
* — set when the link is minted via the email-composer flow so reps can
* see per-email click-throughs. Manual one-off short links leave it null.
*/
export const trackedLinks = pgTable(
'tracked_links',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id),
slug: text('slug').notNull(),
targetUrl: text('target_url').notNull(),
sendId: text('send_id').references(() => documentSends.id, { onDelete: 'set null' }),
clickCount: integer('click_count').notNull().default(0),
firstClickedAt: timestamp('first_clicked_at', { withTimezone: true }),
lastClickedAt: timestamp('last_clicked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
createdByUserId: text('created_by_user_id').references(() => user.id, { onDelete: 'set null' }),
},
(t) => [
uniqueIndex('uniq_tracked_links_slug').on(t.slug),
index('idx_tracked_links_send').on(t.sendId),
index('idx_tracked_links_port').on(t.portId, t.createdAt),
],
);
/** Per-click log. Apple Mail privacy proxy will pre-fetch tracked link
* URLs the same way it does pixels — clicks from iOS users are
* over-counted. Standard email-tracking caveats apply. */
export const trackedLinkClicks = pgTable(
'tracked_link_clicks',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
trackedLinkId: text('tracked_link_id')
.notNull()
.references(() => trackedLinks.id, { onDelete: 'cascade' }),
portId: text('port_id')
.notNull()
.references(() => ports.id),
clickedAt: timestamp('clicked_at', { withTimezone: true }).notNull().defaultNow(),
userAgent: text('user_agent'),
referer: text('referer'),
},
(t) => [
index('idx_tlc_link').on(t.trackedLinkId, t.clickedAt),
index('idx_tlc_port').on(t.portId, t.clickedAt),
],
);
export type TrackedLink = typeof trackedLinks.$inferSelect;
export type NewTrackedLink = typeof trackedLinks.$inferInsert;
export type TrackedLinkClick = typeof trackedLinkClicks.$inferSelect;
export type NewTrackedLinkClick = typeof trackedLinkClicks.$inferInsert;

View File

@@ -0,0 +1,46 @@
/**
* Open-tracking pixel injector (Phase 4b). Appends a 1×1 transparent
* image pointing at /api/public/email-pixel/[sendId] to outbound HTML
* emails. The pixel endpoint records the open + cross-posts the event
* to Umami.
*
* Sites that want to opt out of tracking simply don't call this helper.
* The pixel URL is unguessable per-send (UUID), but a `track_opens=false`
* row in `document_sends` makes the endpoint a no-op even if someone
* does guess one.
*
* Privacy: respects EMAIL_REDIRECT_TO (no pixel injected when dev
* redirect is active) so a re-routed message doesn't fire a fake open.
*/
import { env } from '@/lib/env';
interface InjectOptions {
/** Public base URL of the CRM (e.g. https://crm.portnimara.com).
* Required so the pixel link is absolute — relative URLs break in
* email clients. */
appBaseUrl: string;
/** UUID of the row in `document_sends`. */
sendId: string;
}
/**
* Append a 1×1 tracking pixel just before `</body>` (or at the end of the
* document if no `</body>` is present). Returns the HTML unchanged when
* EMAIL_REDIRECT_TO is set so dev-mode re-routing doesn't generate
* misleading open events.
*/
export function injectTrackingPixel(html: string, opts: InjectOptions): string {
if (env.EMAIL_REDIRECT_TO) return html;
const base = opts.appBaseUrl.replace(/\/$/, '');
const pixelUrl = `${base}/api/public/email-pixel/${opts.sendId}`;
const pixelTag =
`<img src="${pixelUrl}" width="1" height="1" alt="" ` +
`style="display:block;border:0;margin:0;padding:0" />`;
if (html.includes('</body>')) {
return html.replace('</body>', `${pixelTag}</body>`);
}
return html + pixelTag;
}

View File

@@ -0,0 +1,70 @@
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { env } from '@/lib/env';
import { trackedLinks, type NewTrackedLink } from '@/lib/db/schema/tracked-links';
/**
* Phase 4c — service-layer helpers for tracked redirect links. Use
* `createTrackedLink` from any email-composer flow to wrap an outbound
* URL in a `/q/<slug>` short-link that records click-throughs.
*
* Slug format: random URL-safe ID. Short enough not to overwhelm an
* inbox preview pane but long enough that collision probability is
* negligible across the lifetime of the system.
*/
function generateSlug(): string {
// 8 random bytes → 11-char base64url string. Collision probability
// across 1M links: ~1e-7. The DB unique index is the backstop.
const bytes = crypto.getRandomValues(new Uint8Array(8));
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export interface CreateTrackedLinkInput {
portId: string;
targetUrl: string;
/** Optional FK to `document_sends.id` so per-email click-throughs are
* attributable. Leave null for one-off short links. */
sendId?: string;
createdByUserId?: string;
}
export async function createTrackedLink(input: CreateTrackedLinkInput) {
// Retry on slug collision (extremely rare). Three attempts is more
// than enough — at our slug entropy a single collision in 1M links
// would be a once-per-century event.
for (let attempt = 0; attempt < 3; attempt++) {
const slug = generateSlug();
try {
const values: NewTrackedLink = {
portId: input.portId,
slug,
targetUrl: input.targetUrl,
...(input.sendId ? { sendId: input.sendId } : {}),
...(input.createdByUserId ? { createdByUserId: input.createdByUserId } : {}),
};
const [row] = await db.insert(trackedLinks).values(values).returning();
return row!;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('uniq_tracked_links_slug') && attempt < 2) continue;
throw err;
}
}
throw new Error('Failed to mint a unique tracked-link slug after 3 attempts');
}
/** Build the public-facing tracked URL for an existing record. */
export function buildTrackedUrl(slug: string): string {
const base = env.NEXT_PUBLIC_APP_URL.replace(/\/$/, '');
return `${base}/q/${slug}`;
}
/** Look up click stats for a single tracked link. */
export async function getTrackedLink(id: string) {
return db.query.trackedLinks.findFirst({ where: eq(trackedLinks.id, id) });
}

View File

@@ -211,12 +211,30 @@ function pickUnit(range: DateRange): 'hour' | 'day' | 'month' {
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Stats response from `/api/websites/:id/stats` on Umami v2.x / v3.x.
*
* Each top-level metric is a plain number for the requested range; the
* `comparison` block carries the equivalent values for the previous
* window of the same length (so a 30-day range comes back with the prior
* 30 days as `comparison.*`). Verified empirically against Umami v3.1.0
* — earlier internal types modelled this as `{value, prev}` per metric,
* which matched neither v2 nor v3 and caused the dashboard tile to read
* `pageviews.value` as undefined and render 0.
*/
export interface UmamiStats {
pageviews: { value: number; prev: number };
visitors: { value: number; prev: number };
visits: { value: number; prev: number };
bounces: { value: number; prev: number };
totaltime: { value: number; prev: number };
pageviews: number;
visitors: number;
visits: number;
bounces: number;
totaltime: number;
comparison?: {
pageviews: number;
visitors: number;
visits: number;
bounces: number;
totaltime: number;
};
}
export async function getStats(portId: string, range: DateRange): Promise<UmamiStats | null> {
@@ -227,9 +245,15 @@ export async function getStats(portId: string, range: DateRange): Promise<UmamiS
});
}
/**
* Pageviews time-series response. Umami v3 returns the `pageviews` array
* unconditionally; the `sessions` array only appears when the request
* includes a `compare` directive (omitted today). The optional field
* keeps the type honest so consumers don't blindly read `.sessions[0]`.
*/
export interface UmamiPageviewsSeries {
pageviews: Array<{ x: string; y: number }>;
sessions: Array<{ x: string; y: number }>;
sessions?: Array<{ x: string; y: number }>;
}
export async function getPageviewsSeries(
@@ -245,14 +269,25 @@ export async function getPageviewsSeries(
});
}
/**
* Valid `type` values for `/api/websites/:id/metrics` on Umami v2.x / v3.x.
* `path` replaces the old `url` value — sending `type=url` against a v3
* instance returns 400. The full Umami enum also includes `entry|exit|
* title|query|region|city|language|screen|hostname|tag|distinctId`; only
* the ones the CRM actually surfaces are listed here.
*/
export type UmamiMetricType =
| 'url'
| 'path'
| 'referrer'
| 'browser'
| 'os'
| 'device'
| 'country'
| 'event';
| 'region'
| 'city'
| 'event'
| 'title'
| 'query';
export interface UmamiMetricRow {
x: string;
@@ -284,6 +319,30 @@ export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisi
return umamiFetch<UmamiActiveVisitors>(config, `/api/websites/${config.websiteId}/active`, {});
}
/** Website-level metadata (name + domain) so the analytics page can show
* which site it's reporting on without the operator having to hard-code
* the domain in system_settings. */
export interface UmamiWebsiteInfo {
id: string;
name: string;
domain: string;
}
export async function getWebsiteInfo(portId: string): Promise<UmamiWebsiteInfo | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
const res = await umamiFetch<{ id?: string; name?: string; domain?: string }>(
config,
`/api/websites/${config.websiteId}`,
{},
);
return {
id: res.id ?? config.websiteId,
name: res.name ?? res.domain ?? 'Website',
domain: res.domain ?? '',
};
}
/**
* Verify the connection by hitting `/api/websites/:id/active` - the cheapest
* authenticated endpoint that proves both auth + websiteId are good.
@@ -314,3 +373,333 @@ export async function testConnection(
return { ok: false, error: message };
}
}
// ─── Realtime panel ────────────────────────────────────────────────────────
//
// `/api/realtime/:id` is the richer alternative to `/active` — returns
// totals, top URLs being viewed right now, top countries, a 30-min
// time-series and a recent-event stream. Used by the realtime dashboard.
export interface UmamiRealtime {
urls: Record<string, number>;
countries: Record<string, number>;
events: Array<{
__type: string;
os?: string;
device?: string;
country?: string;
sessionId?: string;
eventName?: string;
browser?: string;
createdAt: string;
urlPath?: string;
referrerDomain?: string;
}>;
series: {
views: Array<{ x: string; y: number }>;
visitors: Array<{ x: string; y: number }>;
};
referrers: Record<string, number>;
totals: {
visitors: number;
views: number;
events: number;
countries: number;
};
timestamp: number;
}
export async function getRealtime(portId: string): Promise<UmamiRealtime | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
// 30-minute window matches Umami's own realtime page default.
const startAt = Date.now() - 30 * 60 * 1000;
return umamiFetch<UmamiRealtime>(config, `/api/realtime/${config.websiteId}`, {
startAt,
endAt: Date.now(),
timezone: 'UTC',
});
}
// ─── Sessions ──────────────────────────────────────────────────────────────
export interface UmamiSession {
id: string;
websiteId: string;
hostname: string;
browser: string;
os: string;
device: string;
screen: string;
language: string;
country: string;
subdivision1?: string;
city?: string;
firstAt: string;
lastAt: string;
visits: number;
views: number;
events: number;
totaltime?: number;
}
export interface UmamiSessionsPage {
data: UmamiSession[];
count: number;
page: number;
pageSize: number;
}
export async function getSessions(
portId: string,
range: DateRange,
opts: { page?: number; pageSize?: number; query?: string } = {},
): Promise<UmamiSessionsPage | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiSessionsPage>(config, `/api/websites/${config.websiteId}/sessions`, {
...rangeToParams(range),
page: opts.page ?? 1,
pageSize: opts.pageSize ?? 25,
query: opts.query,
});
}
export async function getSession(portId: string, sessionId: string): Promise<UmamiSession | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiSession>(
config,
`/api/websites/${config.websiteId}/sessions/${sessionId}`,
{},
);
}
export interface UmamiSessionActivity {
eventType: number;
urlQuery?: string;
urlPath: string;
eventName?: string;
createdAt: string;
referrerDomain?: string;
eventId: string;
visitId: string;
}
export async function getSessionActivity(
portId: string,
sessionId: string,
range: DateRange,
): Promise<UmamiSessionActivity[] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiSessionActivity[]>(
config,
`/api/websites/${config.websiteId}/sessions/${sessionId}/activity`,
{ ...rangeToParams(range) },
);
}
/**
* Sessions by hour-of-week heatmap — returns a 7×24 nested-array (rows are
* days Sun..Sat, columns are hours 0..23). Drives the engagement heatmap
* card.
*/
export async function getSessionsWeekly(
portId: string,
range: DateRange,
timezone = 'UTC',
): Promise<number[][] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<number[][]>(config, `/api/websites/${config.websiteId}/sessions/weekly`, {
...rangeToParams(range),
timezone,
});
}
// ─── Events ────────────────────────────────────────────────────────────────
//
// Wrappers ready for when the marketing site starts firing `umami.track()`
// calls. Until then, every read returns an empty list — wired now so the
// UI surface can light up immediately on the day events start arriving.
export interface UmamiEvent {
id: string;
sessionId: string;
websiteId: string;
createdAt: string;
urlPath: string;
eventName?: string;
pageTitle?: string;
}
export async function getEvents(
portId: string,
range: DateRange,
opts: { page?: number; pageSize?: number } = {},
): Promise<{ data: UmamiEvent[]; count: number; page: number; pageSize: number } | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch(config, `/api/websites/${config.websiteId}/events`, {
...rangeToParams(range),
page: opts.page ?? 1,
pageSize: opts.pageSize ?? 25,
});
}
export async function getEventsStats(
portId: string,
range: DateRange,
): Promise<{ pageviews: number; visitors: number; events: number } | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch(config, `/api/websites/${config.websiteId}/events/stats`, {
...rangeToParams(range),
});
}
export async function getEventsSeries(
portId: string,
range: DateRange,
eventName: string,
unit: 'hour' | 'day' | 'month' = 'day',
): Promise<Array<{ x: string; y: number }> | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch(config, `/api/websites/${config.websiteId}/events/series`, {
...rangeToParams(range),
eventName,
unit,
timezone: 'UTC',
});
}
// ─── Reports (POST endpoints) ──────────────────────────────────────────────
//
// Reports are POST-only and take a JSON body; build a sibling `umamiPost`
// helper that handles auth + error shape the same way as `umamiFetch`.
async function umamiPost<T>(config: UmamiPortConfig, path: string, body: unknown): Promise<T> {
const bearer = await resolveBearer(config);
const res = await fetchWithTimeout(`${config.apiUrl}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${bearer}`,
'Content-Type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (res.status === 401 || res.status === 403) {
if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`);
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
internalMessage: `Umami unauthorized: ${res.status}`,
});
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
internalMessage: `Umami ${path} failed: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`,
});
}
return (await res.json()) as T;
}
export interface UmamiFunnelStep {
x: string;
y: number;
z: number;
dropoff: number;
}
export async function runFunnelReport(
portId: string,
range: DateRange,
steps: Array<{ type: 'url' | 'event'; value: string }>,
windowHours = 24,
): Promise<UmamiFunnelStep[] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
const { from, to } = rangeToBounds(range);
return umamiPost<UmamiFunnelStep[]>(config, `/api/reports/funnel`, {
websiteId: config.websiteId,
steps,
window: windowHours * 3600,
dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' },
});
}
export interface UmamiJourneyStep {
items: string[];
count: number;
}
export async function runJourneyReport(
portId: string,
range: DateRange,
startStep?: string,
endStep?: string,
stepCount = 5,
): Promise<UmamiJourneyStep[] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
const { from, to } = rangeToBounds(range);
return umamiPost<UmamiJourneyStep[]>(config, `/api/reports/journey`, {
websiteId: config.websiteId,
startStep,
endStep,
steps: stepCount,
dateRange: { startDate: from.toISOString(), endDate: to.toISOString(), timezone: 'UTC' },
});
}
// ─── CRM → Umami event push (Phase 6) ──────────────────────────────────────
//
// Thin wrapper around `@umami/node` so CRM outcome events land in the same
// Umami instance the marketing site reports to. Per-port client instances
// are cached so we don't re-instantiate on every event.
import { Umami } from '@umami/node';
const trackerByPort = new Map<string, Umami>();
async function getTracker(portId: string): Promise<Umami | null> {
const cached = trackerByPort.get(portId);
if (cached) return cached;
const config = await loadUmamiConfig(portId);
if (!config) return null;
const tracker = new Umami({ websiteId: config.websiteId, hostUrl: config.apiUrl });
trackerByPort.set(portId, tracker);
return tracker;
}
/**
* Push a CRM-side event back to Umami. Outcome milestones (eoi-sent,
* eoi-signed, reservation-paid, contract-signed) flow through here so
* Umami's funnel + attribution reports can correlate marketing-site
* traffic with downstream deal outcomes.
*
* Soft-fail: if Umami is unreachable or misconfigured the call swallows
* the error and logs a warning — outcome events shouldn't fail a CRM
* mutation.
*/
export async function trackEvent(
portId: string,
name: string,
data?: Record<string, unknown>,
url?: string,
): Promise<void> {
try {
const tracker = await getTracker(portId);
if (!tracker) return;
await tracker.track({
url: url ?? `/crm/${name}`,
name,
...(data ? { data } : {}),
});
} catch (err) {
logger.warn({ err, name }, 'Umami trackEvent failed (non-blocking)');
}
}