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 */}
USERS IN LAST 30 MINUTES
{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 (
); }