import { useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import type React from 'react';
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { z } from 'zod';
import { AnimatedNumber } from '@/components/animated-number';
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '@/components/charts/chart-tooltip';
import { LogoSquare } from '@/components/logo';
import { Ping } from '@/components/ping';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { countries } from '@/translations/countries';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
const widgetSearchSchema = z.object({
shareId: z.string(),
limit: z.number().default(10),
color: z.string().optional(),
});
export const Route = createFileRoute('/widget/realtime')({
component: RouteComponent,
validateSearch: widgetSearchSchema,
});
function RouteComponent() {
const { shareId, limit, color } = Route.useSearch();
const trpc = useTRPC();
// Fetch widget data
const { data: widgetData, isLoading } = useQuery(
trpc.widget.realtimeData.queryOptions({ shareId })
);
if (isLoading) {
return ;
}
if (!widgetData) {
return (
Widget not found
This widget is not available or has been removed.
);
}
return (
);
}
interface RealtimeWidgetProps {
shareId: string;
limit: number;
color: string | undefined;
data: RouterOutputs['widget']['realtimeData'];
}
function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
// WebSocket subscription for real-time updates
useWS(
`/live/visitors/${data.projectId}`,
() => {
if (!document.hidden) {
queryClient.refetchQueries(
trpc.widget.realtimeData.queryFilter({ shareId })
);
}
},
{
debounce: {
delay: 1000,
maxWait: 60_000,
},
}
);
const maxDomain =
Math.max(...data.histogram.map((item) => item.sessionCount), 1) * 1.2;
const grids = (() => {
const countries = data.countries.length > 0 ? 1 : 0;
const referrers = data.referrers.length > 0 ? 1 : 0;
const paths = data.paths.length > 0 ? 1 : 0;
const value = countries + referrers + paths;
if (value === 3) {
return 'md:grid-cols-3';
}
if (value === 2) {
return 'md:grid-cols-2';
}
return 'md:grid-cols-1';
})();
return (
{/* Header with live counter */}
USERS IN LAST 30 MINUTES
{data.project.domain &&
}
{(data.countries.length > 0 ||
data.referrers.length > 0 ||
data.paths.length > 0) && (
{/* Countries */}
{data.countries.length > 0 && (
COUNTRY
{(() => {
const { visible, rest, restCount } = getRestItems(
data.countries,
limit
);
return (
<>
{visible.map((item) => (
{countries[
item.country as keyof typeof countries
] || item.country}
))}
{rest.length > 0 && (
)}
>
);
})()}
)}
{/* Referrers */}
{data.referrers.length > 0 && (
REFERRER
{(() => {
const { visible, rest, restCount } = getRestItems(
data.referrers,
limit
);
return (
<>
{visible.map((item) => (
{item.referrer}
))}
{rest.length > 0 && (
)}
>
);
})()}
)}
{/* Paths */}
{data.paths.length > 0 && (
PATH
{(() => {
const { visible, rest, restCount } = getRestItems(
data.paths,
limit
);
return (
<>
{visible.map((item) => (
{item.path}
))}
{rest.length > 0 && (
)}
>
);
})()}
)}
)}
);
}
// Custom tooltip component that uses portals to escape overflow hidden
const CustomTooltip = ({ active, payload }: any) => {
const number = useNumber();
if (!(active && payload && payload.length)) {
return null;
}
const data = payload[0].payload;
return (
{data.time}
Visitors
{number.short(data.sessionCount)}
);
};
function RowItem({
children,
count,
}: {
children: React.ReactNode;
count: number;
}) {
const number = useNumber();
return (
{children}
{number.short(count)}
);
}
function getRestItems(
items: T[],
limit: number
): { visible: T[]; rest: T[]; restCount: number } {
const visible = items.slice(0, limit);
const rest = items.slice(limit);
const restCount = rest.reduce((sum, item) => sum + item.count, 0);
return { visible, rest, restCount };
}
function RestRow({
firstName,
restCount,
totalCount,
type,
}: {
firstName: string;
restCount: number;
totalCount: number;
type: 'countries' | 'referrers' | 'paths';
}) {
const number = useNumber();
const otherCount = restCount - 1;
const typeLabel =
type === 'countries'
? otherCount === 1
? 'country'
: 'countries'
: type === 'referrers'
? otherCount === 1
? 'referrer'
: 'referrers'
: otherCount === 1
? 'path'
: 'paths';
return (
{firstName} and {otherCount} more {typeLabel}...
{number.short(totalCount)}
);
}
// Pre-generated skeleton keys to avoid index-based keys in render
const SKELETON_KEYS = {
countries: [
'country-0',
'country-1',
'country-2',
'country-3',
'country-4',
'country-5',
'country-6',
'country-7',
'country-8',
'country-9',
],
referrers: [
'referrer-0',
'referrer-1',
'referrer-2',
'referrer-3',
'referrer-4',
'referrer-5',
'referrer-6',
'referrer-7',
'referrer-8',
'referrer-9',
],
paths: [
'path-0',
'path-1',
'path-2',
'path-3',
'path-4',
'path-5',
'path-6',
'path-7',
'path-8',
'path-9',
],
};
// Pre-generated skeleton histogram data
const SKELETON_HISTOGRAM = [
24, 48, 21, 32, 19, 16, 52, 14, 11, 7, 12, 18, 25, 65, 55, 62, 9, 68, 10, 31,
58, 70, 10, 47, 43, 10, 38, 35, 41, 28,
];
function RealtimeWidgetSkeleton({ limit }: { limit: number }) {
const itemCount = Math.min(limit, 5);
return (
{/* Header with live counter */}
{SKELETON_HISTOGRAM.map((item, index) => (
))}
{/* Countries, Referrers, and Paths skeleton */}
{/* Countries skeleton */}
COUNTRY
{SKELETON_KEYS.countries.slice(0, itemCount).map((key) => (
))}
{/* Referrers skeleton */}
REFERRER
{SKELETON_KEYS.referrers.slice(0, itemCount).map((key) => (
))}
{/* Paths skeleton */}
PATH
{SKELETON_KEYS.paths.slice(0, itemCount).map((key) => (
))}
);
}
function RowItemSkeleton() {
return (
);
}