wip share

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-14 08:59:25 +01:00
parent 13bd16b207
commit f880b9a697
19 changed files with 1577 additions and 399 deletions

View File

@@ -10,7 +10,6 @@
"cf-typegen": "wrangler types",
"build": "pnpm with-env vite build",
"serve": "vite preview",
"test": "vitest run",
"format": "biome format",
"lint": "biome lint",
"check": "biome check",
@@ -26,7 +25,7 @@
"@hookform/resolvers": "^3.3.4",
"@hyperdx/node-opentelemetry": "^0.8.1",
"@nivo/sankey": "^0.99.0",
"@number-flow/react": "0.3.5",
"@number-flow/react": "0.5.10",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/integrations": "workspace:^",
@@ -150,7 +149,7 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@cloudflare/vite-plugin": "^1.13.12",
"@cloudflare/vite-plugin": "1.20.3",
"@openpanel/db": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@tanstack/devtools-event-client": "^0.3.3",
@@ -171,6 +170,6 @@
"vite": "^6.3.5",
"vitest": "^3.0.5",
"web-vitals": "^4.2.4",
"wrangler": "^4.42.2"
"wrangler": "4.59.1"
}
}

View File

@@ -1,20 +1,6 @@
import type { NumberFlowProps } from '@number-flow/react';
import { useEffect, useState } from 'react';
import ReactAnimatedNumber from '@number-flow/react';
// NumberFlow is breaking ssr and forces loaders to fetch twice
export function AnimatedNumber(props: NumberFlowProps) {
const [Component, setComponent] =
useState<React.ComponentType<NumberFlowProps> | null>(null);
useEffect(() => {
import('@number-flow/react').then(({ default: NumberFlow }) => {
setComponent(NumberFlow);
});
}, []);
if (!Component) {
return <>{props.value}</>;
}
return <Component {...props} />;
return <ReactAnimatedNumber {...props} />;
}

View File

