diff --git a/apps/api/src/controllers/export.controller.ts b/apps/api/src/controllers/export.controller.ts index ac6229083..294f59c8c 100644 --- a/apps/api/src/controllers/export.controller.ts +++ b/apps/api/src/controllers/export.controller.ts @@ -13,7 +13,7 @@ import { getSettingsForProject, } from '@openpanel/db'; import { ChartEngine } from '@openpanel/db'; -import { zChartEvent, zChartInputBase } from '@openpanel/validation'; +import { zChartEvent, zReport } from '@openpanel/validation'; import { omit } from 'ramda'; async function getProjectId( @@ -139,7 +139,7 @@ export async function events( }); } -const chartSchemeFull = zChartInputBase +const chartSchemeFull = zReport .pick({ breakdowns: true, interval: true, diff --git a/apps/api/src/controllers/webhook.controller.ts b/apps/api/src/controllers/webhook.controller.ts index ccd447a2d..788e2741a 100644 --- a/apps/api/src/controllers/webhook.controller.ts +++ b/apps/api/src/controllers/webhook.controller.ts @@ -191,7 +191,9 @@ export async function polarWebhook( where: { subscriptionCustomerId: event.data.customer.id, subscriptionId: event.data.id, - subscriptionStatus: 'active', + subscriptionStatus: { + in: ['active', 'past_due', 'unpaid'], + }, }, }); diff --git a/apps/api/src/utils/ai-tools.ts b/apps/api/src/utils/ai-tools.ts index e261b551b..70f43d277 100644 --- a/apps/api/src/utils/ai-tools.ts +++ b/apps/api/src/utils/ai-tools.ts @@ -9,7 +9,7 @@ import { } from '@openpanel/db'; import { ChartEngine } from '@openpanel/db'; import { getCache } from '@openpanel/redis'; -import { zChartInputAI } from '@openpanel/validation'; +import { zReportInput } from '@openpanel/validation'; import { tool } from 'ai'; import { z } from 'zod'; @@ -27,7 +27,10 @@ export function getReport({ - ${chartTypes.metric} - ${chartTypes.bar} `, - parameters: zChartInputAI, + parameters: zReportInput.extend({ + startDate: z.string().describe('The start date for the report'), + endDate: z.string().describe('The end date for the report'), + }), execute: async (report) => { return { type: 'report', @@ -72,7 +75,10 @@ export function getConversionReport({ return tool({ description: 'Generate a report (a chart) for conversions between two actions a unique user took.', - parameters: zChartInputAI, + parameters: zReportInput.extend({ + startDate: z.string().describe('The start date for the report'), + endDate: z.string().describe('The end date for the report'), + }), execute: async (report) => { return { type: 'report', @@ -94,7 +100,10 @@ export function getFunnelReport({ return tool({ description: 'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.', - parameters: zChartInputAI, + parameters: zReportInput.extend({ + startDate: z.string().describe('The start date for the report'), + endDate: z.string().describe('The end date for the report'), + }), execute: async (report) => { return { type: 'report', diff --git a/apps/justfuckinguseopenpanel/favicon.ico b/apps/justfuckinguseopenpanel/favicon.ico new file mode 100644 index 000000000..d0979c25f Binary files /dev/null and b/apps/justfuckinguseopenpanel/favicon.ico differ diff --git a/apps/justfuckinguseopenpanel/index.html b/apps/justfuckinguseopenpanel/index.html new file mode 100644 index 000000000..7c880bafa --- /dev/null +++ b/apps/justfuckinguseopenpanel/index.html @@ -0,0 +1,505 @@ + + + + + + + + Just Fucking Use OpenPanel - Stop Overpaying for Analytics + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Just Fucking Use OpenPanel

+

Stop settling for basic metrics. Get real insights that actually help you build a better product.

+
+ +
+
+
+
+
+
+
+
+ OpenPanel Real-time Analytics +
+
+
Real-time analytics - see events as they happen. No waiting, no delays.
+
+ +

The PostHog/Mixpanel Problem (Volume Pricing Hell)

+ +

Let's talk about what happens when you have a real product with real users.

+ +

Real pricing at scale (20M+ events/month):

+ + +

"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.

+ +

Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.

+ +

The Web-Only Analytics Trap

+ +

You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.

+ +
+ "Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?" +
+ +

You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have NO FUCKING IDEA what users actually do.

+ +

Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.

+ +

Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.

+ +

And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a SECOND tool on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.

+ +

Counter One Dollar Stats

+ +

"$1/month for page views. Adorable."

+ +

Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.

+ +

Here's the thing: if you want to make good decisions about your product, you need to understand what your users are actually doing, not just where the fuck they're from.

+ +

OpenPanel gives you the full product analytics suite. Or self-host for FREE with UNLIMITED events.

+ +

You get:

+ + +

One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.

+ +

Why OpenPanel is the Answer

+ +

You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.

+ +

To make good decisions, you need to understand what your users are doing, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.

+ + + +
+
+
+
+
+
+
+
+ OpenPanel Overview Dashboard +
+
+
OpenPanel overview showing web analytics and product analytics in one clean interface
+
+ +

Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell

+ +

Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?

+ +

OpenPanel is open source. AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can self-host it for FREE with UNLIMITED events.

+ +

That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.

+ +

Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? $0/month. Forever.

+ +

Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.

+ +

The Comparison Table (The Brutal Truth)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolPrice at 20M eventsWhat You Get
Mixpanel$2,300+/monthNot all feautres... since addons are extra
PostHog$1,982+/monthNot all feautres... since addons are extra
PlausibleVarious pricingSimple analytics with basic goals. Page views and visitors. That's it.
One Dollar Stats$1/monthPage views (but cheaper!)
OpenPanel~$530/mo or FREE (self-hosted)Web + Product analytics. The full package. Open source. Your data.
+ +
+
+
+
+
+
+
+
+ OpenPanel User Profiles +
+
+
User profiles - see individual user journeys and behavior. Something web-only tools can't give you.
+
+ +
+
+
+
+
+
+
+
+ OpenPanel Reports and Funnels +
+
+
Funnels, retention, and custom reports - the features you CAN'T get with web-only tools
+
+ +

The Bottom Fucking Line

+ +

If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.

+ +

You have three choices:

+ +
    +
  1. Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy
  2. +
  3. Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from
  4. +
  5. Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do
  6. +
+ +

If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.

+ +

But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.

+ +
+

Ready to understand what your users actually do?

+

Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.

+ Get Started with OpenPanel + Self-Host Guide +
+ +
+
+
+
+
+
+
+
+ OpenPanel Custom Dashboards +
+
+
Custom dashboards - build exactly what you need to understand your product
+
+ + +
+ + + + + diff --git a/apps/justfuckinguseopenpanel/ogimage.png b/apps/justfuckinguseopenpanel/ogimage.png new file mode 100644 index 000000000..3c78a117e Binary files /dev/null and b/apps/justfuckinguseopenpanel/ogimage.png differ diff --git a/apps/justfuckinguseopenpanel/screenshots/dashboard-dark.webp b/apps/justfuckinguseopenpanel/screenshots/dashboard-dark.webp new file mode 100644 index 000000000..ff4d74aa2 Binary files /dev/null and b/apps/justfuckinguseopenpanel/screenshots/dashboard-dark.webp differ diff --git a/apps/justfuckinguseopenpanel/screenshots/overview-dark.webp b/apps/justfuckinguseopenpanel/screenshots/overview-dark.webp new file mode 100644 index 000000000..922b3b8d8 Binary files /dev/null and b/apps/justfuckinguseopenpanel/screenshots/overview-dark.webp differ diff --git a/apps/justfuckinguseopenpanel/screenshots/profile-dark.webp b/apps/justfuckinguseopenpanel/screenshots/profile-dark.webp new file mode 100644 index 000000000..8a022a8af Binary files /dev/null and b/apps/justfuckinguseopenpanel/screenshots/profile-dark.webp differ diff --git a/apps/justfuckinguseopenpanel/screenshots/realtime-dark.webp b/apps/justfuckinguseopenpanel/screenshots/realtime-dark.webp new file mode 100644 index 000000000..5c1d5cc6e Binary files /dev/null and b/apps/justfuckinguseopenpanel/screenshots/realtime-dark.webp differ diff --git a/apps/justfuckinguseopenpanel/screenshots/report-dark.webp b/apps/justfuckinguseopenpanel/screenshots/report-dark.webp new file mode 100644 index 000000000..7cc2f15e0 Binary files /dev/null and b/apps/justfuckinguseopenpanel/screenshots/report-dark.webp differ diff --git a/apps/justfuckinguseopenpanel/wrangler.jsonc b/apps/justfuckinguseopenpanel/wrangler.jsonc new file mode 100644 index 000000000..005d95fb1 --- /dev/null +++ b/apps/justfuckinguseopenpanel/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "name": "justfuckinguseopenpanel", + "compatibility_date": "2025-12-19", + "assets": { + "directory": "." + } +} \ No newline at end of file diff --git a/apps/start/package.json b/apps/start/package.json index a3dbf91cb..98ec9948d 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 61f5e8b54..813a7979b 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/components/auth/share-enter-password.tsx b/apps/start/src/components/auth/share-enter-password.tsx index a04759bb4..b25dae612 100644 --- a/apps/start/src/components/auth/share-enter-password.tsx +++ b/apps/start/src/components/auth/share-enter-password.tsx @@ -8,7 +8,13 @@ import { LogoSquare } from '../logo'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; -export function ShareEnterPassword({ shareId }: { shareId: string }) { +export function ShareEnterPassword({ + shareId, + shareType = 'overview', +}: { + shareId: string; + shareType?: 'overview' | 'dashboard' | 'report'; +}) { const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signInShare.mutationOptions({ @@ -25,6 +31,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) { defaultValues: { password: '', shareId, + shareType, }, }); @@ -32,6 +39,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) { mutation.mutate({ password: data.password, shareId, + shareType, }); }); @@ -40,9 +48,20 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
-
Overview is locked
+
+ {shareType === 'dashboard' + ? 'Dashboard is locked' + : shareType === 'report' + ? 'Report is locked' + : 'Overview is locked'} +
- Please enter correct password to access this overview + Please enter correct password to access this{' '} + {shareType === 'dashboard' + ? 'dashboard' + : shareType === 'report' + ? 'report' + : 'overview'}
diff --git a/apps/start/src/components/chat/chat-message.tsx b/apps/start/src/components/chat/chat-message.tsx index 5d08db9e5..e380c2472 100644 --- a/apps/start/src/components/chat/chat-message.tsx +++ b/apps/start/src/components/chat/chat-message.tsx @@ -1,6 +1,7 @@ import { Markdown } from '@/components/markdown'; import { cn } from '@/utils/cn'; -import { zChartInputAI } from '@openpanel/validation'; +import { zReport } from '@openpanel/validation'; +import { z } from 'zod'; import type { UIMessage } from 'ai'; import { Loader2Icon, UserIcon } from 'lucide-react'; import { Fragment, memo } from 'react'; @@ -77,7 +78,10 @@ export const ChatMessage = memo( const { result } = p.toolInvocation; if (result.type === 'report') { - const report = zChartInputAI.safeParse(result.report); + const report = zReport.extend({ + startDate: z.string(), + endDate: z.string(), + }).safeParse(result.report); if (report.success) { return ( diff --git a/apps/start/src/components/chat/chat-report.tsx b/apps/start/src/components/chat/chat-report.tsx index f8538ccda..bc967001f 100644 --- a/apps/start/src/components/chat/chat-report.tsx +++ b/apps/start/src/components/chat/chat-report.tsx @@ -1,6 +1,6 @@ import { pushModal } from '@/modals'; import type { - IChartInputAi, + IReport, IChartRange, IChartType, IInterval, @@ -16,7 +16,7 @@ import { Button } from '../ui/button'; export function ChatReport({ lazy, ...props -}: { report: IChartInputAi; lazy: boolean }) { +}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) { const [chartType, setChartType] = useState( props.report.chartType, ); diff --git a/apps/start/src/components/grafana-grid.tsx b/apps/start/src/components/grafana-grid.tsx new file mode 100644 index 000000000..4b4232ccd --- /dev/null +++ b/apps/start/src/components/grafana-grid.tsx @@ -0,0 +1,95 @@ +import type { IServiceReport } from '@openpanel/db'; +import { useMemo } from 'react'; +import { Responsive, WidthProvider } from 'react-grid-layout'; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +export type Layout = ReactGridLayout.Layout; + +export const useReportLayouts = ( + reports: NonNullable[], +): ReactGridLayout.Layouts => { + return 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, + })); + + 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]); +}; + +export function GrafanaGrid({ + layouts, + children, + transitions, + onLayoutChange, + onDragStop, + onResizeStop, + isDraggable, + isResizable, +}: { + children: React.ReactNode; + transitions?: boolean; +} & Pick< + ReactGridLayout.ResponsiveProps, + | 'layouts' + | 'onLayoutChange' + | 'onDragStop' + | 'onResizeStop' + | 'isDraggable' + | 'isResizable' +>) { + return ( + <> + +
+ + {children} + +
+ + ); +} diff --git a/apps/start/src/components/login-navbar.tsx b/apps/start/src/components/login-navbar.tsx index e8cb47701..1c703abb1 100644 --- a/apps/start/src/components/login-navbar.tsx +++ b/apps/start/src/components/login-navbar.tsx @@ -33,7 +33,7 @@ export function LoginNavbar({ className }: { className?: string }) {
  • - + Posthog alternative
  • diff --git a/apps/start/src/components/organization/prompt-card.tsx b/apps/start/src/components/organization/prompt-card.tsx index 97a49d48e..8c9111a02 100644 --- a/apps/start/src/components/organization/prompt-card.tsx +++ b/apps/start/src/components/organization/prompt-card.tsx @@ -33,7 +33,7 @@ export function PromptCard({ }} className="fixed bottom-0 right-0 z-50 p-4 max-w-sm" > -
    +
    ({ limit: 1000, projectId, @@ -96,9 +96,7 @@ export default function OverviewTopEvents({ }, ], chartType: 'bar' as const, - lineType: 'monotone' as const, interval, - name: widget.title, range, previous, metric: 'sum' as const, diff --git a/apps/start/src/components/overview/overview-top-geo.tsx b/apps/start/src/components/overview/overview-top-geo.tsx index 1ce235520..2afaa58a3 100644 --- a/apps/start/src/components/overview/overview-top-geo.tsx +++ b/apps/start/src/components/overview/overview-top-geo.tsx @@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query'; import { ChevronRightIcon } from 'lucide-react'; import { ReportChart } from '../report-chart'; import { SerieIcon } from '../report-chart/common/serie-icon'; +import { ReportChartShortcut } from '../report-chart/shortcut'; import { Widget, WidgetBody } from '../widget'; import { OVERVIEW_COLUMNS_NAME } from './overview-constants'; import OverviewDetailsButton from './overview-details-button'; @@ -210,9 +211,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
    Map
    - diff --git a/apps/start/src/components/profiles/profile-charts.tsx b/apps/start/src/components/profiles/profile-charts.tsx index 41797dd9b..21428ea6d 100644 --- a/apps/start/src/components/profiles/profile-charts.tsx +++ b/apps/start/src/components/profiles/profile-charts.tsx @@ -2,7 +2,7 @@ import { ReportChart } from '@/components/report-chart'; import { Widget, WidgetBody } from '@/components/widget'; import { memo } from 'react'; -import type { IChartProps } from '@openpanel/validation'; +import type { IReport } from '@openpanel/validation'; import { WidgetHead } from '../overview/overview-widget'; type Props = { @@ -12,7 +12,7 @@ type Props = { export const ProfileCharts = memo( ({ profileId, projectId }: Props) => { - const pageViewsChart: IChartProps = { + const pageViewsChart: IReport = { projectId, chartType: 'linear', series: [ @@ -46,7 +46,7 @@ export const ProfileCharts = memo( metric: 'sum', }; - const eventsChart: IChartProps = { + const eventsChart: IReport = { projectId, chartType: 'linear', series: [ diff --git a/apps/start/src/components/report-chart/area/index.tsx b/apps/start/src/components/report-chart/area/index.tsx index c8d3f74a4..fea775870 100644 --- a/apps/start/src/components/report-chart/area/index.tsx +++ b/apps/start/src/components/report-chart/area/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -9,15 +10,27 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportAreaChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + trpc.chart.chart.queryOptions( + { + ...report, + shareId, + reportId: 'id' in report ? report.id : undefined, + range: range ?? report.range, + startDate: startDate ?? report.startDate, + endDate: endDate ?? report.endDate, + interval: interval ?? report.interval, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ), ); if ( diff --git a/apps/start/src/components/report-chart/bar/index.tsx b/apps/start/src/components/report-chart/bar/index.tsx index 877f104dd..38855bdea 100644 --- a/apps/start/src/components/report-chart/bar/index.tsx +++ b/apps/start/src/components/report-chart/bar/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { cn } from '@/utils/cn'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; @@ -9,15 +10,27 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportBarChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.aggregate.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + trpc.chart.aggregate.queryOptions( + { + ...report, + shareId, + reportId: 'id' in report ? report.id : undefined, + range: range ?? report.range, + startDate: startDate ?? report.startDate, + endDate: endDate ?? report.endDate, + interval: interval ?? report.interval, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ), ); if ( diff --git a/apps/start/src/components/report-chart/common/previous-diff-indicator.tsx b/apps/start/src/components/report-chart/common/previous-diff-indicator.tsx index 38988963c..669af54b2 100644 --- a/apps/start/src/components/report-chart/common/previous-diff-indicator.tsx +++ b/apps/start/src/components/report-chart/common/previous-diff-indicator.tsx @@ -42,10 +42,10 @@ export function PreviousDiffIndicator({ className, }: PreviousDiffIndicatorProps) { const { - report: { previousIndicatorInverted, previous }, + report: { previous }, } = useReportChartContext(); const variant = getDiffIndicator( - inverted ?? previousIndicatorInverted, + inverted, state, 'bg-emerald-300', 'bg-rose-300', diff --git a/apps/start/src/components/report-chart/context.tsx b/apps/start/src/components/report-chart/context.tsx index 8dd603318..fc648623e 100644 --- a/apps/start/src/components/report-chart/context.tsx +++ b/apps/start/src/components/report-chart/context.tsx @@ -2,16 +2,11 @@ import isEqual from 'lodash.isequal'; import type { LucideIcon } from 'lucide-react'; import { createContext, useContext, useEffect, useState } from 'react'; -import type { - IChartInput, - IChartProps, - IChartSerie, -} from '@openpanel/validation'; +import type { IChartSerie, IReportInput } from '@openpanel/validation'; export type ReportChartContextType = { options: Partial<{ columns: React.ReactNode[]; - hideID: boolean; hideLegend: boolean; hideXAxis: boolean; hideYAxis: boolean; @@ -28,9 +23,11 @@ export type ReportChartContextType = { onClick: () => void; }[]; }>; - report: IChartProps; + report: IReportInput & { id?: string }; isLazyLoading: boolean; isEditMode: boolean; + shareId?: string; + reportId?: string; }; type ReportChartContextProviderProps = ReportChartContextType & { @@ -38,7 +35,7 @@ type ReportChartContextProviderProps = ReportChartContextType & { }; export type ReportChartProps = Partial & { - report: IChartInput; + report: IReportInput & { id?: string }; lazy?: boolean; }; @@ -54,20 +51,6 @@ export const useReportChartContext = () => { return ctx; }; -export const useSelectReportChartContext = ( - selector: (ctx: ReportChartContextType) => T, -) => { - const ctx = useReportChartContext(); - const [state, setState] = useState(selector(ctx)); - useEffect(() => { - const newState = selector(ctx); - if (!isEqual(newState, state)) { - setState(newState); - } - }, [ctx]); - return state; -}; - export const ReportChartProvider = ({ children, ...propsToContext diff --git a/apps/start/src/components/report-chart/conversion/index.tsx b/apps/start/src/components/report-chart/conversion/index.tsx index fc75527b5..a594b5cb6 100644 --- a/apps/start/src/components/report-chart/conversion/index.tsx +++ b/apps/start/src/components/report-chart/conversion/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { cn } from '@/utils/cn'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; @@ -11,15 +12,27 @@ import { Chart } from './chart'; import { Summary } from './summary'; export function ReportConversionChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); console.log(report.limit); const res = useQuery( - trpc.chart.conversion.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + trpc.chart.conversion.queryOptions( + { + ...report, + shareId, + reportId: 'id' in report ? report.id : undefined, + range: range ?? report.range, + startDate: startDate ?? report.startDate, + endDate: endDate ?? report.endDate, + interval: interval ?? report.interval, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ), ); if ( diff --git a/apps/start/src/components/report-chart/funnel/chart.tsx b/apps/start/src/components/report-chart/funnel/chart.tsx index e48fce132..66506f0c7 100644 --- a/apps/start/src/components/report-chart/funnel/chart.tsx +++ b/apps/start/src/components/report-chart/funnel/chart.tsx @@ -131,34 +131,36 @@ export function Tables({ series: reportSeries, breakdowns: reportBreakdowns, previous, - funnelWindow, - funnelGroup, + options, }, } = useReportChartContext(); + const funnelOptions = options?.type === 'funnel' ? options : undefined; + const funnelWindow = funnelOptions?.funnelWindow; + const funnelGroup = funnelOptions?.funnelGroup; + const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => { if (!projectId || !step.event.id) return; // For funnels, we need to pass the step index so the modal can query // users who completed at least that step in the funnel sequence - pushModal('ViewChartUsers', { - type: 'funnel', - report: { - projectId, - series: reportSeries, - breakdowns: reportBreakdowns || [], - interval: interval || 'day', - startDate, - endDate, - range, - previous, - chartType: 'funnel', - metric: 'sum', - funnelWindow, - funnelGroup, - }, - stepIndex, // Pass the step index for funnel queries - }); + pushModal('ViewChartUsers', { + type: 'funnel', + report: { + projectId, + series: reportSeries, + breakdowns: reportBreakdowns || [], + interval: interval || 'day', + startDate, + endDate, + range, + previous, + chartType: 'funnel', + metric: 'sum', + options: funnelOptions, + }, + stepIndex, // Pass the step index for funnel queries + }); }; return (
    diff --git a/apps/start/src/components/report-chart/funnel/index.tsx b/apps/start/src/components/report-chart/funnel/index.tsx index 5fdda9c28..9239eb341 100644 --- a/apps/start/src/components/report-chart/funnel/index.tsx +++ b/apps/start/src/components/report-chart/funnel/index.tsx @@ -2,7 +2,8 @@ import { useTRPC } from '@/integrations/trpc/react'; import type { RouterOutputs } from '@/trpc/client'; import { useQuery } from '@tanstack/react-query'; -import type { IChartInput } from '@openpanel/validation'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; +import type { IReportInput } from '@openpanel/validation'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; @@ -14,35 +15,39 @@ import { Chart, Summary, Tables } from './chart'; export function ReportFunnelChart() { const { report: { + id, series, range, projectId, - funnelWindow, - funnelGroup, + options, startDate, endDate, previous, breakdowns, + interval, }, isLazyLoading, + shareId, } = useReportChartContext(); + const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions(); - const input: IChartInput = { + const funnelOptions = options?.type === 'funnel' ? options : undefined; + + const trpc = useTRPC(); + const input: IReportInput = { series, - range, + range: overviewRange ?? range, projectId, - interval: 'day', + interval: overviewInterval ?? interval ?? 'day', chartType: 'funnel', breakdowns, - funnelWindow, - funnelGroup, previous, metric: 'sum', - startDate, - endDate, + startDate: overviewStartDate ?? startDate, + endDate: overviewEndDate ?? endDate, limit: 20, + options: funnelOptions, }; - const trpc = useTRPC(); const res = useQuery( trpc.chart.funnel.queryOptions(input, { enabled: !isLazyLoading && input.series.length > 0, diff --git a/apps/start/src/components/report-chart/histogram/index.tsx b/apps/start/src/components/report-chart/histogram/index.tsx index 1f6d01468..e3c2b384a 100644 --- a/apps/start/src/components/report-chart/histogram/index.tsx +++ b/apps/start/src/components/report-chart/histogram/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -9,15 +10,27 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportHistogramChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + trpc.chart.chart.queryOptions( + { + ...report, + shareId, + reportId: 'id' in report ? report.id : undefined, + range: range ?? report.range, + startDate: startDate ?? report.startDate, + endDate: endDate ?? report.endDate, + interval: interval ?? report.interval, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ), ); if ( diff --git a/apps/start/src/components/report-chart/index.tsx b/apps/start/src/components/report-chart/index.tsx index f116cf1f7..95567b51f 100644 --- a/apps/start/src/components/report-chart/index.tsx +++ b/apps/start/src/components/report-chart/index.tsx @@ -15,6 +15,7 @@ import { ReportMapChart } from './map'; import { ReportMetricChart } from './metric'; import { ReportPieChart } from './pie'; import { ReportRetentionChart } from './retention'; +import { ReportSankeyChart } from './sankey'; export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => { const ref = useRef(null); @@ -57,6 +58,8 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => { return ; case 'conversion': return ; + case 'sankey': + return ; default: return null; } diff --git a/apps/start/src/components/report-chart/line/index.tsx b/apps/start/src/components/report-chart/line/index.tsx index 5c11c5d78..5b2c90a17 100644 --- a/apps/start/src/components/report-chart/line/index.tsx +++ b/apps/start/src/components/report-chart/line/index.tsx @@ -2,6 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { cn } from '@/utils/cn'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -10,15 +11,27 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportLineChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + trpc.chart.chart.queryOptions( + { + ...report, + shareId, + reportId: 'id' in report ? report.id : undefined, + range: range ?? report.range, + startDate: startDate ?? report.startDate, + endDate: endDate ?? report.endDate, + interval: interval ?? report.interval, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ), ); if ( diff --git a/apps/start/src/components/report-chart/map/index.tsx b/apps/start/src/components/report-chart/map/index.tsx index d6ca11c74..8dd256f75 100644 --- a/apps/start/src/components/report-chart/map/index.tsx +++ b/apps/start/src/components/report-chart/map/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -9,15 +10,27 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportMapChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + trpc.chart.chart.queryOptions( + { + ...report, + shareId, + reportId: 'id' in report ? report.id : undefined, + range: range ?? report.range, + startDate: startDate ?? report.startDate, + endDate: endDate ?? report.endDate, + interval: interval ?? report.interval, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ), ); if ( diff --git a/apps/start/src/components/report-chart/metric/index.tsx b/apps/start/src/components/report-chart/metric/index.tsx index 7d8e5829e..83447a7b2 100644 --- a/apps/start/src/components/report-chart/metric/index.tsx +++ b/apps/start/src/components/report-chart/metric/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -8,15 +9,27 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportMetricChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + trpc.chart.chart.queryOptions( + { + ...report, + shareId, + reportId: 'id' in report ? report.id : undefined, + range: range ?? report.range, + startDate: startDate ?? report.startDate, + endDate: endDate ?? report.endDate, + interval: interval ?? report.interval, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ), ); if ( diff --git a/apps/start/src/components/report-chart/metric/metric-card.tsx b/apps/start/src/components/report-chart/metric/metric-card.tsx index 7383889f8..334091bfc 100644 --- a/apps/start/src/components/report-chart/metric/metric-card.tsx +++ b/apps/start/src/components/report-chart/metric/metric-card.tsx @@ -54,10 +54,7 @@ export function MetricCard({ metric, unit, }: MetricCardProps) { - const { - report: { previousIndicatorInverted }, - isEditMode, - } = useReportChartContext(); + const { isEditMode } = useReportChartContext(); const number = useNumber(); const renderValue = (value: number | undefined, unitClassName?: string) => { @@ -80,7 +77,7 @@ export function MetricCard({ const previous = serie.metrics.previous?.[metric]; const graphColors = getDiffIndicator( - previousIndicatorInverted, + false, previous?.state, '#6ee7b7', // green '#fda4af', // red diff --git a/apps/start/src/components/report-chart/pie/index.tsx b/apps/start/src/components/report-chart/pie/index.tsx index bf2589cc5..7420ac6d2 100644 --- a/apps/start/src/components/report-chart/pie/index.tsx +++ b/apps/start/src/components/report-chart/pie/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -9,15 +10,27 @@ import { useReportChartContext } from '../context'; import { Chart } from './chart'; export function ReportPieChart() { - const { isLazyLoading, report } = useReportChartContext(); + const { isLazyLoading, report, shareId } = useReportChartContext(); const trpc = useTRPC(); + const { range, startDate, endDate, interval } = useOverviewOptions(); const res = useQuery( - trpc.chart.aggregate.queryOptions(report, { - placeholderData: keepPreviousData, - staleTime: 1000 * 60 * 1, - enabled: !isLazyLoading, - }), + trpc.chart.aggregate.queryOptions( + { + ...report, + shareId, + reportId: 'id' in report ? report.id : undefined, + range: range ?? report.range, + startDate: startDate ?? report.startDate, + endDate: endDate ?? report.endDate, + interval: interval ?? report.interval, + }, + { + placeholderData: keepPreviousData, + staleTime: 1000 * 60 * 1, + enabled: !isLazyLoading, + }, + ), ); if ( diff --git a/apps/start/src/components/report-chart/report-editor.tsx b/apps/start/src/components/report-chart/report-editor.tsx index 28617064e..dc1a04a98 100644 --- a/apps/start/src/components/report-chart/report-editor.tsx +++ b/apps/start/src/components/report-chart/report-editor.tsx @@ -11,7 +11,6 @@ import { changeStartDate, ready, reset, - setName, setReport, } from '@/components/report/reportSlice'; import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar'; @@ -19,9 +18,10 @@ import { TimeWindowPicker } from '@/components/time-window-picker'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { useAppParams } from '@/hooks/use-app-params'; +import { pushModal } from '@/modals'; import { useDispatch, useSelector } from '@/redux'; import { bind } from 'bind-event-listener'; -import { GanttChartSquareIcon } from 'lucide-react'; +import { GanttChartSquareIcon, ShareIcon } from 'lucide-react'; import { useEffect } from 'react'; import type { IServiceReport } from '@openpanel/db'; @@ -54,8 +54,19 @@ export default function ReportEditor({ return (
    -
    +
    + {initialReport?.id && ( + + )}
    diff --git a/apps/start/src/components/report-chart/retention/index.tsx b/apps/start/src/components/report-chart/retention/index.tsx index 58bbffff4..9619ab0e7 100644 --- a/apps/start/src/components/report-chart/retention/index.tsx +++ b/apps/start/src/components/report-chart/retention/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -12,21 +13,33 @@ import CohortTable from './table'; export function ReportRetentionChart() { const { report: { + id, series, range, projectId, + options, startDate, endDate, - criteria, interval, }, isLazyLoading, + shareId, } = useReportChartContext(); + const { + range: overviewRange, + startDate: overviewStartDate, + endDate: overviewEndDate, + interval: overviewInterval, + } = useOverviewOptions(); const eventSeries = series.filter((item) => item.type === 'event'); const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String); const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String); const isEnabled = firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading; + + const retentionOptions = options?.type === 'retention' ? options : undefined; + const criteria = retentionOptions?.criteria ?? 'on_or_after'; + const trpc = useTRPC(); const res = useQuery( trpc.chart.cohort.queryOptions( @@ -34,11 +47,13 @@ export function ReportRetentionChart() { firstEvent, secondEvent, projectId, - range, - startDate, - endDate, + range: overviewRange ?? range, + startDate: overviewStartDate ?? startDate, + endDate: overviewEndDate ?? endDate, criteria, - interval, + interval: overviewInterval ?? interval, + shareId, + reportId: id, }, { placeholderData: keepPreviousData, diff --git a/apps/start/src/components/report-chart/sankey/chart.tsx b/apps/start/src/components/report-chart/sankey/chart.tsx new file mode 100644 index 000000000..57cd4a2d6 --- /dev/null +++ b/apps/start/src/components/report-chart/sankey/chart.tsx @@ -0,0 +1,302 @@ +import { + ChartTooltipContainer, + ChartTooltipHeader, + ChartTooltipItem, +} from '@/components/charts/chart-tooltip'; +import { useNumber } from '@/hooks/use-numer-formatter'; +import { round } from '@/utils/math'; +import { ResponsiveSankey } from '@nivo/sankey'; +import { + type ReactNode, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + +import { useTheme } from '@/components/theme-provider'; +import { truncate } from '@/utils/truncate'; +import { ArrowRightIcon } from 'lucide-react'; +import { AspectContainer } from '../aspect-container'; + +type PortalTooltipPosition = { left: number; top: number; ready: boolean }; + +function SankeyPortalTooltip({ + children, + offset = 12, + padding = 8, +}: { + children: ReactNode; + offset?: number; + padding?: number; +}) { + const anchorRef = useRef(null); + const tooltipRef = useRef(null); + const [anchorRect, setAnchorRect] = useState(null); + const [pos, setPos] = useState({ + left: 0, + top: 0, + ready: false, + }); + const [mounted, setMounted] = useState(false); + + useLayoutEffect(() => { + setMounted(true); + }, []); + + useLayoutEffect(() => { + const el = anchorRef.current; + if (!el) return; + + const wrapper = el.parentElement; + if (!wrapper) return; + + const update = () => { + setAnchorRect(wrapper.getBoundingClientRect()); + }; + + update(); + + const ro = new ResizeObserver(update); + ro.observe(wrapper); + + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + + return () => { + ro.disconnect(); + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, []); + + useLayoutEffect(() => { + if (!mounted) return; + if (!anchorRect) return; + const tooltipEl = tooltipRef.current; + if (!tooltipEl) return; + + const rect = tooltipEl.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + let left = anchorRect.left + offset; + let top = anchorRect.top + offset; + + left = Math.min( + Math.max(padding, left), + Math.max(padding, vw - rect.width - padding), + ); + top = Math.min( + Math.max(padding, top), + Math.max(padding, vh - rect.height - padding), + ); + + setPos({ left, top, ready: true }); + }, [mounted, anchorRect, children, offset, padding]); + + if (typeof document === 'undefined') { + return <>{children}; + } + + return ( + <> + + {mounted && + createPortal( +
    + {children} +
    , + document.body, + )} + + ); +} + +type SankeyData = { + nodes: Array<{ + id: string; + label: string; + nodeColor: string; + percentage?: number; + value?: number; + step?: number; + }>; + links: Array<{ source: string; target: string; value: number }>; +}; + +export function Chart({ data }: { data: SankeyData }) { + const number = useNumber(); + const containerRef = useRef(null); + const { appTheme } = useTheme(); + + // Process data for Sankey + const sankeyData = useMemo(() => { + if (!data) return { nodes: [], links: [] }; + + return { + nodes: data.nodes.map((node) => ({ + ...node, + label: node.label || node.id, + data: { + percentage: node.percentage, + value: node.value, + step: node.step, + label: node.label || node.id, + }, + })), + links: data.links, + }; + }, [data]); + + const totalSessions = useMemo(() => { + if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0; + const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1); + const base = step1.length > 0 ? step1 : sankeyData.nodes; + return base.reduce((sum: number, n: any) => sum + (n.data?.value ?? 0), 0); + }, [sankeyData.nodes]); + + return ( + +
    + node.nodeColor} + nodeBorderRadius={2} + animate={false} + nodeBorderWidth={0} + nodeOpacity={0.8} + linkContract={1} + linkOpacity={0.3} + linkBlendMode={'normal'} + nodeTooltip={({ node }: any) => { + const label = node?.data?.label ?? node?.label ?? node?.id; + const value = node?.data?.value ?? node?.value ?? 0; + const step = node?.data?.step; + const pct = + typeof node?.data?.percentage === 'number' + ? node.data.percentage + : totalSessions > 0 + ? (value / totalSessions) * 100 + : 0; + const color = + node?.color ?? + node?.data?.nodeColor ?? + node?.data?.color ?? + node?.nodeColor ?? + '#64748b'; + + return ( + + + +
    + {label} +
    + {typeof step === 'number' && ( +
    + Step {step} +
    + )} +
    + +
    +
    Sessions
    +
    {number.format(value)}
    +
    +
    +
    Share
    +
    {number.format(round(pct, 1))} %
    +
    +
    +
    +
    + ); + }} + linkTooltip={({ link }: any) => { + const sourceLabel = + link?.source?.data?.label ?? + link?.source?.label ?? + link?.source?.id; + const targetLabel = + link?.target?.data?.label ?? + link?.target?.label ?? + link?.target?.id; + + const value = link?.value ?? 0; + const sourceValue = + link?.source?.data?.value ?? link?.source?.value ?? 0; + + const pctOfTotal = + totalSessions > 0 ? (value / totalSessions) * 100 : 0; + const pctOfSource = + sourceValue > 0 ? (value / sourceValue) * 100 : 0; + + const sourceStep = link?.source?.data?.step; + const targetStep = link?.target?.data?.step; + + const color = + link?.color ?? + link?.source?.color ?? + link?.source?.data?.nodeColor ?? + '#64748b'; + + return ( + + + +
    + {sourceLabel} + + {targetLabel} +
    + {typeof sourceStep === 'number' && + typeof targetStep === 'number' && ( +
    + {sourceStep} → {targetStep} +
    + )} +
    + + +
    +
    Sessions
    +
    {number.format(value)}
    +
    +
    +
    % of total
    +
    {number.format(round(pctOfTotal, 1))} %
    +
    +
    +
    % of source
    +
    {number.format(round(pctOfSource, 1))} %
    +
    +
    +
    +
    + ); + }} + label={(node: any) => { + const label = node.data?.label || node.label || node.id; + return truncate(label, 30, 'middle'); + }} + labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'} + nodeSpacing={10} + /> +
    +
    + ); +} diff --git a/apps/start/src/components/report-chart/sankey/index.tsx b/apps/start/src/components/report-chart/sankey/index.tsx new file mode 100644 index 000000000..879d90211 --- /dev/null +++ b/apps/start/src/components/report-chart/sankey/index.tsx @@ -0,0 +1,93 @@ +import { useTRPC } from '@/integrations/trpc/react'; +import { useQuery } from '@tanstack/react-query'; + +import type { IReportInput } from '@openpanel/validation'; + +import { AspectContainer } from '../aspect-container'; +import { ReportChartEmpty } from '../common/empty'; +import { ReportChartError } from '../common/error'; +import { ReportChartLoading } from '../common/loading'; +import { useReportChartContext } from '../context'; +import { Chart } from './chart'; + +export function ReportSankeyChart() { + const { + report: { + series, + range, + projectId, + options, + startDate, + endDate, + breakdowns, + }, + isLazyLoading, + } = useReportChartContext(); + + if (!options) { + return ; + } + + const input: IReportInput = { + series, + range, + projectId, + interval: 'day', + chartType: 'sankey', + breakdowns, + options, + metric: 'sum', + startDate, + endDate, + limit: 20, + previous: false, + }; + const trpc = useTRPC(); + const res = useQuery( + trpc.chart.sankey.queryOptions(input, { + enabled: !isLazyLoading && input.series.length > 0, + }), + ); + + if (isLazyLoading || res.isLoading) { + return ; + } + + if (res.isError) { + return ; + } + + if (!res.data || res.data.nodes.length === 0) { + return ; + } + + return ( +
    + +
    + ); +} + +function Loading() { + return ( + + + + ); +} + +function Error() { + return ( + + + + ); +} + +function Empty() { + return ( + + + + ); +} diff --git a/apps/start/src/components/report-chart/shortcut.tsx b/apps/start/src/components/report-chart/shortcut.tsx index c42e6aa4e..f53ddf711 100644 --- a/apps/start/src/components/report-chart/shortcut.tsx +++ b/apps/start/src/components/report-chart/shortcut.tsx @@ -26,7 +26,6 @@ export const ReportChartShortcut = ({ return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +} + +export 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 ( +
    +
    +
    { + if (event.metaKey) { + window.open( + `/${organizationId}/${projectId}/reports/${report.id}`, + '_blank', + ); + return; + } + router.navigate({ + to: '/$organizationId/$projectId/reports/$reportId', + params: { + organizationId, + projectId, + reportId: report.id, + }, + }); + }} + onKeyUp={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + router.navigate({ + to: '/$organizationId/$projectId/reports/$reportId', + params: { + organizationId, + projectId, + reportId: report.id, + }, + }); + } + }} + role="button" + tabIndex={0} + > +
    {report.name}
    + {chartRange !== null && ( +
    + + {timeWindows[chartRange as keyof typeof timeWindows]?.label} + + {startDate && endDate ? ( + Custom dates + ) : ( + range !== null && + chartRange !== range && ( + + {timeWindows[range as keyof typeof timeWindows]?.label} + + ) + )} +
    + )} +
    +
    +
    + + + + + + + + +
    + + + + + + { + event.stopPropagation(); + onDuplicate(report.id); + }} + > + + Duplicate + + + { + event.stopPropagation(); + onDelete(report.id); + }} + > + + Delete + + + + +
    +
    +
    + +
    +
    + ); +} + +export function ReportItemReadOnly({ + report, + shareId, + range, + startDate, + endDate, + interval, +}: { + report: any; + shareId: string; + range: any; + startDate: any; + endDate: any; + interval: any; +}) { + const chartRange = report.range; + + return ( +
    +
    +
    +
    {report.name}
    + {chartRange !== null && ( +
    + + {timeWindows[chartRange as keyof typeof timeWindows]?.label} + + {startDate && endDate ? ( + Custom dates + ) : ( + range !== null && + chartRange !== range && ( + + {timeWindows[range as keyof typeof timeWindows]?.label} + + ) + )} +
    + )} +
    +
    +
    + +
    +
    + ); +} diff --git a/apps/start/src/components/report/reportSlice.ts b/apps/start/src/components/report/reportSlice.ts index 9ec30e32b..243511765 100644 --- a/apps/start/src/components/report/reportSlice.ts +++ b/apps/start/src/components/report/reportSlice.ts @@ -1,6 +1,5 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; -import { endOfDay, format, isSameDay, isSameMonth, startOfDay } from 'date-fns'; import { shortId } from '@openpanel/common'; import { @@ -12,18 +11,19 @@ import { import type { IChartBreakdown, IChartEventItem, - IChartFormula, IChartLineType, - IChartProps, IChartRange, IChartType, IInterval, + IReport, + IReportOptions, UnionOmit, zCriteria, } from '@openpanel/validation'; import type { z } from 'zod'; -type InitialState = IChartProps & { +type InitialState = IReport & { + id?: string; dirty: boolean; ready: boolean; startDate: string | null; @@ -34,7 +34,6 @@ type InitialState = IChartProps & { const initialState: InitialState = { ready: false, dirty: false, - // TODO: remove this projectId: '', name: '', chartType: 'linear', @@ -50,9 +49,7 @@ const initialState: InitialState = { unit: undefined, metric: 'sum', limit: 500, - criteria: 'on_or_after', - funnelGroup: undefined, - funnelWindow: undefined, + options: undefined, }; export const reportSlice = createSlice({ @@ -74,7 +71,7 @@ export const reportSlice = createSlice({ ready: true, }; }, - setReport(state, action: PayloadAction) { + setReport(state, action: PayloadAction) { return { ...state, ...action.payload, @@ -187,6 +184,16 @@ export const reportSlice = createSlice({ state.dirty = true; state.chartType = action.payload; + // Initialize sankey options if switching to sankey + if (action.payload === 'sankey' && !state.options) { + state.options = { + type: 'sankey', + mode: 'after', + steps: 5, + exclude: [], + }; + } + if ( !isMinuteIntervalEnabledByRange(state.range) && state.interval === 'minute' @@ -254,7 +261,14 @@ export const reportSlice = createSlice({ changeCriteria(state, action: PayloadAction>) { state.dirty = true; - state.criteria = action.payload; + if (!state.options || state.options.type !== 'retention') { + state.options = { + type: 'retention', + criteria: action.payload, + }; + } else { + state.options.criteria = action.payload; + } }, changeUnit(state, action: PayloadAction) { @@ -264,12 +278,88 @@ export const reportSlice = createSlice({ changeFunnelGroup(state, action: PayloadAction) { state.dirty = true; - state.funnelGroup = action.payload || undefined; + if (!state.options || state.options.type !== 'funnel') { + state.options = { + type: 'funnel', + funnelGroup: action.payload, + funnelWindow: undefined, + }; + } else { + state.options.funnelGroup = action.payload; + } }, changeFunnelWindow(state, action: PayloadAction) { state.dirty = true; - state.funnelWindow = action.payload || undefined; + if (!state.options || state.options.type !== 'funnel') { + state.options = { + type: 'funnel', + funnelGroup: undefined, + funnelWindow: action.payload, + }; + } else { + state.options.funnelWindow = action.payload; + } + }, + changeOptions(state, action: PayloadAction) { + state.dirty = true; + state.options = action.payload || undefined; + }, + changeSankeyMode( + state, + action: PayloadAction<'between' | 'after' | 'before'>, + ) { + state.dirty = true; + if (!state.options) { + state.options = { + type: 'sankey', + mode: action.payload, + steps: 5, + exclude: [], + }; + } else if (state.options.type === 'sankey') { + state.options.mode = action.payload; + } + }, + changeSankeySteps(state, action: PayloadAction) { + state.dirty = true; + if (!state.options) { + state.options = { + type: 'sankey', + mode: 'after', + steps: action.payload, + exclude: [], + }; + } else if (state.options.type === 'sankey') { + state.options.steps = action.payload; + } + }, + changeSankeyExclude(state, action: PayloadAction) { + state.dirty = true; + if (!state.options) { + state.options = { + type: 'sankey', + mode: 'after', + steps: 5, + exclude: action.payload, + }; + } else if (state.options.type === 'sankey') { + state.options.exclude = action.payload; + } + }, + changeSankeyInclude(state, action: PayloadAction) { + state.dirty = true; + if (!state.options) { + state.options = { + type: 'sankey', + mode: 'after', + steps: 5, + exclude: [], + include: action.payload, + }; + } else if (state.options.type === 'sankey') { + state.options.include = action.payload; + } }, reorderEvents( state, @@ -311,6 +401,11 @@ export const { changeUnit, changeFunnelGroup, changeFunnelWindow, + changeOptions, + changeSankeyMode, + changeSankeySteps, + changeSankeyExclude, + changeSankeyInclude, reorderEvents, } = reportSlice.actions; diff --git a/apps/start/src/components/report/sidebar/ReportSeries.tsx b/apps/start/src/components/report/sidebar/ReportSeries.tsx index 103cceecf..e7bd29555 100644 --- a/apps/start/src/components/report/sidebar/ReportSeries.tsx +++ b/apps/start/src/components/report/sidebar/ReportSeries.tsx @@ -23,15 +23,13 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { shortId } from '@openpanel/common'; import { alphabetIds } from '@openpanel/constants'; import type { IChartEvent, IChartEventItem, IChartFormula, } from '@openpanel/validation'; -import { FilterIcon, HandIcon, PiIcon } from 'lucide-react'; -import { ReportSegment } from '../ReportSegment'; +import { HandIcon, PiIcon, PlusIcon } from 'lucide-react'; import { addSerie, changeEvent, @@ -39,27 +37,21 @@ import { removeEvent, reorderEvents, } from '../reportSlice'; -import { EventPropertiesCombobox } from './EventPropertiesCombobox'; -import { PropertiesCombobox } from './PropertiesCombobox'; import type { ReportEventMoreProps } from './ReportEventMore'; import { ReportEventMore } from './ReportEventMore'; -import { FiltersList } from './filters/FiltersList'; +import { + ReportSeriesItem, + type ReportSeriesItemProps, +} from './ReportSeriesItem'; -function SortableSeries({ +function SortableReportSeriesItem({ event, index, showSegment, showAddFilter, isSelectManyEvents, ...props -}: { - event: IChartEventItem | IChartEvent; - index: number; - showSegment: boolean; - showAddFilter: boolean; - isSelectManyEvents: boolean; -} & React.HTMLAttributes) { - const dispatch = useDispatch(); +}: Omit) { const eventId = 'type' in event ? event.id : (event as IChartEvent).id; const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: eventId ?? '' }); @@ -69,85 +61,26 @@ function SortableSeries({ transition, }; - // Normalize event to have type field - const normalizedEvent: IChartEventItem = - 'type' in event ? event : { ...event, type: 'event' as const }; - - const isFormula = normalizedEvent.type === 'formula'; - const chartEvent = isFormula - ? null - : (normalizedEvent as IChartEventItem & { type: 'event' }); - return ( -
    -
    - - {props.children} -
    - - {/* Segment and Filter buttons - only for events */} - {chartEvent && (showSegment || showAddFilter) && ( -
    - {showSegment && ( - { - dispatch( - changeEvent({ - ...chartEvent, - segment, - }), - ); - }} - /> - )} - {showAddFilter && ( - { - dispatch( - changeEvent({ - ...chartEvent, - filters: [ - ...chartEvent.filters, - { - id: shortId(), - name: action.value, - operator: 'is', - value: [], - }, - ], - }), - ); - }} - > - {(setOpen) => ( - - )} - - )} - - {showSegment && chartEvent.segment.startsWith('property_') && ( - - )} -
    - )} - - {/* Filters - only for events */} - {chartEvent && !isSelectManyEvents && } +
    + ( + + )} + {...props} + />
    ); } @@ -161,12 +94,23 @@ export function ReportSeries() { projectId, }); - const showSegment = !['retention', 'funnel'].includes(chartType); - const showAddFilter = !['retention'].includes(chartType); - const showDisplayNameInput = !['retention'].includes(chartType); + const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType); + const showAddFilter = !['retention', 'sankey'].includes(chartType); + const showDisplayNameInput = !['retention', 'sankey'].includes(chartType); + const options = useSelector((state) => state.report.options); + const isSankey = chartType === 'sankey'; const isAddEventDisabled = (chartType === 'retention' || chartType === 'conversion') && selectedSeries.length >= 2; + const isSankeyEventLimitReached = + isSankey && + options && + ((options.type === 'sankey' && + options.mode === 'between' && + selectedSeries.length >= 2) || + (options.type === 'sankey' && + options.mode !== 'between' && + selectedSeries.length >= 1)); const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => { dispatch(changeEvent(event)); }); @@ -218,7 +162,8 @@ export function ReportSeries() { const showFormula = chartType !== 'conversion' && chartType !== 'funnel' && - chartType !== 'retention'; + chartType !== 'retention' && + chartType !== 'sankey'; return (
    @@ -239,7 +184,7 @@ export function ReportSeries() { const isFormula = event.type === 'formula'; return ( - )} - + ); })}
    { @@ -386,13 +332,13 @@ export function ReportSeries() { }} placeholder="Select event" items={eventNames} - className="flex-1" /> {showFormula && ( )}
    diff --git a/apps/start/src/components/report/sidebar/ReportSeriesItem.tsx b/apps/start/src/components/report/sidebar/ReportSeriesItem.tsx new file mode 100644 index 000000000..d647df985 --- /dev/null +++ b/apps/start/src/components/report/sidebar/ReportSeriesItem.tsx @@ -0,0 +1,114 @@ +import { ColorSquare } from '@/components/color-square'; +import { useDispatch } from '@/redux'; +import { shortId } from '@openpanel/common'; +import { alphabetIds } from '@openpanel/constants'; +import type { IChartEvent, IChartEventItem } from '@openpanel/validation'; +import { FilterIcon } from 'lucide-react'; +import { ReportSegment } from '../ReportSegment'; +import { changeEvent } from '../reportSlice'; +import { EventPropertiesCombobox } from './EventPropertiesCombobox'; +import { PropertiesCombobox } from './PropertiesCombobox'; +import { FiltersList } from './filters/FiltersList'; + +export interface ReportSeriesItemProps + extends React.HTMLAttributes { + event: IChartEventItem | IChartEvent; + index: number; + showSegment: boolean; + showAddFilter: boolean; + isSelectManyEvents: boolean; + renderDragHandle?: (index: number) => React.ReactNode; +} + +export function ReportSeriesItem({ + event, + index, + showSegment, + showAddFilter, + isSelectManyEvents, + renderDragHandle, + ...props +}: ReportSeriesItemProps) { + const dispatch = useDispatch(); + + // Normalize event to have type field + const normalizedEvent: IChartEventItem = + 'type' in event ? event : { ...event, type: 'event' as const }; + + const isFormula = normalizedEvent.type === 'formula'; + const chartEvent = isFormula + ? null + : (normalizedEvent as IChartEventItem & { type: 'event' }); + + return ( +
    +
    + {renderDragHandle ? ( + renderDragHandle(index) + ) : ( + + {alphabetIds[index]} + + )} + {props.children} +
    + + {/* Segment and Filter buttons - only for events */} + {chartEvent && (showSegment || showAddFilter) && ( +
    + {showSegment && ( + { + dispatch( + changeEvent({ + ...chartEvent, + segment, + }), + ); + }} + /> + )} + {showAddFilter && ( + { + dispatch( + changeEvent({ + ...chartEvent, + filters: [ + ...chartEvent.filters, + { + id: shortId(), + name: action.value, + operator: 'is', + value: [], + }, + ], + }), + ); + }} + > + {(setOpen) => ( + + )} + + )} + + {showSegment && chartEvent.segment.startsWith('property_') && ( + + )} +
    + )} + + {/* Filters - only for events */} + {chartEvent && !isSelectManyEvents && } +
    + ); +} diff --git a/apps/start/src/components/report/sidebar/ReportSettings.tsx b/apps/start/src/components/report/sidebar/ReportSettings.tsx index a212db84b..6c31095be 100644 --- a/apps/start/src/components/report/sidebar/ReportSettings.tsx +++ b/apps/start/src/components/report/sidebar/ReportSettings.tsx @@ -1,32 +1,46 @@ import { Combobox } from '@/components/ui/combobox'; import { useDispatch, useSelector } from '@/redux'; +import { ComboboxEvents } from '@/components/ui/combobox-events'; import { InputEnter } from '@/components/ui/input-enter'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useEventNames } from '@/hooks/use-event-names'; import { useMemo } from 'react'; import { changeCriteria, changeFunnelGroup, changeFunnelWindow, changePrevious, + changeSankeyExclude, + changeSankeyInclude, + changeSankeyMode, + changeSankeySteps, changeUnit, } from '../reportSlice'; export function ReportSettings() { const chartType = useSelector((state) => state.report.chartType); const previous = useSelector((state) => state.report.previous); - const criteria = useSelector((state) => state.report.criteria); const unit = useSelector((state) => state.report.unit); - const funnelGroup = useSelector((state) => state.report.funnelGroup); - const funnelWindow = useSelector((state) => state.report.funnelWindow); + const options = useSelector((state) => state.report.options); + + const retentionOptions = options?.type === 'retention' ? options : undefined; + const criteria = retentionOptions?.criteria ?? 'on_or_after'; + + const funnelOptions = options?.type === 'funnel' ? options : undefined; + const funnelGroup = funnelOptions?.funnelGroup; + const funnelWindow = funnelOptions?.funnelWindow; const dispatch = useDispatch(); + const { projectId } = useAppParams(); + const eventNames = useEventNames({ projectId }); const fields = useMemo(() => { const fields = []; - if (chartType !== 'retention') { + if (chartType !== 'retention' && chartType !== 'sankey') { fields.push('previous'); } @@ -40,6 +54,13 @@ export function ReportSettings() { fields.push('funnelWindow'); } + if (chartType === 'sankey') { + fields.push('sankeyMode'); + fields.push('sankeySteps'); + fields.push('sankeyExclude'); + fields.push('sankeyInclude'); + } + return fields; }, [chartType]); @@ -50,7 +71,7 @@ export function ReportSettings() { return (

    Settings

    -
    +
    {fields.includes('previous') && (
    ); diff --git a/apps/start/src/components/report/sidebar/ReportSidebar.tsx b/apps/start/src/components/report/sidebar/ReportSidebar.tsx index da28bb758..df8a48e8b 100644 --- a/apps/start/src/components/report/sidebar/ReportSidebar.tsx +++ b/apps/start/src/components/report/sidebar/ReportSidebar.tsx @@ -5,14 +5,24 @@ import { useSelector } from '@/redux'; import { ReportBreakdowns } from './ReportBreakdowns'; import { ReportSeries } from './ReportSeries'; import { ReportSettings } from './ReportSettings'; +import { ReportFixedEvents } from './report-fixed-events'; export function ReportSidebar() { - const { chartType } = useSelector((state) => state.report); - const showBreakdown = chartType !== 'retention'; + const { chartType, options } = useSelector((state) => state.report); + const showBreakdown = chartType !== 'retention' && chartType !== 'sankey'; + const showFixedEvents = chartType === 'sankey'; return ( <>
    - + {showFixedEvents ? ( + + ) : ( + + )} {showBreakdown && }
    diff --git a/apps/start/src/components/report/sidebar/report-fixed-events.tsx b/apps/start/src/components/report/sidebar/report-fixed-events.tsx new file mode 100644 index 000000000..bdea470e1 --- /dev/null +++ b/apps/start/src/components/report/sidebar/report-fixed-events.tsx @@ -0,0 +1,223 @@ +import { ColorSquare } from '@/components/color-square'; +import { ComboboxEvents } from '@/components/ui/combobox-events'; +import { Input } from '@/components/ui/input'; +import { InputEnter } from '@/components/ui/input-enter'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useDebounceFn } from '@/hooks/use-debounce-fn'; +import { useEventNames } from '@/hooks/use-event-names'; +import { useDispatch, useSelector } from '@/redux'; +import { alphabetIds } from '@openpanel/constants'; +import type { + IChartEvent, + IChartEventItem, + IChartFormula, +} from '@openpanel/validation'; +import { + addSerie, + changeEvent, + duplicateEvent, + removeEvent, +} from '../reportSlice'; +import type { ReportEventMoreProps } from './ReportEventMore'; +import { ReportEventMore } from './ReportEventMore'; +import { ReportSeriesItem } from './ReportSeriesItem'; + +export function ReportFixedEvents({ + numberOfEvents, +}: { + numberOfEvents: number; +}) { + const selectedSeries = useSelector((state) => state.report.series); + const chartType = useSelector((state) => state.report.chartType); + const dispatch = useDispatch(); + const { projectId } = useAppParams(); + const eventNames = useEventNames({ + projectId, + }); + + const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType); + const showAddFilter = !['retention'].includes(chartType); + const showDisplayNameInput = !['retention', 'sankey'].includes(chartType); + const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => { + dispatch(changeEvent(event)); + }); + const isSelectManyEvents = chartType === 'retention'; + + const handleMore = (event: IChartEventItem | IChartEvent) => { + const callback: ReportEventMoreProps['onClick'] = (action) => { + switch (action) { + case 'remove': { + return dispatch( + removeEvent({ + id: 'type' in event ? event.id : (event as IChartEvent).id, + }), + ); + } + case 'duplicate': { + const normalized = + 'type' in event ? event : { ...event, type: 'event' as const }; + return dispatch(duplicateEvent(normalized)); + } + } + }; + + return callback; + }; + + const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => { + dispatch(changeEvent(formula)); + }); + + const showFormula = + chartType !== 'conversion' && + chartType !== 'funnel' && + chartType !== 'retention' && + chartType !== 'sankey'; + + return ( +
    +

    Metrics

    +
    + {Array.from({ length: numberOfEvents }, (_, index) => ({ + slotId: `fixed-event-slot-${index}`, + index, + })).map(({ slotId, index }) => { + const event = selectedSeries[index]; + + // If no event exists at this index, render an empty slot + if (!event) { + return ( +
    +
    + + {alphabetIds[index]} + + { + if (isSelectManyEvents) { + dispatch( + addSerie({ + type: 'event', + segment: 'user', + name: value, + filters: [ + { + name: 'name', + operator: 'is', + value: [value], + }, + ], + }), + ); + } else { + dispatch( + addSerie({ + type: 'event', + name: value, + segment: 'event', + filters: [], + }), + ); + } + }} + items={eventNames} + placeholder="Select event" + /> +
    +
    + ); + } + + const isFormula = event.type === 'formula'; + if (isFormula) { + return null; + } + + return ( + + { + dispatch( + changeEvent( + Array.isArray(value) + ? { + id: event.id, + type: 'event', + segment: 'user', + filters: [ + { + name: 'name', + operator: 'is', + value: value, + }, + ], + name: '*', + } + : { + ...event, + type: 'event', + name: value, + filters: [], + }, + ), + ); + }} + items={eventNames} + placeholder="Select event" + /> + {showDisplayNameInput && ( + { + dispatchChangeEvent({ + ...(event as IChartEventItem & { + type: 'event'; + }), + displayName: e.target.value, + }); + }} + /> + )} + + + ); + })} +
    +
    + ); +} diff --git a/apps/start/src/components/ui/combobox-events.tsx b/apps/start/src/components/ui/combobox-events.tsx index c9b3fd015..78e53c452 100644 --- a/apps/start/src/components/ui/combobox-events.tsx +++ b/apps/start/src/components/ui/combobox-events.tsx @@ -178,7 +178,7 @@ export function ComboboxEvents< Nothing selected { if (search === '') return true; return item.name.toLowerCase().includes(search.toLowerCase()); diff --git a/apps/start/src/components/ui/label.tsx b/apps/start/src/components/ui/label.tsx index 6f7cea77f..61c5a2377 100644 --- a/apps/start/src/components/ui/label.tsx +++ b/apps/start/src/components/ui/label.tsx @@ -5,7 +5,7 @@ import type { VariantProps } from 'class-variance-authority'; import * as React from 'react'; const labelVariants = cva( - 'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', + 'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground/70', ); const Label = React.forwardRef< diff --git a/apps/start/src/integrations/tanstack-query/root-provider.tsx b/apps/start/src/integrations/tanstack-query/root-provider.tsx index 50ee2ac85..a015111b5 100644 --- a/apps/start/src/integrations/tanstack-query/root-provider.tsx +++ b/apps/start/src/integrations/tanstack-query/root-provider.tsx @@ -25,12 +25,40 @@ export function createTRPCClientWithHeaders(apiUrl: string) { transformer: superjson, url: `${apiUrl}/trpc`, headers: () => getIsomorphicHeaders(), - fetch: (url, options) => { - return fetch(url, { - ...options, - mode: 'cors', - credentials: 'include', - }); + fetch: async (url, options) => { + try { + console.log('fetching', url, options); + const response = await fetch(url, { + ...options, + mode: 'cors', + credentials: 'include', + }); + + // Log HTTP errors on server + if (!response.ok && typeof window === 'undefined') { + const text = await response.clone().text(); + console.error('[tRPC SSR Error]', { + url: url.toString(), + status: response.status, + statusText: response.statusText, + body: text, + options, + }); + } + + return response; + } catch (error) { + // Log fetch errors on server + if (typeof window === 'undefined') { + console.error('[tRPC SSR Error]', { + url: url.toString(), + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + options, + }); + } + throw error; + } }, }), ], diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx index e723eaf34..bc835c98a 100644 --- a/apps/start/src/modals/index.tsx +++ b/apps/start/src/modals/index.tsx @@ -30,7 +30,9 @@ import OverviewFilters from './overview-filters'; import RequestPasswordReset from './request-reset-password'; import SaveReport from './save-report'; import SelectBillingPlan from './select-billing-plan'; +import ShareDashboardModal from './share-dashboard-modal'; import ShareOverviewModal from './share-overview-modal'; +import ShareReportModal from './share-report-modal'; import ViewChartUsers from './view-chart-users'; const modals = { @@ -51,6 +53,8 @@ const modals = { EditReport: EditReport, EditReference: EditReference, ShareOverviewModal: ShareOverviewModal, + ShareDashboardModal: ShareDashboardModal, + ShareReportModal: ShareReportModal, AddReference: AddReference, ViewChartUsers: ViewChartUsers, Instructions: Instructions, diff --git a/apps/start/src/modals/overview-chart-details.tsx b/apps/start/src/modals/overview-chart-details.tsx index f5ccfd006..1ab580e91 100644 --- a/apps/start/src/modals/overview-chart-details.tsx +++ b/apps/start/src/modals/overview-chart-details.tsx @@ -1,12 +1,12 @@ import { ReportChart } from '@/components/report-chart'; import { ScrollArea } from '@/components/ui/scroll-area'; -import type { IChartProps } from '@openpanel/validation'; +import type { IReport } from '@openpanel/validation'; import { ModalContent, ModalHeader } from './Modal/Container'; type Props = { - chart: IChartProps; + chart: IReport; }; const OverviewChartDetails = (props: Props) => { diff --git a/apps/start/src/modals/save-report.tsx b/apps/start/src/modals/save-report.tsx index eb8eb3fbf..c59fdb0e4 100644 --- a/apps/start/src/modals/save-report.tsx +++ b/apps/start/src/modals/save-report.tsx @@ -10,7 +10,7 @@ import { Controller, useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; -import type { IChartProps } from '@openpanel/validation'; +import type { IReport } from '@openpanel/validation'; import { Input } from '@/components/ui/input'; import { useTRPC } from '@/integrations/trpc/react'; @@ -21,7 +21,7 @@ import { popModal } from '.'; import { ModalContent, ModalHeader } from './Modal/Container'; type SaveReportProps = { - report: IChartProps; + report: IReport; disableRedirect?: boolean; }; diff --git a/apps/start/src/modals/share-dashboard-modal.tsx b/apps/start/src/modals/share-dashboard-modal.tsx new file mode 100644 index 000000000..3a2e1ca83 --- /dev/null +++ b/apps/start/src/modals/share-dashboard-modal.tsx @@ -0,0 +1,192 @@ +import { ButtonContainer } from '@/components/button-container'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/use-app-params'; +import { handleError } from '@/integrations/trpc/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +import { zShareDashboard } from '@openpanel/validation'; + +import { Input } from '@/components/ui/input'; +import { Tooltiper } from '@/components/ui/tooltip'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react'; +import { useState } from 'react'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; + +const validator = zShareDashboard; + +type IForm = z.infer; + +export default function ShareDashboardModal({ + dashboardId, +}: { + dashboardId: string; +}) { + const { projectId, organizationId } = useAppParams(); + const navigate = useNavigate(); + const [copied, setCopied] = useState(false); + + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + // Fetch current share status + const shareQuery = useQuery( + trpc.share.dashboard.queryOptions({ + dashboardId, + }), + ); + + const existingShare = shareQuery.data; + const isShared = existingShare?.public ?? false; + const shareUrl = existingShare?.id + ? `${window.location.origin}/share/dashboard/${existingShare.id}` + : ''; + + const { register, handleSubmit, watch } = useForm({ + resolver: zodResolver(validator), + defaultValues: { + public: true, + password: existingShare?.password ? '••••••••' : '', + projectId, + organizationId, + dashboardId, + }, + }); + + const password = watch('password'); + + const mutation = useMutation( + trpc.share.createDashboard.mutationOptions({ + onError: handleError, + onSuccess(res) { + queryClient.invalidateQueries(trpc.share.dashboard.pathFilter()); + toast('Success', { + description: `Your dashboard is now ${ + res.public ? 'public' : 'private' + }`, + action: res.public + ? { + label: 'View', + onClick: () => + navigate({ + to: '/share/dashboard/$shareId', + params: { + shareId: res.id, + }, + }), + } + : undefined, + }); + popModal(); + }, + }), + ); + + const handleCopyLink = () => { + navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + toast('Link copied to clipboard'); + }; + + const handleMakePrivate = () => { + mutation.mutate({ + public: false, + password: null, + projectId, + organizationId, + dashboardId, + }); + }; + + return ( + + + + {isShared && ( +
    +
    + + Currently shared +
    +
    + + + + + + + + + + +
    +
    + )} + + { + mutation.mutate({ + ...values, + // Only send password if it's not the placeholder + password: + values.password === '••••••••' ? null : values.password || null, + }); + })} + > + + + + + + + +
    + ); +} diff --git a/apps/start/src/modals/share-overview-modal.tsx b/apps/start/src/modals/share-overview-modal.tsx index 5ee503d2b..5d4b35630 100644 --- a/apps/start/src/modals/share-overview-modal.tsx +++ b/apps/start/src/modals/share-overview-modal.tsx @@ -11,8 +11,11 @@ import type { z } from 'zod'; import { zShareOverview } from '@openpanel/validation'; import { Input } from '@/components/ui/input'; +import { Tooltiper } from '@/components/ui/tooltip'; import { useTRPC } from '@/integrations/trpc/react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react'; +import { useState } from 'react'; import { popModal } from '.'; import { ModalContent, ModalHeader } from './Modal/Container'; @@ -23,19 +26,36 @@ type IForm = z.infer; export default function ShareOverviewModal() { const { projectId, organizationId } = useAppParams(); const navigate = useNavigate(); + const [copied, setCopied] = useState(false); - const { register, handleSubmit } = useForm({ + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + // Fetch current share status + const shareQuery = useQuery( + trpc.share.overview.queryOptions({ + projectId, + }), + ); + + const existingShare = shareQuery.data; + const isShared = existingShare?.public ?? false; + const shareUrl = existingShare?.id + ? `${window.location.origin}/share/overview/${existingShare.id}` + : ''; + + const { register, handleSubmit, watch } = useForm({ resolver: zodResolver(validator), defaultValues: { public: true, - password: '', + password: existingShare?.password ? '••••••••' : '', projectId, organizationId, }, }); - const trpc = useTRPC(); - const queryClient = useQueryClient(); + const password = watch('password'); + const mutation = useMutation( trpc.share.createOverview.mutationOptions({ onError: handleError, @@ -45,47 +65,122 @@ export default function ShareOverviewModal() { description: `Your overview is now ${ res.public ? 'public' : 'private' }`, - action: { - label: 'View', - onClick: () => - navigate({ - to: '/share/overview/$shareId', - params: { - shareId: res.id, - }, - }), - }, + action: res.public + ? { + label: 'View', + onClick: () => + navigate({ + to: '/share/overview/$shareId', + params: { + shareId: res.id, + }, + }), + } + : undefined, }); popModal(); }, }), ); + const handleCopyLink = () => { + navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + toast('Link copied to clipboard'); + }; + + const handleMakePrivate = () => { + mutation.mutate({ + public: false, + password: null, + projectId, + organizationId, + }); + }; + return ( + + {isShared && ( +
    +
    + + Currently shared +
    +
    + + + + + + + + + + +
    +
    + )} +
    { - mutation.mutate(values); + mutation.mutate({ + ...values, + // Only send password if it's not the placeholder + password: + values.password === '••••••••' ? null : values.password || null, + }); })} > -
    ); -} +} \ No newline at end of file diff --git a/apps/start/src/modals/share-report-modal.tsx b/apps/start/src/modals/share-report-modal.tsx new file mode 100644 index 000000000..be98d241d --- /dev/null +++ b/apps/start/src/modals/share-report-modal.tsx @@ -0,0 +1,186 @@ +import { ButtonContainer } from '@/components/button-container'; +import { Button } from '@/components/ui/button'; +import { useAppParams } from '@/hooks/use-app-params'; +import { handleError } from '@/integrations/trpc/react'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +import { zShareReport } from '@openpanel/validation'; + +import { Input } from '@/components/ui/input'; +import { Tooltiper } from '@/components/ui/tooltip'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react'; +import { useState } from 'react'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; + +const validator = zShareReport; + +type IForm = z.infer; + +export default function ShareReportModal({ reportId }: { reportId: string }) { + const { projectId, organizationId } = useAppParams(); + const navigate = useNavigate(); + const [copied, setCopied] = useState(false); + + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + // Fetch current share status + const shareQuery = useQuery( + trpc.share.report.queryOptions({ + reportId, + }), + ); + + const existingShare = shareQuery.data; + const isShared = existingShare?.public ?? false; + const shareUrl = existingShare?.id + ? `${window.location.origin}/share/report/${existingShare.id}` + : ''; + + const { register, handleSubmit, watch } = useForm({ + resolver: zodResolver(validator), + defaultValues: { + public: true, + password: existingShare?.password ? '••••••••' : '', + projectId, + organizationId, + reportId, + }, + }); + + const password = watch('password'); + + const mutation = useMutation( + trpc.share.createReport.mutationOptions({ + onError: handleError, + onSuccess(res) { + queryClient.invalidateQueries(trpc.share.report.pathFilter()); + toast('Success', { + description: `Your report is now ${res.public ? 'public' : 'private'}`, + action: res.public + ? { + label: 'View', + onClick: () => + navigate({ + to: '/share/report/$shareId', + params: { + shareId: res.id, + }, + }), + } + : undefined, + }); + popModal(); + }, + }), + ); + + const handleCopyLink = () => { + navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + toast('Link copied to clipboard'); + }; + + const handleMakePrivate = () => { + mutation.mutate({ + public: false, + password: null, + projectId, + organizationId, + reportId, + }); + }; + + return ( + + + + {isShared && ( +
    +
    + + Currently shared +
    +
    + + + + + + + + + + +
    +
    + )} + +
    { + mutation.mutate({ + ...values, + // Only send password if it's not the placeholder + password: + values.password === '••••••••' ? null : values.password || null, + }); + })} + > + + + + + + +
    +
    + ); +} \ No newline at end of file diff --git a/apps/start/src/modals/view-chart-users.tsx b/apps/start/src/modals/view-chart-users.tsx index 31c7f760c..44f4380ea 100644 --- a/apps/start/src/modals/view-chart-users.tsx +++ b/apps/start/src/modals/view-chart-users.tsx @@ -13,7 +13,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import type { IChartData } from '@/trpc/client'; import { cn } from '@/utils/cn'; import { getProfileName } from '@/utils/getters'; -import type { IChartInput } from '@openpanel/validation'; +import type { IReportInput } from '@openpanel/validation'; import { useQuery } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useEffect, useMemo, useState } from 'react'; @@ -152,7 +152,7 @@ function ProfileList({ profiles }: { profiles: any[] }) { // Chart-specific props and component interface ChartUsersViewProps { chartData: IChartData; - report: IChartInput; + report: IReportInput; date: string; } @@ -279,7 +279,7 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) { // Funnel-specific props and component interface FunnelUsersViewProps { - report: IChartInput; + report: IReportInput; stepIndex: number; } @@ -297,8 +297,14 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) { series: report.series, stepIndex: stepIndex, showDropoffs: showDropoffs, - funnelWindow: report.funnelWindow, - funnelGroup: report.funnelGroup, + funnelWindow: + report.options?.type === 'funnel' + ? report.options.funnelWindow + : undefined, + funnelGroup: + report.options?.type === 'funnel' + ? report.options.funnelGroup + : undefined, breakdowns: report.breakdowns, }, { @@ -371,12 +377,12 @@ type ViewChartUsersProps = | { type: 'chart'; chartData: IChartData; - report: IChartInput; + report: IReportInput; date: string; } | { type: 'funnel'; - report: IChartInput; + report: IReportInput; stepIndex: number; }; diff --git a/apps/start/src/routeTree.gen.ts b/apps/start/src/routeTree.gen.ts index a75fc4b09..5d5144014 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' @@ -23,7 +26,9 @@ import { Route as LoginResetPasswordRouteImport } from './routes/_login.reset-pa import { Route as LoginLoginRouteImport } from './routes/_login.login' import { Route as AppOrganizationIdRouteImport } from './routes/_app.$organizationId' import { Route as AppOrganizationIdIndexRouteImport } from './routes/_app.$organizationId.index' +import { Route as ShareReportShareIdRouteImport } from './routes/share.report.$shareId' import { Route as ShareOverviewShareIdRouteImport } from './routes/share.overview.$shareId' +import { Route as ShareDashboardShareIdRouteImport } from './routes/share.dashboard.$shareId' import { Route as StepsOnboardingProjectRouteImport } from './routes/_steps.onboarding.project' import { Route as AppOrganizationIdSettingsRouteImport } from './routes/_app.$organizationId.settings' import { Route as AppOrganizationIdBillingRouteImport } from './routes/_app.$organizationId.billing' @@ -58,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' @@ -117,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', @@ -164,11 +185,21 @@ const AppOrganizationIdIndexRoute = AppOrganizationIdIndexRouteImport.update({ path: '/', getParentRoute: () => AppOrganizationIdRoute, } as any) +const ShareReportShareIdRoute = ShareReportShareIdRouteImport.update({ + id: '/share/report/$shareId', + path: '/share/report/$shareId', + getParentRoute: () => rootRouteImport, +} as any) const ShareOverviewShareIdRoute = ShareOverviewShareIdRouteImport.update({ id: '/share/overview/$shareId', path: '/share/overview/$shareId', getParentRoute: () => rootRouteImport, } as any) +const ShareDashboardShareIdRoute = ShareDashboardShareIdRouteImport.update({ + id: '/share/dashboard/$shareId', + path: '/share/dashboard/$shareId', + getParentRoute: () => rootRouteImport, +} as any) const StepsOnboardingProjectRoute = StepsOnboardingProjectRouteImport.update({ id: '/onboarding/project', path: '/onboarding/project', @@ -396,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', @@ -494,11 +531,16 @@ 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 '/onboarding/project': typeof StepsOnboardingProjectRoute + '/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute '/share/overview/$shareId': typeof ShareOverviewShareIdRoute + '/share/report/$shareId': typeof ShareReportShareIdRoute '/$organizationId/': typeof AppOrganizationIdIndexRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute @@ -539,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 @@ -553,10 +596,15 @@ 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 + '/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute '/share/overview/$shareId': typeof ShareOverviewShareIdRoute + '/share/report/$shareId': typeof ShareReportShareIdRoute '/$organizationId': typeof AppOrganizationIdIndexRoute '/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute @@ -595,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 { @@ -610,11 +659,16 @@ 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 '/_steps/onboarding/project': typeof StepsOnboardingProjectRoute + '/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute '/share/overview/$shareId': typeof ShareOverviewShareIdRoute + '/share/report/$shareId': typeof ShareReportShareIdRoute '/_app/$organizationId/': typeof AppOrganizationIdIndexRoute '/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute '/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute @@ -662,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 @@ -679,11 +734,16 @@ export interface FileRouteTypes { | '/onboarding' | '/api/config' | '/api/healthcheck' + | '/widget/counter' + | '/widget/realtime' + | '/widget/test' | '/$organizationId/$projectId' | '/$organizationId/billing' | '/$organizationId/settings' | '/onboarding/project' + | '/share/dashboard/$shareId' | '/share/overview/$shareId' + | '/share/report/$shareId' | '/$organizationId/' | '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/dashboards' @@ -724,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/' @@ -738,10 +799,15 @@ export interface FileRouteTypes { | '/onboarding' | '/api/config' | '/api/healthcheck' + | '/widget/counter' + | '/widget/realtime' + | '/widget/test' | '/$organizationId/billing' | '/$organizationId/settings' | '/onboarding/project' + | '/share/dashboard/$shareId' | '/share/overview/$shareId' + | '/share/report/$shareId' | '/$organizationId' | '/$organizationId/$projectId/chat' | '/$organizationId/$projectId/dashboards' @@ -780,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__' @@ -794,11 +861,16 @@ export interface FileRouteTypes { | '/_public/onboarding' | '/api/config' | '/api/healthcheck' + | '/widget/counter' + | '/widget/realtime' + | '/widget/test' | '/_app/$organizationId/$projectId' | '/_app/$organizationId/billing' | '/_app/$organizationId/settings' | '/_steps/onboarding/project' + | '/share/dashboard/$shareId' | '/share/overview/$shareId' + | '/share/report/$shareId' | '/_app/$organizationId/' | '/_app/$organizationId/$projectId/chat' | '/_app/$organizationId/$projectId/dashboards' @@ -846,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/' @@ -862,7 +935,12 @@ 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 } declare module '@tanstack/react-router' { @@ -902,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' @@ -965,6 +1064,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppOrganizationIdIndexRouteImport parentRoute: typeof AppOrganizationIdRoute } + '/share/report/$shareId': { + id: '/share/report/$shareId' + path: '/share/report/$shareId' + fullPath: '/share/report/$shareId' + preLoaderRoute: typeof ShareReportShareIdRouteImport + parentRoute: typeof rootRouteImport + } '/share/overview/$shareId': { id: '/share/overview/$shareId' path: '/share/overview/$shareId' @@ -972,6 +1078,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ShareOverviewShareIdRouteImport parentRoute: typeof rootRouteImport } + '/share/dashboard/$shareId': { + id: '/share/dashboard/$shareId' + path: '/share/dashboard/$shareId' + fullPath: '/share/dashboard/$shareId' + preLoaderRoute: typeof ShareDashboardShareIdRouteImport + parentRoute: typeof rootRouteImport + } '/_steps/onboarding/project': { id: '/_steps/onboarding/project' path: '/onboarding/project' @@ -1245,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' @@ -1508,6 +1628,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren { AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute + AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute AppOrganizationIdProjectIdSettingsTabsIndexRoute: typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute } @@ -1521,6 +1642,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj AppOrganizationIdProjectIdSettingsTabsEventsRoute, AppOrganizationIdProjectIdSettingsTabsImportsRoute: AppOrganizationIdProjectIdSettingsTabsImportsRoute, + AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: + AppOrganizationIdProjectIdSettingsTabsWidgetsRoute, AppOrganizationIdProjectIdSettingsTabsIndexRoute: AppOrganizationIdProjectIdSettingsTabsIndexRoute, } @@ -1751,7 +1874,12 @@ const rootRouteChildren: RootRouteChildren = { StepsRoute: StepsRouteWithChildren, ApiConfigRoute: ApiConfigRoute, ApiHealthcheckRoute: ApiHealthcheckRoute, + WidgetCounterRoute: WidgetCounterRoute, + WidgetRealtimeRoute: WidgetRealtimeRoute, + WidgetTestRoute: WidgetTestRoute, + ShareDashboardShareIdRoute: ShareDashboardShareIdRoute, ShareOverviewShareIdRoute: ShareOverviewShareIdRoute, + ShareReportShareIdRoute: ShareReportShareIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/start/src/routes/__root.tsx b/apps/start/src/routes/__root.tsx index c76afa363..a7e591902 100644 --- a/apps/start/src/routes/__root.tsx +++ b/apps/start/src/routes/__root.tsx @@ -2,7 +2,6 @@ import { HeadContent, Scripts, createRootRouteWithContext, - useRouteContext, } from '@tanstack/react-router'; import 'flag-icons/css/flag-icons.min.css'; diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards.tsx index 641ce5396..7660d690e 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards.tsx @@ -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 ( diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx index 503cb1e80..31e52d3c3 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.dashboards_.$dashboardId.tsx @@ -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 ( -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - ); -} - -// 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 ( -
    -
    -
    { - 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} - > -
    {report.name}
    - {chartRange !== null && ( -
    - - {timeWindows[chartRange as keyof typeof timeWindows]?.label} - - {startDate && endDate ? ( - Custom dates - ) : ( - range !== null && - chartRange !== range && ( - - {timeWindows[range as keyof typeof timeWindows]?.label} - - ) - )} -
    - )} -
    -
    -
    - - - - - - - - -
    - - - - - - { - event.stopPropagation(); - onDuplicate(report.id); - }} - > - - Duplicate - - - { - event.stopPropagation(); - onDelete(report.id); - }} - > - - Delete - - - - -
    -
    -
    - -
    -
    - ); -} - 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() { @@ -484,6 +278,14 @@ function Component() { + + pushModal('ShareDashboardModal', { dashboardId }) + } + > + + Share dashboard + showConfirm({ @@ -532,69 +334,43 @@ function Component() { ) : !isGridReady || reportsQuery.isLoading ? (
    - - - - - - + + + + + +
    ) : ( -
    - - - {reports.map((report) => ( -
    - { - reportDeletion.mutate({ reportId }); - }} - onDuplicate={(reportId) => { - reportDuplicate.mutate({ reportId }); - }} - /> -
    - ))} -
    -
    + + {reports.map((report) => ( +
    + { + reportDeletion.mutate({ reportId }); + }} + onDuplicate={(reportId) => { + reportDuplicate.mutate({ reportId }); + }} + /> +
    + ))} +
    )} ); diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx index 539e4cb13..365121afc 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx @@ -274,20 +274,16 @@ const PageCard = memo(
    ; } 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 620894cdb..2acfa01d9 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 000000000..536b27d5b --- /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

    +
    +