From 790801b728eb031dd73e75eb49cc6cdd3a6781d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesv=C3=A4rd?= <1987198+lindesvard@users.noreply.github.com> Date: Wed, 19 Nov 2025 14:27:34 +0100 Subject: [PATCH] feat: revenue tracking * wip * wip * wip * wip * show revenue better on overview * align realtime and overview counters * update revenue docs * always return device id * add project settings, improve projects charts, * fix: comments * fixes * fix migration * ignore sql files * fix comments --- .gitignore | 1 + apps/api/src/controllers/event.controller.ts | 30 +- .../api/src/controllers/profile.controller.ts | 2 +- apps/api/src/controllers/track.controller.ts | 93 +- apps/api/src/routes/track.router.ts | 19 +- apps/api/src/utils/auth.ts | 16 + apps/public/components/flow-step.tsx | 79 + .../docs/get-started/revenue-tracking.mdx | 364 + apps/public/package.json | 2 +- apps/start/src/components/chart-ssr.tsx | 112 - .../src/components/charts/chart-tooltip.tsx | 14 +- .../src/components/charts/common-bar.tsx | 9 + .../src/components/events/event-icon.tsx | 4 + .../src/components/events/table/item.tsx | 9 +- .../overview/overview-live-histogram.tsx | 4 +- .../overview/overview-metric-card.tsx | 67 +- .../components/overview/overview-metrics.tsx | 324 +- .../overview/overview-widget-table.tsx | 109 +- .../components/profiles/profile-metrics.tsx | 26 +- .../src/components/projects/project-card.tsx | 31 +- .../src/components/projects/project-chart.tsx | 215 + .../realtime/realtime-live-histogram.tsx | 301 +- .../components/realtime/realtime-reloader.tsx | 9 +- .../components/report-chart/common/axis.tsx | 9 +- .../common/report-chart-tooltip.tsx | 5 +- .../report-chart/common/report-table.tsx | 5 +- .../report-chart/conversion/chart.tsx | 9 +- .../settings/edit-project-details.tsx | 39 +- apps/start/src/components/widget-table.tsx | 214 +- .../src/hooks/use-format-date-interval.ts | 26 +- apps/start/src/hooks/use-numer-formatter.ts | 30 +- ...ectId.profiles.$profileId._tabs.events.tsx | 10 + ...jectId.profiles.$profileId._tabs.index.tsx | 10 + apps/start/src/utils/title.ts | 5 +- .../src/jobs/events.create-session-end.ts | 30 +- apps/worker/src/jobs/events.incoming-event.ts | 68 +- .../src/jobs/events.incoming-events.test.ts | 66 +- packages/db/code-migrations/3-init-ch.sql | 167 - .../db/code-migrations/4-add-sessions.sql | 22933 ---------------- .../code-migrations/5-add-imports-table.sql | 47 - .../code-migrations/6-add-revenue-column.ts | 36 + .../migration.sql | 2 + packages/db/prisma/schema.prisma | 21 +- packages/db/src/buffers/session-buffer.ts | 38 +- packages/db/src/clickhouse/migration.ts | 16 +- packages/db/src/services/chart.service.ts | 36 +- packages/db/src/services/event.service.ts | 54 + packages/db/src/services/overview.service.ts | 33 +- packages/db/src/services/profile.service.ts | 7 +- packages/db/src/session-consistency.ts | 19 +- packages/sdks/sdk/src/index.ts | 22 + packages/sdks/web/src/index.ts | 47 + packages/sdks/web/src/types.d.ts | 6 +- packages/trpc/src/routers/chart.ts | 17 +- packages/trpc/src/routers/overview.ts | 10 +- packages/trpc/src/routers/project.ts | 2 + packages/trpc/src/trpc.ts | 2 +- packages/validation/src/index.ts | 1 + 58 files changed, 2191 insertions(+), 23691 deletions(-) create mode 100644 apps/public/components/flow-step.tsx create mode 100644 apps/public/content/docs/get-started/revenue-tracking.mdx delete mode 100644 apps/start/src/components/chart-ssr.tsx create mode 100644 apps/start/src/components/projects/project-chart.tsx delete mode 100644 packages/db/code-migrations/3-init-ch.sql delete mode 100644 packages/db/code-migrations/4-add-sessions.sql delete mode 100644 packages/db/code-migrations/5-add-imports-table.sql create mode 100644 packages/db/code-migrations/6-add-revenue-column.ts create mode 100644 packages/db/prisma/migrations/20251118100123_add_revenue_tracking_setting_on_project/migration.sql diff --git a/.gitignore b/.gitignore index c2f17021..7a6ec957 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .secrets packages/db/src/generated/prisma +packages/db/code-migrations/*.sql # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore packages/sdk/profileId.txt diff --git a/apps/api/src/controllers/event.controller.ts b/apps/api/src/controllers/event.controller.ts index eac5a414..4d31c034 100644 --- a/apps/api/src/controllers/event.controller.ts +++ b/apps/api/src/controllers/event.controller.ts @@ -20,7 +20,7 @@ export async function postEvent( request.body, ); const ip = request.clientIp; - const ua = request.headers['user-agent']!; + const ua = request.headers['user-agent']; const projectId = request.client?.projectId; const headers = getStringHeaders(request.headers); @@ -30,18 +30,22 @@ export async function postEvent( } const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); - const currentDeviceId = generateDeviceId({ - salt: salts.current, - origin: projectId, - ip, - ua, - }); - const previousDeviceId = generateDeviceId({ - salt: salts.previous, - origin: projectId, - ip, - ua, - }); + const currentDeviceId = ua + ? generateDeviceId({ + salt: salts.current, + origin: projectId, + ip, + ua, + }) + : ''; + const previousDeviceId = ua + ? generateDeviceId({ + salt: salts.previous, + origin: projectId, + ip, + ua, + }) + : ''; const uaInfo = parseUserAgent(ua, request.body?.properties); const groupId = uaInfo.isServer diff --git a/apps/api/src/controllers/profile.controller.ts b/apps/api/src/controllers/profile.controller.ts index 77e9ead7..4e86f035 100644 --- a/apps/api/src/controllers/profile.controller.ts +++ b/apps/api/src/controllers/profile.controller.ts @@ -21,7 +21,7 @@ export async function updateProfile( return reply.status(400).send('No projectId'); } const ip = request.clientIp; - const ua = request.headers['user-agent']!; + const ua = request.headers['user-agent']; const uaInfo = parseUserAgent(ua, payload.properties); const geo = await getGeoLocation(ip); diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index ae1fdc06..b3feb5ef 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -6,6 +6,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; import { getProfileById, getSalts, upsertProfile } from '@openpanel/db'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { getEventsGroupQueueShard } from '@openpanel/queue'; +import { getRedisCache } from '@openpanel/redis'; import type { DecrementPayload, IdentifyPayload, @@ -102,7 +103,7 @@ export async function handler( request.body.payload.properties?.__ip ? (request.body.payload.properties.__ip as string) : request.clientIp; - const ua = request.headers['user-agent']!; + const ua = request.headers['user-agent']; const projectId = request.client?.projectId; if (!projectId) { @@ -115,6 +116,16 @@ export async function handler( const identity = getIdentity(request.body); const profileId = identity?.profileId; + const overrideDeviceId = (() => { + const deviceId = + 'properties' in request.body.payload + ? request.body.payload.properties?.__deviceId + : undefined; + if (typeof deviceId === 'string') { + return deviceId; + } + return undefined; + })(); // We might get a profileId from the alias table // If we do, we should use that instead of the one from the payload @@ -125,14 +136,16 @@ export async function handler( switch (request.body.type) { case 'track': { const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]); - const currentDeviceId = ua - ? generateDeviceId({ - salt: salts.current, - origin: projectId, - ip, - ua, - }) - : ''; + const currentDeviceId = + overrideDeviceId || + (ua + ? generateDeviceId({ + salt: salts.current, + origin: projectId, + ip, + ua, + }) + : ''); const previousDeviceId = ua ? generateDeviceId({ salt: salts.previous, @@ -370,3 +383,65 @@ async function decrement({ isExternal: true, }); } + +export async function fetchDeviceId( + request: FastifyRequest, + reply: FastifyReply, +) { + const salts = await getSalts(); + const projectId = request.client?.projectId; + if (!projectId) { + return reply.status(400).send('No projectId'); + } + + const ip = request.clientIp; + if (!ip) { + return reply.status(400).send('Missing ip address'); + } + + const ua = request.headers['user-agent']; + if (!ua) { + return reply.status(400).send('Missing header: user-agent'); + } + + const currentDeviceId = generateDeviceId({ + salt: salts.current, + origin: projectId, + ip, + ua, + }); + const previousDeviceId = generateDeviceId({ + salt: salts.previous, + origin: projectId, + ip, + ua, + }); + + try { + const multi = getRedisCache().multi(); + multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`); + multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`); + const res = await multi.exec(); + + if (res?.[0]?.[1]) { + return reply.status(200).send({ + deviceId: currentDeviceId, + message: 'current session exists for this device id', + }); + } + + if (res?.[1]?.[1]) { + return reply.status(200).send({ + deviceId: previousDeviceId, + message: 'previous session exists for this device id', + }); + } + } catch (error) { + request.log.error('Error getting session end GET /track/device-id', error); + } + + return reply.status(200).send({ + deviceId: currentDeviceId, + message: 'No session exists for this device id', + }); +} diff --git a/apps/api/src/routes/track.router.ts b/apps/api/src/routes/track.router.ts index 41a9890e..ec777a3a 100644 --- a/apps/api/src/routes/track.router.ts +++ b/apps/api/src/routes/track.router.ts @@ -1,4 +1,4 @@ -import { handler } from '@/controllers/track.controller'; +import { fetchDeviceId, handler } from '@/controllers/track.controller'; import type { FastifyPluginCallback } from 'fastify'; import { clientHook } from '@/hooks/client.hook'; @@ -31,6 +31,23 @@ const trackRouter: FastifyPluginCallback = async (fastify) => { }, }, }); + + fastify.route({ + method: 'GET', + url: '/device-id', + handler: fetchDeviceId, + schema: { + response: { + 200: { + type: 'object', + properties: { + deviceId: { type: 'string' }, + message: { type: 'string', optional: true }, + }, + }, + }, + }, + }); }; export default trackRouter; diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index 14a97a6e..8d4e4332 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -105,6 +105,22 @@ export async function validateSdkRequest( throw createError('Ingestion: Profile id is blocked by project filter'); } + const revenue = + path(['payload', 'properties', '__revenue'], req.body) ?? + path(['properties', '__revenue'], req.body); + + // Only allow revenue tracking if it was sent with a client secret + // or if the project has allowUnsafeRevenueTracking enabled + if ( + !client.project.allowUnsafeRevenueTracking && + !clientSecret && + typeof revenue !== 'undefined' + ) { + throw createError( + 'Ingestion: Revenue tracking is not allowed without a client secret', + ); + } + if (client.ignoreCorsAndSecret) { return client; } diff --git a/apps/public/components/flow-step.tsx b/apps/public/components/flow-step.tsx new file mode 100644 index 00000000..9aa62852 --- /dev/null +++ b/apps/public/components/flow-step.tsx @@ -0,0 +1,79 @@ +import { CheckCircle, CreditCard, Globe, Server, User } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface FlowStepProps { + step: number; + actor: string; + description: string; + children?: ReactNode; + icon?: 'visitor' | 'website' | 'backend' | 'payment' | 'success'; + isLast?: boolean; +} + +const iconMap = { + visitor: User, + website: Globe, + backend: Server, + payment: CreditCard, + success: CheckCircle, +}; + +const iconColorMap = { + visitor: 'text-blue-500', + website: 'text-green-500', + backend: 'text-purple-500', + payment: 'text-yellow-500', + success: 'text-green-600', +}; + +const iconBorderColorMap = { + visitor: 'border-blue-500', + website: 'border-green-500', + backend: 'border-purple-500', + payment: 'border-yellow-500', + success: 'border-green-600', +}; + +export function FlowStep({ + step, + actor, + description, + children, + icon = 'visitor', + isLast = false, +}: FlowStepProps) { + const Icon = iconMap[icon]; + + return ( +
+ {/* Step number and icon */} +
+
+
+ {step} +
+
+ +
+
+ {/* Connector line - extends from badge through content to next step */} + {!isLast && ( +
+ )} +
+ + {/* Content */} +
+
+ {actor}:{' '} + {description} +
+ {children &&
{children}
} +
+
+ ); +} diff --git a/apps/public/content/docs/get-started/revenue-tracking.mdx b/apps/public/content/docs/get-started/revenue-tracking.mdx new file mode 100644 index 00000000..f07cb22c --- /dev/null +++ b/apps/public/content/docs/get-started/revenue-tracking.mdx @@ -0,0 +1,364 @@ +--- +title: Revenue tracking +description: Learn how to easily track your revenue with OpenPanel and how to get it shown directly in your dashboard. +--- + +import { FlowStep } from '@/components/flow-step'; + +Revenue tracking is a great way to get a better understanding of what your best revenue source is. On this page we'll break down how to get started. + +Before we start, we need to know some fundamentals about how OpenPanel and your payment provider work and how we can link a payment to a visitor. + +### Payment providers + +Usually, you create your checkout from your backend, which then returns a payment link that your visitor will be redirected to. When creating the checkout link, you usually add additional fields such as metadata, customer information, or order details. We'll add the device ID information in this metadata field to be able to link your payment to a visitor. + +### OpenPanel + +OpenPanel is a cookieless analytics tool that identifies visitors using a `device_id`. To link a payment to a visitor, you need to capture their `device_id` before they complete checkout. This `device_id` will be stored in your payment provider's metadata, and when the payment webhook arrives, you'll use it to associate the revenue with the correct visitor. + +## Some typical flows + +- [Revenue tracking from your backend (not identified)](#revenue-tracking-from-your-backend-webhook) +- [Revenue tracking from your backend (identified)](#revenue-tracking-from-your-backend-webhook-identified) +- [Revenue tracking from your frontend](#revenue-tracking-from-your-frontend) +- [Revenue tracking without linking it to a identity or device](#revenue-tracking-without-linking-it-to-an-identity-or-device) + +### Revenue tracking from your backend (webhook) + +This is the most common flow and most secure one. Your backend receives webhooks from your payment provider, and here is the best opportunity to do revenue tracking. + + + + + + +When you create the checkout, you should first call `op.fetchDeviceId()`, which will return your visitor's current `deviceId`. Pass this to your checkout endpoint. + +```javascript +fetch('https://domain.com/api/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + deviceId: await op.fetchDeviceId(), // ✅ since deviceId is here we can link the payment now + // ... other checkout data + }), +}) + .then(response => response.json()) + .then(data => { + // Handle checkout response, e.g., redirect to payment link + window.location.href = data.paymentUrl; + }) +``` + + + +```javascript +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + +export async function POST(req: Request) { + const { deviceId, amount, currency } = await req.json(); + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: currency, + product_data: { name: 'Product Name' }, + unit_amount: amount * 100, // Convert to cents + }, + quantity: 1, + }, + ], + mode: 'payment', + metadata: { + deviceId: deviceId, // ✅ since deviceId is here we can link the payment now + }, + success_url: 'https://domain.com/success', + cancel_url: 'https://domain.com/cancel', + }); + + return Response.json({ + paymentUrl: session.url, + }); +} +``` + + + + + + + +```javascript +export async function POST(req: Request) { + const event = await req.json(); + + // Stripe sends events with type and data.object structure + if (event.type === 'checkout.session.completed') { + const session = event.data.object; + const deviceId = session.metadata.deviceId; + const amount = session.amount_total; + + op.revenue(amount, { deviceId }); // ✅ since deviceId is here we can link the payment now + } + + return Response.json({ received: true }); +} +``` + + + + +--- + +### Revenue tracking from your backend (webhook) - Identified users + +If your visitors are identified (meaning you have called `identify` with a `profileId`), this process gets a bit easier. You don't need to pass the `deviceId` when creating your checkout, and you only need to provide the `profileId` (in backend) to the revenue call. + + + + +When a visitor logs in or is identified, call `op.identify()` with their unique `profileId`. + +```javascript +op.identify({ + profileId: 'user-123', // Unique identifier for this user + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', +}); +``` + + + + + +Since the visitor is already identified, you don't need to fetch or pass the `deviceId`. Just send the checkout data. + +```javascript +fetch('https://domain.com/api/checkout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + // ✅ No deviceId needed - user is already identified + // ... other checkout data + }), +}) + .then(response => response.json()) + .then(data => { + // Handle checkout response, e.g., redirect to payment link + window.location.href = data.paymentUrl; + }) +``` + + + +Since the user is authenticated, you can get their `profileId` from the session and store it in metadata for easy retrieval in the webhook. + +```javascript +import Stripe from 'stripe'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + +export async function POST(req: Request) { + const { amount, currency } = await req.json(); + + // Get profileId from authenticated session + const profileId = req.session.userId; // or however you get the user ID + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: currency, + product_data: { name: 'Product Name' }, + unit_amount: amount * 100, // Convert to cents + }, + quantity: 1, + }, + ], + mode: 'payment', + metadata: { + profileId: profileId, // ✅ Store profileId instead of deviceId + }, + success_url: 'https://domain.com/success', + cancel_url: 'https://domain.com/cancel', + }); + + return Response.json({ + paymentUrl: session.url, + }); +} +``` + + + + + + + +In the webhook handler, retrieve the `profileId` from the session metadata. + +```javascript +export async function POST(req: Request) { + const event = await req.json(); + + // Stripe sends events with type and data.object structure + if (event.type === 'checkout.session.completed') { + const session = event.data.object; + const profileId = session.metadata.profileId; + const amount = session.amount_total; + + op.revenue(amount, { profileId }); // ✅ Use profileId instead of deviceId + } + + return Response.json({ received: true }); +} +``` + + + + +--- + +### Revenue tracking from your frontend + +This flow tracks revenue directly from your frontend. Since the success page doesn't have access to the payment amount (payment happens on Stripe's side), we track revenue when checkout is initiated and then confirm it on the success page. + + + + + + +When the visitor clicks the checkout button, track the revenue with the amount. + +```javascript +async function handleCheckout() { + const amount = 2000; // Amount in cents + + // Create a pending revenue (stored in sessionStorage) + op.pendingRevenue(amount, { + productId: '123', + // ... other properties + }); + + // Redirect to Stripe checkout + window.location.href = 'https://checkout.stripe.com/...'; +} +``` + + + + + + + + + +On your success page, flush all pending revenue events. This will send all pending revenues tracked during checkout and clear them from sessionStorage. + +```javascript +// Flush all pending revenues +await op.flushRevenue(); + +// Or if you want to clear without sending (e.g., payment was cancelled) +op.clearRevenue(); +``` + + +#### Pros: +- Quick way to get going +- No backend required +- Can track revenue immediately when checkout starts + +#### Cons: +- Less accurate (visitor might not complete payment) +- Less "secure" meaning anyone could post revenue data + +--- + +### Revenue tracking without linking it to an identity or device + +If you simply want to track revenue totals without linking payments to specific visitors or devices, you can call `op.revenue()` directly from your backend without providing a `deviceId` or `profileId`. This is the simplest approach and works well when you only need aggregate revenue data. + + + + + + +Simply call `op.revenue()` with the amount. No `deviceId` or `profileId` is needed. + +```javascript +export async function POST(req: Request) { + const event = await req.json(); + + // Stripe sends events with type and data.object structure + if (event.type === 'checkout.session.completed') { + const session = event.data.object; + const amount = session.amount_total; + + op.revenue(amount); // ✅ Simple revenue tracking without linking to a visitor + } + + return Response.json({ received: true }); +} +``` + + +#### Pros: +- Simplest implementation +- No need to capture or pass device IDs +- Works well for aggregate revenue tracking + +#### Cons: +- **You can't dive deeper into where this revenue came from.** For instance, you won't be able to see which source generates the best revenue, which campaigns are most profitable, or which visitors are your highest-value customers. +- Revenue events won't be linked to specific user journeys or sessions + +## Available methods + +### Revenue + +The revenue method will create a revenue event. It's important to know that this method will not work if your OpenPanel instance didn't receive a client secret (for security reasons). You can enable frontend revenue tracking within your project settings. + +```javascript +op.revenue(amount: number, properties: Record): Promise +``` + +### Add a pending revenue + +This method will create a pending revenue item and store it in sessionStorage. It will not be sent to OpenPanel until you call `flushRevenue()`. Pending revenues are automatically restored from sessionStorage when the SDK initializes. + +```javascript +op.pendingRevenue(amount: number, properties?: Record): void +``` + +### Send all pending revenues + +This method will send all pending revenues to OpenPanel and then clear them from sessionStorage. Returns a Promise that resolves when all revenues have been sent. + +```javascript +await op.flushRevenue(): Promise +``` + +### Clear any pending revenue + +This method will clear all pending revenues from memory and sessionStorage without sending them to OpenPanel. Useful if a payment was cancelled or you want to discard pending revenues. + +```javascript +op.clearRevenue(): void +``` + +### Fetch your current users device id + +```javascript +op.fetchDeviceId(): Promise +``` diff --git a/apps/public/package.json b/apps/public/package.json index 220493a6..a5968431 100644 --- a/apps/public/package.json +++ b/apps/public/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "pnpm with-env next dev", + "dev": "pnpm with-env next dev --port 3001", "build": "pnpm with-env next build", "start": "next start", "postinstall": "fumadocs-mdx", diff --git a/apps/start/src/components/chart-ssr.tsx b/apps/start/src/components/chart-ssr.tsx deleted file mode 100644 index d3bdc57a..00000000 --- a/apps/start/src/components/chart-ssr.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import * as d3 from 'd3'; - -export function ChartSSR({ - data, - dots = false, - color = 'blue', -}: { - dots?: boolean; - color?: 'blue' | 'green' | 'red'; - data: { value: number; date: Date }[]; -}) { - if (data.length === 0) { - return null; - } - - const xScale = d3 - .scaleTime() - .domain([data[0]!.date, data[data.length - 1]!.date]) - .range([0, 100]); - const yScale = d3 - .scaleLinear() - .domain([0, d3.max(data.map((d) => d.value)) ?? 0]) - .range([100, 0]); - - const line = d3 - .line<(typeof data)[number]>() - .curve(d3.curveMonotoneX) - .x((d) => xScale(d.date)) - .y((d) => yScale(d.value)); - - const area = d3 - .area<(typeof data)[number]>() - .curve(d3.curveMonotoneX) - .x((d) => xScale(d.date)) - .y0(yScale(0)) - .y1((d) => yScale(d.value)); - - const pathLine = line(data); - const pathArea = area(data); - - if (!pathLine) { - return null; - } - - const gradientId = `gradient-${color}`; - - return ( -
- {/* Chart area */} - - - - - - - - - - {/* Gradient area */} - {pathArea && ( - - )} - {/* Line */} - - - {/* Circles */} - {dots && - data.map((d) => ( - - ))} - - -
- ); -} diff --git a/apps/start/src/components/charts/chart-tooltip.tsx b/apps/start/src/components/charts/chart-tooltip.tsx index b9e12da5..9fe73e33 100644 --- a/apps/start/src/components/charts/chart-tooltip.tsx +++ b/apps/start/src/components/charts/chart-tooltip.tsx @@ -1,3 +1,4 @@ +import { cn } from '@/utils/cn'; import { createContext, useContext as useBaseContext } from 'react'; import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts'; @@ -21,11 +22,18 @@ export const ChartTooltipHeader = ({ export const ChartTooltipItem = ({ children, color, -}: { children: React.ReactNode; color: string }) => { + className, + innerClassName, +}: { + children: React.ReactNode; + color: string; + className?: string; + innerClassName?: string; +}) => { return ( -
+
-
{children}
+
{children}
); }; diff --git a/apps/start/src/components/charts/common-bar.tsx b/apps/start/src/components/charts/common-bar.tsx index 12ff7224..7d628c65 100644 --- a/apps/start/src/components/charts/common-bar.tsx +++ b/apps/start/src/components/charts/common-bar.tsx @@ -77,6 +77,15 @@ export const BarShapeBlue = BarWithBorder({ fill: 'rgba(59, 121, 255, 0.4)', }, }); +export const BarShapeGreen = BarWithBorder({ + borderHeight: 2, + border: 'rgba(59, 169, 116, 1)', + fill: 'rgba(59, 169, 116, 0.3)', + active: { + border: 'rgba(59, 169, 116, 1)', + fill: 'rgba(59, 169, 116, 0.4)', + }, +}); export const BarShapeProps = BarWithBorder({ borderHeight: 2, border: 'props', diff --git a/apps/start/src/components/events/event-icon.tsx b/apps/start/src/components/events/event-icon.tsx index 154206c1..db469103 100644 --- a/apps/start/src/components/events/event-icon.tsx +++ b/apps/start/src/components/events/event-icon.tsx @@ -48,6 +48,10 @@ export const EventIconRecords: Record< icon: 'ExternalLinkIcon', color: 'indigo', }, + revenue: { + icon: 'DollarSignIcon', + color: 'green', + }, }; export const EventIconMapper: Record = { diff --git a/apps/start/src/components/events/table/item.tsx b/apps/start/src/components/events/table/item.tsx index e9a78816..7c42c8b1 100644 --- a/apps/start/src/components/events/table/item.tsx +++ b/apps/start/src/components/events/table/item.tsx @@ -54,7 +54,7 @@ export const EventItem = memo( }} data-slot="inner" className={cn( - 'col gap-2 flex-1 p-2', + 'col gap-1 flex-1 p-2', // Desktop '@lg:row @lg:items-center', 'cursor-pointer', @@ -63,7 +63,7 @@ export const EventItem = memo( : 'hover:bg-def-200', )} > -
+
- + {event.name === 'screen_view' ? ( <> Visit: @@ -87,13 +87,12 @@ export const EventItem = memo( ) : ( <> - Event: {event.name} )}
-
+
{event.referrerName && viewOptions.referrerName !== false && ( } diff --git a/apps/start/src/components/overview/overview-live-histogram.tsx b/apps/start/src/components/overview/overview-live-histogram.tsx index b82d8a00..f49aeede 100644 --- a/apps/start/src/components/overview/overview-live-histogram.tsx +++ b/apps/start/src/components/overview/overview-live-histogram.tsx @@ -108,8 +108,8 @@ function Wrapper({ children, count, icons }: WrapperProps) { return (
-
- {count} sessions last 30 minutes +
+ {count} sessions last 30 min
{icons}
diff --git a/apps/start/src/components/overview/overview-metric-card.tsx b/apps/start/src/components/overview/overview-metric-card.tsx index e2d31fba..598dc47b 100644 --- a/apps/start/src/components/overview/overview-metric-card.tsx +++ b/apps/start/src/components/overview/overview-metric-card.tsx @@ -6,7 +6,7 @@ import { Area, AreaChart, Tooltip } from 'recharts'; import { formatDate, timeAgo } from '@/utils/date'; import { getChartColor } from '@/utils/theme'; import { getPreviousMetric } from '@openpanel/common'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { ChartTooltipContainer, ChartTooltipHeader, @@ -24,12 +24,13 @@ interface MetricCardProps { data: { current: number; previous?: number; + date: string; }[]; metric: { current: number; previous?: number | null; }; - unit?: '' | 'date' | 'timeAgo' | 'min' | '%'; + unit?: '' | 'date' | 'timeAgo' | 'min' | '%' | 'currency'; label: string; onClick?: () => void; active?: boolean; @@ -48,9 +49,28 @@ export function OverviewMetricCard({ inverted = false, isLoading = false, }: MetricCardProps) { - const [value, setValue] = useState(metric.current); + const [currentIndex, setCurrentIndex] = useState(null); const number = useNumber(); const { current, previous } = metric; + const timer = useRef(null); + + useEffect(() => { + if (timer.current) { + clearTimeout(timer.current); + } + + if (currentIndex) { + timer.current = setTimeout(() => { + setCurrentIndex(null); + }, 1000); + } + + return () => { + if (timer.current) { + clearTimeout(timer.current); + } + }; + }, [currentIndex]); const renderValue = (value: number, unitClassName?: string, short = true) => { if (unit === 'date') { @@ -65,6 +85,11 @@ export function OverviewMetricCard({ return <>{fancyMinutes(value)}; } + if (unit === 'currency') { + // Revenue is stored in cents, convert to dollars + return <>{number.currency(value / 100)}; + } + return ( <> {short ? number.short(value) : number.format(value)} @@ -81,19 +106,33 @@ export function OverviewMetricCard({ '#93c5fd', // blue ); - return ( - { + if (currentIndex) { + return ( - {label}:{' '} + {formatDate(new Date(data[currentIndex]?.date))}:{' '} - {renderValue(value, 'ml-1 font-light text-xl', false)} + {renderValue( + data[currentIndex].current, + 'ml-1 font-light text-xl', + false, + )} - } - asChild - sideOffset={-20} - > + ); + } + + return ( + + {label}:{' '} + + {renderValue(metric.current, 'ml-1 font-light text-xl', false)} + + + ); + }; + return ( + - -
{overviewQuery.isLoading && } @@ -181,7 +154,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { activeMetric={activeMetric} interval={interval} data={data} - chartType={chartType} projectId={projectId} />
@@ -194,18 +166,25 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { const { Tooltip, TooltipProvider } = createChartTooltip< RouterOutputs['overview']['stats']['series'][number], { + anyMetric?: boolean; metric: (typeof TITLES)[number]; interval: IInterval; } ->(({ context: { metric, interval }, data: dataArray }) => { +>(({ context: { metric, interval, anyMetric }, data: dataArray }) => { const data = dataArray[0]; - const formatDate = useFormatDateInterval(interval); + const formatDate = useFormatDateInterval({ + interval, + short: false, + }); const number = useNumber(); if (!data) { return null; } + const revenue = data.total_revenue ?? 0; + const prevRevenue = data.prev_total_revenue ?? 0; + return ( <>
@@ -215,16 +194,25 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
{metric.title}
- {number.formatWithUnit(data[metric.key])} + {metric.unit === 'currency' + ? number.currency((data[metric.key] ?? 0) / 100) + : number.formatWithUnit(data[metric.key], metric.unit)} {!!data[`prev_${metric.key}`] && ( - ({number.formatWithUnit(data[`prev_${metric.key}`])}) + ( + {metric.unit === 'currency' + ? number.currency((data[`prev_${metric.key}`] ?? 0) / 100) + : number.formatWithUnit( + data[`prev_${metric.key}`], + metric.unit, + )} + ) )}
@@ -238,6 +226,32 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
+ {anyMetric && revenue > 0 && ( +
+
+
+
Revenue
+
+
+ {number.currency(revenue / 100)} + {prevRevenue > 0 && ( + + ({number.currency(prevRevenue / 100)}) + + )} +
+ {prevRevenue > 0 && ( + + )} +
+
+
+ )} ); @@ -247,17 +261,19 @@ function Chart({ activeMetric, interval, data, - chartType, projectId, }: { activeMetric: (typeof TITLES)[number]; interval: IInterval; data: RouterOutputs['overview']['stats']['series']; - chartType: 'bars' | 'lines'; projectId: string; }) { const xAxisProps = useXAxisProps({ interval }); const yAxisProps = useYAxisProps(); + const number = useNumber(); + const revenueYAxisProps = useYAxisProps({ + tickFormatter: (value) => number.short(value / 100), + }); const [activeBar, setActiveBar] = useState(-1); const { range, startDate, endDate } = useOverviewOptions(); @@ -278,13 +294,11 @@ function Chart({ // Line chart specific logic let dotIndex = undefined; - if (chartType === 'lines') { - if (interval === 'hour') { - // Find closest index based on times - dotIndex = data.findIndex((item) => { - return isSameHour(item.date, new Date()); - }); - } + if (interval === 'hour') { + // Find closest index based on times + dotIndex = data.findIndex((item) => { + return isSameHour(item.date, new Date()); + }); } const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } = @@ -294,6 +308,10 @@ function Chart({ const lastSerieDataItem = last(data)?.date || new Date(); const useDashedLastLine = (() => { + if (range === 'today') { + return true; + } + if (interval === 'hour') { return isSameHour(lastSerieDataItem, new Date()); } @@ -313,11 +331,11 @@ function Chart({ return false; })(); - if (chartType === 'lines') { + if (activeMetric.key === 'total_revenue') { return ( - + - + - + + + + + + + + + + - 90 ? false : { - stroke: getChartColor(0), - fill: 'var(--def-100)', + stroke: '#3ba974', + fill: '#3ba974', strokeWidth: 1.5, r: 3, } } activeDot={{ - stroke: getChartColor(0), + stroke: '#3ba974', fill: 'var(--def-100)', strokeWidth: 2, r: 4, }} + filter="url(#rainbow-line-glow)" /> {references.data?.map((ref) => ( @@ -410,36 +446,48 @@ function Chart({ fontSize={10} /> ))} - + ); } - // Bar chart (default) return ( - + - { setActiveBar(e.activeTooltipIndex ?? -1); }} - barCategoryGap={2} > - + + - + Math.max(max, item.total_revenue ?? 0), + 0, + ) * 2, + ]} + width={30} + /> + - + + + + + + + + + + ( - - )} + dot={ + data.length > 90 + ? false + : { + stroke: 'oklch(from var(--foreground) l c h / 0.1)', + fill: 'transparent', + strokeWidth: 1.5, + r: 2, + } + } + activeDot={{ + stroke: 'oklch(from var(--foreground) l c h / 0.2)', + fill: 'transparent', + strokeWidth: 1.5, + r: 3, + }} /> ( - - )} + radius={5} + maxBarSize={20} + > + {data.map((item, index) => { + return ( + + ); + })} + + 90 + ? false + : { + stroke: getChartColor(0), + fill: 'transparent', + strokeWidth: 1.5, + r: 3, + } + } + activeDot={{ + stroke: getChartColor(0), + fill: 'var(--def-100)', + strokeWidth: 2, + r: 4, + }} + filter="url(#rainbow-line-glow)" /> {references.data?.map((ref) => ( @@ -480,7 +610,7 @@ function Chart({ fontSize={10} /> ))} - + ); diff --git a/apps/start/src/components/overview/overview-widget-table.tsx b/apps/start/src/components/overview/overview-widget-table.tsx index f2e9cc53..c04d2ae0 100644 --- a/apps/start/src/components/overview/overview-widget-table.tsx +++ b/apps/start/src/components/overview/overview-widget-table.tsx @@ -8,6 +8,43 @@ import { Skeleton } from '../skeleton'; import { Tooltiper } from '../ui/tooltip'; import { WidgetTable, type Props as WidgetTableProps } from '../widget-table'; +function RevenuePieChart({ percentage }: { percentage: number }) { + const size = 16; + const strokeWidth = 2; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - percentage * circumference; + + return ( + + {/* Background circle */} + + {/* Revenue arc */} + + + ); +} + type Props = WidgetTableProps & { getColumnPercentage: (item: T) => number; }; @@ -45,9 +82,7 @@ export const OverviewWidgetTable = ({ index === 0 ? 'text-left w-full font-medium min-w-0' : 'text-right font-mono', - index !== 0 && - index !== columns.length - 1 && - 'hidden @[310px]:table-cell', + // Remove old responsive logic - now handled by responsive prop column.className, ), }; @@ -119,12 +154,15 @@ export function OverviewWidgetTablePages({ avg_duration: number; bounce_rate: number; sessions: number; + revenue: number; }[]; showDomain?: boolean; }) { const [_filters, setFilter] = useEventQueryFilters(); const number = useNumber(); const maxSessions = Math.max(...data.map((item) => item.sessions)); + const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0); + const hasRevenue = data.some((item) => item.revenue > 0); return ( @@ -178,6 +217,7 @@ export function OverviewWidgetTablePages({ { name: 'BR', width: '60px', + responsive: { priority: 6 }, // Hidden when space is tight render(item) { return number.shortWithUnit(item.bounce_rate, '%'); }, @@ -185,13 +225,41 @@ export function OverviewWidgetTablePages({ { name: 'Duration', width: '75px', + responsive: { priority: 7 }, // Hidden when space is tight render(item) { return number.shortWithUnit(item.avg_duration, 'min'); }, }, + ...(hasRevenue + ? [ + { + name: 'Revenue', + width: '100px', + responsive: { priority: 3 }, // Always show if possible + render(item: (typeof data)[number]) { + const revenuePercentage = + totalRevenue > 0 ? item.revenue / totalRevenue : 0; + return ( +
+ + {item.revenue > 0 + ? number.currency(item.revenue / 100) + : '-'} + + +
+ ); + }, + } as const, + ] + : []), { name: lastColumnName, width: '84px', + responsive: { priority: 2 }, // Always show if possible render(item) { return (
@@ -303,20 +371,24 @@ export function OverviewWidgetTableGeneric({ }) { const number = useNumber(); const maxSessions = Math.max(...data.map((item) => item.sessions)); + const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0); + const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0); return ( item.name} + keyExtractor={(item) => item.prefix + item.name} getColumnPercentage={(item) => item.sessions / maxSessions} columns={[ { ...column, width: 'w-full', + responsive: { priority: 1 }, // Always visible }, { name: 'BR', width: '60px', + responsive: { priority: 6 }, // Hidden when space is tight render(item) { return number.shortWithUnit(item.bounce_rate, '%'); }, @@ -327,9 +399,38 @@ export function OverviewWidgetTableGeneric({ // return number.shortWithUnit(item.avg_session_duration, 'min'); // }, // }, + + ...(hasRevenue + ? [ + { + name: 'Revenue', + width: '100px', + responsive: { priority: 3 }, // Always show if possible + render(item: RouterOutputs['overview']['topGeneric'][number]) { + const revenue = item.revenue ?? 0; + const revenuePercentage = + totalRevenue > 0 ? revenue / totalRevenue : 0; + return ( +
+ + {revenue > 0 + ? number.currency(revenue / 100, { short: true }) + : '-'} + + +
+ ); + }, + } as const, + ] + : []), { name: 'Sessions', width: '84px', + responsive: { priority: 2 }, // Always show if possible render(item) { return (
diff --git a/apps/start/src/components/profiles/profile-metrics.tsx b/apps/start/src/components/profiles/profile-metrics.tsx index abfe7731..eb30c53c 100644 --- a/apps/start/src/components/profiles/profile-metrics.tsx +++ b/apps/start/src/components/profiles/profile-metrics.tsx @@ -12,72 +12,91 @@ const PROFILE_METRICS = [ key: 'totalEvents', unit: '', inverted: false, + hideOnZero: false, }, { title: 'Sessions', key: 'sessions', unit: '', inverted: false, + hideOnZero: false, }, { title: 'Page Views', key: 'screenViews', unit: '', inverted: false, + hideOnZero: false, }, { title: 'Avg Events/Session', key: 'avgEventsPerSession', unit: '', inverted: false, + hideOnZero: false, }, { title: 'Bounce Rate', key: 'bounceRate', unit: '%', inverted: true, + hideOnZero: false, }, { title: 'Session Duration (Avg)', key: 'durationAvg', unit: 'min', inverted: false, + hideOnZero: false, }, { title: 'Session Duration (P90)', key: 'durationP90', unit: 'min', inverted: false, + hideOnZero: false, }, { title: 'First seen', key: 'firstSeen', unit: 'timeAgo', inverted: false, + hideOnZero: false, }, { title: 'Last seen', key: 'lastSeen', unit: 'timeAgo', inverted: false, + hideOnZero: false, }, { title: 'Days Active', key: 'uniqueDaysActive', unit: '', inverted: false, + hideOnZero: false, }, { title: 'Conversion Events', key: 'conversionEvents', unit: '', inverted: false, + hideOnZero: false, }, { title: 'Avg Time Between Sessions (h)', key: 'avgTimeBetweenSessions', unit: 'min', inverted: false, + hideOnZero: false, + }, + { + title: 'Revenue', + key: 'revenue', + unit: 'currency', + inverted: false, + hideOnZero: true, }, ] as const; @@ -85,7 +104,12 @@ export const ProfileMetrics = ({ data }: Props) => { return (
- {PROFILE_METRICS.map((metric) => ( + {PROFILE_METRICS.filter((metric) => { + if (metric.hideOnZero && data[metric.key] === 0) { + return false; + } + return true; + }).map((metric) => (
- +
@@ -77,7 +77,7 @@ function ProjectCard({ id, domain, name, organizationId }: IServiceProject) { ); } -function ProjectChart({ id }: { id: string }) { +function ProjectChartOuter({ id }: { id: string }) { const trpc = useTRPC(); const { data } = useQuery( trpc.chart.projectCard.queryOptions({ @@ -87,7 +87,7 @@ function ProjectChart({ id }: { id: string }) { return ( - + ); } @@ -102,6 +102,7 @@ function Metric({ value, label }: { value: React.ReactNode; label: string }) { } function ProjectMetrics({ id }: { id: string }) { + const number = useNumber(); const trpc = useTRPC(); const { data } = useQuery( trpc.chart.projectCard.queryOptions({ @@ -138,16 +139,18 @@ function ProjectMetrics({ id }: { id: string }) { } /> )} + {!!data?.metrics?.revenue && ( + + )}
- - - + + + ); } diff --git a/apps/start/src/components/projects/project-chart.tsx b/apps/start/src/components/projects/project-chart.tsx new file mode 100644 index 00000000..26985bb9 --- /dev/null +++ b/apps/start/src/components/projects/project-chart.tsx @@ -0,0 +1,215 @@ +import { + ChartTooltipHeader, + ChartTooltipItem, + createChartTooltip, +} from '@/components/charts/chart-tooltip'; +import { useNumber } from '@/hooks/use-numer-formatter'; +import { cn } from '@/utils/cn'; +import { getChartColor } from '@/utils/theme'; +import { useState } from 'react'; +import { + Bar, + Cell, + ComposedChart, + Line, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; + +type ChartDataItem = { + value: number; + date: Date; + revenue: number; + timestamp: number; +}; + +const { Tooltip, TooltipProvider } = createChartTooltip< + ChartDataItem, + { + color: 'blue' | 'green' | 'red'; + } +>( + ({ + context, + data: dataArray, + }: { + context: { color: 'blue' | 'green' | 'red' }; + data: ChartDataItem[]; + }) => { + const { color } = context; + const data = dataArray[0]; + const number = useNumber(); + + if (!data) { + return null; + } + + const getColorValue = () => { + if (color === 'green') return '#16a34a'; + if (color === 'red') return '#dc2626'; + return getChartColor(0); + }; + + const formatDate = (date: Date) => { + return new Intl.DateTimeFormat('en-GB', { + weekday: 'short', + day: '2-digit', + month: 'short', + }).format(date); + }; + + return ( + <> + +
{formatDate(data.date)}
+
+ +
Sessions
+
{number.format(data.value)}
+
+ {data.revenue > 0 && ( + +
Revenue
+
+ {number.currency(data.revenue / 100)} +
+
+ )} + + ); + }, +); + +export function ProjectChart({ + data, + dots = false, + color = 'blue', +}: { + dots?: boolean; + color?: 'blue' | 'green' | 'red'; + data: { value: number; date: Date; revenue: number }[]; +}) { + const [activeBar, setActiveBar] = useState(-1); + + if (data.length === 0) { + return null; + } + + // Transform data for Recharts (needs timestamp for time-based x-axis) + const chartData = data.map((item) => ({ + ...item, + timestamp: item.date.getTime(), + })); + + const maxValue = Math.max(...data.map((d) => d.value), 0); + const maxRevenue = Math.max(...data.map((d) => d.revenue), 0); + + const getColorValue = () => { + if (color === 'green') return '#16a34a'; + if (color === 'red') return '#dc2626'; + return getChartColor(0); + }; + + return ( +
+ + + { + setActiveBar(e.activeTooltipIndex ?? -1); + }} + > + + + + + + + + + + + + + + + + + + + + {chartData.map((item, index) => ( + + ))} + + + + +
+ ); +} diff --git a/apps/start/src/components/realtime/realtime-live-histogram.tsx b/apps/start/src/components/realtime/realtime-live-histogram.tsx index eba53b98..a6c8fece 100644 --- a/apps/start/src/components/realtime/realtime-live-histogram.tsx +++ b/apps/start/src/components/realtime/realtime-live-histogram.tsx @@ -1,126 +1,99 @@ -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; import { useTRPC } from '@/integrations/trpc/react'; -import { cn } from '@/utils/cn'; import { useQuery } from '@tanstack/react-query'; -import type { IChartProps } from '@openpanel/validation'; +import { useNumber } from '@/hooks/use-numer-formatter'; +import { getChartColor } from '@/utils/theme'; +import * as Portal from '@radix-ui/react-portal'; +import { bind } from 'bind-event-listener'; +import throttle from 'lodash.throttle'; +import React, { useEffect, useState } from 'react'; +import { + Bar, + BarChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; import { AnimatedNumber } from '../animated-number'; +import { BarShapeBlue } from '../charts/common-bar'; +import { SerieIcon } from '../report-chart/common/serie-icon'; interface RealtimeLiveHistogramProps { projectId: string; } -export const getReport = (projectId: string): IChartProps => { - return { - projectId, - events: [ - { - segment: 'user', - filters: [], - name: '*', - displayName: 'Active users', - }, - ], - chartType: 'histogram', - interval: 'minute', - range: '30min', - name: '', - metric: 'sum', - breakdowns: [], - lineType: 'monotone', - previous: false, - }; -}; - -export const getCountReport = (projectId: string): IChartProps => { - return { - name: '', - projectId, - events: [ - { - segment: 'user', - filters: [], - id: 'A', - name: 'session_start', - }, - ], - breakdowns: [], - chartType: 'metric', - lineType: 'monotone', - interval: 'minute', - range: '30min', - previous: false, - metric: 'sum', - }; -}; - export function RealtimeLiveHistogram({ projectId, }: RealtimeLiveHistogramProps) { - const report = getReport(projectId); - const countReport = getCountReport(projectId); - const trpc = useTRPC(); - const res = useQuery(trpc.chart.chart.queryOptions(report)); - const countRes = useQuery(trpc.chart.chart.queryOptions(countReport)); - const metrics = res.data?.series[0]?.metrics; - const minutes = (res.data?.series[0]?.data || []).slice(-30); - const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0; + // Use the same liveData endpoint as overview + const { data: liveData, isLoading } = useQuery( + trpc.overview.liveData.queryOptions({ projectId }), + ); - if (res.isInitialLoading || countRes.isInitialLoading || liveCount === 0) { - const staticArray = [ - 10, 25, 30, 45, 20, 5, 55, 18, 40, 12, 50, 35, 8, 22, 38, 42, 15, 28, 52, - 5, 48, 14, 32, 58, 7, 19, 33, 56, 24, 5, - ]; + const chartData = liveData?.minuteCounts ?? []; + // Calculate total unique visitors (sum of unique visitors per minute) + // Note: This is an approximation - ideally we'd want unique visitors across all minutes + const totalVisitors = liveData?.totalSessions ?? 0; + if (isLoading) { return ( - {staticArray.map((percent, i) => ( -
- ))} +
); } - if (!res.isSuccess && !countRes.isSuccess) { + if (!liveData) { return null; } + const maxDomain = + Math.max(...chartData.map((item) => item.visitorCount), 0) * 1.2 || 1; + return ( - - {minutes.map((minute) => { - return ( - - + 0 ? ( +
+ {liveData.referrers.slice(0, 3).map((ref, index) => (
- - -
{minute.count} active users
-
@ {new Date(minute.date).toLocaleTimeString()}
-
- - ); - })} + key={`${ref.referrer}-${ref.count}-${index}`} + className="font-bold text-xs row gap-1 items-center" + > + + {ref.count} +
+ ))} +
+ ) : null + } + > + + + + + + + +
); } @@ -128,22 +101,144 @@ export function RealtimeLiveHistogram({ interface WrapperProps { children: React.ReactNode; count: number; + icons?: React.ReactNode; } -function Wrapper({ children, count }: WrapperProps) { +function Wrapper({ children, count, icons }: WrapperProps) { return (
-
-
- Unique vistors last 30 minutes +
+
+ Unique visitors {icons ?
: null} + last 30 min
+
{icons}
+
+
-
- {children} -
+
{children}
); } + +// Custom tooltip component that uses portals to escape overflow hidden +const CustomTooltip = ({ active, payload, coordinate }: any) => { + const number = useNumber(); + const [position, setPosition] = useState<{ x: number; y: number } | null>( + null, + ); + + const inactive = !active || !payload?.length; + useEffect(() => { + const setPositionThrottled = throttle(setPosition, 50); + const unsubMouseMove = bind(window, { + type: 'mousemove', + listener(event) { + if (!inactive) { + setPositionThrottled({ x: event.clientX, y: event.clientY + 20 }); + } + }, + }); + const unsubDragEnter = bind(window, { + type: 'pointerdown', + listener() { + setPosition(null); + }, + }); + + return () => { + unsubMouseMove(); + unsubDragEnter(); + }; + }, [inactive]); + + if (inactive) { + return null; + } + + if (!active || !payload || !payload.length) { + return null; + } + + const data = payload[0].payload; + + const tooltipWidth = 200; + const correctXPosition = (x: number | undefined) => { + if (!x) { + return undefined; + } + + const screenWidth = window.innerWidth; + const newX = x; + + if (newX + tooltipWidth > screenWidth) { + return screenWidth - tooltipWidth; + } + return newX; + }; + + return ( + +
+
{data.time}
+
+ +
+
+
+
Active users
+
+
+ {number.formatWithUnit(data.visitorCount)} +
+
+
+
+ {data.referrers && data.referrers.length > 0 && ( +
+
Referrers:
+
+ {data.referrers.slice(0, 3).map((ref: any, index: number) => ( +
+
+ + + {ref.referrer} + +
+ {ref.count} +
+ ))} + {data.referrers.length > 3 && ( +
+ +{data.referrers.length - 3} more +
+ )} +
+
+ )} + + + ); +}; diff --git a/apps/start/src/components/realtime/realtime-reloader.tsx b/apps/start/src/components/realtime/realtime-reloader.tsx index 934812f9..d4db49d4 100644 --- a/apps/start/src/components/realtime/realtime-reloader.tsx +++ b/apps/start/src/components/realtime/realtime-reloader.tsx @@ -1,7 +1,6 @@ import useWS from '@/hooks/use-ws'; import { useTRPC } from '@/integrations/trpc/react'; import { useQueryClient } from '@tanstack/react-query'; -import { getCountReport, getReport } from './realtime-live-histogram'; type Props = { projectId: string; @@ -17,11 +16,15 @@ const RealtimeReloader = ({ projectId }: Props) => { if (!document.hidden) { client.refetchQueries(trpc.realtime.pathFilter()); client.refetchQueries( - trpc.chart.chart.queryFilter(getReport(projectId)), + trpc.overview.liveData.queryFilter({ projectId }), ); client.refetchQueries( - trpc.chart.chart.queryFilter(getCountReport(projectId)), + trpc.realtime.activeSessions.queryFilter({ projectId }), ); + client.refetchQueries( + trpc.realtime.referrals.queryFilter({ projectId }), + ); + client.refetchQueries(trpc.realtime.paths.queryFilter({ projectId })); } }, { diff --git a/apps/start/src/components/report-chart/common/axis.tsx b/apps/start/src/components/report-chart/common/axis.tsx index 97daa1b3..2d3ac4de 100644 --- a/apps/start/src/components/report-chart/common/axis.tsx +++ b/apps/start/src/components/report-chart/common/axis.tsx @@ -6,7 +6,6 @@ import { useRef, useState } from 'react'; import type { AxisDomain } from 'recharts/types/util/types'; import type { IInterval } from '@openpanel/validation'; - export const AXIS_FONT_PROPS = { fontSize: 8, className: 'font-mono', @@ -69,9 +68,11 @@ export const useXAxisProps = ( interval: 'auto', }, ) => { - const formatDate = useFormatDateInterval( - interval === 'auto' ? 'day' : interval, - ); + const formatDate = useFormatDateInterval({ + interval: interval === 'auto' ? 'day' : interval, + short: true, + }); + return { ...X_AXIS_STYLE_PROPS, height: hide ? 0 : X_AXIS_STYLE_PROPS.height, diff --git a/apps/start/src/components/report-chart/common/report-chart-tooltip.tsx b/apps/start/src/components/report-chart/common/report-chart-tooltip.tsx index 97f19eaf..02c02469 100644 --- a/apps/start/src/components/report-chart/common/report-chart-tooltip.tsx +++ b/apps/start/src/components/report-chart/common/report-chart-tooltip.tsx @@ -62,7 +62,10 @@ export const ReportChartTooltip = createChartTooltip( const { report: { interval, unit }, } = useReportChartContext(); - const formatDate = useFormatDateInterval(interval); + const formatDate = useFormatDateInterval({ + interval, + short: false, + }); const number = useNumber(); if (!data || data.length === 0) { diff --git a/apps/start/src/components/report-chart/common/report-table.tsx b/apps/start/src/components/report-chart/common/report-table.tsx index 5fb61c4b..a4d21892 100644 --- a/apps/start/src/components/report-chart/common/report-table.tsx +++ b/apps/start/src/components/report-chart/common/report-table.tsx @@ -40,7 +40,10 @@ export function ReportTable({ const number = useNumber(); const interval = useSelector((state) => state.report.interval); const breakdowns = useSelector((state) => state.report.breakdowns); - const formatDate = useFormatDateInterval(interval); + const formatDate = useFormatDateInterval({ + interval, + short: true, + }); function handleChange(name: string, checked: boolean) { setVisibleSeries((prev) => { diff --git a/apps/start/src/components/report-chart/conversion/chart.tsx b/apps/start/src/components/report-chart/conversion/chart.tsx index bce52379..4237a351 100644 --- a/apps/start/src/components/report-chart/conversion/chart.tsx +++ b/apps/start/src/components/report-chart/conversion/chart.tsx @@ -1,6 +1,8 @@ +import { pushModal } from '@/modals'; import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; +import { useCallback } from 'react'; import { CartesianGrid, Line, @@ -10,8 +12,6 @@ import { XAxis, YAxis, } from 'recharts'; -import { pushModal } from '@/modals'; -import { useCallback } from 'react'; import { createChartTooltip } from '@/components/charts/chart-tooltip'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; @@ -171,7 +171,10 @@ const { Tooltip, TooltipProvider } = createChartTooltip< } const { date } = data[0]; - const formatDate = useFormatDateInterval(context.interval); + const formatDate = useFormatDateInterval({ + interval: context.interval, + short: false, + }); const number = useNumber(); return ( <> diff --git a/apps/start/src/components/settings/edit-project-details.tsx b/apps/start/src/components/settings/edit-project-details.tsx index 6839c7ba..efebce10 100644 --- a/apps/start/src/components/settings/edit-project-details.tsx +++ b/apps/start/src/components/settings/edit-project-details.tsx @@ -26,6 +26,7 @@ const validator = zProject.pick({ domain: true, cors: true, crossDomain: true, + allowUnsafeRevenueTracking: true, }); type IForm = z.infer; @@ -39,6 +40,7 @@ export default function EditProjectDetails({ project }: Props) { domain: project.domain, cors: project.cors, crossDomain: project.crossDomain, + allowUnsafeRevenueTracking: project.allowUnsafeRevenueTracking, }, }); const trpc = useTRPC(); @@ -155,22 +157,45 @@ export default function EditProjectDetails({ project }: Props) { control={form.control} render={({ field }) => { return ( + + +
Enable cross domain support
+
+ This will let you track users across multiple domains +
+
+
+ ); + }} + /> + + + { + return ( + -
Enable cross domain support
+
Allow "unsafe" revenue tracking
- This will let you track users across multiple domains + With this enabled, you can track revenue from client code.
- ); - }} - /> - +
+ ); + }} + />