@@ -16,6 +16,9 @@ import { Route as PublicRouteImport } from './routes/_public'
import { Route as LoginRouteImport } from './routes/_login'
import { Route as AppRouteImport } from './routes/_app'
import { Route as IndexRouteImport } from './routes/index'
import { Route as WidgetTestRouteImport } from './routes/widget/test'
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
import { Route as ApiConfigRouteImport } from './routes/api/config'
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
@@ -60,6 +63,7 @@ import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from
import { Route as AppOrganizationIdProjectIdProfilesTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs.index'
import { Route as AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.index'
import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.index'
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
@@ -119,6 +123,21 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const WidgetTestRoute = WidgetTestRouteImport.update({
id: '/widget/test',
path: '/widget/test',
getParentRoute: () => rootRouteImport,
} as any)
const WidgetRealtimeRoute = WidgetRealtimeRouteImport.update({
id: '/widget/realtime',
path: '/widget/realtime',
getParentRoute: () => rootRouteImport,
} as any)
const WidgetCounterRoute = WidgetCounterRouteImport.update({
id: '/widget/counter',
path: '/widget/counter',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
id: '/api/healthcheck',
path: '/api/healthcheck',
@@ -408,6 +427,12 @@ const AppOrganizationIdProjectIdEventsTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdProjectIdEventsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsWidgetsRoute =
AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport.update({
id: '/widgets',
path: '/widgets',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
AppOrganizationIdProjectIdSettingsTabsImportsRouteImport.update({
id: '/imports',
@@ -506,6 +531,9 @@ export interface FileRoutesByFullPath {
'/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
@@ -553,6 +581,7 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/events/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
@@ -567,6 +596,9 @@ export interface FileRoutesByTo {
'/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
'/onboarding/project': typeof StepsOnboardingProjectRoute
@@ -611,6 +643,7 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
}
export interface FileRoutesById {
@@ -626,6 +659,9 @@ export interface FileRoutesById {
'/_public/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
'/_app/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
'/_app/$organizationId/billing': typeof AppOrganizationIdBillingRoute
'/_app/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
@@ -680,6 +716,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
'/_app/$organizationId/$projectId/events/_tabs/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
@@ -697,6 +734,9 @@ export interface FileRouteTypes {
| '/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
| '/$organizationId/$projectId'
| '/$organizationId/billing'
| '/$organizationId/settings'
@@ -744,6 +784,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/events/'
| '/$organizationId/$projectId/notifications/'
| '/$organizationId/$projectId/profiles/'
@@ -758,6 +799,9 @@ export interface FileRouteTypes {
| '/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
| '/$organizationId/billing'
| '/$organizationId/settings'
| '/onboarding/project'
@@ -802,6 +846,7 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/settings/details'
| '/$organizationId/$projectId/settings/events'
| '/$organizationId/$projectId/settings/imports'
| '/$organizationId/$projectId/settings/widgets'
| '/$organizationId/$projectId/profiles/$profileId/events'
id:
| '__root__'
@@ -816,6 +861,9 @@ export interface FileRouteTypes {
| '/_public/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
| '/_app/$organizationId/$projectId'
| '/_app/$organizationId/billing'
| '/_app/$organizationId/settings'
@@ -870,6 +918,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/settings/_tabs/details'
| '/_app/$organizationId/$projectId/settings/_tabs/events'
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
| '/_app/$organizationId/$projectId/events/_tabs/'
| '/_app/$organizationId/$projectId/notifications/_tabs/'
| '/_app/$organizationId/$projectId/profiles/_tabs/'
@@ -886,6 +935,9 @@ export interface RootRouteChildren {
StepsRoute: typeof StepsRouteWithChildren
ApiConfigRoute: typeof ApiConfigRoute
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
WidgetCounterRoute: typeof WidgetCounterRoute
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
WidgetTestRoute: typeof WidgetTestRoute
ShareDashboardShareIdRoute: typeof ShareDashboardShareIdRoute
ShareOverviewShareIdRoute: typeof ShareOverviewShareIdRoute
ShareReportShareIdRoute: typeof ShareReportShareIdRoute
@@ -928,6 +980,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/widget/test': {
id: '/widget/test'
path: '/widget/test'
fullPath: '/widget/test'
preLoaderRoute: typeof WidgetTestRouteImport
parentRoute: typeof rootRouteImport
}
'/widget/realtime': {
id: '/widget/realtime'
path: '/widget/realtime'
fullPath: '/widget/realtime'
preLoaderRoute: typeof WidgetRealtimeRouteImport
parentRoute: typeof rootRouteImport
}
'/widget/counter': {
id: '/widget/counter'
path: '/widget/counter'
fullPath: '/widget/counter'
preLoaderRoute: typeof WidgetCounterRouteImport
parentRoute: typeof rootRouteImport
}
'/api/healthcheck': {
id: '/api/healthcheck'
path: '/api/healthcheck'
@@ -1285,6 +1358,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdEventsTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdEventsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/widgets': {
id: '/_app/$organizationId/$projectId/settings/_tabs/widgets'
path: '/widgets'
fullPath: '/$organizationId/$projectId/settings/widgets'
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/imports': {
id: '/_app/$organizationId/$projectId/settings/_tabs/imports'
path: '/imports'
@@ -1548,6 +1628,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
AppOrganizationIdProjectIdSettingsTabsIndexRoute: typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
}
@@ -1561,6 +1642,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute:
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute,
AppOrganizationIdProjectIdSettingsTabsIndexRoute:
AppOrganizationIdProjectIdSettingsTabsIndexRoute,
}
@@ -1791,6 +1874,9 @@ const rootRouteChildren: RootRouteChildren = {
StepsRoute: StepsRouteWithChildren,
ApiConfigRoute: ApiConfigRoute,
ApiHealthcheckRoute: ApiHealthcheckRoute,
WidgetCounterRoute: WidgetCounterRoute,
WidgetRealtimeRoute: WidgetRealtimeRoute,
WidgetTestRoute: WidgetTestRoute,
ShareDashboardShareIdRoute: ShareDashboardShareIdRoute,
ShareOverviewShareIdRoute: ShareOverviewShareIdRoute,
ShareReportShareIdRoute: ShareReportShareIdRoute,

View File

@@ -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' },
];

View File

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

View File

@@ -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({

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

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

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