diff --git a/apps/start/package.json b/apps/start/package.json index a3dbf91c..98ec9948 100644 --- a/apps/start/package.json +++ b/apps/start/package.json @@ -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" } } \ No newline at end of file diff --git a/apps/start/src/components/animated-number.tsx b/apps/start/src/components/animated-number.tsx index 61f5e8b5..813a7979 100644 --- a/apps/start/src/components/animated-number.tsx +++ b/apps/start/src/components/animated-number.tsx @@ -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 | null>(null); - - useEffect(() => { - import('@number-flow/react').then(({ default: NumberFlow }) => { - setComponent(NumberFlow); - }); - }, []); - - if (!Component) { - return <>{props.value}; - } - - return ; + return ; } diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index b392a3a2..5d514401 100644 --- a/apps/start/src/routeTree.gen.ts +++ b/apps/start/src/routeTree.gen.ts @@ -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, diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx index 620894cd..2acfa01d 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.tsx @@ -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' }, ]; diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.widgets.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.widgets.tsx new file mode 100644 index 00000000..536b27d5 --- /dev/null +++ b/apps/start/src/routes/_app.$organizationId.$projectId.settings._tabs.widgets.tsx @@ -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 ; + } + + const realtimeWidget = realtimeWidgetQuery.data; + const counterWidget = counterWidgetQuery.data; + + return ( +
+ {realtimeWidget && ( + handleToggle('realtime', enabled)} + onUpdateOptions={(options) => + updateOptionsMutation.mutate({ + projectId, + organizationId, + options, + }) + } + /> + )} + + {counterWidget && ( + handleToggle('counter', enabled)} + /> + )} +
+ ); +} + +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 + ? `` + : null; + + // Default options + const defaultOptions: IRealtimeWidgetOptions = { + type: 'realtime', + referrers: true, + countries: true, + paths: false, + }; + + const [options, setOptions] = useState( + (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 ( + + +
+ Realtime Widget +

+ Embed a realtime visitor counter widget on your website. The widget + shows live visitor count, activity histogram, top countries, + referrers and paths. +

+
+ +
+ {isEnabled && ( + +
+

Widget Options

+
+
+ + + handleUpdateOptions({ ...options, referrers: checked }) + } + disabled={isUpdatingOptions} + /> +
+
+ + + handleUpdateOptions({ ...options, countries: checked }) + } + disabled={isUpdatingOptions} + /> +
+
+ + + handleUpdateOptions({ ...options, paths: checked }) + } + disabled={isUpdatingOptions} + /> +
+
+
+
+

Widget URL

+ +

+ Direct link to the widget. You can open this in a new tab or embed + it. +

+
+ +
+

Embed Code

+ +

+ Copy this code and paste it into your website HTML where you want + the widget to appear. +

+
+ +
+

Preview

+
+ ` + : null; + + return ( + + +
+ Counter Widget +

+ A compact live visitor counter badge you can embed anywhere. Shows + the current number of unique visitors with a live indicator. +

+
+ +
+ {isEnabled && counterUrl && ( + +
+

Widget URL

+ +

+ Direct link to the counter widget. +

+
+ +
+

Embed Code

+ +

+ Copy this code and paste it into your website HTML where you want + the counter to appear. +

+
+ +
+

Preview

+
+