1 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
bc3d7b7ea8 docs: add guides 2025-12-15 10:14:40 +01:00
265 changed files with 4801 additions and 23255 deletions

View File

@@ -98,10 +98,6 @@ You can find the how to [here](https://openpanel.dev/docs/self-hosting/self-host
### Start
```bash
pnpm install
cp .env.example .env
echo "API_URL=http://localhost:3333" > apps/start/.env
pnpm dock:up
pnpm codegen
pnpm migrate:deploy # once to setup the db
@@ -114,4 +110,4 @@ You can now access the following:
- API: https://api.localhost:3333
- Bullboard (queue): http://localhost:9999
- `pnpm dock:ch` to access clickhouse terminal
- `pnpm dock:redis` to access redis terminal
- `pnpm dock:redis` to access redis terminal

View File

@@ -38,9 +38,11 @@ COPY packages/redis/package.json packages/redis/
COPY packages/logger/package.json packages/logger/
COPY packages/common/package.json packages/common/
COPY packages/payments/package.json packages/payments/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY patches ./patches
# BUILD
@@ -105,6 +107,7 @@ COPY --from=build /app/packages/redis ./packages/redis
COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/common ./packages/common
COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations

View File

@@ -52,6 +52,7 @@
},
"devDependencies": {
"@faker-js/faker": "^9.0.1",
"@openpanel/sdk": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.9",

View File

@@ -3,15 +3,15 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { PostEventPayload } from '@openpanel/sdk';
import { generateId, slug } from '@openpanel/common';
import { getGeoLocation } from '@openpanel/geo';
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
import { getStringHeaders, getTimestamp } from './track.controller';
export async function postEvent(
request: FastifyRequest<{
Body: DeprecatedPostEventPayload;
Body: PostEventPayload;
}>,
reply: FastifyReply,
) {

View File

@@ -13,7 +13,7 @@ import {
getSettingsForProject,
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zReport } from '@openpanel/validation';
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
import { omit } from 'ramda';
async function getProjectId(
@@ -139,7 +139,7 @@ export async function events(
});
}
const chartSchemeFull = zReport
const chartSchemeFull = zChartInputBase
.pick({
breakdowns: true,
interval: true,

View File

@@ -96,6 +96,8 @@ export async function getPages(
startDate: startDate,
endDate: endDate,
timezone,
cursor: parsed.data.cursor,
limit: Math.min(parsed.data.limit, 50),
});
}
@@ -168,6 +170,8 @@ export function getOverviewGeneric(
startDate: startDate,
endDate: endDate,
timezone,
cursor: parsed.data.cursor,
limit: Math.min(parsed.data.limit, 50),
}),
);
};

View File

@@ -118,11 +118,7 @@ async function fetchImage(
// Check if URL is an ICO file
function isIcoFile(url: string, contentType?: string): boolean {
return (
url.toLowerCase().endsWith('.ico') ||
contentType === 'image/x-icon' ||
contentType === 'image/vnd.microsoft.icon'
);
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
}
function isSvgFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
@@ -243,9 +239,7 @@ export async function getFavicon(
try {
const url = validateUrl(request.query.url);
if (!url) {
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600');
return reply.send(createFallbackImage());
return createFallbackImage();
}
const cacheKey = createCacheKey(url.toString());
@@ -266,65 +260,21 @@ export async function getFavicon(
} else {
// For website URLs, extract favicon from HTML
const meta = await parseUrlMeta(url.toString());
logger.info('parseUrlMeta result', {
url: url.toString(),
favicon: meta?.favicon,
});
if (meta?.favicon) {
imageUrl = new URL(meta.favicon);
} else {
// Try standard favicon location first
const { origin } = url;
imageUrl = new URL(`${origin}/favicon.ico`);
// Fallback to Google's favicon service
const { hostname } = url;
imageUrl = new URL(
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
);
}
}
logger.info('Fetching favicon', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
});
// Fetch the image
let { buffer, contentType, status } = await fetchImage(imageUrl);
const { buffer, contentType, status } = await fetchImage(imageUrl);
logger.info('Favicon fetch result', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
status,
bufferLength: buffer.length,
contentType,
});
// If the direct favicon fetch failed and it's not from DuckDuckGo's service,
// try DuckDuckGo's favicon service as a fallback
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
const { hostname } = url;
const duckduckgoUrl = new URL(
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
);
logger.info('Trying DuckDuckGo favicon service', {
originalUrl: url.toString(),
duckduckgoUrl: duckduckgoUrl.toString(),
});
const duckduckgoResult = await fetchImage(duckduckgoUrl);
buffer = duckduckgoResult.buffer;
contentType = duckduckgoResult.contentType;
status = duckduckgoResult.status;
imageUrl = duckduckgoUrl;
logger.info('DuckDuckGo favicon result', {
status,
bufferLength: buffer.length,
contentType,
});
}
// Accept any response as long as we have valid image data
if (buffer.length === 0) {
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600');
if (status !== 200 || buffer.length === 0) {
return reply.send(createFallbackImage());
}
@@ -335,31 +285,9 @@ export async function getFavicon(
contentType,
);
logger.info('Favicon processing result', {
originalUrl: url.toString(),
originalBufferLength: buffer.length,
processedBufferLength: processedBuffer.length,
});
// Determine the correct content type for caching and response
const isIco = isIcoFile(imageUrl.toString(), contentType);
const isSvg = isSvgFile(imageUrl.toString(), contentType);
let responseContentType = contentType;
if (isIco) {
responseContentType = 'image/x-icon';
} else if (isSvg) {
responseContentType = 'image/svg+xml';
} else if (
processedBuffer.length < 5000 &&
buffer.length === processedBuffer.length
) {
// Image was returned as-is, keep original content type
responseContentType = contentType;
} else {
// Image was processed by Sharp, it's now a PNG
responseContentType = 'image/png';
}
const responseContentType = isIco ? 'image/x-icon' : contentType;
// Cache the result with correct content type
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);

View File

@@ -5,13 +5,13 @@ import { parseUserAgent } from '@openpanel/common/server';
import { getProfileById, upsertProfile } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import type {
DeprecatedIncrementProfilePayload,
DeprecatedUpdateProfilePayload,
} from '@openpanel/validation';
IncrementProfilePayload,
UpdateProfilePayload,
} from '@openpanel/sdk';
export async function updateProfile(
request: FastifyRequest<{
Body: DeprecatedUpdateProfilePayload;
Body: UpdateProfilePayload;
}>,
reply: FastifyReply,
) {
@@ -52,7 +52,7 @@ export async function updateProfile(
export async function incrementProfileProperty(
request: FastifyRequest<{
Body: DeprecatedIncrementProfilePayload;
Body: IncrementProfilePayload;
}>,
reply: FastifyReply,
) {
@@ -94,7 +94,7 @@ export async function incrementProfileProperty(
export async function decrementProfileProperty(
request: FastifyRequest<{
Body: DeprecatedIncrementProfilePayload;
Body: IncrementProfilePayload;
}>,
reply: FastifyReply,
) {

View File

@@ -1,22 +1,19 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr, pick } from 'ramda';
import { HttpError } from '@/utils/errors';
import { generateId, slug } from '@openpanel/common';
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 IDecrementPayload,
type IIdentifyPayload,
type IIncrementPayload,
type ITrackHandlerPayload,
type ITrackPayload,
zTrackHandlerPayload,
} from '@openpanel/validation';
import type {
DecrementPayload,
IdentifyPayload,
IncrementPayload,
TrackHandlerPayload,
TrackPayload,
} from '@openpanel/sdk';
export function getStringHeaders(headers: FastifyRequest['headers']) {
return Object.entries(
@@ -39,28 +36,25 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
);
}
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
if (body.type === 'track') {
const identity = body.payload.properties?.__identify as
| IIdentifyPayload
| undefined;
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
const identity =
'properties' in body.payload
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
: undefined;
return (
identity ||
(body.payload.profileId
? {
profileId: body.payload.profileId,
}
: undefined)
);
}
return undefined;
return (
identity ||
(body?.payload?.profileId
? {
profileId: body.payload.profileId,
}
: undefined)
);
}
export function getTimestamp(
timestamp: FastifyRequest['timestamp'],
payload: ITrackHandlerPayload['payload'],
payload: TrackHandlerPayload['payload'],
) {
const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp =
@@ -87,7 +81,7 @@ export function getTimestamp(
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
}
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
// isTimestampFromThePast is true only if timestamp is older than 1 hour
const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
@@ -97,113 +91,163 @@ export function getTimestamp(
};
}
interface TrackContext {
projectId: string;
ip: string;
ua?: string;
headers: Record<string, string | undefined>;
timestamp: { value: number; isFromPast: boolean };
identity?: IIdentifyPayload;
currentDeviceId?: string;
previousDeviceId?: string;
geo: GeoLocation;
}
async function buildContext(
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
Body: TrackHandlerPayload;
}>,
validatedBody: ITrackHandlerPayload,
): Promise<TrackContext> {
const projectId = request.client?.projectId;
if (!projectId) {
throw new HttpError('Missing projectId', { status: 400 });
}
const timestamp = getTimestamp(request.timestamp, validatedBody.payload);
reply: FastifyReply,
) {
const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip =
validatedBody.type === 'track' && validatedBody.payload.properties?.__ip
? (validatedBody.payload.properties.__ip as string)
'properties' in request.body.payload &&
request.body.payload.properties?.__ip
? (request.body.payload.properties.__ip as string)
: request.clientIp;
const ua = request.headers['user-agent'];
const headers = getStringHeaders(request.headers);
const projectId = request.client?.projectId;
const identity = getIdentity(validatedBody);
if (!projectId) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Missing projectId',
});
}
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
if (profileId && validatedBody.type === 'track') {
validatedBody.payload.profileId = profileId;
if (profileId) {
request.body.payload.profileId = profileId;
}
// Get geo location (needed for track and identify)
const geo = await getGeoLocation(ip);
// Generate device IDs if needed (for track)
let currentDeviceId: string | undefined;
let previousDeviceId: string | undefined;
if (validatedBody.type === 'track') {
const overrideDeviceId =
typeof validatedBody.payload.properties?.__deviceId === 'string'
? validatedBody.payload.properties.__deviceId
: undefined;
const [salts] = await Promise.all([getSalts()]);
currentDeviceId =
overrideDeviceId ||
(ua
switch (request.body.type) {
case 'track': {
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
const previousDeviceId = ua
? generateDeviceId({
salt: salts.current,
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '');
previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
: '';
const promises = [];
// If we have more than one property in the identity object, we should identify the user
// Otherwise its only a profileId and we should not identify the user
if (identity && Object.keys(identity).length > 1) {
promises.push(
identify({
payload: identity,
projectId,
geo,
ua,
}),
);
}
promises.push(
track({
payload: request.body.payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers: getStringHeaders(request.headers),
timestamp: timestamp.timestamp,
isTimestampFromThePast: timestamp.isTimestampFromThePast,
}),
);
await Promise.all(promises);
break;
}
case 'identify': {
const geo = await getGeoLocation(ip);
await identify({
payload: request.body.payload,
projectId,
geo,
ua,
});
break;
}
case 'alias': {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
}
case 'increment': {
await increment({
payload: request.body.payload,
projectId,
});
break;
}
case 'decrement': {
await decrement({
payload: request.body.payload,
projectId,
});
break;
}
default: {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
}
return {
projectId,
ip,
ua,
headers,
timestamp: {
value: timestamp.timestamp,
isFromPast: timestamp.isTimestampFromThePast,
},
identity,
currentDeviceId,
previousDeviceId,
geo,
};
reply.status(200).send();
}
async function handleTrack(
payload: ITrackPayload,
context: TrackContext,
): Promise<void> {
const {
projectId,
currentDeviceId,
previousDeviceId,
geo,
headers,
timestamp,
} = context;
if (!currentDeviceId || !previousDeviceId) {
throw new HttpError('Device ID generation failed', { status: 500 });
}
async function track({
payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers,
timestamp,
isTimestampFromThePast,
}: {
payload: TrackPayload;
currentDeviceId: string;
previousDeviceId: string;
projectId: string;
geo: GeoLocation;
headers: Record<string, string | undefined>;
timestamp: number;
isTimestampFromThePast: boolean;
}) {
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer
? payload.profileId
@@ -212,51 +256,44 @@ async function handleTrack(
: currentDeviceId;
const jobId = [
slug(payload.name),
timestamp.value,
timestamp,
projectId,
currentDeviceId,
groupId,
]
.filter(Boolean)
.join('-');
const promises = [];
// If we have more than one property in the identity object, we should identify the user
// Otherwise its only a profileId and we should not identify the user
if (context.identity && Object.keys(context.identity).length > 1) {
promises.push(handleIdentify(context.identity, context));
}
promises.push(
getEventsGroupQueueShard(groupId).add({
orderMs: timestamp.value,
data: {
projectId,
headers,
event: {
...payload,
timestamp: timestamp.value,
isTimestampFromThePast: timestamp.isFromPast,
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
await getEventsGroupQueueShard(groupId).add({
orderMs: timestamp,
data: {
projectId,
headers,
event: {
...payload,
timestamp,
isTimestampFromThePast,
},
groupId,
jobId,
}),
);
await Promise.all(promises);
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,
});
}
async function handleIdentify(
payload: IIdentifyPayload,
context: TrackContext,
): Promise<void> {
const { projectId, geo, ua } = context;
async function identify({
payload,
projectId,
geo,
ua,
}: {
payload: IdentifyPayload;
projectId: string;
geo: GeoLocation;
ua?: string;
}) {
const uaInfo = parseUserAgent(ua, payload.properties);
await upsertProfile({
...payload,
@@ -281,15 +318,17 @@ async function handleIdentify(
});
}
async function adjustProfileProperty(
payload: IIncrementPayload | IDecrementPayload,
projectId: string,
direction: 1 | -1,
): Promise<void> {
async function increment({
payload,
projectId,
}: {
payload: IncrementPayload;
projectId: string;
}) {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
if (!profile) {
throw new HttpError('Profile not found', { status: 404 });
throw new Error('Not found');
}
const parsed = Number.parseInt(
@@ -298,12 +337,12 @@ async function adjustProfileProperty(
);
if (Number.isNaN(parsed)) {
throw new HttpError('Property value is not a number', { status: 400 });
throw new Error('Not number');
}
profile.properties = assocPath(
property.split('.'),
parsed + direction * (value || 1),
parsed + (value || 1),
profile.properties,
);
@@ -315,74 +354,40 @@ async function adjustProfileProperty(
});
}
async function handleIncrement(
payload: IIncrementPayload,
context: TrackContext,
): Promise<void> {
await adjustProfileProperty(payload, context.projectId, 1);
}
async function handleDecrement(
payload: IDecrementPayload,
context: TrackContext,
): Promise<void> {
await adjustProfileProperty(payload, context.projectId, -1);
}
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
}>,
reply: FastifyReply,
) {
// Validate request body with Zod
const validationResult = zTrackHandlerPayload.safeParse(request.body);
if (!validationResult.success) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Validation failed',
errors: validationResult.error.errors,
});
async function decrement({
payload,
projectId,
}: {
payload: DecrementPayload;
projectId: string;
}) {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
if (!profile) {
throw new Error('Not found');
}
const validatedBody = validationResult.data;
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
);
// Handle alias (not supported)
if (validatedBody.type === 'alias') {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
if (Number.isNaN(parsed)) {
throw new Error('Not number');
}
// Build request context
const context = await buildContext(request, validatedBody);
profile.properties = assocPath(
property.split('.'),
parsed - (value || 1),
profile.properties,
);
// Dispatch to appropriate handler
switch (validatedBody.type) {
case 'track':
await handleTrack(validatedBody.payload, context);
break;
case 'identify':
await handleIdentify(validatedBody.payload, context);
break;
case 'increment':
await handleIncrement(validatedBody.payload, context);
break;
case 'decrement':
await handleDecrement(validatedBody.payload, context);
break;
default:
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
reply.status(200).send();
await upsertProfile({
id: profile.id,
projectId,
properties: profile.properties,
isExternal: true,
});
}
export async function fetchDeviceId(

View File

@@ -191,9 +191,7 @@ export async function polarWebhook(
where: {
subscriptionCustomerId: event.data.customer.id,
subscriptionId: event.data.id,
subscriptionStatus: {
in: ['active', 'past_due', 'unpaid'],
},
subscriptionStatus: 'active',
},
});

View File

@@ -1,13 +1,10 @@
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function clientHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: PostEventPayload | TrackHandlerPayload;
}>,
reply: FastifyReply,
) {

View File

@@ -1,13 +1,10 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function duplicateHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: PostEventPayload | TrackHandlerPayload;
}>,
reply: FastifyReply,
) {

View File

@@ -1,15 +1,17 @@
import { isBot } from '@/bots';
import { createBotEvent } from '@openpanel/db';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
type DeprecatedEventPayload = {
name: string;
properties: Record<string, unknown>;
timestamp: string;
};
export async function isBotHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: TrackHandlerPayload | DeprecatedEventPayload;
}>,
reply: FastifyReply,
) {

View File

@@ -14,6 +14,22 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
method: 'POST',
url: '/',
handler: handler,
schema: {
body: {
type: 'object',
required: ['type', 'payload'],
properties: {
type: {
type: 'string',
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
},
payload: {
type: 'object',
additionalProperties: true,
},
},
},
},
});
fastify.route({

View File

@@ -9,7 +9,7 @@ import {
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import { zReportInput } from '@openpanel/validation';
import { zChartInputAI } from '@openpanel/validation';
import { tool } from 'ai';
import { z } from 'zod';
@@ -27,10 +27,7 @@ export function getReport({
- ${chartTypes.metric}
- ${chartTypes.bar}
`,
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',
@@ -75,10 +72,7 @@ export function getConversionReport({
return tool({
description:
'Generate a report (a chart) for conversions between two actions a unique user took.',
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',
@@ -100,10 +94,7 @@ 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: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',

View File

@@ -4,11 +4,10 @@ import { verifyPassword } from '@openpanel/common/server';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientType, getClientByIdCached } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type {
DeprecatedPostEventPayload,
IProjectFilterIp,
IProjectFilterProfileId,
ITrackHandlerPayload,
} from '@openpanel/validation';
import { path } from 'ramda';
@@ -42,7 +41,7 @@ export class SdkAuthError extends Error {
export async function validateSdkRequest(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: PostEventPayload | TrackHandlerPayload;
}>,
): Promise<IServiceClientWithProject> {
const { headers, clientIp } = req;

View File

@@ -1,13 +1,7 @@
import urlMetadata from 'url-metadata';
function fallbackFavicon(url: string) {
try {
const hostname = new URL(url).hostname;
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
} catch {
// If URL parsing fails, use the original string
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
}
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
}
function findBestFavicon(favicons: UrlMetaData['favicons']) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,505 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
<meta name="author" content="OpenPanel">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta property="og:image" content="/ogimage.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="twitter:image" content="/ogimage.png">
<!-- Additional Meta Tags -->
<meta name="theme-color" content="#0a0a0a">
<meta name="color-scheme" content="dark">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
color: #e5e5e5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 18px;
line-height: 1.75;
padding: 2rem 1.5rem;
}
.container {
max-width: 700px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.5rem;
color: #ffffff;
}
h2 {
font-size: 1.875rem;
font-weight: 800;
line-height: 1.3;
margin-top: 3rem;
margin-bottom: 1.5rem;
color: #ffffff;
}
h3 {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.4;
margin-top: 2rem;
margin-bottom: 1rem;
color: #ffffff;
}
p {
margin-bottom: 1.25em;
}
strong {
font-weight: 700;
color: #ffffff;
}
a {
color: #3b82f6;
text-decoration: underline;
text-underline-offset: 2px;
}
a:hover {
color: #60a5fa;
}
ul, ol {
margin-left: 1.5rem;
margin-bottom: 1.25em;
}
li {
margin-bottom: 0.5em;
}
blockquote {
border-left: 4px solid #3b82f6;
padding-left: 1.5rem;
margin: 1.5rem 0;
font-style: italic;
color: #d1d5db;
}
table {
width: 100%;
border-collapse: collapse;
margin: 2rem 0;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #374151;
}
th {
font-weight: 700;
color: #ffffff;
background: #131313;
}
tr:hover {
background: #131313;
}
.screenshot {
margin: 0 -4rem 4rem;
position: relative;
z-index: 10;
}
@media (max-width: 840px) {
.screenshot {
margin: 0;
}
}
.screenshot-inner {
border-radius: 8px;
overflow: hidden;
padding: 0.5rem;
background: #1a1a1a;
border: 1px solid #2a2a2a;
}
.window-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0 0.25rem;
}
.window-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.window-dot.red {
background: #ef4444;
}
.window-dot.yellow {
background: #eab308;
}
.window-dot.green {
background: #22c55e;
}
.screenshot-image-wrapper {
width: 100%;
border: 1px solid #2a2a2a;
border-radius: 6px;
overflow: hidden;
background: #0a0a0a;
}
.screenshot img {
width: 100%;
height: auto;
display: block;
}
.cta {
background: #131313;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 2rem;
margin: 3rem 0;
text-align: center;
margin: 0 -4rem 4rem;
}
@media (max-width: 840px) {
.cta {
margin: 0;
}
}
.cta h2 {
margin-top: 0;
}
.cta a {
display: inline-block;
background: #fff;
color: #000;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
margin: 0.5rem;
transition: background 0.2s;
}
.cta a:hover {
background: #fff;
color: #000;
}
footer {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid #374151;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
}
.hero {
text-align: left;
margin-top: 4rem;
line-height: 1.5;
}
.hero p {
font-size: 1.25rem;
color: #8f8f8f;
margin-top: 1rem;
}
figcaption {
margin-top: 1rem;
font-size: 0.875rem;
text-align: center;
color: #9ca3af;
max-width: 100%;
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h1>Just Fucking Use OpenPanel</h1>
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
</div>
</div>
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
</figure>
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
<ul>
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
</ul>
<p>"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.</p>
<p>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.</p>
<h2>The Web-Only Analytics Trap</h2>
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
<blockquote>
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
</blockquote>
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
<p>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.</p>
<p>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.</p>
<p>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 <strong>SECOND tool</strong> 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.</p>
<h2>Counter One Dollar Stats</h2>
<p>"$1/month for page views. Adorable."</p>
<p>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.</p>
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
<p>You get:</p>
<ul>
<li>Funnels to see where users drop off</li>
<li>Retention analysis to see who comes back</li>
<li>Cohorts to segment your users</li>
<li>User profiles to understand individual behavior</li>
<li>Custom dashboards to see what matters to YOU</li>
<li>Revenue tracking to see what actually makes money</li>
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
</ul>
<p>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.</p>
<h2>Why OpenPanel is the Answer</h2>
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, 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.</p>
<ul>
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
</ul>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
</div>
</div>
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
</figure>
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
<p>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?</p>
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
<p>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.</p>
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
<p>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.</p>
<h2>The Comparison Table (The Brutal Truth)</h2>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Price at 20M events</th>
<th>What You Get</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mixpanel</strong></td>
<td>$2,300+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>PostHog</strong></td>
<td>$1,982+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>Plausible</strong></td>
<td>Various pricing</td>
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
</tr>
<tr>
<td><strong>One Dollar Stats</strong></td>
<td>$1/month</td>
<td>Page views (but cheaper!)</td>
</tr>
<tr style="background: #131313; border: 2px solid #3b82f6;">
<td><strong>OpenPanel</strong></td>
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
</tr>
</tbody>
</table>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
</div>
</div>
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
</figure>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
</div>
</div>
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
</figure>
<h2>The Bottom Fucking Line</h2>
<p>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.</p>
<p>You have three choices:</p>
<ol>
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
<li>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</li>
<li>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</li>
</ol>
<p>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.</p>
<p>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.</p>
<div class="cta">
<h2>Ready to understand what your users actually do?</h2>
<p>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.</p>
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
</div>
</div>
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
</figure>
<footer>
<p><strong>Just Fucking Use OpenPanel</strong></p>
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
</footer>
</div>
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -1,7 +0,0 @@
{
"name": "justfuckinguseopenpanel",
"compatibility_date": "2025-12-19",
"assets": {
"directory": "."
}
}

View File

@@ -1,256 +0,0 @@
---
title: Nuxt
---
import Link from 'next/link';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { DeviceIdWarning } from '@/components/device-id-warning';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import { Callout } from 'fumadocs-ui/components/callout';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [Nuxt analytics guide](/guides/nuxt-analytics).
</Callout>
## Good to know
Keep in mind that all tracking here happens on the client!
Read more about server side tracking in the [Server Side Tracking](#track-server-events) section.
## Installation
<Steps>
### Install dependencies
```bash
pnpm install @openpanel/nuxt
```
### Initialize
Add the module to your `nuxt.config.ts`:
```typescript
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
},
});
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
##### Nuxt options
- `clientId` - Your OpenPanel client ID (required)
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
- `trackScreenViews` - Automatically track screen views (default: `true`)
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
- `trackHashChanges` - Track hash changes in URL (default: `false`)
- `disabled` - Disable tracking (default: `false`)
- `proxy` - Enable server-side proxy to avoid adblockers (default: `false`)
</Steps>
## Usage
### Using the composable
The `useOpenPanel` composable is auto-imported, so you can use it directly in any component:
```vue
<script setup>
const op = useOpenPanel(); // Auto-imported!
function handleClick() {
op.track('button_click', { button: 'signup' });
}
</script>
<template>
<button @click="handleClick">Trigger event</button>
</template>
```
### Accessing via useNuxtApp
You can also access the OpenPanel instance directly via `useNuxtApp()`:
```vue
<script setup>
const { $openpanel } = useNuxtApp();
$openpanel.track('my_event', { foo: 'bar' });
</script>
```
### Tracking Events
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
```vue
<script setup>
const op = useOpenPanel();
op.track('my_event', { foo: 'bar' });
</script>
```
### Identifying Users
To identify a user, call the `op.identify()` method with a unique identifier.
```vue
<script setup>
const op = useOpenPanel();
op.identify({
profileId: '123', // Required
firstName: 'Joe',
lastName: 'Doe',
email: 'joe@doe.com',
properties: {
tier: 'premium',
},
});
</script>
```
### Setting Global Properties
To set properties that will be sent with every event:
```vue
<script setup>
const op = useOpenPanel();
op.setGlobalProperties({
app_version: '1.0.2',
environment: 'production',
});
</script>
```
### Incrementing Properties
To increment a numeric property on a user profile.
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
```vue
<script setup>
const op = useOpenPanel();
op.increment({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Decrementing Properties
To decrement a numeric property on a user profile.
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
```vue
<script setup>
const op = useOpenPanel();
op.decrement({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Clearing User Data
To clear the current user's data:
```vue
<script setup>
const op = useOpenPanel();
op.clear();
</script>
```
## Server side
If you want to track server-side events, you should create an instance of our Javascript SDK. Import `OpenPanel` from `@openpanel/sdk`
<Callout>
When using server events it's important that you use a secret to authenticate the request. This is to prevent unauthorized requests since we cannot use cors headers.
You can use the same clientId but you should pass the associated client secret to the SDK.
</Callout>
```typescript
import { OpenPanel } from '@openpanel/sdk';
const opServer = new OpenPanel({
clientId: '{YOUR_CLIENT_ID}',
clientSecret: '{YOUR_CLIENT_SECRET}',
});
opServer.track('my_server_event', { ok: '✅' });
// Pass `profileId` to track events for a specific user
opServer.track('my_server_event', { profileId: '123', ok: '✅' });
```
### Serverless & Edge Functions
If you log events in a serverless environment, make sure to await the event call to ensure it completes before the function terminates.
```typescript
import { OpenPanel } from '@openpanel/sdk';
const opServer = new OpenPanel({
clientId: '{YOUR_CLIENT_ID}',
clientSecret: '{YOUR_CLIENT_SECRET}',
});
export default defineEventHandler(async (event) => {
// Await to ensure event is logged before function completes
await opServer.track('my_server_event', { foo: 'bar' });
return { message: 'Event logged!' };
});
```
### Proxy events
With the `proxy` option enabled, you can proxy your events through your server, which ensures all events are tracked since many adblockers block requests to third-party domains.
```typescript title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
proxy: true, // Enables proxy at /api/openpanel/*
},
});
```
When `proxy: true` is set:
- The module automatically sets `apiUrl` to `/api/openpanel`
- A server handler is registered at `/api/openpanel/**`
- All tracking requests route through your server
This helps bypass adblockers that might block requests to `api.openpanel.dev`.

View File

@@ -2,244 +2,4 @@
title: React
---
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
## Good to know
Keep in mind that all tracking here happens on the client!
For React SPAs, you can use `@openpanel/web` directly - no need for a separate React SDK. Simply create an OpenPanel instance and use it throughout your application.
## Installation
<Steps>
### Step 1: Install
```bash
npm install @openpanel/web
```
### Step 2: Initialize
Create a shared OpenPanel instance in your project:
```ts title="src/openpanel.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
- `clientId` - Your OpenPanel client ID (required)
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
- `trackScreenViews` - Automatically track screen views (default: `true`)
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
- `trackHashChanges` - Track hash changes in URL (default: `false`)
- `disabled` - Disable tracking (default: `false`)
### Step 3: Usage
Import and use the instance in your React components:
```tsx
import { op } from '@/openpanel';
function MyComponent() {
const handleClick = () => {
op.track('button_click', { button: 'signup' });
};
return <button onClick={handleClick}>Trigger event</button>;
}
```
</Steps>
## Usage
### Tracking Events
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
```tsx
import { op } from '@/openpanel';
function MyComponent() {
useEffect(() => {
op.track('my_event', { foo: 'bar' });
}, []);
return <div>My Component</div>;
}
```
### Identifying Users
To identify a user, call the `op.identify()` method with a unique identifier.
```tsx
import { op } from '@/openpanel';
function LoginComponent() {
const handleLogin = (user: User) => {
op.identify({
profileId: user.id, // Required
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
tier: 'premium',
},
});
};
return <button onClick={() => handleLogin(user)}>Login</button>;
}
```
### Setting Global Properties
To set properties that will be sent with every event:
```tsx
import { op } from '@/openpanel';
function App() {
useEffect(() => {
op.setGlobalProperties({
app_version: '1.0.2',
environment: 'production',
});
}, []);
return <div>App</div>;
}
```
### Incrementing Properties
To increment a numeric property on a user profile.
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
```tsx
import { op } from '@/openpanel';
function MyComponent() {
const handleAction = () => {
op.increment({
profileId: '1',
property: 'visits',
value: 1, // optional
});
};
return <button onClick={handleAction}>Increment</button>;
}
```
### Decrementing Properties
To decrement a numeric property on a user profile.
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
```tsx
import { op } from '@/openpanel';
function MyComponent() {
const handleAction = () => {
op.decrement({
profileId: '1',
property: 'visits',
value: 1, // optional
});
};
return <button onClick={handleAction}>Decrement</button>;
}
```
### Clearing User Data
To clear the current user's data:
```tsx
import { op } from '@/openpanel';
function LogoutComponent() {
const handleLogout = () => {
op.clear();
// ... logout logic
};
return <button onClick={handleLogout}>Logout</button>;
}
```
### Revenue Tracking
Track revenue events:
```tsx
import { op } from '@/openpanel';
function CheckoutComponent() {
const handlePurchase = async () => {
// Track revenue immediately
await op.revenue(29.99, { currency: 'USD' });
// Or accumulate revenue and flush later
op.pendingRevenue(29.99, { currency: 'USD' });
op.pendingRevenue(19.99, { currency: 'USD' });
await op.flushRevenue(); // Sends both revenue events
// Clear pending revenue
op.clearRevenue();
};
return <button onClick={handlePurchase}>Purchase</button>;
}
```
### Optional: Create a Hook
If you prefer using a React hook pattern, you can create your own wrapper:
```ts title="src/hooks/useOpenPanel.ts"
import { op } from '@/openpanel';
export function useOpenPanel() {
return op;
}
```
Then use it in your components:
```tsx
import { useOpenPanel } from '@/hooks/useOpenPanel';
function MyComponent() {
const op = useOpenPanel();
useEffect(() => {
op.track('my_event', { foo: 'bar' });
}, []);
return <div>My Component</div>;
}
```
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated react sdk soon.

View File

@@ -216,4 +216,3 @@ tracker = OpenPanel::SDK::Tracker.new(
)
```

View File

@@ -2,219 +2,4 @@
title: Vue
---
import Link from 'next/link';
import { Step, Steps } from 'fumadocs-ui/components/steps';
import { DeviceIdWarning } from '@/components/device-id-warning';
import { PersonalDataWarning } from '@/components/personal-data-warning';
import { Callout } from 'fumadocs-ui/components/callout';
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
import WebSdkConfig from '@/components/web-sdk-config.mdx';
<Callout>
Looking for a step-by-step tutorial? Check out the [Vue analytics guide](/guides/vue-analytics).
</Callout>
## Good to know
Keep in mind that all tracking here happens on the client!
For Vue SPAs, you can use `@openpanel/web` directly - no need for a separate Vue SDK. Simply create an OpenPanel instance and use it throughout your application.
## Installation
<Steps>
### Step 1: Install
```bash
pnpm install @openpanel/web
```
### Step 2: Initialize
Create a shared OpenPanel instance in your project:
```ts title="src/openpanel.ts"
import { OpenPanel } from '@openpanel/web';
export const op = new OpenPanel({
clientId: 'YOUR_CLIENT_ID',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
```
#### Options
<CommonSdkConfig />
<WebSdkConfig />
- `clientId` - Your OpenPanel client ID (required)
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
- `trackScreenViews` - Automatically track screen views (default: `true`)
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
- `trackHashChanges` - Track hash changes in URL (default: `false`)
- `disabled` - Disable tracking (default: `false`)
### Step 3: Usage
Import and use the instance in your Vue components:
```vue
<script setup>
import { op } from '@/openpanel';
function handleClick() {
op.track('button_click', { button: 'signup' });
}
</script>
<template>
<button @click="handleClick">Trigger event</button>
</template>
```
</Steps>
## Usage
### Tracking Events
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
```vue
<script setup>
import { op } from '@/openpanel';
op.track('my_event', { foo: 'bar' });
</script>
```
### Identifying Users
To identify a user, call the `op.identify()` method with a unique identifier.
```vue
<script setup>
import { op } from '@/openpanel';
op.identify({
profileId: '123', // Required
firstName: 'Joe',
lastName: 'Doe',
email: 'joe@doe.com',
properties: {
tier: 'premium',
},
});
</script>
```
### Setting Global Properties
To set properties that will be sent with every event:
```vue
<script setup>
import { op } from '@/openpanel';
op.setGlobalProperties({
app_version: '1.0.2',
environment: 'production',
});
</script>
```
### Incrementing Properties
To increment a numeric property on a user profile.
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
```vue
<script setup>
import { op } from '@/openpanel';
op.increment({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Decrementing Properties
To decrement a numeric property on a user profile.
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
```vue
<script setup>
import { op } from '@/openpanel';
op.decrement({
profileId: '1',
property: 'visits',
value: 1, // optional
});
</script>
```
### Clearing User Data
To clear the current user's data:
```vue
<script setup>
import { op } from '@/openpanel';
op.clear();
</script>
```
### Revenue Tracking
Track revenue events:
```vue
<script setup>
import { op } from '@/openpanel';
// Track revenue immediately
await op.revenue(29.99, { currency: 'USD' });
// Or accumulate revenue and flush later
op.pendingRevenue(29.99, { currency: 'USD' });
op.pendingRevenue(19.99, { currency: 'USD' });
await op.flushRevenue(); // Sends both revenue events
// Clear pending revenue
op.clearRevenue();
</script>
```
### Optional: Create a Composable
If you prefer using a composable pattern, you can create your own wrapper:
```ts title="src/composables/useOpenPanel.ts"
import { op } from '@/openpanel';
export function useOpenPanel() {
return op;
}
```
Then use it in your components:
```vue
<script setup>
import { useOpenPanel } from '@/composables/useOpenPanel';
const op = useOpenPanel();
op.track('my_event', { foo: 'bar' });
</script>
```
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated vue sdk soon.

View File

@@ -1,101 +0,0 @@
---
title: Migration from v1 to v2
description: Finally we feel ready to release v2 for all self-hostings. This is a big one!
---
## What's New in v2
- **Redesigned dashboard** - New UI built with Tanstack
- **Revenue tracking** - Track revenue alongside your analytics
- **Sessions** - View individual user sessions
- **Real-time view** - Live event stream
- **Customizable dashboards** - Grafana-style widget layouts
- **Improved report builder** - Faster and more flexible
- **General improvements** - We have also made a bunch of bug fixes, minor improvements and much more
## Migrating from v1
### Ensure you're on the self-hosting branch
Sometimes we add new helper scripts and what not. Always make sure you're on the latest commit before continuing.
```bash
cd ./self-hosting
git fetch origin
git checkout self-hosting
git pull origin self-hosting
```
### Envs
Since we have migrated to tanstack from nextjs we first need to update our envs. We have added a dedicated page for the [environment variables here](/docs/self-hosting/environment-variables).
```js title=".env"
NEXT_PUBLIC_DASHBOARD_URL="..." // [!code --]
NEXT_PUBLIC_API_URL="..." // [!code --]
NEXT_PUBLIC_SELF_HOSTED="..." // [!code --]
DASHBOARD_URL="..." // [!code ++]
API_URL="..." // [!code ++]
SELF_HOSTED="..." // [!code ++]
```
### Clickhouse 24 -> 25
We have updated Clickhouse to 25, this is important to not skip, otherwise your OpenPanel instance wont work.
You should edit your `./self-hosting/docker-compose.yml`
```js title="./self-hosting/docker-compose.yml"
services:
op-ch:
image: clickhouse/clickhouse-server:24.3.2-alpine // [!code --]
image: clickhouse/clickhouse-server:25.10.2.65 // [!code ++]
```
Since version 25 clickhouse enabled default user setup, this means that we need to disable it to avoid connection issues. With this setting we can still access our clickhouse instance (internally) without having a user.
```
services:
op-ch:
environment:
- CLICKHOUSE_SKIP_USER_SETUP=1
```
### Use our latest docker images
Last thing to do is to start using our latest docker images.
> Note: Before you might have been using the latest tag, which is not recommended. Change it to the actual latest version instead.
```js title="./self-hosting/docker-compose.yml"
services:
op-api:
image: lindesvard/openpanel-api:latest // [!code --]
image: lindesvard/openpanel-api:2.0.0 // [!code ++]
op-worker:
image: lindesvard/openpanel-worker:latest // [!code --]
image: lindesvard/openpanel-worker:2.0.0 // [!code ++]
op-dashboard:
image: lindesvard/openpanel-dashboard:latest // [!code --]
image: lindesvard/openpanel-dashboard:2.0.0 // [!code ++]
```
### Done?
When you're done with above steps you should need to restart all services. This will take quite some time depending on your hardware and how many events you have. Since we have made significant changes to the database schema and data we need to run migrations.
```bash
./stop
./start
```
## Using Coolify?
If you're using Coolify and running OpenPanel v1 you'll need to apply the above changes. You can take a look at our [Coolify PR](https://github.com/coollabsio/coolify/pull/7653) which shows what you need to change.
## Any issues with migrations?
If you stumble upon any issues during migrations, please reach out to us on [Discord](https://discord.gg/openpanel) and we'll try our best to help you out.

View File

@@ -3,20 +3,6 @@ title: Changelog for self-hosting
description: This is a list of changes that have been made to the self-hosting setup.
---
## 2.0.0
We have released the first stable version of OpenPanel v2. This is a big one!
Read more about it in our [migration guide](/docs/migration/migrate-v1-to-v2).
TLDR;
- Clickhouse upgraded from 24.3.2-alpine to 25.10.2.65
- Add `CLICKHOUSE_SKIP_USER_SETUP=1` to op-ch service
- `NEXT_PUBLIC_DASHBOARD_URL` -> `DASHBOARD_URL`
- `NEXT_PUBLIC_API_URL` -> `API_URL`
- `NEXT_PUBLIC_SELF_HOSTED` -> `SELF_HOSTED`
## 1.2.0
We have renamed `SELF_HOSTED` to `NEXT_PUBLIC_SELF_HOSTED`. It's important to rename this env before your upgrade to this version.
@@ -44,7 +30,7 @@ If you upgrading from a previous version, you'll need to edit your `.env` file i
### Removed Clickhouse Keeper
In 0.0.6 we introduced a cluster mode for Clickhouse. This was a mistake and we have removed it.
In 0.0.6 we introduced a cluster mode for Clickhouse. This was a misstake and we have removed it.
Remove op-zk from services and volumes

View File

@@ -109,8 +109,8 @@ Coolify automatically handles these variables:
- `DATABASE_URL`: PostgreSQL connection string
- `REDIS_URL`: Redis connection string
- `CLICKHOUSE_URL`: ClickHouse connection string
- `API_URL`: API endpoint URL (set via `SERVICE_FQDN_OPAPI`)
- `DASHBOARD_URL`: Dashboard URL (set via `SERVICE_FQDN_OPDASHBOARD`)
- `NEXT_PUBLIC_API_URL`: API endpoint URL (set via `SERVICE_FQDN_OPAPI`)
- `NEXT_PUBLIC_DASHBOARD_URL`: Dashboard URL (set via `SERVICE_FQDN_OPDASHBOARD`)
- `COOKIE_SECRET`: Automatically generated secret
You can configure optional variables like `ALLOW_REGISTRATION`, `RESEND_API_KEY`, `OPENAI_API_KEY`, etc. through Coolify's environment variable interface.

View File

@@ -126,7 +126,7 @@ If you want to use specific image versions, edit the `docker-compose.yml` file a
```yaml
op-api:
image: lindesvard/openpanel-api:2.0.0 # Specify version
image: lindesvard/openpanel-api:v1.0.0 # Specify version
```
### Scaling Workers

View File

@@ -54,8 +54,8 @@ Edit the `.env` file or environment variables in Dokploy. You **must** set these
```bash
# Required: Set these to your actual domain
API_URL=https://yourdomain.com/api
DASHBOARD_URL=https://yourdomain.com
NEXT_PUBLIC_API_URL=https://yourdomain.com/api
NEXT_PUBLIC_DASHBOARD_URL=https://yourdomain.com
# Database Configuration (automatically set by Dokploy)
OPENPANEL_POSTGRES_DB=openpanel-db
@@ -71,7 +71,7 @@ OPENPANEL_EMAIL_SENDER=noreply@yourdomain.com
```
<Callout type="warn">
⚠️ **Critical**: Unlike Coolify, Dokploy does not support `SERVICE_FQDN_*` variables. You **must** hardcode `API_URL` and `DASHBOARD_URL` with your actual domain values.
⚠️ **Critical**: Unlike Coolify, Dokploy does not support `SERVICE_FQDN_*` variables. You **must** hardcode `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_DASHBOARD_URL` with your actual domain values.
</Callout>
</Step>
@@ -133,8 +133,8 @@ If you're using Cloudflare in front of Dokploy, remember to purge the Cloudflare
For Dokploy, you **must** hardcode these variables (unlike Coolify, Dokploy doesn't support `SERVICE_FQDN_*` variables):
- `API_URL` - Full API URL (e.g., `https://analytics.example.com/api`)
- `DASHBOARD_URL` - Full Dashboard URL (e.g., `https://analytics.example.com`)
- `NEXT_PUBLIC_API_URL` - Full API URL (e.g., `https://analytics.example.com/api`)
- `NEXT_PUBLIC_DASHBOARD_URL` - Full Dashboard URL (e.g., `https://analytics.example.com`)
Dokploy automatically sets:
- `OPENPANEL_POSTGRES_DB` - PostgreSQL database name
@@ -166,9 +166,9 @@ If API requests fail after deployment:
1. **Verify environment variables**:
```bash
# Check that API_URL is set correctly
docker exec <op-api-container> env | grep API_URL
docker exec <op-dashboard-container> env | grep API_URL
# Check that NEXT_PUBLIC_API_URL is set correctly
docker exec <op-api-container> env | grep NEXT_PUBLIC_API_URL
docker exec <op-dashboard-container> env | grep NEXT_PUBLIC_API_URL
```
2. **Check "Strip external path" setting**:
@@ -188,7 +188,7 @@ If account creation fails:
# In Dokploy, view logs for op-api service
```
2. Verify `API_URL` matches your domain:
2. Verify `NEXT_PUBLIC_API_URL` matches your domain:
- Should be `https://yourdomain.com/api`
- Not `http://localhost:3000` or similar
@@ -240,7 +240,7 @@ The Dokploy template differs from Coolify in these ways:
1. **Environment Variables**:
- Dokploy does not support `SERVICE_FQDN_*` variables
- Must hardcode `API_URL` and `DASHBOARD_URL`
- Must hardcode `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_DASHBOARD_URL`
2. **Domain Configuration**:
- Must manually configure domain paths

View File

@@ -116,7 +116,7 @@ Remove `convert_any_join` from ClickHouse settings. Used for compatibility with
## Application URLs
### API_URL
### NEXT_PUBLIC_API_URL
**Type**: `string`
**Required**: Yes
@@ -126,10 +126,10 @@ Public API URL exposed to the browser. Used by the dashboard frontend and API se
**Example**:
```bash
API_URL=https://analytics.example.com/api
NEXT_PUBLIC_API_URL=https://analytics.example.com/api
```
### DASHBOARD_URL
### NEXT_PUBLIC_DASHBOARD_URL
**Type**: `string`
**Required**: Yes
@@ -139,7 +139,7 @@ Public dashboard URL exposed to the browser. Used by the dashboard frontend and
**Example**:
```bash
DASHBOARD_URL=https://analytics.example.com
NEXT_PUBLIC_DASHBOARD_URL=https://analytics.example.com
```
### API_CORS_ORIGINS
@@ -368,7 +368,7 @@ SLACK_STATE_SECRET=your-state-secret
## Self-hosting
### SELF_HOSTED
### NEXT_PUBLIC_SELF_HOSTED
**Type**: `boolean`
**Required**: No
@@ -378,7 +378,7 @@ Enable self-hosted mode. Set to `true` or `1` to enable self-hosting features. U
**Example**:
```bash
SELF_HOSTED=true
NEXT_PUBLIC_SELF_HOSTED=true
```
## Worker & Queue
@@ -784,8 +784,8 @@ For a basic self-hosted installation, these variables are required:
- `DATABASE_URL` - PostgreSQL connection
- `REDIS_URL` - Redis connection
- `CLICKHOUSE_URL` - ClickHouse connection
- `API_URL` - API endpoint URL
- `DASHBOARD_URL` - Dashboard URL
- `NEXT_PUBLIC_API_URL` - API endpoint URL
- `NEXT_PUBLIC_DASHBOARD_URL` - Dashboard URL
- `COOKIE_SECRET` - Session encryption secret
### Optional but Recommended

View File

@@ -163,7 +163,7 @@ For complete AI configuration details, see the [Environment Variables documentat
If you use a managed Redis service, you may need to set the `notify-keyspace-events` manually.
Without this setting we won't be able to listen for expired keys which we use for calculating currently active visitors.
Without this setting we wont be able to listen for expired keys which we use for caluclating currently active vistors.
> You will see a warning in the logs if this needs to be set manually.

View File

@@ -6,7 +6,6 @@ difficulty: intermediate
timeToComplete: 10
date: 2025-12-15
lastUpdated: 2025-12-15
team: OpenPanel Team
steps:
- name: "Add the dependency"
anchor: "install"

View File

@@ -1,354 +0,0 @@
---
title: "How to add analytics to Nuxt"
description: "Add privacy-first analytics to your Nuxt app in under 5 minutes with OpenPanel's official Nuxt module."
difficulty: beginner
timeToComplete: 5
date: 2025-01-07
cover: /content/cover-default.jpg
team: OpenPanel Team
steps:
- name: "Install the module"
anchor: "install"
- name: "Configure the module"
anchor: "setup"
- name: "Track custom events"
anchor: "events"
- name: "Identify users"
anchor: "identify"
- name: "Set up server-side tracking"
anchor: "server"
- name: "Verify your setup"
anchor: "verify"
---
# How to add analytics to Nuxt
This guide walks you through adding OpenPanel to a Nuxt 3 application. The official `@openpanel/nuxt` module makes integration effortless with auto-imported composables, automatic page view tracking, and a built-in proxy option to bypass ad blockers.
OpenPanel is an open-source alternative to Mixpanel and Google Analytics. It uses cookieless tracking by default, so you won't need cookie consent banners for basic analytics.
## Prerequisites
- A Nuxt 3 project
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
- Your Client ID from the OpenPanel dashboard
## Install the module [#install]
Start by installing the OpenPanel Nuxt module. This package includes everything you need for client-side tracking, including the auto-imported `useOpenPanel` composable.
```bash
npm install @openpanel/nuxt
```
If you prefer pnpm or yarn, those work too.
## Configure the module [#setup]
Add the module to your `nuxt.config.ts` and configure it with your Client ID. The module automatically sets up page view tracking and makes the `useOpenPanel` composable available throughout your app.
```ts title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
},
});
```
That's it. Page views are now being tracked automatically as users navigate your app.
### Configuration options
| Option | Default | Description |
|--------|---------|-------------|
| `clientId` | — | Your OpenPanel client ID (required) |
| `apiUrl` | `https://api.openpanel.dev` | The API URL to send events to |
| `trackScreenViews` | `true` | Automatically track page views |
| `trackOutgoingLinks` | `true` | Track clicks on external links |
| `trackAttributes` | `true` | Track elements with `data-track` attributes |
| `trackHashChanges` | `false` | Track hash changes in URL |
| `disabled` | `false` | Disable all tracking |
| `proxy` | `false` | Route events through your server |
### Using environment variables
For production applications, store your Client ID in environment variables.
```ts title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: process.env.NUXT_PUBLIC_OPENPANEL_CLIENT_ID,
trackScreenViews: true,
},
});
```
```bash title=".env"
NUXT_PUBLIC_OPENPANEL_CLIENT_ID=your-client-id
```
## Track custom events [#events]
Page views only tell part of the story. To understand how users interact with your product, track custom events like button clicks, form submissions, or feature usage.
### Using the composable
The `useOpenPanel` composable is auto-imported, so you can use it directly in any component without importing anything.
```vue title="components/SignupButton.vue"
<script setup>
const op = useOpenPanel();
function handleClick() {
op.track('button_clicked', {
button_name: 'signup',
button_location: 'hero',
});
}
</script>
<template>
<button type="button" @click="handleClick">Sign Up</button>
</template>
```
### Accessing via useNuxtApp
You can also access the OpenPanel instance through `useNuxtApp()` if you prefer.
```vue
<script setup>
const { $openpanel } = useNuxtApp();
$openpanel.track('my_event', { foo: 'bar' });
</script>
```
### Track form submissions
Form tracking helps you understand conversion rates and identify where users drop off.
```vue title="components/ContactForm.vue"
<script setup>
const op = useOpenPanel();
const email = ref('');
async function handleSubmit() {
op.track('form_submitted', {
form_name: 'contact',
form_location: 'homepage',
});
// Your form submission logic
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="email" type="email" placeholder="Enter your email" />
<button type="submit">Submit</button>
</form>
</template>
```
### Use data attributes for declarative tracking
The SDK supports declarative tracking using `data-track` attributes. This is useful for simple click tracking without writing JavaScript.
```vue
<template>
<button
data-track="button_clicked"
data-track-button_name="signup"
data-track-button_location="hero"
>
Sign Up
</button>
</template>
```
When a user clicks this button, OpenPanel automatically sends a `button_clicked` event with the specified properties. This requires `trackAttributes: true` in your configuration.
## Identify users [#identify]
Anonymous tracking is useful, but identifying users unlocks more valuable insights. You can track behavior across sessions, segment users by properties, and build cohort analyses.
Call `identify` after a user logs in or when you have their information available.
```vue title="components/UserProfile.vue"
<script setup>
const op = useOpenPanel();
const props = defineProps(['user']);
watch(() => props.user, (user) => {
if (user) {
op.identify({
profileId: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
properties: {
plan: user.plan,
signupDate: user.createdAt,
},
});
}
}, { immediate: true });
</script>
<template>
<div>Welcome, {{ user?.firstName }}!</div>
</template>
```
### Set global properties
Properties set with `setGlobalProperties` are included with every event. This is useful for app version tracking, feature flags, or A/B test variants.
```vue title="app.vue"
<script setup>
const op = useOpenPanel();
onMounted(() => {
op.setGlobalProperties({
app_version: '1.0.0',
environment: useRuntimeConfig().public.environment,
});
});
</script>
```
### Clear user data on logout
When users log out, clear the stored profile data to ensure subsequent events aren't associated with the previous user.
```vue title="components/LogoutButton.vue"
<script setup>
const op = useOpenPanel();
function handleLogout() {
op.clear();
navigateTo('/login');
}
</script>
<template>
<button @click="handleLogout">Logout</button>
</template>
```
## Set up server-side tracking [#server]
For tracking events in server routes, API endpoints, or server middleware, use the `@openpanel/sdk` package. Server-side tracking requires a client secret for authentication.
### Install the SDK
```bash
npm install @openpanel/sdk
```
### Create a server instance
```ts title="server/utils/op.ts"
import { OpenPanel } from '@openpanel/sdk';
export const op = new OpenPanel({
clientId: process.env.OPENPANEL_CLIENT_ID!,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET!,
});
```
### Track events in server routes
```ts title="server/api/webhook.post.ts"
export default defineEventHandler(async (event) => {
const body = await readBody(event);
await op.track('webhook_received', {
source: body.source,
event_type: body.type,
});
return { success: true };
});
```
Never expose your client secret on the client side. Keep it in server-only code.
### Awaiting in serverless environments
If you're deploying to a serverless platform like Vercel or Netlify, make sure to await the tracking call to ensure it completes before the function terminates.
```ts
export default defineEventHandler(async (event) => {
// Always await in serverless environments
await op.track('my_server_event', { foo: 'bar' });
return { message: 'Event logged!' };
});
```
## Bypass ad blockers with proxy [#proxy]
Many ad blockers block requests to third-party analytics domains. The Nuxt module includes a built-in proxy that routes events through your own server.
Enable the proxy option in your configuration:
```ts title="nuxt.config.ts"
export default defineNuxtConfig({
modules: ['@openpanel/nuxt'],
openpanel: {
clientId: 'your-client-id',
proxy: true, // Routes events through /api/openpanel/*
},
});
```
When `proxy: true` is set:
- The module automatically sets `apiUrl` to `/api/openpanel`
- A server handler is registered at `/api/openpanel/**`
- All tracking requests route through your Nuxt server
This makes tracking requests invisible to browser extensions that block third-party analytics.
## Verify your setup [#verify]
Open your Nuxt app in the browser and navigate between a few pages. Interact with elements that trigger custom events. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view to see events appearing within seconds.
If events aren't showing up, check the browser console for errors. The most common issues are:
- Incorrect client ID
- Ad blockers intercepting requests (enable the proxy option)
- Client ID exposed in server-only code
The Network tab in your browser's developer tools can help you confirm that requests are being sent.
## Next steps
The [Nuxt SDK reference](/docs/sdks/nuxt) covers additional features like incrementing user properties and event filtering. If you're interested in understanding how OpenPanel handles privacy, read our article on [cookieless analytics](/articles/cookieless-analytics).
<Faqs>
<FaqItem question="Does OpenPanel work with Nuxt 3 and Nuxt 4?">
Yes. The `@openpanel/nuxt` module supports both Nuxt 3 and Nuxt 4. It uses Nuxt's module system and auto-imports, so everything works seamlessly with either version.
</FaqItem>
<FaqItem question="Is the useOpenPanel composable auto-imported?">
Yes. The module automatically registers the `useOpenPanel` composable, so you can use it in any component without importing it. You can also access the instance via `useNuxtApp().$openpanel`.
</FaqItem>
<FaqItem question="Does OpenPanel use cookies?">
No. OpenPanel uses cookieless tracking by default. This means you don't need cookie consent banners for basic analytics under most privacy regulations, including GDPR and PECR.
</FaqItem>
<FaqItem question="How do I avoid ad blockers?">
Enable the `proxy: true` option in your configuration. This routes all tracking requests through your Nuxt server at `/api/openpanel/*`, which ad blockers don't typically block.
</FaqItem>
<FaqItem question="Is OpenPanel GDPR compliant?">
Yes. OpenPanel is designed for GDPR compliance with cookieless tracking, data minimization, and full support for data subject rights. With self-hosting, you also eliminate international data transfer concerns entirely.
</FaqItem>
</Faqs>

View File

@@ -6,7 +6,6 @@ difficulty: beginner
timeToComplete: 7
date: 2025-12-15
lastUpdated: 2025-12-15
team: OpenPanel Team
steps:
- name: "Install the SDK"
anchor: "install"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

File diff suppressed because one or more lines are too long

View File

@@ -121,7 +121,7 @@ export default async function Page({
/>
<div className="row gap-4 items-center mt-8">
<div className="size-10 center-center bg-black rounded-full">
{author?.image ? (
{author.image ? (
<Image
className="size-10 object-cover rounded-full"
src={author.image}
@@ -134,7 +134,7 @@ export default async function Page({
)}
</div>
<div className="col flex-1">
<p className="font-medium">{author?.name || 'OpenPanel Team'}</p>
<p className="font-medium">{author.name}</p>
<div className="row gap-4 items-center">
<p className="text-muted-foreground text-sm">
{guide?.data.date.toLocaleDateString()}

View File

@@ -7,9 +7,10 @@ import Image from 'next/image';
const images = [
{
name: 'Lucide Animated',
url: 'https://lucide-animated.com',
logo: '/logos/lucide-animated.png',
name: 'Helpy UI',
url: 'https://helpy-ui.com',
logo: '/logos/helpy-ui.png',
className: 'size-12',
},
{
name: 'KiddoKitchen',
@@ -66,7 +67,10 @@ export function WhyOpenPanel() {
alt={image.name}
width={64}
height={64}
className={cn('size-16 object-contain dark:invert')}
className={cn(
'size-16 object-contain dark:invert',
image.className,
)}
/>
</a>
</div>

View File

@@ -27,6 +27,10 @@ interface IPInfoResponse {
latitude: number | undefined;
longitude: number | undefined;
};
isp: string | null;
asn: string | null;
organization: string | null;
hostname: string | null;
isLocalhost: boolean;
isPrivate: boolean;
}
@@ -86,6 +90,84 @@ function isPrivateIP(ip: string): boolean {
return false;
}
async function getIPInfo(ip: string): Promise<IPInfo> {
if (!ip || ip === '127.0.0.1' || ip === '::1') {
return {
ip,
location: {
country: undefined,
city: undefined,
region: undefined,
latitude: undefined,
longitude: undefined,
},
isp: null,
asn: null,
organization: null,
hostname: null,
};
}
// Get geolocation
const geo = await getGeoLocation(ip);
// Get ISP/ASN info
let isp: string | null = null;
let asn: string | null = null;
let organization: string | null = null;
if (!isPrivateIP(ip)) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(
`https://ip-api.com/json/${ip}?fields=isp,as,org,query,reverse`,
{
signal: controller.signal,
},
);
clearTimeout(timeout);
if (response.ok) {
const data = await response.json();
if (data.status !== 'fail') {
isp = data.isp || null;
asn = data.as ? `AS${data.as.split(' ')[0]}` : null;
organization = data.org || null;
}
}
} catch {
// Ignore errors
}
}
// Reverse DNS lookup for hostname
let hostname: string | null = null;
try {
const hostnames = await dns.reverse(ip);
hostname = hostnames[0] || null;
} catch {
// Ignore errors
}
return {
ip,
location: {
country: geo.country,
city: geo.city,
region: geo.region,
latitude: geo.latitude,
longitude: geo.longitude,
},
isp,
asn,
organization,
hostname,
};
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const ipParam = searchParams.get('ip');
@@ -127,17 +209,12 @@ export async function GET(request: Request) {
}
try {
const geo = await fetch('https://api.openpanel.dev/misc/geo', {
headers: request.headers,
})
.then((res) => res.json())
.then((data) => data.selected.geo);
const info = await getIPInfo(ipToLookup);
const isLocalhost = ipToLookup === '127.0.0.1' || ipToLookup === '::1';
const isPrivate = isPrivateIP(ipToLookup);
const response: IPInfoResponse = {
location: geo,
ip: ipToLookup,
...info,
isLocalhost,
isPrivate,
};

View File

@@ -12,11 +12,7 @@ import {
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
type PageProps = {
params: Promise<{ slug: string[] }>;
};
export default async function Page(props: PageProps) {
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
@@ -43,7 +39,9 @@ export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'>,
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();

View File

@@ -1,8 +1,8 @@
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: { children: React.ReactNode }) {
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions()}>
{children}

View File

@@ -28,7 +28,7 @@ export const viewport: Viewport = {
export const metadata: Metadata = getRootMetadata();
export default function Layout({ children }: { children: React.ReactNode }) {
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html
lang="en"

View File

@@ -84,34 +84,6 @@ async function getOgData(
data?.data.description || 'Whooops, could not find this page',
};
}
case 'tools': {
if (segments.length > 1) {
const tool = segments[1];
switch (tool) {
case 'ip-lookup':
return {
title: 'IP Lookup Tool',
description:
'Find detailed information about any IP address including geolocation, ISP, and network details.',
};
case 'url-checker':
return {
title: 'URL Checker',
description:
'Analyze any website for SEO, social media, technical, and security information. Get comprehensive insights about any URL.',
};
default:
return {
title: 'Tools',
description: 'Free web tools for developers and website owners',
};
}
}
return {
title: 'Tools',
description: 'Free web tools for developers and website owners',
};
}
default: {
const data = await pageSource.getPage(segments);
return {

View File

@@ -1,18 +1,11 @@
import { url } from '@/lib/layout.shared';
import {
articleSource,
compareSource,
guideSource,
pageSource,
source,
} from '@/lib/source';
import { articleSource, compareSource, pageSource, source } from '@/lib/source';
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await articleSource.getPages();
const docs = await source.getPages();
const pages = await pageSource.getPages();
const guides = await guideSource.getPages();
return [
{
url: url('/'),
@@ -56,12 +49,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'yearly' as const,
priority: 0.5,
})),
...guides.map((item) => ({
url: url(item.url),
lastModified: item.data.date,
changeFrequency: 'monthly' as const,
priority: 0.5,
})),
...docs.map((item) => ({
url: url(item.url),
changeFrequency: 'monthly' as const,

View File

@@ -275,6 +275,49 @@ export default function IPLookupPage() {
</div>
)}
{/* Network Information */}
{(result.isp ||
result.asn ||
result.organization ||
result.hostname) && (
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Network className="size-5" />
Network Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{result.isp && (
<InfoCard
icon={<Building2 className="size-5" />}
label="ISP"
value={result.isp}
/>
)}
{result.asn && (
<InfoCard
icon={<Network className="size-5" />}
label="ASN"
value={result.asn}
/>
)}
{result.organization && (
<InfoCard
icon={<Building2 className="size-5" />}
label="Organization"
value={result.organization}
/>
)}
{result.hostname && (
<InfoCard
icon={<Server className="size-5" />}
label="Hostname"
value={result.hostname}
/>
)}
</div>
</div>
)}
{/* Map Preview */}
{result.location.latitude && result.location.longitude && (
<div>

View File

@@ -34,6 +34,7 @@ COPY packages/payments/package.json packages/payments/
COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/sdks/_info/package.json packages/sdks/_info/
COPY patches ./patches
# Copy tracking script to self-hosting dashboard
@@ -92,6 +93,7 @@ COPY --from=build /app/packages/payments/package.json ./packages/payments/
COPY --from=build /app/packages/constants/package.json ./packages/constants/
COPY --from=build /app/packages/validation/package.json ./packages/validation/
COPY --from=build /app/packages/integrations/package.json ./packages/integrations/
COPY --from=build /app/packages/sdks/sdk/package.json ./packages/sdks/sdk/
COPY --from=build /app/packages/sdks/_info/package.json ./packages/sdks/_info/
COPY --from=build /app/patches ./patches
@@ -130,6 +132,7 @@ COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/sdks/_info ./packages/sdks/_info
COPY --from=build /app/tooling/typescript ./tooling/typescript

View File

@@ -10,6 +10,7 @@
"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",
@@ -24,8 +25,7 @@
"@faker-js/faker": "^9.6.0",
"@hookform/resolvers": "^3.3.4",
"@hyperdx/node-opentelemetry": "^0.8.1",
"@nivo/sankey": "^0.99.0",
"@number-flow/react": "0.5.10",
"@number-flow/react": "0.3.5",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/integrations": "workspace:^",
@@ -149,7 +149,7 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@cloudflare/vite-plugin": "1.20.3",
"@cloudflare/vite-plugin": "^1.13.12",
"@openpanel/db": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@tanstack/devtools-event-client": "^0.3.3",
@@ -170,6 +170,6 @@
"vite": "^6.3.5",
"vitest": "^3.0.5",
"web-vitals": "^4.2.4",
"wrangler": "4.59.1"
"wrangler": "^4.42.2"
}
}
}

View File

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

View File

@@ -8,13 +8,7 @@ import { LogoSquare } from '../logo';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
export function ShareEnterPassword({
shareId,
shareType = 'overview',
}: {
shareId: string;
shareType?: 'overview' | 'dashboard' | 'report';
}) {
export function ShareEnterPassword({ shareId }: { shareId: string }) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInShare.mutationOptions({
@@ -31,7 +25,6 @@ export function ShareEnterPassword({
defaultValues: {
password: '',
shareId,
shareType,
},
});
@@ -39,7 +32,6 @@ export function ShareEnterPassword({
mutation.mutate({
password: data.password,
shareId,
shareType,
});
});
@@ -48,20 +40,9 @@ export function ShareEnterPassword({
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">
{shareType === 'dashboard'
? 'Dashboard is locked'
: shareType === 'report'
? 'Report is locked'
: 'Overview is locked'}
</div>
<div className="text-xl font-semibold">Overview is locked</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this{' '}
{shareType === 'dashboard'
? 'dashboard'
: shareType === 'report'
? 'report'
: 'overview'}
Please enter correct password to access this overview
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">

View File

@@ -5,15 +5,9 @@ import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
export const ChartTooltipContainer = ({
children,
className,
}: { children: React.ReactNode; className?: string }) => {
}: { children: React.ReactNode }) => {
return (
<div
className={cn(
'min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm',
className,
)}
>
<div className="min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
{children}
</div>
);

View File

@@ -1,7 +1,6 @@
import { Markdown } from '@/components/markdown';
import { cn } from '@/utils/cn';
import { zReport } from '@openpanel/validation';
import { z } from 'zod';
import { zChartInputAI } from '@openpanel/validation';
import type { UIMessage } from 'ai';
import { Loader2Icon, UserIcon } from 'lucide-react';
import { Fragment, memo } from 'react';
@@ -78,10 +77,7 @@ export const ChatMessage = memo(
const { result } = p.toolInvocation;
if (result.type === 'report') {
const report = zReport.extend({
startDate: z.string(),
endDate: z.string(),
}).safeParse(result.report);
const report = zChartInputAI.safeParse(result.report);
if (report.success) {
return (
<Fragment key={key}>

View File

@@ -1,6 +1,6 @@
import { pushModal } from '@/modals';
import type {
IReport,
IChartInputAi,
IChartRange,
IChartType,
IInterval,
@@ -16,7 +16,7 @@ import { Button } from '../ui/button';
export function ChatReport({
lazy,
...props
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
}: { report: IChartInputAi; lazy: boolean }) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
);

View File

@@ -1,65 +0,0 @@
import { cn } from '@/utils/cn';
import { type VariantProps, cva } from 'class-variance-authority';
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
const deltaChipVariants = cva(
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
{
variants: {
variant: {
inc: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
dec: 'bg-red-500/10 text-red-600 dark:text-red-400',
default: 'bg-muted text-muted-foreground',
},
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
},
);
type DeltaChipProps = VariantProps<typeof deltaChipVariants> & {
children: React.ReactNode;
inverted?: boolean;
};
const iconVariants: Record<NonNullable<DeltaChipProps['size']>, number> = {
sm: 12,
md: 16,
lg: 20,
};
const getVariant = (variant: DeltaChipProps['variant'], inverted?: boolean) => {
if (inverted) {
return variant === 'inc' ? 'dec' : variant === 'dec' ? 'inc' : variant;
}
return variant;
};
export function DeltaChip({
variant,
size,
inverted,
children,
}: DeltaChipProps) {
return (
<div
className={cn(
deltaChipVariants({ variant: getVariant(variant, inverted), size }),
)}
>
{variant === 'inc' ? (
<ArrowUpIcon size={iconVariants[size || 'md']} className="shrink-0" />
) : variant === 'dec' ? (
<ArrowDownIcon size={iconVariants[size || 'md']} className="shrink-0" />
) : null}
<span>{children}</span>
</div>
);
}

View File

@@ -27,7 +27,7 @@ export function useColumns() {
accessorKey: 'name',
header: 'Name',
cell({ row }) {
const { name, path, duration, properties, revenue } = row.original;
const { name, path, duration, properties } = row.original;
const renderName = () => {
if (name === 'screen_view') {
if (path.includes('/')) {
@@ -42,10 +42,6 @@ export function useColumns() {
);
}
if (name === 'revenue' && revenue) {
return `${name} (${number.currency(revenue / 100)})`;
}
return name.replace(/_/g, ' ');
};

View File

@@ -1,95 +0,0 @@
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<IServiceReport>[],
): 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 (
<>
<style>{`
.react-grid-item {
transition: ${transitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
}
.react-grid-item.react-grid-placeholder {
background: none !important;
opacity: 0.5;
transition-duration: 100ms;
border-radius: 0.5rem;
border: 1px dashed var(--primary);
}
.react-grid-item.resizing {
transition: none !important;
}
`}</style>
<div className="-m-4">
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
margin={[16, 16]}
transformScale={1}
useCSSTransforms={true}
onLayoutChange={onLayoutChange}
onDragStop={onDragStop}
onResizeStop={onResizeStop}
isDraggable={isDraggable}
isResizable={isResizable}
>
{children}
</ResponsiveGridLayout>
</div>
</>
);
}

View File

@@ -1,201 +0,0 @@
import { countries } from '@/translations/countries';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import type { InsightPayload } from '@openpanel/validation';
import { ArrowDown, ArrowUp, FilterIcon, RotateCcwIcon } from 'lucide-react';
import { last } from 'ramda';
import { useState } from 'react';
import { DeltaChip } from '../delta-chip';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Badge } from '../ui/badge';
function formatWindowKind(windowKind: string): string {
switch (windowKind) {
case 'yesterday':
return 'Yesterday';
case 'rolling_7d':
return '7 Days';
case 'rolling_30d':
return '30 Days';
}
return windowKind;
}
interface InsightCardProps {
insight: RouterOutputs['insight']['list'][number];
className?: string;
onFilter?: () => void;
}
export function InsightCard({
insight,
className,
onFilter,
}: InsightCardProps) {
const payload = insight.payload;
const dimensions = payload?.dimensions;
const availableMetrics = Object.entries(payload?.metrics ?? {});
// Pick what to display: prefer share if available (geo/devices), else primaryMetric
const [metricIndex, setMetricIndex] = useState(
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
);
const currentMetricKey = availableMetrics[metricIndex][0];
const currentMetricEntry = availableMetrics[metricIndex][1];
const metricUnit = currentMetricEntry?.unit;
const currentValue = currentMetricEntry?.current ?? null;
const compareValue = currentMetricEntry?.compare ?? null;
const direction = currentMetricEntry?.direction ?? 'flat';
const isIncrease = direction === 'up';
const isDecrease = direction === 'down';
const deltaText =
metricUnit === 'ratio'
? `${Math.abs((currentMetricEntry?.delta ?? 0) * 100).toFixed(1)}pp`
: `${Math.abs((currentMetricEntry?.changePct ?? 0) * 100).toFixed(1)}%`;
// Format metric values
const formatValue = (value: number | null): string => {
if (value == null) return '-';
if (metricUnit === 'ratio') return `${(value * 100).toFixed(1)}%`;
return Math.round(value).toLocaleString();
};
// Get the metric label
const metricKeyToLabel = (key: string) =>
key === 'share' ? 'Share' : key === 'pageviews' ? 'Pageviews' : 'Sessions';
const metricLabel = metricKeyToLabel(currentMetricKey);
const renderTitle = () => {
if (
dimensions[0]?.key === 'country' ||
dimensions[0]?.key === 'referrer_name' ||
dimensions[0]?.key === 'device'
) {
return (
<span className="capitalize flex items-center gap-2">
<SerieIcon name={dimensions[0]?.value} /> {insight.displayName}
</span>
);
}
if (insight.displayName.startsWith('http')) {
return (
<span className="flex items-center gap-2">
<SerieIcon
name={dimensions[0]?.displayName ?? dimensions[0]?.value}
/>
<span className="line-clamp-2">{dimensions[1]?.displayName}</span>
</span>
);
}
return insight.displayName;
};
return (
<div
className={cn(
'card p-4 h-full flex flex-col hover:bg-def-50 transition-colors group/card',
className,
)}
>
<div
className={cn(
'row justify-between h-4 items-center',
onFilter && 'group-hover/card:hidden',
)}
>
<Badge variant="outline" className="-ml-2">
{formatWindowKind(insight.windowKind)}
</Badge>
{/* Severity: subtle dot instead of big pill */}
{insight.severityBand && (
<div className="flex items-center gap-1 shrink-0">
<span
className={cn(
'h-2 w-2 rounded-full',
insight.severityBand === 'severe'
? 'bg-red-500'
: insight.severityBand === 'moderate'
? 'bg-yellow-500'
: 'bg-blue-500',
)}
/>
<span className="text-[11px] text-muted-foreground capitalize">
{insight.severityBand}
</span>
</div>
)}
</div>
{onFilter && (
<div className="row group-hover/card:flex hidden h-4 justify-between gap-2">
{availableMetrics.length > 1 ? (
<button
type="button"
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
onClick={() =>
setMetricIndex((metricIndex + 1) % availableMetrics.length)
}
>
<RotateCcwIcon className="size-2" />
Show{' '}
{metricKeyToLabel(
availableMetrics[
(metricIndex + 1) % availableMetrics.length
][0],
)}
</button>
) : (
<div />
)}
<button
type="button"
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
onClick={onFilter}
>
Filter <FilterIcon className="size-2" />
</button>
</div>
)}
<div className="font-semibold text-sm leading-snug line-clamp-2 mt-2">
{renderTitle()}
</div>
{/* Metric row */}
<div className="mt-auto pt-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] text-muted-foreground mb-1">
{metricLabel}
</div>
<div className="col gap-1">
<div className="text-2xl font-semibold tracking-tight">
{formatValue(currentValue)}
</div>
{/* Inline compare, smaller */}
{compareValue != null && (
<div className="text-xs text-muted-foreground">
vs {formatValue(compareValue)}
</div>
)}
</div>
</div>
{/* Delta chip */}
<DeltaChip
variant={isIncrease ? 'inc' : isDecrease ? 'dec' : 'default'}
size="sm"
>
{deltaText}
</DeltaChip>
</div>
</div>
</div>
);
}

View File

@@ -33,7 +33,7 @@ export function LoginNavbar({ className }: { className?: string }) {
</a>
</li>
<li>
<a href="https://openpanel.dev/compare/posthog-alternative">
<a href="https://openpanel.dev/compare/mixpanel-alternative">
Posthog alternative
</a>
</li>

View File

@@ -1,82 +0,0 @@
import { PromptCard } from '@/components/organization/prompt-card';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { op } from '@/utils/op';
import { MessageSquareIcon } from 'lucide-react';
import { useEffect, useMemo } from 'react';
const THIRTY_DAYS_IN_SECONDS = 60 * 60 * 24 * 30;
export default function FeedbackPrompt() {
const { isSelfHosted } = useAppContext();
const [feedbackPromptSeen, setFeedbackPromptSeen] = useCookieStore(
'feedback-prompt-seen',
'',
{ maxAge: THIRTY_DAYS_IN_SECONDS },
);
const shouldShow = useMemo(() => {
if (isSelfHosted) {
return false;
}
if (!feedbackPromptSeen) {
return true;
}
try {
const lastSeenDate = new Date(feedbackPromptSeen);
const now = new Date();
const daysSinceLastSeen =
(now.getTime() - lastSeenDate.getTime()) / (1000 * 60 * 60 * 24);
return daysSinceLastSeen >= 30;
} catch {
// If date parsing fails, show the prompt
return true;
}
}, [isSelfHosted, feedbackPromptSeen]);
const handleGiveFeedback = () => {
// Open userjot widget
if (typeof window !== 'undefined' && 'uj' in window) {
(window.uj as any).showWidget();
}
// Set cookie with current timestamp
setFeedbackPromptSeen(new Date().toISOString());
op.track('feedback_prompt_button_clicked');
};
const handleClose = () => {
// Set cookie with current timestamp when closed
setFeedbackPromptSeen(new Date().toISOString());
};
useEffect(() => {
if (shouldShow) {
op.track('feedback_prompt_viewed');
}
}, [shouldShow]);
return (
<PromptCard
title="Share Your Feedback"
subtitle="Help us improve OpenPanel with your insights"
onClose={handleClose}
show={shouldShow}
gradientColor="rgb(59 130 246)"
>
<div className="px-6 col gap-4">
<p className="text-sm text-foreground leading-normal">
Your feedback helps us build features you actually need. Share your
thoughts, report bugs, or suggest improvements
</p>
<Button className="self-start" onClick={handleGiveFeedback}>
Give Feedback
</Button>
</div>
</PromptCard>
);
}

View File

@@ -1,66 +0,0 @@
import { Button } from '@/components/ui/button';
import { AnimatePresence, motion } from 'framer-motion';
import { XIcon } from 'lucide-react';
interface PromptCardProps {
title: string;
subtitle: string;
onClose: () => void;
children: React.ReactNode;
gradientColor?: string;
show: boolean;
}
export function PromptCard({
title,
subtitle,
onClose,
children,
gradientColor = 'rgb(16 185 129)',
show,
}: PromptCardProps) {
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
className="fixed bottom-0 right-0 z-50 p-4 max-w-sm"
>
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_var(--color-background)] col gap-6 py-6 overflow-hidden">
<div className="relative px-6 col gap-1">
<div
className="absolute -bottom-10 -right-10 h-64 w-64 rounded-full opacity-30 blur-3xl pointer-events-none"
style={{
background: `radial-gradient(circle, ${gradientColor} 0%, transparent 70%)`,
}}
/>
<div className="row items-center justify-between">
<h2 className="text-xl font-semibold max-w-[200px] leading-snug">
{title}
</h2>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={onClose}
>
<XIcon className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{children}
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,7 +1,7 @@
import { PromptCard } from '@/components/organization/prompt-card';
import { LinkButton } from '@/components/ui/button';
import { Button, LinkButton } from '@/components/ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { AnimatePresence, motion } from 'framer-motion';
import {
AwardIcon,
HeartIcon,
@@ -9,6 +9,7 @@ import {
MessageCircleIcon,
RocketIcon,
SparklesIcon,
XIcon,
ZapIcon,
} from 'lucide-react';
@@ -77,43 +78,70 @@ export default function SupporterPrompt() {
}
return (
<PromptCard
title="Support OpenPanel"
subtitle="Help us build the future of open analytics"
onClose={() => setSupporterPromptClosed(true)}
show={!supporterPromptClosed}
gradientColor="rgb(16 185 129)"
>
<div className="col gap-3 px-6">
{PERKS.map((perk) => (
<PerkPoint
key={perk.text}
icon={perk.icon}
text={perk.text}
description={perk.description}
/>
))}
</div>
<div className="px-6">
<LinkButton
className="w-full"
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
<AnimatePresence>
{!supporterPromptClosed && (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
className="fixed bottom-0 right-0 z-50 p-4 max-w-md"
>
Become a Supporter
</LinkButton>
<p className="text-xs text-muted-foreground text-center mt-4">
Starting at $20/month Cancel anytime {' '}
<a
href="https://openpanel.dev/supporter"
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Learn more
</a>
</p>
</div>
</PromptCard>
<div className="bg-card border p-6 rounded-lg shadow-lg col gap-4">
<div>
<div className="row items-center justify-between">
<h2 className="text-xl font-semibold">Support OpenPanel</h2>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => setSupporterPromptClosed(true)}
>
<XIcon className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Help us build the future of open analytics
</p>
</div>
<div className="col gap-3">
{PERKS.map((perk) => (
<PerkPoint
key={perk.text}
icon={perk.icon}
text={perk.text}
description={perk.description}
/>
))}
</div>
<div className="pt-2">
<LinkButton
className="w-full"
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
>
Become a Supporter
</LinkButton>
<p className="text-xs text-muted-foreground text-center mt-4">
Starting at $20/month Cancel anytime {' '}
<a
href="https://openpanel.dev/supporter"
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Learn more
</a>
</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,75 +0,0 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { InsightCard } from '../insights/insight-card';
import { Skeleton } from '../skeleton';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '../ui/carousel';
interface OverviewInsightsProps {
projectId: string;
}
export default function OverviewInsights({ projectId }: OverviewInsightsProps) {
const trpc = useTRPC();
const [filters, setFilter] = useEventQueryFilters();
const { data: insights, isLoading } = useQuery(
trpc.insight.list.queryOptions({
projectId,
limit: 20,
}),
);
if (isLoading) {
const keys = Array.from({ length: 4 }, (_, i) => `insight-skeleton-${i}`);
return (
<div className="col-span-6">
<Carousel opts={{ align: 'start' }} className="w-full">
<CarouselContent className="-ml-4">
{keys.map((key) => (
<CarouselItem
key={key}
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
>
<Skeleton className="h-36 w-full" />
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
);
}
if (!insights || insights.length === 0) return null;
return (
<div className="col-span-6 -mx-4">
<Carousel opts={{ align: 'start' }} className="w-full group">
<CarouselContent className="mr-4">
{insights.map((insight) => (
<CarouselItem
key={insight.id}
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
>
<InsightCard
insight={insight}
onFilter={() => {
insight.payload.dimensions.forEach((dim) => {
void setFilter(dim.key, dim.value, 'is');
});
}}
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
<CarouselNext className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
</Carousel>
</div>
);
}

View File

@@ -1,153 +0,0 @@
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import React from 'react';
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import type { IInterval } from '@openpanel/validation';
import { SerieIcon } from '../report-chart/common/serie-icon';
type Data = {
date: string;
timestamp: number;
[key: `${string}:sessions`]: number;
[key: `${string}:pageviews`]: number;
[key: `${string}:revenue`]: number | undefined;
[key: `${string}:payload`]: {
name: string;
prefix?: string;
color: string;
};
};
type Context = {
interval: IInterval;
};
export const OverviewLineChartTooltip = createChartTooltip<Data, Context>(
({ context: { interval }, data }) => {
const formatDate = useFormatDateInterval({
interval,
short: false,
});
const number = useNumber();
if (!data || data.length === 0) {
return null;
}
const firstItem = data[0];
// Get all payload items from the first data point
// Keys are in format "prefix:name:payload" or "name:payload"
const payloadItems = Object.keys(firstItem)
.filter((key) => key.endsWith(':payload'))
.map((key) => {
const payload = firstItem[key as keyof typeof firstItem] as {
name: string;
prefix?: string;
color: string;
};
// Extract the base key (without :payload) to access sessions/pageviews/revenue
const baseKey = key.replace(':payload', '');
return {
payload,
baseKey,
};
})
.filter(
(item) =>
item.payload &&
typeof item.payload === 'object' &&
'name' in item.payload,
);
// Sort by sessions (descending)
const sorted = payloadItems.sort((a, b) => {
const aSessions =
(firstItem[
`${a.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
const bSessions =
(firstItem[
`${b.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
return bSessions - aSessions;
});
const limit = 3;
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
return (
<>
{visible.map((item, index) => {
const sessions =
(firstItem[
`${item.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
const pageviews =
(firstItem[
`${item.baseKey}:pageviews` as keyof typeof firstItem
] as number) ?? 0;
const revenue = firstItem[
`${item.baseKey}:revenue` as keyof typeof firstItem
] as number | undefined;
return (
<React.Fragment key={item.baseKey}>
{index === 0 && firstItem.date && (
<ChartTooltipHeader>
<div>{formatDate(new Date(firstItem.date))}</div>
</ChartTooltipHeader>
)}
<ChartTooltipItem color={item.payload.color}>
<div className="flex items-center gap-1">
<SerieIcon name={item.payload.prefix || item.payload.name} />
<div className="font-medium">
{item.payload.prefix && (
<>
<span className="text-muted-foreground">
{item.payload.prefix}
</span>
<span className="mx-1">/</span>
</>
)}
{item.payload.name || 'Not set'}
</div>
</div>
<div className="col gap-1 text-sm">
{revenue !== undefined && revenue > 0 && (
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Revenue</span>
<span style={{ color: '#3ba974' }}>
{number.currency(revenue / 100, { short: true })}
</span>
</div>
)}
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Pageviews</span>
<span>{number.short(pageviews)}</span>
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Sessions</span>
<span>{number.short(sessions)}</span>
</div>
</div>
</ChartTooltipItem>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground text-sm">
and {hidden.length} more {hidden.length === 1 ? 'item' : 'items'}
</div>
)}
</>
);
},
);

View File

@@ -1,303 +0,0 @@
import { useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { RouterOutputs } from '@/trpc/client';
import type { IInterval } from '@openpanel/validation';
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { OverviewLineChartTooltip } from './overview-line-chart-tooltip';
type SeriesData =
RouterOutputs['overview']['topGenericSeries']['items'][number];
interface OverviewLineChartProps {
data: RouterOutputs['overview']['topGenericSeries'];
interval: IInterval;
searchQuery?: string;
className?: string;
}
function transformDataForRecharts(
items: SeriesData[],
searchQuery?: string,
): Array<{
date: string;
timestamp: number;
[key: `${string}:sessions`]: number;
[key: `${string}:pageviews`]: number;
[key: `${string}:revenue`]: number | undefined;
[key: `${string}:payload`]: {
name: string;
prefix?: string;
color: string;
};
}> {
// Filter items by search query
const filteredItems = searchQuery
? items.filter((item) => {
const queryLower = searchQuery.toLowerCase();
return (
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
);
})
: items;
// Limit to top 15
const topItems = filteredItems.slice(0, 15);
// Get all unique dates from all items
const allDates = new Set<string>();
topItems.forEach((item) => {
item.data.forEach((d) => allDates.add(d.date));
});
const sortedDates = Array.from(allDates).sort();
// Transform to recharts format
return sortedDates.map((date) => {
const timestamp = new Date(date).getTime();
const result: Record<string, any> = {
date,
timestamp,
};
topItems.forEach((item, index) => {
const dataPoint = item.data.find((d) => d.date === date);
if (dataPoint) {
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
const key = item.prefix ? `${item.prefix}:${item.name}` : item.name;
result[`${key}:sessions`] = dataPoint.sessions;
result[`${key}:pageviews`] = dataPoint.pageviews;
if (dataPoint.revenue !== undefined) {
result[`${key}:revenue`] = dataPoint.revenue;
}
result[`${key}:payload`] = {
name: item.name,
prefix: item.prefix,
color: getChartColor(index),
};
}
});
return result as typeof result & {
date: string;
timestamp: number;
};
});
}
export function OverviewLineChart({
data,
interval,
searchQuery,
className,
}: OverviewLineChartProps) {
const number = useNumber();
const chartData = useMemo(
() => transformDataForRecharts(data.items, searchQuery),
[data.items, searchQuery],
);
const visibleItems = useMemo(() => {
const filtered = searchQuery
? data.items.filter((item) => {
const queryLower = searchQuery.toLowerCase();
return (
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
);
})
: data.items;
return filtered.slice(0, 15);
}, [data.items, searchQuery]);
const xAxisProps = useXAxisProps({ interval, hide: false });
const yAxisProps = useYAxisProps({});
if (visibleItems.length === 0) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">
{searchQuery ? 'No results found' : 'No data available'}
</div>
</div>
);
}
return (
<div className={cn('w-full p-4', className)}>
<div className="h-[358px] w-full">
<OverviewLineChartTooltip.TooltipProvider interval={interval}>
<ResponsiveContainer>
<LineChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<Tooltip content={<OverviewLineChartTooltip.Tooltip />} />
{visibleItems.map((item, index) => {
const color = getChartColor(index);
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
const key = item.prefix
? `${item.prefix}:${item.name}`
: item.name;
return (
<Line
key={key}
type="monotone"
dataKey={`${key}:sessions`}
stroke={color}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</OverviewLineChartTooltip.TooltipProvider>
</div>
{/* Legend */}
<LegendScrollable items={visibleItems} />
</div>
);
}
function LegendScrollable({
items,
}: {
items: SeriesData[];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const updateGradients = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
const hasOverflow = scrollWidth > clientWidth;
setShowLeftGradient(hasOverflow && scrollLeft > 0);
setShowRightGradient(
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateGradients();
el.addEventListener('scroll', updateGradients);
window.addEventListener('resize', updateGradients);
return () => {
el.removeEventListener('scroll', updateGradients);
window.removeEventListener('resize', updateGradients);
};
}, [updateGradients]);
// Update gradients when items change
useEffect(() => {
requestAnimationFrame(updateGradients);
}, [items, updateGradients]);
return (
<div className="relative mt-4 -mb-2">
{/* Left gradient */}
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
showLeftGradient ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Scrollable legend */}
<div
ref={scrollRef}
className="flex gap-x-4 gap-y-1 overflow-x-auto px-2 py-1 hide-scrollbar text-xs"
>
{items.map((item, index) => {
const color = getChartColor(index);
return (
<div
className="flex shrink-0 items-center gap-1"
key={item.prefix ? `${item.prefix}:${item.name}` : item.name}
style={{ color }}
>
<SerieIcon name={item.prefix || item.name} />
<span className="font-semibold whitespace-nowrap">
{item.prefix && (
<>
<span className="text-muted-foreground">{item.prefix}</span>
<span className="mx-1">/</span>
</>
)}
{item.name || 'Not set'}
</span>
</div>
);
})}
</div>
{/* Right gradient */}
<div
className={cn(
'pointer-events-none absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
showRightGradient ? 'opacity-100' : 'opacity-0',
)}
/>
</div>
);
}
export function OverviewLineChartLoading({
className,
}: {
className?: string;
}) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">Loading...</div>
</div>
);
}
export function OverviewLineChartEmpty({
className,
}: {
className?: string;
}) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">No data available</div>
</div>
);
}

View File

@@ -1,264 +0,0 @@
import { useNumber } from '@/hooks/use-numer-formatter';
import { ModalContent } from '@/modals/Modal/Container';
import { cn } from '@/utils/cn';
import { DialogTitle } from '@radix-ui/react-dialog';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SearchIcon } from 'lucide-react';
import React, { useMemo, useRef, useState } from 'react';
import { Input } from '../ui/input';
const ROW_HEIGHT = 36;
// Revenue pie chart component
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 (
<svg width={size} height={size} className="flex-shrink-0">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-def-200"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#3ba974"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
// Base data type that all items must conform to
export interface OverviewListItem {
sessions: number;
pageviews: number;
revenue?: number;
}
interface OverviewListModalProps<T extends OverviewListItem> {
/** Modal title */
title: string;
/** Search placeholder text */
searchPlaceholder?: string;
/** The data to display */
data: T[];
/** Extract a unique key for each item */
keyExtractor: (item: T) => string;
/** Filter function for search - receives item and lowercase search query */
searchFilter: (item: T, query: string) => boolean;
/** Render the main content cell (first column) */
renderItem: (item: T) => React.ReactNode;
/** Optional footer content */
footer?: React.ReactNode;
/** Optional header content (appears below title/search) */
headerContent?: React.ReactNode;
/** Column name for the first column */
columnName?: string;
/** Whether to show pageviews column */
showPageviews?: boolean;
/** Whether to show sessions column */
showSessions?: boolean;
}
export function OverviewListModal<T extends OverviewListItem>({
title,
searchPlaceholder = 'Search...',
data,
keyExtractor,
searchFilter,
renderItem,
footer,
headerContent,
columnName = 'Name',
showPageviews = true,
showSessions = true,
}: OverviewListModalProps<T>) {
const [searchQuery, setSearchQuery] = useState('');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const number = useNumber();
// Filter data based on search query
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => searchFilter(item, queryLower));
}, [data, searchQuery, searchFilter]);
// Calculate totals and check for revenue
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
useMemo(() => {
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
const totalRevenue = filteredData.reduce(
(sum, item) => sum + (item.revenue ?? 0),
0,
);
const hasRevenue = filteredData.some((item) => (item.revenue ?? 0) > 0);
const hasPageviews =
showPageviews && filteredData.some((item) => item.pageviews > 0);
return { maxSessions, totalRevenue, hasRevenue, hasPageviews };
}, [filteredData, showPageviews]);
// Virtual list setup
const virtualizer = useVirtualizer({
count: filteredData.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
});
const virtualItems = virtualizer.getVirtualItems();
return (
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0 sm:max-w-2xl">
{/* Sticky Header */}
<div className="flex-shrink-0 border-b border-border">
<div className="p-6 pb-4">
<DialogTitle className="text-lg font-semibold mb-4">
{title}
</DialogTitle>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{headerContent}
</div>
{/* Column Headers */}
<div
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
<div className="text-left truncate">{columnName}</div>
{hasRevenue && <div className="text-right">Revenue</div>}
{hasPageviews && <div className="text-right">Views</div>}
{showSessions && <div className="text-right">Sessions</div>}
</div>
</div>
{/* Virtualized Scrollable Body */}
<div
ref={scrollAreaRef}
className="flex-1 min-h-0 overflow-y-auto"
style={{ maxHeight: '60vh' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => {
const item = filteredData[virtualRow.index];
if (!item) return null;
const percentage = item.sessions / maxSessions;
const revenuePercentage =
totalRevenue > 0 ? (item.revenue ?? 0) / totalRevenue : 0;
return (
<div
key={keyExtractor(item)}
className="absolute top-0 left-0 w-full group/row"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* Background bar */}
<div className="absolute inset-0 overflow-hidden">
<div
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors"
style={{ width: `${percentage * 100}%` }}
/>
</div>
{/* Row content */}
<div
className="relative grid h-full items-center px-4 border-b border-border"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
{/* Main content cell */}
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
{/* Revenue cell */}
{hasRevenue && (
<div className="flex items-center justify-end gap-2">
<span
className="font-semibold font-mono text-sm"
style={{ color: '#3ba974' }}
>
{(item.revenue ?? 0) > 0
? number.currency((item.revenue ?? 0) / 100, {
short: true,
})
: '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
)}
{/* Pageviews cell */}
{hasPageviews && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.pageviews)}
</div>
)}
{/* Sessions cell */}
{showSessions && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.sessions)}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Empty state */}
{filteredData.length === 0 && (
<div className="flex items-center justify-center h-32 text-muted-foreground">
{searchQuery ? 'No results found' : 'No data available'}
</div>
)}
</div>
{/* Fixed Footer */}
{footer && (
<div className="flex-shrink-0 border-t border-border p-4">{footer}</div>
)}
</ModalContent>
);
}

View File

@@ -1,4 +1,5 @@
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import { useNumber } from '@/hooks/use-numer-formatter';
@@ -7,14 +8,18 @@ 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 { createPortal } from 'react-dom';
import {
Bar,
BarChart,
CartesianGrid,
Customized,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon';
interface OverviewLiveHistogramProps {
projectId: string;
@@ -81,8 +86,10 @@ export function OverviewLiveHistogram({
<YAxis hide domain={[0, maxDomain]} />
<Bar
dataKey="sessionCount"
className="fill-chart-0"
fill="rgba(59, 121, 255, 0.2)"
isAnimationActive={false}
shape={BarShapeBlue}
activeBar={BarShapeBlue}
/>
</BarChart>
</ResponsiveContainer>

View File

@@ -1,7 +1,7 @@
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart, Bar, BarChart, Tooltip } from 'recharts';
import { Area, AreaChart, Tooltip } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
@@ -144,33 +144,51 @@ export function OverviewMetricCard({
<div className={cn('group relative p-4')}>
<div
className={cn(
'absolute left-4 right-4 bottom-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
'absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
)}
>
<AutoSizer style={{ height: 20 }}>
{({ width }) => (
<BarChart
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={20}
height={height / 4}
data={data}
style={{
background: 'transparent',
}}
style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
onMouseMove={(event) => {
setCurrentIndex(event.activeTooltipIndex ?? null);
}}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<Tooltip content={() => null} cursor={false} />
<Bar
<defs>
<linearGradient
id={`colorUv${id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={graphColors}
stopOpacity={0.2}
/>
<stop
offset="100%"
stopColor={graphColors}
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<Tooltip content={() => null} />
<Area
dataKey={'current'}
type="step"
fill={graphColors}
fill={`url(#colorUv${id})`}
fillOpacity={1}
strokeWidth={0}
stroke={graphColors}
strokeWidth={1}
isAnimationActive={false}
/>
</BarChart>
</AreaChart>
)}
</AutoSizer>
</div>
@@ -207,11 +225,13 @@ export function OverviewMetricCardNumber({
isLoading?: boolean;
}) {
return (
<div className={cn('min-w-0 col gap-2 items-start', className)}>
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label}
</span>
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label}
</span>
</div>
</div>
{isLoading ? (
<div className="flex items-end justify-between gap-4">
@@ -219,13 +239,13 @@ export function OverviewMetricCardNumber({
<Skeleton className="h-6 w-12" />
</div>
) : (
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
{value}
<div className="flex items-end justify-between gap-4">
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
{value}
</div>
{enhancer}
</div>
)}
<div className="absolute right-0 top-0 bottom-0 center justify-center col pr-4">
{enhancer}
</div>
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
@@ -11,12 +13,7 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -34,7 +31,6 @@ export default function OverviewTopDevices({
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [chartType] = useState<IChartType>('bar');
const [searchQuery, setSearchQuery] = useState('');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
device: {
@@ -320,7 +316,6 @@ export default function OverviewTopDevices({
});
const trpc = useTRPC();
const [view] = useOverviewView();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
@@ -333,67 +328,31 @@ export default function OverviewTopDevices({
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={filteredData}
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -425,8 +384,7 @@ export default function OverviewTopDevices({
})
}
/>
<div className="flex-1" />
<OverviewViewToggle />
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>

View File

@@ -1,172 +1,225 @@
import { ReportChart } from '@/components/report-chart';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import type { IReportInput } from '@openpanel/validation';
import type { IChartType } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { Widget, WidgetBody } from '../widget';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
type EventTableItem,
OverviewWidgetTableEvents,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidgetV2 } from './useOverviewWidget';
import { useOverviewWidget } from './useOverviewWidget';
export interface OverviewTopEventsProps {
projectId: string;
}
export default function OverviewTopEvents({
projectId,
}: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [filters] = useEventQueryFilters();
const trpc = useTRPC();
const { data: conversions } = useQuery(
trpc.event.conversionNames.queryOptions({ projectId }),
);
const [searchQuery, setSearchQuery] = useState('');
const [widget, setWidget, widgets] = useOverviewWidgetV2('ev', {
const [chartType, setChartType] = useState<IChartType>('bar');
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
your: {
title: 'Events',
btn: 'Events',
meta: {
filters: [
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
eventName: '*',
title: 'Top events',
btn: 'Your',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Your top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
all: {
title: 'Top events',
btn: 'All',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [...filters],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'All top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: !conversions || conversions.length === 0,
meta: {
filters: [
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
eventName: '*',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Conversions',
range: range,
previous: previous,
metric: 'sum',
},
},
},
link_out: {
title: 'Link out',
btn: 'Link out',
meta: {
filters: [],
eventName: 'link_out',
breakdownProperty: 'properties.href',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
id: 'A',
name: 'link_out',
filters: [],
},
],
breakdowns: [
{
id: 'A',
name: 'properties.href',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Link out',
range: range,
previous: previous,
metric: 'sum',
},
},
},
});
const report: IReportInput = useMemo(
() => ({
limit: 1000,
projectId,
startDate,
endDate,
series: [
{
type: 'event' as const,
segment: 'event' as const,
filters: [...filters, ...(widget.meta?.filters ?? [])],
id: 'A',
name: widget.meta?.eventName ?? '*',
},
],
breakdowns: [
{
id: 'A',
name: widget.meta?.breakdownProperty ?? 'name',
},
],
chartType: 'bar' as const,
interval,
range,
previous,
metric: 'sum' as const,
}),
[projectId, startDate, endDate, filters, widget, interval, range, previous],
);
const query = useQuery(trpc.chart.aggregate.queryOptions(report));
const tableData: EventTableItem[] = useMemo(() => {
if (!query.data?.series) return [];
return query.data.series.map((serie) => ({
id: serie.id,
name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '',
count: serie.metrics.sum,
}));
}, [query.data]);
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return tableData.slice(0, 15);
}
const queryLower = searchQuery.toLowerCase();
return tableData
.filter((item) => item.name?.toLowerCase().includes(queryLower))
.slice(0, 15);
}, [tableData, searchQuery]);
const tabs = useMemo(
() =>
widgets
.filter((item) => item.hide !== true)
.map((w) => ({
key: w.key,
label: w.btn,
})),
[widgets],
);
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableEvents
data={filteredData}
onItemClick={(name) => {
if (widget.meta?.breakdownProperty) {
setFilter(widget.meta.breakdownProperty, name);
} else {
setFilter('name', name);
}
}}
/>
)}
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-3">
<ReportChart
options={{
hideID: true,
columns: ['Event'],
renderSerieName(names) {
return names[1];
},
}}
report={{
...widget.chart.report,
previous: false,
}}
/>
</WidgetBody>
<WidgetFooter>
<div className="flex-1" />
<OverviewChartToggle {...{ chartType, setChartType }} />
</WidgetFooter>
</Widget>
</>

View File

@@ -1,15 +1,18 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import type { IGetTopGenericInput } from '@openpanel/db';
import { useQuery } from '@tanstack/react-query';
import { useInfiniteQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import {
OVERVIEW_COLUMNS_NAME,
OVERVIEW_COLUMNS_NAME_PLURAL,
} from './overview-constants';
import { OverviewListModal } from './overview-list-modal';
import { OverviewWidgetTableGeneric } from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopGenericModalProps {
@@ -21,55 +24,83 @@ export default function OverviewTopGenericModal({
projectId,
column,
}: OverviewTopGenericModalProps) {
const [_filters, setFilter] = useEventQueryFilters();
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
filters: _filters,
startDate,
endDate,
range,
column,
}),
const query = useInfiniteQuery(
trpc.overview.topGeneric.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
range,
limit: 50,
column,
},
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length === 0) {
return null;
}
return pages.length + 1;
},
},
),
);
const data = query.data?.pages.flat() || [];
const isEmpty = !query.hasNextPage && !query.isFetching;
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
const columnName = OVERVIEW_COLUMNS_NAME[column];
return (
<OverviewListModal
title={`Top ${columnNamePlural}`}
searchPlaceholder={`Search ${columnNamePlural.toLowerCase()}...`}
data={query.data ?? []}
keyExtractor={(item) => (item.prefix ?? '') + item.name}
searchFilter={(item, query) =>
item.name?.toLowerCase().includes(query) ||
item.prefix?.toLowerCase().includes(query) ||
false
}
columnName={columnName}
renderItem={(item) => (
<div className="flex items-center gap-2 min-w-0">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate hover:underline"
onClick={() => {
setFilter(column, item.name);
}}
<ModalContent>
<ModalHeader title={`Top ${columnNamePlural}`} />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTableGeneric
data={data}
column={{
name: columnName,
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter(column, item.name);
}}
>
{item.prefix && (
<span className="mr-1 row inline-flex items-center gap-1">
<span>{item.prefix}</span>
<span>
<ChevronRightIcon className="size-3" />
</span>
</span>
)}
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
disabled={isEmpty}
>
{item.prefix && (
<span className="mr-1 inline-flex items-center gap-1">
<span>{item.prefix}</span>
<ChevronRightIcon className="size-3" />
</span>
)}
{item.name || 'Not set'}
</button>
Load more
</Button>
</div>
)}
/>
</ScrollArea>
</ModalContent>
);
}

View File

@@ -1,8 +1,10 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { countries } from '@/translations/countries';
@@ -11,20 +13,10 @@ 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';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import {
WidgetFooter,
WidgetHead,
WidgetHeadSearchable,
} from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -40,7 +32,6 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar');
const [filters, setFilter] = useEventQueryFilters();
const [searchQuery, setSearchQuery] = useState('');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
country: {
@@ -57,8 +48,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
},
});
const number = useNumber();
const trpc = useTRPC();
const [view] = useOverviewView();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
@@ -71,74 +62,31 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter(
(item) =>
item.name?.toLowerCase().includes(queryLower) ||
item.prefix?.toLowerCase().includes(queryLower) ||
countries[item.name as keyof typeof countries]
?.toLowerCase()
.includes(queryLower),
);
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={filteredData}
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -182,7 +130,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
/>
)}
</WidgetBody>
<WidgetFooter className="row items-center justify-between">
<WidgetFooter>
<OverviewDetailsButton
onClick={() =>
pushModal('OverviewTopGenericModal', {
@@ -191,19 +139,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
})
}
/>
<div className="flex-1" />
<OverviewViewToggle />
<span className="text-sm text-muted-foreground pr-2 ml-2">
Geo data provided by{' '}
<a
href="https://ipdata.co"
target="_blank"
rel="noopener noreferrer nofollow"
className="hover:underline"
>
MaxMind
</a>
</span>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
<Widget className="col-span-6 md:col-span-3">
@@ -211,8 +147,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ReportChartShortcut
{...{
<ReportChart
options={{ hideID: true }}
report={{
projectId,
startDate,
endDate,
@@ -232,9 +169,12 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>

View File

@@ -1,11 +1,11 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { ExternalLinkIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } from '../ui/tooltip';
import { OverviewListModal } from './overview-list-modal';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { OverviewWidgetTablePages } from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopPagesProps {
@@ -18,54 +18,44 @@ export default function OverviewTopPagesModal({
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useQuery(
trpc.overview.topPages.queryOptions({
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
}),
const query = useInfiniteQuery(
trpc.overview.topPages.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
limit: 50,
},
{
getNextPageParam: (_, pages) => pages.length + 1,
},
),
);
const data = query.data?.pages.flat();
return (
<OverviewListModal
title="Top Pages"
searchPlaceholder="Search pages..."
data={query.data ?? []}
keyExtractor={(item) => item.path + item.origin}
searchFilter={(item, query) =>
item.path.toLowerCase().includes(query) ||
item.origin.toLowerCase().includes(query)
}
columnName="Path"
renderItem={(item) => (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="flex items-center gap-2 min-w-0">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate hover:underline"
onClick={() => {
setFilter('path', item.path);
setFilter('origin', item.origin);
}}
>
{item.path || <span className="opacity-40">Not set</span>}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0"
>
<ExternalLinkIcon className="size-3 opacity-0 group-hover/row:opacity-100 transition-opacity" />
</a>
</div>
</Tooltiper>
)}
/>
<ModalContent>
<ModalHeader title="Top Pages" />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={'Sessions'}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
loading={query.isFetching}
>
Load more
</Button>
</div>
</ScrollArea>
</ModalContent>
);
}

View File

@@ -1,7 +1,7 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { Globe2Icon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useMemo, useState } from 'react';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -9,9 +9,8 @@ import { useQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { Widget, WidgetBody } from '../widget';
import OverviewDetailsButton from './overview-details-button';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableEntries,
OverviewWidgetTableLoading,
OverviewWidgetTablePages,
} from './overview-widget-table';
@@ -26,11 +25,15 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
const [searchQuery, setSearchQuery] = useState('');
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
page: {
title: 'Top pages',
btn: 'Pages',
btn: 'Top pages',
meta: {
columns: {
sessions: 'Sessions',
},
},
},
entry: {
title: 'Entry Pages',
@@ -50,6 +53,10 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
},
},
},
// bot: {
// title: 'Bots',
// btn: 'Bots',
// },
});
const trpc = useTRPC();
@@ -64,53 +71,37 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
}),
);
const filteredData = useMemo(() => {
const data = query.data?.slice(0, 15) ?? [];
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter(
(item) =>
item.path.toLowerCase().includes(queryLower) ||
item.origin.toLowerCase().includes(queryLower),
);
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
const data = query.data;
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<>
{widget.meta?.columns.sessions ? (
<OverviewWidgetTableEntries
data={filteredData}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
) : (
<OverviewWidgetTablePages
data={filteredData}
showDomain={!!domain}
/>
)}
{/*<OverviewWidgetTableBots data={data ?? []} />*/}
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
</>
)}
</WidgetBody>
@@ -118,6 +109,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
<OverviewDetailsButton
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<Button
variant={'ghost'}

View File

@@ -1,5 +1,5 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import { cn } from '@/utils/cn';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -9,12 +9,7 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -28,18 +23,16 @@ interface OverviewTopSourcesProps {
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions();
const { range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [searchQuery, setSearchQuery] = useState('');
const [view] = useOverviewView();
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
referrer_name: {
title: 'Top sources',
btn: 'Refs',
btn: 'All',
},
referrer: {
title: 'Top urls',
btn: 'Urls',
btn: 'URLs',
},
referrer_type: {
title: 'Top types',
@@ -79,67 +72,31 @@ export default function OverviewTopSources({
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-0">
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={filteredData}
data={query.data ?? []}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -180,8 +137,7 @@ export default function OverviewTopSources({
})
}
/>
<div className="flex-1" />
<OverviewViewToggle />
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
</WidgetFooter>
</Widget>
</>

View File

@@ -1,408 +0,0 @@
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '@/components/charts/chart-tooltip';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { ResponsiveSankey } from '@nivo/sankey';
import { parseAsInteger, useQueryState } from 'nuqs';
import {
type ReactNode,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useTRPC } from '@/integrations/trpc/react';
import { truncate } from '@/utils/truncate';
import { useQuery } from '@tanstack/react-query';
import { ArrowRightIcon } from 'lucide-react';
import { useTheme } from '../theme-provider';
import { Widget, WidgetBody } from '../widget';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewUserJourneyProps {
projectId: string;
}
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
const showPath = (string: string) => {
try {
const url = new URL(string);
return url.pathname;
} catch {
return string;
}
};
const showDomain = (string: string) => {
try {
const url = new URL(string);
return url.hostname;
} catch {
return string;
}
};
function SankeyPortalTooltip({
children,
offset = 12,
padding = 8,
}: {
children: ReactNode;
offset?: number;
padding?: number;
}) {
const anchorRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
const [pos, setPos] = useState<PortalTooltipPosition>({
left: 0,
top: 0,
ready: false,
});
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
}, []);
useLayoutEffect(() => {
const el = anchorRef.current;
if (!el) return;
// Nivo renders the tooltip content inside an absolutely-positioned wrapper <div>.
// The wrapper is the immediate parent of our rendered content.
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;
// Start by following Nivo's tooltip anchor position.
let left = anchorRect.left + offset;
let top = anchorRect.top + offset;
// Clamp inside viewport with a little padding.
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]);
// SSR safety: on the server, just render the tooltip normally.
if (typeof document === 'undefined') {
return <>{children}</>;
}
return (
<>
{/* Render a tiny (screen-reader-only) anchor inside Nivo's tooltip wrapper. */}
<span ref={anchorRef} className="sr-only" />
{mounted &&
createPortal(
<div
ref={tooltipRef}
className="pointer-events-none fixed z-[9999]"
style={{
left: pos.left,
top: pos.top,
visibility: pos.ready ? 'visible' : 'hidden',
}}
>
{children}
</div>,
document.body,
)}
</>
);
}
export default function OverviewUserJourney({
projectId,
}: OverviewUserJourneyProps) {
const { range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [steps, setSteps] = useQueryState(
'journeySteps',
parseAsInteger.withDefault(5).withOptions({ history: 'push' }),
);
const containerRef = useRef<HTMLDivElement>(null);
const trpc = useTRPC();
const query = useQuery(
trpc.overview.userJourney.queryOptions({
projectId,
filters,
startDate,
endDate,
range,
steps: steps ?? 5,
}),
);
const data = query.data;
const number = useNumber();
// Process data for Sankey - nodes are already sorted by step then value from backend
const sankeyData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
return {
nodes: data.nodes.map((node: any) => ({
...node,
// Store label for display in tooltips
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;
// Total sessions used by backend for percentages is the sum of entry nodes (step 1).
// Fall back to summing all nodes if step is missing for some reason.
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]);
const stepOptions = [3, 5];
const { appTheme } = useTheme();
return (
<Widget className="col-span-6">
<WidgetHead>
<div className="title">User Journey</div>
<WidgetButtons>
{stepOptions.map((option) => (
<button
type="button"
key={option}
onClick={() => setSteps(option)}
className={cn((steps ?? 5) === option && 'active')}
>
{option} Steps
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
{query.isLoading ? (
<div className="flex items-center justify-center h-96">
<div className="text-sm text-muted-foreground">Loading...</div>
</div>
) : sankeyData.nodes.length === 0 ? (
<div className="flex items-center justify-center h-96">
<div className="text-sm text-muted-foreground">
No journey data available
</div>
</div>
) : (
<div
ref={containerRef}
className="w-full relative aspect-square md:aspect-[2]"
>
<ResponsiveSankey
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
data={sankeyData}
colors={(node: any) => 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 (
<SankeyPortalTooltip>
<ChartTooltipContainer className="min-w-[250px]">
<ChartTooltipHeader>
<div className="min-w-0 flex-1 font-medium break-words">
<span className="opacity-40 mr-1">
{showDomain(label)}
</span>
{showPath(label)}
</div>
{typeof step === 'number' && (
<div className="shrink-0 text-muted-foreground">
Step {step}
</div>
)}
</ChartTooltipHeader>
<ChartTooltipItem color={color} innerClassName="gap-2">
<div className="flex items-center justify-between gap-8 font-mono font-medium">
<div className="text-muted-foreground">Sessions</div>
<div>{number.format(value)}</div>
</div>
<div className="flex items-center justify-between gap-8 font-mono font-medium">
<div className="text-muted-foreground">Share</div>
<div>{number.format(round(pct, 1))} %</div>
</div>
</ChartTooltipItem>
</ChartTooltipContainer>
</SankeyPortalTooltip>
);
}}
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';
const sourceDomain = showDomain(sourceLabel);
const targetDomain = showDomain(targetLabel);
const isSameDomain = sourceDomain === targetDomain;
return (
<SankeyPortalTooltip>
<ChartTooltipContainer>
<ChartTooltipHeader>
<div className="min-w-0 flex-1 font-medium break-words">
<span className="opacity-40 mr-1">
{showDomain(sourceLabel)}
</span>
{showPath(sourceLabel)}
<ArrowRightIcon className="size-2 inline-block mx-3" />
{!isSameDomain && (
<span className="opacity-40 mr-1">
{showDomain(targetLabel)}
</span>
)}
{showPath(targetLabel)}
</div>
{typeof sourceStep === 'number' &&
typeof targetStep === 'number' && (
<div className="shrink-0 text-muted-foreground">
{sourceStep} {targetStep}
</div>
)}
</ChartTooltipHeader>
<ChartTooltipItem color={color} innerClassName="gap-2">
<div className="flex items-center justify-between gap-8 font-mono font-medium">
<div className="text-muted-foreground">Sessions</div>
<div>{number.format(value)}</div>
</div>
<div className="flex items-center justify-between gap-8 font-mono text-sm">
<div className="text-muted-foreground">
% of total
</div>
<div>{number.format(round(pctOfTotal, 1))} %</div>
</div>
<div className="flex items-center justify-between gap-8 font-mono text-sm">
<div className="text-muted-foreground">
% of source
</div>
<div>{number.format(round(pctOfSource, 1))} %</div>
</div>
</ChartTooltipItem>
</ChartTooltipContainer>
</SankeyPortalTooltip>
);
}}
label={(node: any) => {
const label = showPath(
node.data?.label || node.label || node.id,
);
return truncate(label, 30, 'middle');
}}
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
nodeSpacing={10}
/>
</div>
)}
</WidgetBody>
<WidgetFooter>
<div className="text-xs text-muted-foreground">
Shows the most common paths users take through your application
</div>
</WidgetFooter>
</Widget>
);
}

View File

@@ -1,54 +0,0 @@
import { LineChartIcon, TableIcon } from 'lucide-react';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { Button } from '../ui/button';
type ViewType = 'table' | 'chart';
interface OverviewViewToggleProps {
defaultView?: ViewType;
className?: string;
}
export function OverviewViewToggle({
defaultView = 'table',
className,
}: OverviewViewToggleProps) {
const [view, setView] = useQueryState<ViewType>(
'view',
parseAsStringEnum(['table', 'chart'])
.withDefault(defaultView)
.withOptions({ history: 'push' }),
);
return (
<div className={className}>
<Button
size="icon"
variant="ghost"
onClick={() => {
setView(view === 'table' ? 'chart' : 'table');
}}
title={view === 'table' ? 'Switch to chart view' : 'Switch to table view'}
>
{view === 'table' ? (
<LineChartIcon size={16} />
) : (
<TableIcon size={16} />
)}
</Button>
</div>
);
}
export function useOverviewView() {
const [view, setView] = useQueryState<ViewType>(
'view',
parseAsStringEnum(['table', 'chart'])
.withDefault('table')
.withOptions({ history: 'push' }),
);
return [view, setView] as const;
}

View File

@@ -61,7 +61,7 @@ export const OverviewWidgetTable = <T,>({
<WidgetTable
data={data ?? []}
keyExtractor={keyExtractor}
className={'text-sm min-h-[358px] @container'}
className={'text-sm min-h-[358px] @container [&_.head]:pt-3'}
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
eachRow={(item) => {
return (
@@ -109,6 +109,15 @@ export function OverviewWidgetTableLoading({
render: () => <Skeleton className="h-4 w-1/3" />,
width: 'w-full',
},
{
name: 'BR',
render: () => <Skeleton className="h-4 w-[30px]" />,
width: '60px',
},
// {
// name: 'Duration',
// render: () => <Skeleton className="h-4 w-[30px]" />,
// },
{
name: 'Sessions',
render: () => <Skeleton className="h-4 w-[30px]" />,
@@ -132,135 +141,6 @@ function getPath(path: string, showDomain = false) {
}
export function OverviewWidgetTablePages({
data,
className,
showDomain = false,
}: {
className?: string;
data: {
origin: string;
path: string;
sessions: number;
pageviews: 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), 0);
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.path + item.origin}
getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[
{
name: 'Path',
width: 'w-full',
responsive: { priority: 1 }, // Always visible
render(item) {
return (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter('path', item.path);
setFilter('origin', item.origin);
}}
>
{item.path ? (
<>
{showDomain ? (
<>
<span className="opacity-40">{item.origin}</span>
<span>{item.path}</span>
</>
) : (
item.path
)}
</>
) : (
<span className="opacity-40">Not set</span>
)}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
</a>
</div>
</Tooltiper>
);
},
},
...(hasRevenue
? [
{
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
totalRevenue > 0 ? revenue / totalRevenue : 0;
return (
<div className="row gap-2 items-center justify-end">
<span
className="font-semibold"
style={{ color: '#3ba974' }}
>
{revenue > 0 ? number.currency(revenue / 100) : '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
);
},
} as const,
]
: []),
{
name: 'Views',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.pageviews)}
</span>
</div>
);
},
},
{
name: 'Sess.',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.sessions)}
</span>
</div>
);
},
},
]}
/>
);
}
export function OverviewWidgetTableEntries({
data,
lastColumnName,
className,
@@ -271,17 +151,18 @@ export function OverviewWidgetTableEntries({
data: {
origin: string;
path: string;
avg_duration: number;
bounce_rate: number;
sessions: number;
pageviews: number;
revenue?: 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), 0);
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
const hasRevenue = data.some((item) => item.revenue > 0);
return (
<OverviewWidgetTable
className={className}
@@ -333,6 +214,22 @@ export function OverviewWidgetTableEntries({
);
},
},
{
name: 'BR',
width: '60px',
responsive: { priority: 6 }, // Hidden when space is tight
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
},
},
{
name: 'Duration',
width: '75px',
responsive: { priority: 7 }, // Hidden when space is tight
render(item) {
return number.shortWithUnit(item.avg_duration, 'min');
},
},
...(hasRevenue
? [
{
@@ -340,16 +237,17 @@ export function OverviewWidgetTableEntries({
width: '100px',
responsive: { priority: 3 }, // Always show if possible
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
totalRevenue > 0 ? revenue / totalRevenue : 0;
totalRevenue > 0 ? item.revenue / totalRevenue : 0;
return (
<div className="row gap-2 items-center justify-end">
<span
className="font-semibold"
style={{ color: '#3ba974' }}
>
{revenue > 0 ? number.currency(revenue / 100) : '-'}
{item.revenue > 0
? number.currency(item.revenue / 100)
: '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
@@ -475,7 +373,6 @@ export function OverviewWidgetTableGeneric({
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);
const hasPageviews = data.some((item) => item.pageviews > 0);
return (
<OverviewWidgetTable
className={className}
@@ -488,12 +385,27 @@ export function OverviewWidgetTableGeneric({
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, '%');
},
},
// {
// name: 'Duration',
// render(item) {
// return number.shortWithUnit(item.avg_session_duration, 'min');
// },
// },
...(hasRevenue
? [
{
name: 'Revenue',
width: '100px',
responsive: { priority: 3 },
responsive: { priority: 3 }, // Always show if possible
render(item: RouterOutputs['overview']['topGeneric'][number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
@@ -515,28 +427,10 @@ export function OverviewWidgetTableGeneric({
} as const,
]
: []),
...(hasPageviews
? [
{
name: 'Views',
width: '84px',
responsive: { priority: 2 },
render(item: RouterOutputs['overview']['topGeneric'][number]) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.pageviews)}
</span>
</div>
);
},
} as const,
]
: []),
{
name: 'Sess.',
name: 'Sessions',
width: '84px',
responsive: { priority: 2 },
responsive: { priority: 2 }, // Always show if possible
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -551,65 +445,3 @@ export function OverviewWidgetTableGeneric({
/>
);
}
export type EventTableItem = {
id: string;
name: string;
count: number;
};
export function OverviewWidgetTableEvents({
data,
className,
onItemClick,
}: {
className?: string;
data: EventTableItem[];
onItemClick?: (name: string) => void;
}) {
const number = useNumber();
const maxCount = Math.max(...data.map((item) => item.count), 1);
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.id}
getColumnPercentage={(item) => item.count / maxCount}
columns={[
{
name: 'Event',
width: 'w-full',
responsive: { priority: 1 },
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.name} />
<button
type="button"
className="truncate"
onClick={() => onItemClick?.(item.name)}
>
{item.name || 'Not set'}
</button>
</div>
);
},
},
{
name: 'Count',
width: '84px',
responsive: { priority: 2 },
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.count)}
</span>
</div>
);
},
},
]}
/>
);
}

View File

@@ -1,8 +1,8 @@
import { useThrottle } from '@/hooks/use-throttle';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon, type LucideIcon, SearchIcon } from 'lucide-react';
import { ChevronsUpDownIcon, Icon, type LucideIcon } from 'lucide-react';
import { last } from 'ramda';
import { Children, useCallback, useEffect, useRef, useState } from 'react';
import { Children, useEffect, useRef, useState } from 'react';
import {
DropdownMenu,
@@ -11,7 +11,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Input } from '../ui/input';
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
import { WidgetHead as WidgetHeadBase } from '../widget';
@@ -170,128 +169,6 @@ export function WidgetButtons({
);
}
interface WidgetTab<T extends string = string> {
key: T;
label: string;
}
interface WidgetHeadSearchableProps<T extends string = string> {
tabs: WidgetTab<T>[];
activeTab: T;
className?: string;
onTabChange: (key: T) => void;
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
}
export function WidgetHeadSearchable<T extends string>({
tabs,
className,
activeTab,
onTabChange,
searchValue,
onSearchChange,
searchPlaceholder = 'Search',
}: WidgetHeadSearchableProps<T>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const updateGradients = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
const hasOverflow = scrollWidth > clientWidth;
setShowLeftGradient(hasOverflow && scrollLeft > 0);
setShowRightGradient(
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateGradients();
el.addEventListener('scroll', updateGradients);
window.addEventListener('resize', updateGradients);
return () => {
el.removeEventListener('scroll', updateGradients);
window.removeEventListener('resize', updateGradients);
};
}, [updateGradients]);
// Update gradients when tabs change
useEffect(() => {
// Use RAF to ensure DOM has updated
requestAnimationFrame(updateGradients);
}, [tabs, updateGradients]);
return (
<div className={cn('border-b border-border', className)}>
{/* Scrollable tabs container */}
<div className="relative">
{/* Left gradient */}
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
showLeftGradient ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Scrollable tabs */}
<div
ref={scrollRef}
className="flex gap-1 overflow-x-auto px-2 py-3 hide-scrollbar"
>
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
className={cn(
'shrink-0 rounded-md py-1.5 text-sm font-medium transition-colors px-2',
activeTab === tab.key
? 'text-foreground'
: 'text-muted-foreground hover:bg-def-100 hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
{/* Right gradient */}
<div
className={cn(
'pointer-events-none absolute right-0 top-0 z-10 bottom-px w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
showRightGradient ? 'opacity-100' : 'opacity-0',
)}
/>
</div>
{/* Search input */}
{onSearchChange && (
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchValue ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 bg-transparent border-0 text-sm rounded-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0 border-y"
/>
</div>
)}
</div>
);
}
export function WidgetFooter({
className,
children,

View File

@@ -33,10 +33,7 @@ export function useOverviewWidget<T extends string>(
export function useOverviewWidgetV2<T extends string>(
key: string,
widgets: Record<
T,
{ title: string; btn: string; meta?: any; hide?: boolean }
>,
widgets: Record<T, { title: string; btn: string; meta?: any }>,
) {
const keys = Object.keys(widgets) as T[];
const [widget, setWidget] = useQueryState<T>(

View File

@@ -2,7 +2,7 @@ import { ReportChart } from '@/components/report-chart';
import { Widget, WidgetBody } from '@/components/widget';
import { memo } from 'react';
import type { IReport } from '@openpanel/validation';
import type { IChartProps } 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: IReport = {
const pageViewsChart: IChartProps = {
projectId,
chartType: 'linear',
series: [
@@ -46,7 +46,7 @@ export const ProfileCharts = memo(
metric: 'sum',
};
const eventsChart: IReport = {
const eventsChart: IChartProps = {
projectId,
chartType: 'linear',
series: [

View File

@@ -16,6 +16,7 @@ import {
YAxis,
} from 'recharts';
import { AnimatedNumber } from '../animated-number';
import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon';
interface RealtimeLiveHistogramProps {
@@ -86,8 +87,10 @@ export function RealtimeLiveHistogram({
<YAxis hide domain={[0, maxDomain]} />
<Bar
dataKey="visitorCount"
className="fill-chart-0"
fill="rgba(59, 121, 255, 0.2)"
isAnimationActive={false}
shape={BarShapeBlue}
activeBar={BarShapeBlue}
/>
</BarChart>
</ResponsiveContainer>

View File

@@ -1,7 +1,6 @@
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';
@@ -10,27 +9,15 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportAreaChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const { isLazyLoading, report } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
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,
},
),
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
);
if (

View File

@@ -4,322 +4,160 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useVisibleSeries } from '@/hooks/use-visible-series';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
import { SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { round } from '@openpanel/common';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { DeltaChip } from '@/components/delta-chip';
import { OverviewWidgetTable } from '../../overview/overview-widget-table';
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
import { useReportChartContext } from '../context';
type SortOption =
| 'count-desc'
| 'count-asc'
| 'name-asc'
| 'name-desc'
| 'percent-desc'
| 'percent-asc';
interface Props {
data: IChartData;
}
export function Chart({ data }: Props) {
const [isOpen, setOpen] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('count-desc');
const {
isEditMode,
report: { metric, limit, previous },
options: { onClick, dropdownMenuContent },
options: { onClick, dropdownMenuContent, columns },
} = useReportChartContext();
const number = useNumber();
const series = useMemo(
() => (isEditMode ? data.series : data.series.slice(0, limit || 10)),
[data, isEditMode, limit],
);
const maxCount = Math.max(
...series.map((serie) => serie.metrics[metric] ?? 0),
);
// Use useVisibleSeries to add index property for colors
const { series: allSeriesWithIndex } = useVisibleSeries(data, 500);
const tableColumns = [
{
name: columns?.[0] || 'Name',
width: 'w-full',
render: (serie: (typeof series)[0]) => {
const isClickable = !serie.names.includes(NOT_SET_VALUE) && onClick;
const isDropDownEnabled =
!serie.names.includes(NOT_SET_VALUE) &&
(dropdownMenuContent?.(serie) || []).length > 0;
const totalSum = data.metrics.sum || 1;
return (
<DropdownMenu
onOpenChange={() =>
setOpen((p) => (p === serie.id ? null : serie.id))
}
open={isOpen === serie.id}
>
<DropdownMenuTrigger
asChild
disabled={!isDropDownEnabled}
{...(isDropDownEnabled
? {
onPointerDown: (e) => e.preventDefault(),
onClick: () => setOpen(serie.id),
}
: {})}
>
<div
className={cn(
'flex items-center gap-2 break-all font-medium',
(isClickable || isDropDownEnabled) && 'cursor-pointer',
)}
{...(isClickable && !isDropDownEnabled
? {
onClick: () => onClick(serie),
}
: {})}
>
<SerieIcon name={serie.names[0]} />
<SerieName name={serie.names} />
</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{dropdownMenuContent?.(serie).map((item) => (
<DropdownMenuItem key={item.title} onClick={item.onClick}>
{item.icon && <item.icon size={16} className="mr-2" />}
{item.title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
},
},
// Percentage column
{
name: '%',
width: '70px',
render: (serie: (typeof series)[0]) => (
<div className="text-muted-foreground font-mono">
{number.format(
round((serie.metrics.sum / data.metrics.sum) * 100, 2),
)}
%
</div>
),
},
// Calculate original ranks (based on count descending - default sort)
const seriesWithOriginalRank = useMemo(() => {
const sortedByCount = [...allSeriesWithIndex].sort(
(a, b) => b.metrics.sum - a.metrics.sum,
);
const rankMap = new Map<string, number>();
sortedByCount.forEach((serie, idx) => {
rankMap.set(serie.id, idx + 1);
});
return allSeriesWithIndex.map((serie) => ({
...serie,
originalRank: rankMap.get(serie.id) ?? 0,
}));
}, [allSeriesWithIndex]);
// Previous value column
{
name: 'Previous',
width: '130px',
render: (serie: (typeof series)[0]) => (
<div className="flex items-center gap-2 font-mono justify-end">
<div className="font-bold">
{number.format(serie.metrics.previous?.[metric]?.value)}
</div>
<PreviousDiffIndicator
{...serie.metrics.previous?.[metric]}
size="xs"
className="text-muted-foreground"
/>
</div>
),
},
// Filter and sort series
const series = useMemo(() => {
let filtered = seriesWithOriginalRank;
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((serie) =>
serie.names.some((name) => name.toLowerCase().includes(query)),
);
}
// Sort
const sorted = [...filtered].sort((a, b) => {
switch (sortBy) {
case 'count-desc':
return b.metrics.sum - a.metrics.sum;
case 'count-asc':
return a.metrics.sum - b.metrics.sum;
case 'name-asc':
return a.names.join(' > ').localeCompare(b.names.join(' > '));
case 'name-desc':
return b.names.join(' > ').localeCompare(a.names.join(' > '));
case 'percent-desc':
return b.metrics.sum / totalSum - a.metrics.sum / totalSum;
case 'percent-asc':
return a.metrics.sum / totalSum - b.metrics.sum / totalSum;
default:
return 0;
}
});
// Apply limit if not in edit mode
return isEditMode ? sorted : sorted.slice(0, limit || 10);
}, [
seriesWithOriginalRank,
searchQuery,
sortBy,
totalSum,
isEditMode,
limit,
]);
// Main count column (always last)
{
name: 'Count',
width: '80px',
render: (serie: (typeof series)[0]) => (
<div className="font-bold font-mono">
{number.format(serie.metrics.sum)}
</div>
),
},
];
return (
<div className={cn('w-full', isEditMode && 'card')}>
{isEditMode && (
<div className="flex items-center gap-3 p-4 border-b border-def-200 dark:border-def-800">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Filter by name"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
size="sm"
/>
</div>
<Select
value={sortBy}
onValueChange={(value) => setSortBy(value as SortOption)}
>
<SelectTrigger size="sm" className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="count-desc">Count (High Low)</SelectItem>
<SelectItem value="count-asc">Count (Low High)</SelectItem>
<SelectItem value="name-asc">Name (A Z)</SelectItem>
<SelectItem value="name-desc">Name (Z A)</SelectItem>
<SelectItem value="percent-desc">
Percentage (High Low)
</SelectItem>
<SelectItem value="percent-asc">
Percentage (Low High)
</SelectItem>
</SelectContent>
</Select>
</div>
<div
className={cn(
'text-sm',
isEditMode ? 'card gap-2 p-4 text-base' : '-m-3',
)}
<div className="overflow-hidden">
<div className="divide-y divide-def-200 dark:divide-def-800">
{series.map((serie, idx) => {
const isClickable =
!serie.names.includes(NOT_SET_VALUE) && !!onClick;
const isDropDownEnabled =
!serie.names.includes(NOT_SET_VALUE) &&
(dropdownMenuContent?.(serie) || []).length > 0;
const color = getChartColor(serie.index);
const percentOfTotal = round(
(serie.metrics.sum / totalSum) * 100,
1,
);
return (
<div
key={serie.id}
className={cn(
'group relative px-4 py-3 transition-colors overflow-hidden',
isClickable && 'cursor-pointer',
)}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={() => {
if (isClickable && !isDropDownEnabled) {
onClick?.(serie);
}
}}
onKeyDown={(e) => {
if (!isClickable || isDropDownEnabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.(serie);
}
}}
>
{/* Subtle accent glow */}
<div
className="pointer-events-none absolute -left-10 -top-10 h-40 w-96 rounded-full opacity-0 blur-3xl transition-opacity duration-500 group-hover:opacity-10"
style={{
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
}}
/>
<div className="relative z-10 flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border bg-def-50 dark:border-def-800 dark:bg-def-900"
style={{ borderColor: `${color}22` }}
>
<SerieIcon name={serie.names[0]} />
</div>
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
Rank {serie.originalRank}
</span>
</div>
<DropdownMenu
onOpenChange={() =>
setOpen((p) => (p === serie.id ? null : serie.id))
}
open={isOpen === serie.id}
>
<DropdownMenuTrigger
asChild
disabled={!isDropDownEnabled}
{...(isDropDownEnabled
? {
onPointerDown: (e) => e.preventDefault(),
onClick: (e) => {
e.stopPropagation();
setOpen(serie.id);
},
}
: {})}
>
<div
className={cn(
'min-w-0',
isDropDownEnabled && 'cursor-pointer',
)}
{...(isClickable && !isDropDownEnabled
? {
onClick: (e) => {
e.stopPropagation();
onClick?.(serie);
},
}
: {})}
>
<SerieName
name={serie.names}
className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold tracking-tight"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{dropdownMenuContent?.(serie).map((item) => (
<DropdownMenuItem
key={item.title}
onClick={(e) => {
e.stopPropagation();
item.onClick();
}}
>
{item.icon && (
<item.icon size={16} className="mr-2" />
)}
{item.title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<div className="flex items-center gap-2">
<div className="text-base font-semibold font-mono tracking-tight">
{number.format(serie.metrics.sum)}
</div>
{previous && serie.metrics.previous?.[metric] && (
<DeltaChip
variant={
serie.metrics.previous[metric].state ===
'positive'
? 'inc'
: 'dec'
}
size="sm"
>
{serie.metrics.previous[metric].diff?.toFixed(1)}%
</DeltaChip>
)}
</div>
</div>
</div>
{/* Bar */}
<div className="flex items-center">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-def-100 dark:bg-def-900">
<div
className="h-full rounded-full transition-[width] duration-700 ease-out"
style={{
width: `${percentOfTotal}%`,
background: `linear-gradient(90deg, ${color}aa, ${color})`,
}}
/>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
>
<OverviewWidgetTable
data={series}
keyExtractor={(serie) => serie.id}
columns={tableColumns.filter((column) => {
if (!previous && column.name === 'Previous') {
return false;
}
return true;
})}
getColumnPercentage={(serie) => serie.metrics.sum / maxCount}
className={cn(isEditMode ? 'min-h-[358px]' : 'min-h-0')}
/>
</div>
);
}

View File

@@ -1,8 +1,6 @@
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';
import { ReportChartError } from '../common/error';
@@ -10,27 +8,15 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportBarChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const { isLazyLoading, report } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
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,
},
),
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
);
if (
@@ -40,6 +26,7 @@ export function ReportBarChart() {
) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
@@ -52,62 +39,22 @@ export function ReportBarChart() {
}
function Loading() {
const { isEditMode } = useReportChartContext();
return (
<div className={cn('w-full', isEditMode && 'card')}>
<div className="overflow-hidden">
<div className="divide-y divide-def-200 dark:divide-def-800">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index as number}
className="relative px-4 py-3 animate-pulse"
>
<div className="relative z-10 flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
{/* Icon skeleton */}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border bg-def-100 dark:border-def-800 dark:bg-def-900" />
<div className="min-w-0">
{/* Rank badge skeleton */}
<div className="mb-1 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-def-200 dark:bg-def-700" />
<div className="h-2 w-12 rounded bg-def-200 dark:bg-def-700" />
</div>
{/* Name skeleton */}
<div
className="h-4 rounded bg-def-200 dark:bg-def-700"
style={{
width: `${Math.random() * 100 + 100}px`,
}}
/>
</div>
</div>
{/* Count skeleton */}
<div className="flex shrink-0 flex-col items-end gap-1">
<div className="h-5 w-16 rounded bg-def-200 dark:bg-def-700" />
</div>
</div>
{/* Bar skeleton */}
<div className="flex items-center">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-def-100 dark:bg-def-900">
<div
className="h-full rounded-full bg-def-200 dark:bg-def-700"
style={{
width: `${Math.random() * 60 + 20}%`,
}}
/>
</div>
</div>
</div>
</div>
))}
<AspectContainer className="col gap-4 overflow-hidden">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index as number}
className="row animate-pulse justify-between"
>
<div className="h-4 w-2/5 rounded bg-def-200" />
<div className="row w-1/5 gap-2">
<div className="h-4 w-full rounded bg-def-200" />
<div className="h-4 w-full rounded bg-def-200" />
<div className="h-4 w-full rounded bg-def-200" />
</div>
</div>
</div>
</div>
))}
</AspectContainer>
);
}

View File

@@ -32,7 +32,7 @@ export function ReportChartEmpty({
</div>
<ForkliftIcon
strokeWidth={1.2}
className="mb-4 size-1/3 max-w-40 animate-pulse text-muted-foreground"
className="mb-4 size-1/3 animate-pulse text-muted-foreground"
/>
<div className="font-medium text-muted-foreground">
Ready when you're

View File

@@ -2,7 +2,6 @@ import { useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
import { DeltaChip } from '@/components/delta-chip';
import { useReportChartContext } from '../context';
export function getDiffIndicator<A, B, C>(
@@ -30,7 +29,7 @@ interface PreviousDiffIndicatorProps {
children?: React.ReactNode;
inverted?: boolean;
className?: string;
size?: 'sm' | 'lg' | 'md';
size?: 'sm' | 'lg' | 'md' | 'xs';
}
export function PreviousDiffIndicator({
@@ -42,10 +41,10 @@ export function PreviousDiffIndicator({
className,
}: PreviousDiffIndicatorProps) {
const {
report: { previous },
report: { previousIndicatorInverted, previous },
} = useReportChartContext();
const variant = getDiffIndicator(
inverted,
inverted ?? previousIndicatorInverted,
state,
'bg-emerald-300',
'bg-rose-300',
@@ -82,6 +81,7 @@ export function PreviousDiffIndicator({
variant,
size === 'lg' && 'size-8',
size === 'md' && 'size-6',
size === 'xs' && 'size-3',
)}
>
{renderIcon()}
@@ -97,7 +97,7 @@ interface PreviousDiffIndicatorPureProps {
diff?: number | null | undefined;
state?: string | null | undefined;
inverted?: boolean;
size?: 'sm' | 'lg' | 'md';
size?: 'sm' | 'lg' | 'md' | 'xs';
className?: string;
showPrevious?: boolean;
}
@@ -133,35 +133,25 @@ export function PreviousDiffIndicatorPure({
};
return (
<DeltaChip
variant={state === 'positive' ? 'inc' : 'dec'}
size={size}
inverted={inverted}
<div
className={cn(
'flex items-center gap-1 font-mono font-medium',
size === 'lg' && 'gap-2',
className,
)}
>
<div
className={cn(
'flex size-2.5 items-center justify-center rounded-full',
variant,
size === 'lg' && 'size-8',
size === 'md' && 'size-6',
size === 'xs' && 'size-3',
)}
>
{renderIcon()}
</div>
{diff.toFixed(1)}%
</DeltaChip>
</div>
);
// return (
// <div
// className={cn(
// 'flex items-center gap-1 font-mono font-medium',
// size === 'lg' && 'gap-2',
// className,
// )}
// >
// <div
// className={cn(
// 'flex size-2.5 items-center justify-center rounded-full',
// variant,
// size === 'lg' && 'size-8',
// size === 'md' && 'size-6',
// size === 'xs' && 'size-3',
// )}
// >
// {renderIcon()}
// </div>
// {diff.toFixed(1)}%
// </div>
// );
}

View File

@@ -30,7 +30,6 @@ const data = {
whale: 'https://whale.naver.com',
wechat: 'https://wechat.com',
chrome: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'mobile chrome': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome webview': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome headless': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
chromecast: 'https://upload.wikimedia.org/wikipedia/commons/archive/2/26/20161130022732%21Chromecast_cast_button_icon.svg',
@@ -40,7 +39,6 @@ const data = {
edge: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png',
facebook: 'https://facebook.com',
firefox: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png',
'mobile firefox': 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png',
github: 'https://github.com',
gmail: 'https://mail.google.com',
google: 'https://google.com',

View File

@@ -2,11 +2,16 @@ import isEqual from 'lodash.isequal';
import type { LucideIcon } from 'lucide-react';
import { createContext, useContext, useEffect, useState } from 'react';
import type { IChartSerie, IReportInput } from '@openpanel/validation';
import type {
IChartInput,
IChartProps,
IChartSerie,
} from '@openpanel/validation';
export type ReportChartContextType = {
options: Partial<{
columns: React.ReactNode[];
hideID: boolean;
hideLegend: boolean;
hideXAxis: boolean;
hideYAxis: boolean;
@@ -23,11 +28,9 @@ export type ReportChartContextType = {
onClick: () => void;
}[];
}>;
report: IReportInput & { id?: string };
report: IChartProps;
isLazyLoading: boolean;
isEditMode: boolean;
shareId?: string;
reportId?: string;
};
type ReportChartContextProviderProps = ReportChartContextType & {
@@ -35,7 +38,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
};
export type ReportChartProps = Partial<ReportChartContextType> & {
report: IReportInput & { id?: string };
report: IChartInput;
lazy?: boolean;
};
@@ -51,6 +54,20 @@ export const useReportChartContext = () => {
return ctx;
};
export const useSelectReportChartContext = <T,>(
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

View File

@@ -1,7 +1,6 @@
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';
@@ -12,27 +11,15 @@ import { Chart } from './chart';
import { Summary } from './summary';
export function ReportConversionChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const { isLazyLoading, report } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
console.log(report.limit);
const res = useQuery(
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,
},
),
trpc.chart.conversion.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
);
if (

View File

@@ -131,36 +131,34 @@ export function Tables({
series: reportSeries,
breakdowns: reportBreakdowns,
previous,
options,
funnelWindow,
funnelGroup,
},
} = 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',
options: funnelOptions,
},
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',
funnelWindow,
funnelGroup,
},
stepIndex, // Pass the step index for funnel queries
});
};
return (
<div className={cn('col @container divide-y divide-border card')}>

View File

@@ -2,8 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import type { IReportInput } from '@openpanel/validation';
import type { IChartInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -15,39 +14,35 @@ import { Chart, Summary, Tables } from './chart';
export function ReportFunnelChart() {
const {
report: {
id,
series,
range,
projectId,
options,
funnelWindow,
funnelGroup,
startDate,
endDate,
previous,
breakdowns,
interval,
},
isLazyLoading,
shareId,
} = useReportChartContext();
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const trpc = useTRPC();
const input: IReportInput = {
const input: IChartInput = {
series,
range: overviewRange ?? range,
range,
projectId,
interval: overviewInterval ?? interval ?? 'day',
interval: 'day',
chartType: 'funnel',
breakdowns,
funnelWindow,
funnelGroup,
previous,
metric: 'sum',
startDate: overviewStartDate ?? startDate,
endDate: overviewEndDate ?? endDate,
startDate,
endDate,
limit: 20,
options: funnelOptions,
};
const trpc = useTRPC();
const res = useQuery(
trpc.chart.funnel.queryOptions(input, {
enabled: !isLazyLoading && input.series.length > 0,

View File

@@ -1,7 +1,6 @@
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';
@@ -10,27 +9,15 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportHistogramChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const { isLazyLoading, report } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
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,
},
),
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
);
if (

View File

@@ -15,7 +15,6 @@ 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<HTMLDivElement>(null);
@@ -58,8 +57,6 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
return <ReportRetentionChart />;
case 'conversion':
return <ReportConversionChart />;
case 'sankey':
return <ReportSankeyChart />;
default:
return null;
}

View File

@@ -2,7 +2,6 @@ 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';
@@ -11,27 +10,15 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportLineChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const { isLazyLoading, report } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
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,
},
),
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
);
if (

Some files were not shown because too many files have changed in this diff Show More