feat: share dashboard & reports, sankey report, new widgets
* fix: prompt card shadows on light mode * fix: handle past_due and unpaid from polar * wip * wip * wip 1 * fix: improve types for chart/reports * wip share
This commit is contained in:
committed by
GitHub
parent
39251c8598
commit
ed1c57dbb8
@@ -2,7 +2,6 @@ import {
|
||||
HeadContent,
|
||||
Scripts,
|
||||
createRootRouteWithContext,
|
||||
useRouteContext,
|
||||
} from '@tanstack/react-router';
|
||||
|
||||
import 'flag-icons/css/flag-icons.min.css';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BarChartHorizontalIcon,
|
||||
ChartScatterIcon,
|
||||
ConeIcon,
|
||||
GitBranchIcon,
|
||||
Globe2Icon,
|
||||
HashIcon,
|
||||
LayoutPanelTopIcon,
|
||||
@@ -153,6 +154,7 @@ function Component() {
|
||||
area: AreaChartIcon,
|
||||
retention: ChartScatterIcon,
|
||||
conversion: TrendingUpIcon,
|
||||
sankey: GitBranchIcon,
|
||||
}[report.chartType];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { Button, LinkButton } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -9,48 +8,36 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
import {
|
||||
CopyIcon,
|
||||
LayoutPanelTopIcon,
|
||||
MoreHorizontal,
|
||||
PlusIcon,
|
||||
RotateCcw,
|
||||
Trash,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { timeWindows } from '@openpanel/constants';
|
||||
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import {
|
||||
GrafanaGrid,
|
||||
type Layout,
|
||||
useReportLayouts,
|
||||
} from '@/components/grafana-grid';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import {
|
||||
ReportItem,
|
||||
ReportItemSkeleton,
|
||||
} from '@/components/report/report-item';
|
||||
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
|
||||
import { showConfirm } from '@/modals';
|
||||
import { pushModal, showConfirm } from '@/modals';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, useRouter } from '@tanstack/react-router';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Responsive, WidthProvider } from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
type Layout = {
|
||||
i: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
};
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/dashboards_/$dashboardId',
|
||||
@@ -94,180 +81,6 @@ export const Route = createFileRoute(
|
||||
pendingComponent: FullPageLoadingState,
|
||||
});
|
||||
|
||||
// Report Skeleton Component
|
||||
function ReportSkeleton() {
|
||||
return (
|
||||
<div className="card h-full flex flex-col animate-pulse">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 bg-muted rounded mb-2" />
|
||||
<div className="h-4 w-24 bg-muted/50 rounded" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-muted rounded" />
|
||||
<div className="w-8 h-8 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Report Item Component
|
||||
function ReportItem({
|
||||
report,
|
||||
organizationId,
|
||||
projectId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
}: {
|
||||
report: any;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
range: any;
|
||||
startDate: any;
|
||||
endDate: any;
|
||||
interval: any;
|
||||
onDelete: (reportId: string) => void;
|
||||
onDuplicate: (reportId: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const chartRange = report.range;
|
||||
|
||||
return (
|
||||
<div className="card h-full flex flex-col">
|
||||
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
|
||||
<div
|
||||
className="flex-1 cursor-pointer -m-4 p-4"
|
||||
onClick={(event) => {
|
||||
if (event.metaKey) {
|
||||
window.open(
|
||||
`/${organizationId}/${projectId}/reports/${report.id}`,
|
||||
'_blank',
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.navigate({
|
||||
from: Route.fullPath,
|
||||
to: '/$organizationId/$projectId/reports/$reportId',
|
||||
params: {
|
||||
reportId: report.id,
|
||||
},
|
||||
});
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
router.navigate({
|
||||
from: Route.fullPath,
|
||||
to: '/$organizationId/$projectId/reports/$reportId',
|
||||
params: {
|
||||
reportId: report.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="font-medium">{report.name}</div>
|
||||
{chartRange !== null && (
|
||||
<div className="mt-2 flex gap-2 ">
|
||||
<span
|
||||
className={
|
||||
(chartRange !== range && range !== null) ||
|
||||
(startDate && endDate)
|
||||
? 'line-through'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
{startDate && endDate ? (
|
||||
<span>Custom dates</span>
|
||||
) : (
|
||||
range !== null &&
|
||||
chartRange !== range && (
|
||||
<span>
|
||||
{timeWindows[range as keyof typeof timeWindows]?.label}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className="opacity-30 hover:opacity-100"
|
||||
>
|
||||
<circle cx="4" cy="4" r="1.5" />
|
||||
<circle cx="4" cy="8" r="1.5" />
|
||||
<circle cx="4" cy="12" r="1.5" />
|
||||
<circle cx="12" cy="4" r="1.5" />
|
||||
<circle cx="12" cy="8" r="1.5" />
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
|
||||
<MoreHorizontal size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDuplicate(report.id);
|
||||
}}
|
||||
>
|
||||
<CopyIcon size={16} className="mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(report.id);
|
||||
}}
|
||||
>
|
||||
<Trash size={16} className="mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 overflow-auto flex-1',
|
||||
report.chartType === 'metric' && 'p-0',
|
||||
)}
|
||||
>
|
||||
<ReportChart
|
||||
report={
|
||||
{
|
||||
...report,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? null,
|
||||
endDate: endDate ?? null,
|
||||
interval: interval ?? report.interval,
|
||||
} as any
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Component() {
|
||||
const router = useRouter();
|
||||
const { organizationId, dashboardId, projectId } = Route.useParams();
|
||||
@@ -363,26 +176,7 @@ function Component() {
|
||||
);
|
||||
|
||||
// Convert reports to grid layout format for all breakpoints
|
||||
const layouts = useMemo(() => {
|
||||
const baseLayout = reports.map((report, index) => ({
|
||||
i: report.id,
|
||||
x: report.layout?.x ?? (index % 2) * 6,
|
||||
y: report.layout?.y ?? Math.floor(index / 2) * 4,
|
||||
w: report.layout?.w ?? 6,
|
||||
h: report.layout?.h ?? 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
}));
|
||||
|
||||
// Create responsive layouts for different breakpoints
|
||||
return {
|
||||
lg: baseLayout,
|
||||
md: baseLayout,
|
||||
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
|
||||
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
|
||||
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
|
||||
};
|
||||
}, [reports]);
|
||||
const layouts = useReportLayouts(reports);
|
||||
|
||||
const handleLayoutChange = useCallback((newLayout: Layout[]) => {
|
||||
// This is called during dragging/resizing, we'll save on drag/resize stop
|
||||
@@ -463,7 +257,7 @@ function Component() {
|
||||
<PageHeader
|
||||
title={dashboard.name}
|
||||
description="View and manage your reports"
|
||||
className="mb-0"
|
||||
className="mb-4"
|
||||
actions={
|
||||
<>
|
||||
<OverviewRange />
|
||||
@@ -484,6 +278,14 @@ function Component() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
pushModal('ShareDashboardModal', { dashboardId })
|
||||
}
|
||||
>
|
||||
<ShareIcon className="mr-2 size-4" />
|
||||
Share dashboard
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
showConfirm({
|
||||
@@ -532,69 +334,43 @@ function Component() {
|
||||
</FullPageEmptyState>
|
||||
) : !isGridReady || reportsQuery.isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full overflow-hidden -mx-4">
|
||||
<style>{`
|
||||
.react-grid-item {
|
||||
transition: ${enableTransitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
|
||||
}
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
background: none !important;
|
||||
opacity: 0.5;
|
||||
transition-duration: 100ms;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed var(--primary);
|
||||
}
|
||||
.react-grid-item.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
`}</style>
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
layouts={layouts}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={100}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStop={handleResizeStop}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
isDraggable={true}
|
||||
isResizable={true}
|
||||
margin={[16, 16]}
|
||||
transformScale={1}
|
||||
useCSSTransforms={true}
|
||||
>
|
||||
{reports.map((report) => (
|
||||
<div key={report.id}>
|
||||
<ReportItem
|
||||
report={report}
|
||||
organizationId={organizationId}
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={interval}
|
||||
onDelete={(reportId) => {
|
||||
reportDeletion.mutate({ reportId });
|
||||
}}
|
||||
onDuplicate={(reportId) => {
|
||||
reportDuplicate.mutate({ reportId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
</div>
|
||||
<GrafanaGrid
|
||||
transitions={enableTransitions}
|
||||
layouts={layouts}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStop={handleResizeStop}
|
||||
isDraggable={true}
|
||||
isResizable={true}
|
||||
>
|
||||
{reports.map((report) => (
|
||||
<div key={report.id}>
|
||||
<ReportItem
|
||||
report={report}
|
||||
organizationId={organizationId}
|
||||
projectId={projectId}
|
||||
range={range}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={interval}
|
||||
onDelete={(reportId) => {
|
||||
reportDeletion.mutate({ reportId });
|
||||
}}
|
||||
onDuplicate={(reportId) => {
|
||||
reportDuplicate.mutate({ reportId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GrafanaGrid>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -274,20 +274,16 @@ const PageCard = memo(
|
||||
</div>
|
||||
<ReportChart
|
||||
options={{
|
||||
hideID: true,
|
||||
hideXAxis: true,
|
||||
hideYAxis: true,
|
||||
aspectRatio: 0.15,
|
||||
}}
|
||||
report={{
|
||||
lineType: 'linear',
|
||||
breakdowns: [],
|
||||
name: 'screen_view',
|
||||
metric: 'sum',
|
||||
range,
|
||||
interval,
|
||||
previous: true,
|
||||
|
||||
chartType: 'linear',
|
||||
projectId,
|
||||
series: [
|
||||
|
||||
@@ -36,5 +36,6 @@ function Component() {
|
||||
const { reportId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const query = useSuspenseQuery(trpc.report.get.queryOptions({ reportId }));
|
||||
console.log(query.data);
|
||||
return <ReportEditor report={query.data} />;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ function ProjectDashboard() {
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'events', label: 'Events' },
|
||||
{ id: 'clients', label: 'Clients' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
{ id: 'imports', label: 'Imports' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import CopyInput from '@/components/forms/copy-input';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import Syntax from '@/components/syntax';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type {
|
||||
IRealtimeWidgetOptions,
|
||||
IWidgetType,
|
||||
} from '@openpanel/validation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { ExternalLinkIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/widgets',
|
||||
)({
|
||||
component: Component,
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, organizationId } = useAppParams();
|
||||
const { dashboardUrl } = useAppContext();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch both widget types
|
||||
const realtimeWidgetQuery = useQuery(
|
||||
trpc.widget.get.queryOptions({ projectId, type: 'realtime' }),
|
||||
);
|
||||
const counterWidgetQuery = useQuery(
|
||||
trpc.widget.get.queryOptions({ projectId, type: 'counter' }),
|
||||
);
|
||||
|
||||
// Toggle mutation
|
||||
const toggleMutation = useMutation(
|
||||
trpc.widget.toggle.mutationOptions({
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries(
|
||||
trpc.widget.get.queryFilter({ projectId, type: variables.type }),
|
||||
);
|
||||
toast.success(variables.enabled ? 'Widget enabled' : 'Widget disabled');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update widget');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Update options mutation
|
||||
const updateOptionsMutation = useMutation(
|
||||
trpc.widget.updateOptions.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(
|
||||
trpc.widget.get.queryFilter({ projectId, type: 'realtime' }),
|
||||
);
|
||||
toast.success('Widget options updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to update options');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleToggle = (type: IWidgetType, enabled: boolean) => {
|
||||
toggleMutation.mutate({
|
||||
projectId,
|
||||
organizationId,
|
||||
type,
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
if (realtimeWidgetQuery.isLoading || counterWidgetQuery.isLoading) {
|
||||
return <FullPageLoadingState />;
|
||||
}
|
||||
|
||||
const realtimeWidget = realtimeWidgetQuery.data;
|
||||
const counterWidget = counterWidgetQuery.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{realtimeWidget && (
|
||||
<RealtimeWidgetSection
|
||||
widget={realtimeWidget as any}
|
||||
dashboardUrl={dashboardUrl}
|
||||
isToggling={toggleMutation.isPending}
|
||||
isUpdatingOptions={updateOptionsMutation.isPending}
|
||||
onToggle={(enabled) => handleToggle('realtime', enabled)}
|
||||
onUpdateOptions={(options) =>
|
||||
updateOptionsMutation.mutate({
|
||||
projectId,
|
||||
organizationId,
|
||||
options,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{counterWidget && (
|
||||
<CounterWidgetSection
|
||||
widget={counterWidget}
|
||||
dashboardUrl={dashboardUrl}
|
||||
isToggling={toggleMutation.isPending}
|
||||
onToggle={(enabled) => handleToggle('counter', enabled)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RealtimeWidgetSectionProps {
|
||||
widget: {
|
||||
id: string;
|
||||
public: boolean;
|
||||
options: IRealtimeWidgetOptions;
|
||||
} | null;
|
||||
dashboardUrl: string;
|
||||
isToggling: boolean;
|
||||
isUpdatingOptions: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdateOptions: (options: IRealtimeWidgetOptions) => void;
|
||||
}
|
||||
|
||||
function RealtimeWidgetSection({
|
||||
widget,
|
||||
dashboardUrl,
|
||||
isToggling,
|
||||
isUpdatingOptions,
|
||||
onToggle,
|
||||
onUpdateOptions,
|
||||
}: RealtimeWidgetSectionProps) {
|
||||
const isEnabled = widget?.public ?? false;
|
||||
const widgetUrl =
|
||||
isEnabled && widget?.id
|
||||
? `${dashboardUrl}/widget/realtime?shareId=${widget.id}`
|
||||
: null;
|
||||
const embedCode = widgetUrl
|
||||
? `<iframe src="${widgetUrl}" width="100%" height="400" frameborder="0" style="border-radius: 8px;"></iframe>`
|
||||
: null;
|
||||
|
||||
// Default options
|
||||
const defaultOptions: IRealtimeWidgetOptions = {
|
||||
type: 'realtime',
|
||||
referrers: true,
|
||||
countries: true,
|
||||
paths: false,
|
||||
};
|
||||
|
||||
const [options, setOptions] = useState<IRealtimeWidgetOptions>(
|
||||
(widget?.options as IRealtimeWidgetOptions) || defaultOptions,
|
||||
);
|
||||
|
||||
// Update local options when widget data changes
|
||||
useEffect(() => {
|
||||
if (widget?.options) {
|
||||
setOptions(widget.options as IRealtimeWidgetOptions);
|
||||
}
|
||||
}, [widget?.options]);
|
||||
|
||||
const handleUpdateOptions = (newOptions: IRealtimeWidgetOptions) => {
|
||||
setOptions(newOptions);
|
||||
onUpdateOptions(newOptions);
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead className="row items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<span className="title">Realtime Widget</span>
|
||||
<p className="text-muted-foreground">
|
||||
Embed a realtime visitor counter widget on your website. The widget
|
||||
shows live visitor count, activity histogram, top countries,
|
||||
referrers and paths.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={onToggle}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</WidgetHead>
|
||||
{isEnabled && (
|
||||
<WidgetBody className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">Widget Options</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="referrers" className="text-sm">
|
||||
Show Referrers
|
||||
</Label>
|
||||
<Switch
|
||||
id="referrers"
|
||||
checked={options.referrers}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateOptions({ ...options, referrers: checked })
|
||||
}
|
||||
disabled={isUpdatingOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="countries" className="text-sm">
|
||||
Show Countries
|
||||
</Label>
|
||||
<Switch
|
||||
id="countries"
|
||||
checked={options.countries}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateOptions({ ...options, countries: checked })
|
||||
}
|
||||
disabled={isUpdatingOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="paths" className="text-sm">
|
||||
Show Paths
|
||||
</Label>
|
||||
<Switch
|
||||
id="paths"
|
||||
checked={options.paths}
|
||||
onCheckedChange={(checked) =>
|
||||
handleUpdateOptions({ ...options, paths: checked })
|
||||
}
|
||||
disabled={isUpdatingOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Widget URL</h3>
|
||||
<CopyInput label="" value={widgetUrl!} className="w-full" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Direct link to the widget. You can open this in a new tab or embed
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Embed Code</h3>
|
||||
<Syntax code={embedCode!} language="bash" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy this code and paste it into your website HTML where you want
|
||||
the widget to appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Preview</h3>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
src={widgetUrl!}
|
||||
width="100%"
|
||||
height="600"
|
||||
className="border-0"
|
||||
title="Realtime Widget Preview"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={ExternalLinkIcon}
|
||||
onClick={() =>
|
||||
window.open(widgetUrl!, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
Open in new tab
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
|
||||
interface CounterWidgetSectionProps {
|
||||
widget: {
|
||||
id: string;
|
||||
public: boolean;
|
||||
} | null;
|
||||
dashboardUrl: string;
|
||||
isToggling: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function CounterWidgetSection({
|
||||
widget,
|
||||
dashboardUrl,
|
||||
isToggling,
|
||||
onToggle,
|
||||
}: CounterWidgetSectionProps) {
|
||||
const isEnabled = widget?.public ?? false;
|
||||
const counterUrl =
|
||||
isEnabled && widget?.id
|
||||
? `${dashboardUrl}/widget/counter?shareId=${widget.id}`
|
||||
: null;
|
||||
const counterEmbedCode = counterUrl
|
||||
? `<iframe src="${counterUrl}" height="32" style="border: none; overflow: hidden;" title="Visitor Counter"></iframe>`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Widget className="max-w-screen-md w-full">
|
||||
<WidgetHead className="row items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<span className="title">Counter Widget</span>
|
||||
<p className="text-muted-foreground">
|
||||
A compact live visitor counter badge you can embed anywhere. Shows
|
||||
the current number of unique visitors with a live indicator.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={onToggle}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</WidgetHead>
|
||||
{isEnabled && counterUrl && (
|
||||
<WidgetBody className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Widget URL</h3>
|
||||
<CopyInput label="" value={counterUrl} className="w-full" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Direct link to the counter widget.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Embed Code</h3>
|
||||
<Syntax code={counterEmbedCode!} language="bash" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Copy this code and paste it into your website HTML where you want
|
||||
the counter to appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Preview</h3>
|
||||
<div className="border rounded-lg p-4 bg-muted/30">
|
||||
<iframe
|
||||
src={counterUrl}
|
||||
height="32"
|
||||
className="border-0"
|
||||
title="Counter Widget Preview"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={ExternalLinkIcon}
|
||||
onClick={() =>
|
||||
window.open(counterUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
Open in new tab
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
158
apps/start/src/routes/share.dashboard.$shareId.tsx
Normal file
158
apps/start/src/routes/share.dashboard.$shareId.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { GrafanaGrid, useReportLayouts } from '@/components/grafana-grid';
|
||||
import { LoginNavbar } from '@/components/login-navbar';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import {
|
||||
ReportItem,
|
||||
ReportItemReadOnly,
|
||||
ReportItemSkeleton,
|
||||
} from '@/components/report/report-item';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { timeWindows } from '@openpanel/constants';
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const shareSearchSchema = z.object({
|
||||
header: z.optional(z.number().or(z.string().or(z.boolean()))),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/share/dashboard/$shareId')({
|
||||
component: RouteComponent,
|
||||
validateSearch: shareSearchSchema,
|
||||
loader: async ({ context, params }) => {
|
||||
const share = await context.queryClient.ensureQueryData(
|
||||
context.trpc.share.dashboard.queryOptions({
|
||||
shareId: params.shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
return { share };
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
if (!loaderData || !loaderData.share) {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: 'Share not found - OpenPanel.dev',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: `${loaderData.share.dashboard?.name} - ${loaderData.share.organization?.name} - OpenPanel.dev`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
pendingComponent: FullPageLoadingState,
|
||||
errorComponent: () => (
|
||||
<FullPageEmptyState
|
||||
title="Share not found"
|
||||
description="The dashboard you are looking for does not exist."
|
||||
className="min-h-[calc(100vh-theme(spacing.16))]"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { shareId } = Route.useParams();
|
||||
const { header } = useSearch({ from: '/share/dashboard/$shareId' });
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const shareQuery = useSuspenseQuery(
|
||||
trpc.share.dashboard.queryOptions({
|
||||
shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
const reportsQuery = useQuery(
|
||||
trpc.share.dashboardReports.queryOptions({
|
||||
shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
const hasAccess = shareQuery.data?.hasAccess;
|
||||
|
||||
if (!shareQuery.data) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
if (!shareQuery.data.public) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
const share = shareQuery.data;
|
||||
|
||||
// Handle password protection
|
||||
if (share.password && !hasAccess) {
|
||||
return <ShareEnterPassword shareId={share.id} shareType="dashboard" />;
|
||||
}
|
||||
|
||||
const isHeaderVisible =
|
||||
header !== '0' && header !== 0 && header !== 'false' && header !== false;
|
||||
|
||||
const reports = reportsQuery.data ?? [];
|
||||
|
||||
// Convert reports to grid layout format for all breakpoints
|
||||
const layouts = useReportLayouts(reports);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isHeaderVisible && (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<LoginNavbar className="relative p-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="sticky-header [animation-range:50px_100px]!">
|
||||
<div className="p-4 col gap-2 mx-auto max-w-7xl">
|
||||
<div className="row justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl p-4">
|
||||
{reportsQuery.isLoading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
<ReportItemSkeleton />
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<FullPageEmptyState title="No reports" />
|
||||
) : (
|
||||
<GrafanaGrid layouts={layouts}>
|
||||
{reports.map((report) => (
|
||||
<div key={report.id}>
|
||||
<ReportItemReadOnly
|
||||
report={report}
|
||||
shareId={shareId}
|
||||
range={range}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
interval={interval}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GrafanaGrid>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,8 @@ import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||
import { EyeClosedIcon, FrownIcon } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const shareSearchSchema = z.object({
|
||||
|
||||
123
apps/start/src/routes/share.report.$shareId.tsx
Normal file
123
apps/start/src/routes/share.report.$shareId.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { LoginNavbar } from '@/components/login-navbar';
|
||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||
import { OverviewRange } from '@/components/overview/overview-range';
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const shareSearchSchema = z.object({
|
||||
header: z.optional(z.number().or(z.string().or(z.boolean()))),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/share/report/$shareId')({
|
||||
component: RouteComponent,
|
||||
validateSearch: shareSearchSchema,
|
||||
loader: async ({ context, params }) => {
|
||||
const share = await context.queryClient.ensureQueryData(
|
||||
context.trpc.share.report.queryOptions({
|
||||
shareId: params.shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!share) {
|
||||
return { share: null };
|
||||
}
|
||||
|
||||
return { share };
|
||||
},
|
||||
head: ({ loaderData }) => {
|
||||
if (!loaderData || !loaderData.share) {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: 'Share not found - OpenPanel.dev',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
title: `${loaderData.share.report.name || 'Report'} - ${loaderData.share.organization?.name} - OpenPanel.dev`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
pendingComponent: FullPageLoadingState,
|
||||
errorComponent: () => (
|
||||
<FullPageEmptyState
|
||||
title="Share not found"
|
||||
description="The report you are looking for does not exist."
|
||||
className="min-h-[calc(100vh-theme(spacing.16))]"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { shareId } = Route.useParams();
|
||||
const { header } = useSearch({ from: '/share/report/$shareId' });
|
||||
const trpc = useTRPC();
|
||||
const shareQuery = useSuspenseQuery(
|
||||
trpc.share.report.queryOptions({
|
||||
shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
const hasAccess = shareQuery.data?.hasAccess;
|
||||
|
||||
if (!shareQuery.data) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
if (!shareQuery.data.public) {
|
||||
throw notFound();
|
||||
}
|
||||
|
||||
const share = shareQuery.data;
|
||||
|
||||
console.log('share', share);
|
||||
|
||||
// Handle password protection
|
||||
if (share.password && !hasAccess) {
|
||||
return <ShareEnterPassword shareId={share.id} shareType="report" />;
|
||||
}
|
||||
|
||||
const isHeaderVisible =
|
||||
header !== '0' && header !== 0 && header !== 'false' && header !== false;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isHeaderVisible && (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<LoginNavbar className="relative p-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="sticky-header [animation-range:50px_100px]!">
|
||||
<div className="p-4 col gap-2 mx-auto max-w-7xl">
|
||||
<div className="row justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewRange />
|
||||
<OverviewInterval />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-7xl p-4">
|
||||
<div className="card">
|
||||
<div className="p-4 border-b">
|
||||
<div className="font-medium text-xl">{share.report.name}</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ReportChart report={share.report} shareId={shareId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
apps/start/src/routes/widget/counter.tsx
Normal file
86
apps/start/src/routes/widget/counter.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { AnimatedNumber } from '@/components/animated-number';
|
||||
import { Ping } from '@/components/ping';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const widgetSearchSchema = z.object({
|
||||
shareId: z.string(),
|
||||
limit: z.number().default(10),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/widget/counter')({
|
||||
component: RouteComponent,
|
||||
validateSearch: widgetSearchSchema,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { shareId, limit, color } = Route.useSearch();
|
||||
const trpc = useTRPC();
|
||||
|
||||
// Fetch widget data
|
||||
const { data, isLoading } = useQuery(
|
||||
trpc.widget.counter.queryOptions({ shareId }),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 h-8">
|
||||
<Ping />
|
||||
<AnimatedNumber value={0} suffix=" unique visitors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 h-8">
|
||||
<Ping className="bg-orange-500" />
|
||||
<AnimatedNumber value={0} suffix=" unique visitors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <CounterWidget shareId={shareId} data={data} />;
|
||||
}
|
||||
|
||||
interface RealtimeWidgetProps {
|
||||
shareId: string;
|
||||
data: RouterOutputs['widget']['counter'];
|
||||
}
|
||||
|
||||
function CounterWidget({ shareId, data }: RealtimeWidgetProps) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const number = useNumber();
|
||||
|
||||
// WebSocket subscription for real-time updates
|
||||
useWS<number>(
|
||||
`/live/visitors/${data.projectId}`,
|
||||
(res) => {
|
||||
if (!document.hidden) {
|
||||
queryClient.refetchQueries(
|
||||
trpc.widget.counter.queryFilter({ shareId }),
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 60000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 h-8">
|
||||
<Ping />
|
||||
<AnimatedNumber value={data.counter} suffix=" unique visitors" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
528
apps/start/src/routes/widget/realtime.tsx
Normal file
528
apps/start/src/routes/widget/realtime.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
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';
|
||||
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';
|
||||
|
||||
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 <RealtimeWidgetSkeleton limit={limit} />;
|
||||
}
|
||||
|
||||
if (!widgetData) {
|
||||
return (
|
||||
<div className="flex h-screen w-full center-center bg-background text-foreground col p-4">
|
||||
<LogoSquare className="size-10 mb-4" />
|
||||
<h1 className="text-xl font-semibold">Widget not found</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This widget is not available or has been removed.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RealtimeWidget
|
||||
shareId={shareId}
|
||||
limit={limit}
|
||||
data={widgetData}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
const number = useNumber();
|
||||
|
||||
// WebSocket subscription for real-time updates
|
||||
useWS<number>(
|
||||
`/live/visitors/${data.projectId}`,
|
||||
() => {
|
||||
if (!document.hidden) {
|
||||
queryClient.refetchQueries(
|
||||
trpc.widget.realtimeData.queryFilter({ shareId }),
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 60000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen w-full flex-col bg-background text-foreground">
|
||||
{/* Header with live counter */}
|
||||
<div className="border-b p-6 pb-3">
|
||||
<div className="flex items-center justify-between w-full h-4">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<Ping />
|
||||
<div className="text-sm font-medium text-muted-foreground flex-1">
|
||||
USERS IN LAST 30 MINUTES
|
||||
</div>
|
||||
{data.project.domain && <SerieIcon name={data.project.domain} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="font-mono text-6xl font-bold h-18 text-foreground">
|
||||
<AnimatedNumber value={data.liveCount} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-20 w-full flex-col -mt-4">
|
||||
<div className="flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data.histogram}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<Tooltip
|
||||
content={CustomTooltip}
|
||||
cursor={{ fill: 'var(--def-100)', radius: 4 }}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||
ticks={[
|
||||
data.histogram[0].time,
|
||||
data.histogram[data.histogram.length - 1].time,
|
||||
]}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<Bar
|
||||
dataKey="sessionCount"
|
||||
isAnimationActive={false}
|
||||
radius={[4, 4, 4, 4]}
|
||||
fill={color || 'var(--chart-0)'}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar">
|
||||
{/* Histogram */}
|
||||
{/* Countries and Referrers */}
|
||||
{(data.countries.length > 0 || data.referrers.length > 0) && (
|
||||
<div className={cn('grid grid-cols-1 gap-6', grids)}>
|
||||
{/* Countries */}
|
||||
{data.countries.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
COUNTRY
|
||||
</div>
|
||||
<div className="col">
|
||||
{(() => {
|
||||
const { visible, rest, restCount } = getRestItems(
|
||||
data.countries,
|
||||
limit,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{visible.map((item) => (
|
||||
<RowItem key={item.country} count={item.count}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SerieIcon name={item.country} />
|
||||
<span className="text-sm">
|
||||
{countries[
|
||||
item.country as keyof typeof countries
|
||||
] || item.country}
|
||||
</span>
|
||||
</div>
|
||||
</RowItem>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<RestRow
|
||||
firstName={
|
||||
countries[
|
||||
rest[0].country as keyof typeof countries
|
||||
] || rest[0].country
|
||||
}
|
||||
restCount={rest.length}
|
||||
totalCount={restCount}
|
||||
type="countries"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Referrers */}
|
||||
{data.referrers.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
REFERRER
|
||||
</div>
|
||||
<div className="col">
|
||||
{(() => {
|
||||
const { visible, rest, restCount } = getRestItems(
|
||||
data.referrers,
|
||||
limit,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{visible.map((item) => (
|
||||
<RowItem key={item.referrer} count={item.count}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SerieIcon name={item.referrer} />
|
||||
<span className="truncate text-sm">
|
||||
{item.referrer}
|
||||
</span>
|
||||
</div>
|
||||
</RowItem>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<RestRow
|
||||
firstName={rest[0].referrer}
|
||||
restCount={rest.length}
|
||||
totalCount={restCount}
|
||||
type="referrers"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paths */}
|
||||
{data.paths.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
PATH
|
||||
</div>
|
||||
<div className="col">
|
||||
{(() => {
|
||||
const { visible, rest, restCount } = getRestItems(
|
||||
data.paths,
|
||||
limit,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{visible.map((item) => (
|
||||
<RowItem key={item.path} count={item.count}>
|
||||
<span className="truncate text-sm">
|
||||
{item.path}
|
||||
</span>
|
||||
</RowItem>
|
||||
))}
|
||||
{rest.length > 0 && (
|
||||
<RestRow
|
||||
firstName={rest[0].path}
|
||||
restCount={rest.length}
|
||||
totalCount={restCount}
|
||||
type="paths"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom tooltip component that uses portals to escape overflow hidden
|
||||
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
const number = useNumber();
|
||||
|
||||
if (!active || !payload || !payload.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0].payload;
|
||||
|
||||
return (
|
||||
<ChartTooltipContainer className="max-w-[100px]">
|
||||
<ChartTooltipHeader>
|
||||
<div>{data.time}</div>
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={getChartColor(0)} innerClassName="row gap-1">
|
||||
<div className="flex-1">Visitors</div>
|
||||
<div>{number.short(data.sessionCount)}</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
);
|
||||
};
|
||||
|
||||
function RowItem({
|
||||
children,
|
||||
count,
|
||||
}: { children: React.ReactNode; count: number }) {
|
||||
const number = useNumber();
|
||||
return (
|
||||
<div className="h-10 text-sm flex items-center justify-between px-3 py-2 border-b hover:bg-foreground/5 -mx-3">
|
||||
{children}
|
||||
<span className="font-semibold">{number.short(count)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRestItems<T extends { count: number }>(
|
||||
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 (
|
||||
<div className="h-10 text-sm flex items-center justify-between px-3 py-2 border-b hover:bg-foreground/5 -mx-3">
|
||||
<span className="truncate">
|
||||
{firstName} and {otherCount} more {typeLabel}...
|
||||
</span>
|
||||
<span className="font-semibold">{number.short(totalCount)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex h-screen w-full flex-col bg-background text-foreground animate-pulse">
|
||||
{/* Header with live counter */}
|
||||
<div className="border-b p-6 pb-3">
|
||||
<div className="flex items-center justify-between w-full h-4">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="size-2 rounded-full bg-muted" />
|
||||
<div className="text-sm font-medium text-muted-foreground flex-1">
|
||||
USERS IN LAST 30 MINUTES
|
||||
</div>
|
||||
</div>
|
||||
<div className="size-4 shrink-0 rounded bg-muted" />
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div className="font-mono text-6xl font-bold h-18 flex items-center py-4 gap-1 row">
|
||||
<div className="h-full w-6 bg-muted rounded" />
|
||||
<div className="h-full w-6 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-20 w-full flex-col -mt-4 pb-2.5">
|
||||
<div className="flex-1 row gap-1 h-full">
|
||||
{SKELETON_HISTOGRAM.map((item, index) => (
|
||||
<div
|
||||
key={index.toString()}
|
||||
style={{ height: `${item}%` }}
|
||||
className="h-full w-full bg-muted rounded mt-auto"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="row justify-between pt-2">
|
||||
<div className="h-3 w-8 bg-muted rounded" />
|
||||
<div className="h-3 w-8 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar">
|
||||
{/* Countries, Referrers, and Paths skeleton */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Countries skeleton */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
COUNTRY
|
||||
</div>
|
||||
<div className="col">
|
||||
{SKELETON_KEYS.countries.slice(0, itemCount).map((key) => (
|
||||
<RowItemSkeleton key={key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referrers skeleton */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
REFERRER
|
||||
</div>
|
||||
<div className="col">
|
||||
{SKELETON_KEYS.referrers.slice(0, itemCount).map((key) => (
|
||||
<RowItemSkeleton key={key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paths skeleton */}
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||
PATH
|
||||
</div>
|
||||
<div className="col">
|
||||
{SKELETON_KEYS.paths.slice(0, itemCount).map((key) => (
|
||||
<RowItemSkeleton key={key} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowItemSkeleton() {
|
||||
return (
|
||||
<div className="h-10 text-sm flex items-center justify-between px-3 py-2 border-b -mx-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-5 rounded bg-muted" />
|
||||
<div className="h-4 w-24 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-4 w-8 bg-muted rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
apps/start/src/routes/widget/test.tsx
Normal file
34
apps/start/src/routes/widget/test.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/widget/test')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="center-center h-screen w-screen gap-4">
|
||||
<iframe
|
||||
title="Realtime Widget"
|
||||
src="http://localhost:3000/widget/realtime?shareId=qkC561&limit=2"
|
||||
width="300"
|
||||
height="400"
|
||||
className="rounded-xl border"
|
||||
/>
|
||||
<iframe
|
||||
title="Realtime Widget"
|
||||
src="http://localhost:3000/widget/realtime?shareId=qkC562&limit=2"
|
||||
width="300"
|
||||
height="400"
|
||||
className="rounded-xl border"
|
||||
/>
|
||||
<iframe
|
||||
title="Counter Widget"
|
||||
src="http://localhost:3000/widget/counter?shareId=qkC561"
|
||||
height="32"
|
||||
width="auto"
|
||||
frameBorder="0"
|
||||
className="rounded-xl border"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user