Compare commits
21 Commits
feature/op
...
api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1ce71ffb6 | ||
|
|
20665789e1 | ||
|
|
2fb993fae5 | ||
|
|
b467a6ce7f | ||
|
|
b88b2844b3 | ||
|
|
ddc1b75b58 | ||
|
|
7239c59342 | ||
|
|
a82069c28c | ||
|
|
bca07ae0d7 | ||
|
|
21e51daa5f | ||
|
|
729722bf85 | ||
|
|
a8481a213f | ||
|
|
6287cb7958 | ||
|
|
ebc07e3a16 | ||
|
|
11e9ecac1a | ||
|
|
88a2d876ce | ||
|
|
d1b39c4c93 | ||
|
|
33431510b4 | ||
|
|
5557db83a6 | ||
|
|
eab33d3127 | ||
|
|
4483e464d1 |
@@ -30,7 +30,6 @@
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"groupmq": "catalog:",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/trpc": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
@@ -40,6 +39,7 @@
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-metrics": "^12.1.0",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"groupmq": "catalog:",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"sharp": "^0.33.5",
|
||||
|
||||
@@ -63,6 +63,7 @@ async function main() {
|
||||
imported_at: null,
|
||||
sdk_name: 'test-script',
|
||||
sdk_version: '1.0.0',
|
||||
groups: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import bots from './bots';
|
||||
|
||||
// Pre-compile regex patterns at module load time
|
||||
@@ -15,7 +15,7 @@ const compiledBots = bots.map((bot) => {
|
||||
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
|
||||
const includesBots = compiledBots.filter((bot) => 'includes' in bot);
|
||||
|
||||
export const isBot = cacheableLru(
|
||||
export const isBot = cacheable(
|
||||
'is-bot',
|
||||
(ua: string) => {
|
||||
// Check simple string patterns first (fast)
|
||||
@@ -40,8 +40,5 @@ export const isBot = cacheableLru(
|
||||
|
||||
return null;
|
||||
},
|
||||
{
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
60 * 5
|
||||
);
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import {
|
||||
eventBuffer,
|
||||
getProfileById,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { eventBuffer } from '@openpanel/db';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
import {
|
||||
psubscribeToPublishedEvent,
|
||||
subscribeToPublishedEvent,
|
||||
} from '@openpanel/redis';
|
||||
import { subscribeToPublishedEvent } from '@openpanel/redis';
|
||||
import { getProjectAccess } from '@openpanel/trpc';
|
||||
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
|
||||
|
||||
export function getLiveEventInfo(key: string) {
|
||||
return key.split(':').slice(2) as [string, string];
|
||||
}
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export function wsVisitors(
|
||||
socket: WebSocket,
|
||||
@@ -25,32 +12,32 @@ export function wsVisitors(
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => {
|
||||
if (event?.projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
const sendCount = () => {
|
||||
eventBuffer
|
||||
.getActiveVisitorCount(params.projectId)
|
||||
.then((count) => {
|
||||
socket.send(String(count));
|
||||
})
|
||||
.catch(() => {
|
||||
socket.send('0');
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const punsubscribe = psubscribeToPublishedEvent(
|
||||
'__keyevent@0__:expired',
|
||||
(key) => {
|
||||
const [projectId] = getLiveEventInfo(key);
|
||||
if (projectId && projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
socket.send(String(count));
|
||||
});
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'events',
|
||||
'batch',
|
||||
({ projectId }) => {
|
||||
if (projectId === params.projectId) {
|
||||
sendCount();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => {
|
||||
unsubscribe();
|
||||
punsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,18 +49,10 @@ export async function wsProjectEvents(
|
||||
};
|
||||
Querystring: {
|
||||
token?: string;
|
||||
type?: 'saved' | 'received';
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params, query } = req;
|
||||
const type = query.type || 'saved';
|
||||
|
||||
if (!['saved', 'received'].includes(type)) {
|
||||
socket.send('Invalid type');
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
const { params } = req;
|
||||
|
||||
const userId = req.session?.userId;
|
||||
if (!userId) {
|
||||
@@ -87,24 +66,20 @@ export async function wsProjectEvents(
|
||||
projectId: params.projectId,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
socket.send('No access');
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'events',
|
||||
type,
|
||||
async (event) => {
|
||||
if (event.projectId === params.projectId) {
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
socket.send(
|
||||
superjson.stringify(
|
||||
access
|
||||
? {
|
||||
...event,
|
||||
profile,
|
||||
}
|
||||
: transformMinimalEvent(event),
|
||||
),
|
||||
);
|
||||
'batch',
|
||||
({ projectId, count }) => {
|
||||
if (projectId === params.projectId) {
|
||||
socket.send(setSuperJson({ count }));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
@@ -116,7 +91,7 @@ export async function wsProjectNotifications(
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
@@ -143,9 +118,9 @@ export async function wsProjectNotifications(
|
||||
'created',
|
||||
(notification) => {
|
||||
if (notification.projectId === params.projectId) {
|
||||
socket.send(superjson.stringify(notification));
|
||||
socket.send(setSuperJson(notification));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
@@ -157,7 +132,7 @@ export async function wsOrganizationEvents(
|
||||
Params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
@@ -184,7 +159,7 @@ export async function wsOrganizationEvents(
|
||||
'subscription_updated',
|
||||
(message) => {
|
||||
socket.send(setSuperJson(message));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { stripTrailingSlash } from '@openpanel/common';
|
||||
import { hashPassword } from '@openpanel/common/server';
|
||||
import {
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
} from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
|
||||
// Validation schemas
|
||||
const zCreateProject = z.object({
|
||||
@@ -57,7 +57,7 @@ const zUpdateReference = z.object({
|
||||
// Projects CRUD
|
||||
export async function listProjects(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
@@ -74,7 +74,7 @@ export async function listProjects(
|
||||
|
||||
export async function getProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
@@ -92,7 +92,7 @@ export async function getProject(
|
||||
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zCreateProject.safeParse(request.body);
|
||||
|
||||
@@ -139,12 +139,9 @@ export async function createProject(
|
||||
},
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
project.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
...project.clients.map((client) => getClientByIdCached.clear(client.id)),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
@@ -165,7 +162,7 @@ export async function updateProject(
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateProject>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zUpdateProject.safeParse(request.body);
|
||||
|
||||
@@ -223,12 +220,9 @@ export async function updateProject(
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
existing.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
|
||||
]);
|
||||
|
||||
reply.send({ data: project });
|
||||
@@ -236,7 +230,7 @@ export async function updateProject(
|
||||
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
@@ -266,7 +260,7 @@ export async function deleteProject(
|
||||
// Clients CRUD
|
||||
export async function listClients(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const where: any = {
|
||||
organizationId: request.client!.organizationId,
|
||||
@@ -300,7 +294,7 @@ export async function listClients(
|
||||
|
||||
export async function getClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
@@ -318,7 +312,7 @@ export async function getClient(
|
||||
|
||||
export async function createClient(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zCreateClient.safeParse(request.body);
|
||||
|
||||
@@ -374,7 +368,7 @@ export async function updateClient(
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateClient>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zUpdateClient.safeParse(request.body);
|
||||
|
||||
@@ -417,7 +411,7 @@ export async function updateClient(
|
||||
|
||||
export async function deleteClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
@@ -444,7 +438,7 @@ export async function deleteClient(
|
||||
// References CRUD
|
||||
export async function listReferences(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const where: any = {};
|
||||
|
||||
@@ -488,7 +482,7 @@ export async function listReferences(
|
||||
|
||||
export async function getReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
@@ -516,7 +510,7 @@ export async function getReference(
|
||||
|
||||
export async function createReference(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zCreateReference.safeParse(request.body);
|
||||
|
||||
@@ -559,7 +553,7 @@ export async function updateReference(
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateReference>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zUpdateReference.safeParse(request.body);
|
||||
|
||||
@@ -616,7 +610,7 @@ export async function updateReference(
|
||||
|
||||
export async function deleteReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -3,14 +3,20 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import {
|
||||
getProfileById,
|
||||
getSalts,
|
||||
groupBuffer,
|
||||
replayBuffer,
|
||||
upsertProfile,
|
||||
} from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import {
|
||||
type EventsQueuePayloadIncomingEvent,
|
||||
getEventsGroupQueueShard,
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import {
|
||||
type IAssignGroupPayload,
|
||||
type IDecrementPayload,
|
||||
type IGroupPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type IReplayPayload,
|
||||
@@ -112,6 +118,7 @@ interface TrackContext {
|
||||
identity?: IIdentifyPayload;
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
|
||||
geo: GeoLocation;
|
||||
}
|
||||
|
||||
@@ -141,19 +148,21 @@ async function buildContext(
|
||||
validatedBody.payload.profileId = profileId;
|
||||
}
|
||||
|
||||
const overrideDeviceId =
|
||||
validatedBody.type === 'track' &&
|
||||
typeof validatedBody.payload?.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload?.properties.__deviceId
|
||||
: undefined;
|
||||
|
||||
// Get geo location (needed for track and identify)
|
||||
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
|
||||
|
||||
const { deviceId, sessionId } = await getDeviceId({
|
||||
const deviceIdResult = await getDeviceId({
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
salts,
|
||||
overrideDeviceId:
|
||||
validatedBody.type === 'track' &&
|
||||
typeof validatedBody.payload?.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload?.properties.__deviceId
|
||||
: undefined,
|
||||
overrideDeviceId,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -166,8 +175,9 @@ async function buildContext(
|
||||
isFromPast: timestamp.isTimestampFromThePast,
|
||||
},
|
||||
identity,
|
||||
deviceId,
|
||||
sessionId,
|
||||
deviceId: deviceIdResult.deviceId,
|
||||
sessionId: deviceIdResult.sessionId,
|
||||
session: deviceIdResult.session,
|
||||
geo,
|
||||
};
|
||||
}
|
||||
@@ -176,13 +186,14 @@ async function handleTrack(
|
||||
payload: ITrackPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { projectId, deviceId, geo, headers, timestamp, sessionId } = context;
|
||||
const { projectId, deviceId, geo, headers, timestamp, sessionId, session } =
|
||||
context;
|
||||
|
||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
? payload.profileId
|
||||
? `${projectId}:${payload.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: undefined
|
||||
: deviceId;
|
||||
const jobId = [
|
||||
slug(payload.name),
|
||||
@@ -203,13 +214,14 @@ async function handleTrack(
|
||||
}
|
||||
|
||||
promises.push(
|
||||
getEventsGroupQueueShard(groupId).add({
|
||||
getEventsGroupQueueShard(groupId || generateId()).add({
|
||||
orderMs: timestamp.value,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
groups: payload.groups ?? [],
|
||||
timestamp: timestamp.value,
|
||||
isTimestampFromThePast: timestamp.isFromPast,
|
||||
},
|
||||
@@ -217,6 +229,7 @@ async function handleTrack(
|
||||
geo,
|
||||
deviceId,
|
||||
sessionId,
|
||||
session,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
@@ -324,6 +337,36 @@ async function handleReplay(
|
||||
await replayBuffer.add(row);
|
||||
}
|
||||
|
||||
async function handleGroup(
|
||||
payload: IGroupPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { id, type, name, properties = {} } = payload;
|
||||
await groupBuffer.add({
|
||||
id,
|
||||
projectId: context.projectId,
|
||||
type,
|
||||
name,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAssignGroup(
|
||||
payload: IAssignGroupPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const profileId = payload.profileId ?? context.deviceId;
|
||||
if (!profileId) {
|
||||
return;
|
||||
}
|
||||
await upsertProfile({
|
||||
id: String(profileId),
|
||||
projectId: context.projectId,
|
||||
isExternal: !!payload.profileId,
|
||||
groups: payload.groupIds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
@@ -372,6 +415,12 @@ export async function handler(
|
||||
case 'replay':
|
||||
await handleReplay(validatedBody.payload, context);
|
||||
break;
|
||||
case 'group':
|
||||
await handleGroup(validatedBody.payload, context);
|
||||
break;
|
||||
case 'assign_group':
|
||||
await handleAssignGroup(validatedBody.payload, context);
|
||||
break;
|
||||
default:
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { isBot } from '@/bots';
|
||||
import { createBotEvent } from '@openpanel/db';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isBot } from '@/bots';
|
||||
|
||||
export async function isBotHook(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const bot = req.headers['user-agent']
|
||||
? isBot(req.headers['user-agent'])
|
||||
? await isBot(req.headers['user-agent'])
|
||||
: null;
|
||||
|
||||
if (bot && req.client?.projectId) {
|
||||
@@ -44,6 +43,6 @@ export async function isBotHook(
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(202).send();
|
||||
return reply.status(202).send({ bot });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
@@ -13,7 +12,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
handler: handler,
|
||||
handler,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { generateDeviceId } from '@openpanel/common/server';
|
||||
import { getSafeJson } from '@openpanel/json';
|
||||
import type {
|
||||
EventsQueuePayloadCreateSessionEnd,
|
||||
EventsQueuePayloadIncomingEvent,
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import { pick } from 'ramda';
|
||||
|
||||
export async function getDeviceId({
|
||||
projectId,
|
||||
@@ -37,14 +42,20 @@ export async function getDeviceId({
|
||||
ua,
|
||||
});
|
||||
|
||||
return await getDeviceIdFromSession({
|
||||
return await getInfoFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function getDeviceIdFromSession({
|
||||
interface DeviceIdResult {
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
|
||||
}
|
||||
|
||||
async function getInfoFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
@@ -52,7 +63,7 @@ async function getDeviceIdFromSession({
|
||||
projectId: string;
|
||||
currentDeviceId: string;
|
||||
previousDeviceId: string;
|
||||
}) {
|
||||
}): Promise<DeviceIdResult> {
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.hget(
|
||||
@@ -65,21 +76,33 @@ async function getDeviceIdFromSession({
|
||||
);
|
||||
const res = await multi.exec();
|
||||
if (res?.[0]?.[1]) {
|
||||
const data = getSafeJson<{ payload: { sessionId: string } }>(
|
||||
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
|
||||
(res?.[0]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
const sessionId = data.payload.sessionId;
|
||||
return { deviceId: currentDeviceId, sessionId };
|
||||
return {
|
||||
deviceId: currentDeviceId,
|
||||
sessionId: data.payload.sessionId,
|
||||
session: pick(
|
||||
['referrer', 'referrerName', 'referrerType'],
|
||||
data.payload
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (res?.[1]?.[1]) {
|
||||
const data = getSafeJson<{ payload: { sessionId: string } }>(
|
||||
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
|
||||
(res?.[1]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
const sessionId = data.payload.sessionId;
|
||||
return { deviceId: previousDeviceId, sessionId };
|
||||
return {
|
||||
deviceId: previousDeviceId,
|
||||
sessionId: data.payload.sessionId,
|
||||
session: pick(
|
||||
['referrer', 'referrerName', 'referrerType'],
|
||||
data.payload
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
date: 2025-07-18
|
||||
title: "13 Best Mixpanel Alternatives & Competitors in 2026"
|
||||
description: "Compare the best Mixpanel alternatives for product analytics in 2026. Side-by-side pricing, features, and privacy comparison of 7 top tools plus 6 honorable mentions — including open source and free options."
|
||||
title: "13 Best Product Analytics Tools in 2026 (Ranked & Compared)"
|
||||
description: "Compare the best product analytics tools in 2026. Side-by-side pricing, features, and privacy comparison of 13 platforms — including open source, self-hosted, and free options for every team size."
|
||||
updated: 2026-02-16
|
||||
tag: Comparison
|
||||
team: OpenPanel Team
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Best Amplitude Alternatives 2026 - Open Source, Free & Paid",
|
||||
"description": "Compare the best Amplitude alternatives in 2026: OpenPanel, PostHog, Mixpanel, Heap, and Plausible. Open source, privacy-first, and affordable options for every team size. See which fits you best.",
|
||||
"description": "Compare the best Amplitude alternatives in 2026: OpenPanel, PostHog, Heap, and Plausible. Open source, privacy-first, and affordable options for every team size. See which fits you best.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
@@ -47,7 +47,7 @@
|
||||
"Large enterprises with dedicated analytics teams",
|
||||
"Organizations that need advanced experimentation and feature flags",
|
||||
"Teams requiring sophisticated behavioral cohorts and predictive analytics",
|
||||
"Companies wanting an all-in-one platform with session replay and guides"
|
||||
"Companies wanting an all-in-one platform with guides, surveys, and advanced experimentation"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
@@ -184,9 +184,9 @@
|
||||
},
|
||||
{
|
||||
"name": "Session replay",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Included in Amplitude platform"
|
||||
"notes": "Both platforms include session replay"
|
||||
},
|
||||
{
|
||||
"name": "Custom dashboards",
|
||||
@@ -423,7 +423,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Simpler analytics needs",
|
||||
"description": "If you don't need predictive ML models, feature flags, or session replay, OpenPanel gives you core analytics without the bloat.",
|
||||
"description": "If you don't need predictive ML models or feature flags, OpenPanel gives you core analytics — including session replay — without the enterprise bloat.",
|
||||
"icon": "target"
|
||||
}
|
||||
]
|
||||
@@ -484,7 +484,7 @@
|
||||
},
|
||||
{
|
||||
"question": "What Amplitude features will I lose?",
|
||||
"answer": "OpenPanel doesn't have feature flags, session replay, predictive cohorts, or the Guides & Surveys product. If you rely heavily on these enterprise features, Amplitude may still be the better fit."
|
||||
"answer": "OpenPanel doesn't have feature flags, predictive cohorts, or the Guides & Surveys product. OpenPanel does include session replay. If you rely heavily on Amplitude's enterprise experimentation or ML-powered features, Amplitude may still be the better fit."
|
||||
},
|
||||
{
|
||||
"question": "How does the SDK size affect my app?",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"slug": "fullstory-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Best FullStory Alternative 2026 - Open Source & Free",
|
||||
"description": "Looking for a FullStory alternative? OpenPanel offers product analytics with transparent pricing, self-hosting, and privacy-first tracking \u2014 no expensive session replay costs. Free to start.",
|
||||
"title": "Best FullStory Alternatives 2026 — Cheaper & Privacy-First",
|
||||
"description": "FullStory pricing starts at $300/month. OpenPanel delivers product analytics — events, funnels, and retention — at $2.50/month or free to self-host. No enterprise contract required.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
{
|
||||
"title": "Remove FullStory script",
|
||||
"description": "Once verified, remove the FullStory snippet. Note: You'll lose access to session replay and heatmaps."
|
||||
"description": "Once verified, remove the FullStory snippet. Note: You'll lose access to FullStory's advanced heatmaps, frustration signals, and pixel-perfect replay. OpenPanel includes basic session replay."
|
||||
}
|
||||
],
|
||||
"sdk_compatibility": {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"slug": "heap-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Best Heap Alternative 2026 - Open Source & Free",
|
||||
"description": "Looking for a Heap alternative? OpenPanel offers transparent pricing, lightweight analytics, and self-hosting without autocapture complexity. Open source and free to get started.",
|
||||
"title": "Best Heap Alternatives 2026 — After the Contentsquare Acquisition",
|
||||
"description": "Heap was acquired by Contentsquare in 2023. If you're re-evaluating, OpenPanel is an open-source alternative with transparent pricing from $2.50/month, full self-hosting, and no sales call required.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
@@ -27,8 +27,8 @@
|
||||
"overview": {
|
||||
"title": "Why consider OpenPanel over Heap?",
|
||||
"paragraphs": [
|
||||
"Heap made its name with autocapture \u2014 the ability to automatically record every user interaction and analyze it retroactively. It's a compelling feature for teams that want to ask questions about user behavior without planning instrumentation in advance. But Heap's acquisition by Contentsquare, opaque enterprise pricing, and cloud-only architecture have many teams looking for alternatives.",
|
||||
"OpenPanel takes a different approach with explicit event tracking, giving you precise control over what you measure and how. While you lose Heap's retroactive analysis capability, you gain transparency \u2014 both in your data collection and your costs. OpenPanel's pricing is publicly listed and event-based, starting at just $2.50 per month, compared to Heap's sales-required pricing that reportedly starts at $3,600 per year.",
|
||||
"Heap was acquired by Contentsquare in September 2023. For many teams, that acquisition raised real questions: Will pricing change? Will the product roadmap shift to serve Contentsquare's enterprise customers? Will independent support continue? These concerns, combined with Heap's opaque pricing model and cloud-only architecture, have driven a wave of teams to evaluate alternatives.",
|
||||
"OpenPanel takes a different approach with explicit event tracking, giving you precise control over what you measure and how. While you lose Heap's retroactive analysis capability, you gain transparency \u2014 both in your data collection and your costs. OpenPanel's pricing is publicly listed and event-based, starting at just $2.50 per month, compared to Heap's sales-required pricing that reportedly starts at $3,600 per year. And unlike Heap, OpenPanel is fully self-hostable and open source \u2014 no acquisition can change that.",
|
||||
"For teams that value data sovereignty, OpenPanel offers full self-hosting via a simple Docker deployment \u2014 something Heap doesn't provide at all. Being open source under the MIT license means you can inspect every line of code, contribute improvements, and avoid the vendor lock-in risk that comes with Heap's proprietary, now-Contentsquare-owned platform.",
|
||||
"If you prefer intentional, controlled analytics over autocapture-everything, want transparent pricing without sales calls, and need the option to self-host \u2014 OpenPanel gives you solid product analytics with full ownership of your data."
|
||||
]
|
||||
@@ -443,8 +443,8 @@
|
||||
],
|
||||
"articles": [
|
||||
{
|
||||
"title": "Find an alternative to Mixpanel",
|
||||
"url": "/articles/alternatives-to-mixpanel"
|
||||
"title": "Best product analytics tools in 2026",
|
||||
"url": "/articles/mixpanel-alternatives"
|
||||
},
|
||||
{
|
||||
"title": "9 best open source web analytics tools",
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"slug": "mixpanel-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Best Mixpanel Alternative 2026 - Open Source & Free",
|
||||
"description": "Looking for a Mixpanel alternative? OpenPanel offers powerful product analytics at a fraction of the cost \u2014 with EU-only hosting, self-hosting, and full data ownership. Try free today.",
|
||||
"title": "OpenPanel vs Mixpanel (2026): Full Feature & Pricing Comparison",
|
||||
"description": "Side-by-side comparison of OpenPanel and Mixpanel: pricing, features, self-hosting, privacy, and migration guide. See which product analytics platform is right for your team.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Best Mixpanel Alternative",
|
||||
"subheading": "OpenPanel is an open-source, privacy-first alternative to Mixpanel. Get powerful product analytics\u2014events, funnels, retention, and user profiles\u2014without event-based pricing that scales to thousands per month or sending your data to US servers.",
|
||||
"heading": "OpenPanel vs Mixpanel",
|
||||
"subheading": "A complete side-by-side comparison of OpenPanel and Mixpanel \u2014 pricing, features, self-hosting, privacy, and what it takes to switch. Make an informed decision before you migrate.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"EU-only hosting",
|
||||
@@ -45,7 +45,7 @@
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Enterprise teams needing advanced experimentation and feature flags",
|
||||
"Organizations requiring session replay across web and mobile",
|
||||
"Teams needing Metric Trees for organizational goal alignment",
|
||||
"Companies with complex data warehouse integration needs",
|
||||
"Teams that need Metric Trees for organizational alignment"
|
||||
]
|
||||
@@ -184,9 +184,15 @@
|
||||
},
|
||||
{
|
||||
"name": "Session replay",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Mixpanel supports web, iOS, and Android"
|
||||
"notes": "Mixpanel supports web, iOS, and Android. OpenPanel also offers session replay."
|
||||
},
|
||||
{
|
||||
"name": "Group analytics",
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "Both support group/company-level analytics"
|
||||
},
|
||||
{
|
||||
"name": "Revenue tracking",
|
||||
@@ -441,7 +447,7 @@
|
||||
"items": [
|
||||
{
|
||||
"question": "Does OpenPanel have all the features I use in Mixpanel?",
|
||||
"answer": "OpenPanel covers the core features most teams actually use: event tracking, funnels, retention, cohorts, user profiles, and A/B testing. If you rely heavily on Mixpanel's session replay, feature flags, or Metric Trees, those aren't available in OpenPanel yet."
|
||||
"answer": "OpenPanel covers the core features most teams actually use: event tracking, funnels, retention, cohorts, user profiles, A/B testing, session replay, and group analytics. If you rely heavily on Mixpanel's feature flags or Metric Trees, those aren't available in OpenPanel."
|
||||
},
|
||||
{
|
||||
"question": "Can I import my historical Mixpanel data?",
|
||||
|
||||
@@ -139,9 +139,9 @@
|
||||
"features": [
|
||||
{
|
||||
"name": "Session replay",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
"notes": "Mouseflow's session replay is more advanced with friction scoring and form analytics"
|
||||
},
|
||||
{
|
||||
"name": "Click heatmaps",
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"slug": "posthog-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Best PostHog Alternative 2026 - Open Source & Free",
|
||||
"description": "Looking for a PostHog alternative? OpenPanel offers simpler analytics with better privacy, a lighter SDK, and transparent pricing \u2014 no complex tiers. Open source and free to self-host.",
|
||||
"title": "Best PostHog Alternatives in 2026 — Simpler, Free & Self-Hosted",
|
||||
"description": "Looking for a simpler PostHog alternative? OpenPanel is free, open-source, and self-hostable — 2.3 KB SDK, cookie-free tracking, and no complex feature flags or session replay you don't need.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
@@ -28,7 +28,7 @@
|
||||
"title": "Why consider OpenPanel over PostHog?",
|
||||
"paragraphs": [
|
||||
"PostHog has built an impressive all-in-one platform with product analytics, feature flags, session replay, surveys, A/B testing, and more \u2014 over 10 products under one roof. It's a popular choice among developer-led teams who want everything in a single tool. But that breadth comes with trade-offs: a 52+ KB SDK, complex multi-product pricing, and a self-hosted setup that requires ClickHouse, Kafka, Redis, and PostgreSQL.",
|
||||
"OpenPanel takes a focused approach. Instead of trying to be everything, it delivers excellent analytics \u2014 events, funnels, retention, cohorts, user profiles, and web analytics \u2014 with a dramatically smaller footprint. The SDK is just 2.3 KB (over 20x lighter than PostHog), which directly translates to faster page loads and better Core Web Vitals for your users.",
|
||||
"OpenPanel takes a focused approach. Instead of trying to be everything, it delivers excellent analytics \u2014 events, funnels, retention, cohorts, user profiles, session replay, and web analytics \u2014 with a dramatically smaller footprint. The SDK is just 2.3 KB (over 20x lighter than PostHog), which directly translates to faster page loads and better Core Web Vitals for your users.",
|
||||
"Cookie-free tracking is another key difference. PostHog uses cookies by default and requires configuration to go cookieless, while OpenPanel is cookie-free out of the box \u2014 no consent banners needed. Self-hosting is also far simpler: OpenPanel runs in a single Docker container compared to PostHog's multi-service architecture.",
|
||||
"If you need focused analytics without the feature bloat, want a lighter SDK that doesn't impact performance, and prefer simple event-based pricing over multi-product metering \u2014 OpenPanel gives you exactly what you need without the overhead."
|
||||
]
|
||||
@@ -38,13 +38,13 @@
|
||||
"intro": "Both are open-source analytics platforms. PostHog is an all-in-one platform with many products. OpenPanel focuses on analytics with simplicity.",
|
||||
"one_liner": "PostHog is an all-in-one platform with 10+ products; OpenPanel focuses on analytics with a lighter footprint.",
|
||||
"best_for_openpanel": [
|
||||
"Teams wanting focused analytics without feature flags, session replay, or surveys",
|
||||
"Teams wanting focused analytics without feature flags or surveys",
|
||||
"Privacy-conscious products needing cookie-free tracking by default",
|
||||
"Performance-conscious applications (2.3KB SDK vs 52KB+)",
|
||||
"Teams preferring simple Docker deployment over multi-service architecture"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Teams needing all-in-one platform (analytics, feature flags, session replay, surveys)",
|
||||
"Teams needing all-in-one platform (analytics, feature flags, surveys, A/B experiments)",
|
||||
"Developers wanting SQL access (HogQL) for custom queries",
|
||||
"Y Combinator companies leveraging PostHog's ecosystem",
|
||||
"Teams requiring extensive CDP capabilities with 60+ connectors"
|
||||
@@ -176,9 +176,9 @@
|
||||
},
|
||||
{
|
||||
"name": "Session Replay",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": "PostHog includes session replay for web, Android (beta), iOS (alpha)"
|
||||
"notes": "Both platforms offer session replay."
|
||||
},
|
||||
{
|
||||
"name": "Surveys",
|
||||
@@ -391,7 +391,7 @@
|
||||
"items": [
|
||||
{
|
||||
"title": "Teams Who Want Analytics Without Feature Bloat",
|
||||
"description": "If you need product analytics but don't use PostHog's feature flags, session replay, surveys, or experiments, OpenPanel gives you exactly what you need without the overhead.",
|
||||
"description": "If you need product analytics and session replay but don't need PostHog's feature flags, surveys, or experiments, OpenPanel gives you exactly what you need without the overhead.",
|
||||
"icon": "target"
|
||||
},
|
||||
{
|
||||
@@ -430,7 +430,7 @@
|
||||
},
|
||||
{
|
||||
"question": "What features will I lose switching from PostHog?",
|
||||
"answer": "PostHog includes feature flags, session replay, surveys, and A/B experiments in their platform. If you actively use these, you'd need separate tools. If you primarily use PostHog for analytics, OpenPanel provides everything you need with less complexity."
|
||||
"answer": "PostHog includes feature flags, surveys, and A/B experiments in their platform. If you actively use these, you'd need separate tools. OpenPanel now includes session replay, so you won't lose that. If you primarily use PostHog for analytics, OpenPanel provides everything you need with less complexity."
|
||||
},
|
||||
{
|
||||
"question": "How does OpenPanel compare on privacy?",
|
||||
@@ -442,7 +442,7 @@
|
||||
},
|
||||
{
|
||||
"question": "Is PostHog more feature-rich than OpenPanel?",
|
||||
"answer": "PostHog offers more products (10+ including feature flags, session replay, surveys, A/B testing, data warehouse). However, this comes with added complexity. OpenPanel focuses on doing analytics exceptionally well with a simpler, more focused experience."
|
||||
"answer": "PostHog offers more products (10+ including feature flags, surveys, A/B testing, data warehouse). However, this comes with added complexity. OpenPanel now includes session replay alongside its core analytics, while staying focused on simplicity and performance."
|
||||
},
|
||||
{
|
||||
"question": "How do SDK sizes compare?",
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"slug": "smartlook-alternative",
|
||||
"page_type": "alternative",
|
||||
"seo": {
|
||||
"title": "Best Smartlook Alternative 2026 - Open Source & Free",
|
||||
"description": "Looking for a Smartlook alternative? OpenPanel offers product analytics with self-hosting, transparent pricing, and mobile SDKs \u2014 without session replay costs. Open source and free to start.",
|
||||
"title": "5 Best Smartlook Alternatives in 2026 (Free & Open Source)",
|
||||
"description": "Looking for a Smartlook alternative? OpenPanel is open source with product analytics, session replay, funnels, and retention. Self-hostable, cookie-free, and no consent banners required.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Best Smartlook Alternative",
|
||||
"subheading": "Need product analytics without requiring session replay? OpenPanel is an open-source alternative to Smartlook that focuses on event-based analytics, funnels, and retention\u2014with self-hosting and transparent pricing.",
|
||||
"subheading": "OpenPanel is an open-source alternative to Smartlook with event-based product analytics, session replay, funnels, and retention\u2014with self-hosting, transparent pricing, and no Cisco vendor lock-in.",
|
||||
"badges": [
|
||||
"Open-source",
|
||||
"Self-hostable",
|
||||
@@ -28,28 +28,27 @@
|
||||
"title": "Why consider OpenPanel over Smartlook?",
|
||||
"paragraphs": [
|
||||
"Smartlook combines product analytics with visual insights \u2014 session recordings, heatmaps, and event tracking in one platform. Since its acquisition by Cisco in 2023, it has positioned itself as an enterprise-ready analytics and observation tool. But enterprise ownership often means enterprise pricing, proprietary lock-in, and cloud-only infrastructure with no option for self-hosting.",
|
||||
"OpenPanel focuses purely on product analytics without the session replay overhead, delivering event tracking, funnels, retention analysis, and cohort breakdowns with a cleaner, more focused experience. The result is a lighter tool that does analytics well rather than trying to be everything \u2014 and at a dramatically lower cost with transparent, event-based pricing starting at $2.50 per month.",
|
||||
"OpenPanel delivers event tracking, funnels, retention analysis, cohort breakdowns, and session replay in a focused, open-source package. The result is a tool that covers both product analytics and visual session review \u2014 at a dramatically lower cost with transparent, event-based pricing starting at $2.50 per month.",
|
||||
"Being open source under the MIT license gives OpenPanel advantages that Smartlook's proprietary, Cisco-owned platform can't match. You can self-host on your own infrastructure for complete data sovereignty, audit the source code for security compliance, and avoid the vendor lock-in risk that comes with acquisition-prone platforms. Self-hosting also means unlimited data retention, compared to Smartlook's plan-based limits.",
|
||||
"If you need session replay specifically, Smartlook has the edge in that area. But for teams that want focused, cost-effective product analytics with open-source transparency and the freedom to self-host, OpenPanel delivers more value without the enterprise complexity."
|
||||
"If you need advanced heatmaps or Unity/game analytics, Smartlook has the edge. But for teams that want product analytics plus session replay with open-source transparency, self-hosting, and predictable pricing, OpenPanel delivers more value without the Cisco enterprise complexity."
|
||||
]
|
||||
},
|
||||
"summary_comparison": {
|
||||
"title": "OpenPanel vs Smartlook: Which is right for you?",
|
||||
"intro": "Both platforms offer product analytics, but Smartlook adds visual behavior tools (session replay, heatmaps) while OpenPanel focuses on event-based analytics with self-hosting.",
|
||||
"one_liner": "OpenPanel is open source with self-hosting for product analytics; Smartlook combines analytics with session replay and heatmaps.",
|
||||
"intro": "Both platforms offer product analytics and session replay. Smartlook adds heatmaps and frustration signals; OpenPanel adds self-hosting, open source, and simpler pricing.",
|
||||
"one_liner": "OpenPanel is open source with self-hosting, product analytics, and session replay; Smartlook adds heatmaps and deeper visual behavior tools.",
|
||||
"best_for_openpanel": [
|
||||
"Teams needing self-hosting for data ownership and compliance",
|
||||
"Open source requirements for transparency",
|
||||
"Focus on event-based product analytics without visual replay",
|
||||
"Open source requirements for transparency and auditability",
|
||||
"Product analytics plus session replay without Cisco vendor lock-in",
|
||||
"Teams wanting unlimited data retention with self-hosting",
|
||||
"Server-side SDKs for backend tracking"
|
||||
],
|
||||
"best_for_competitor": [
|
||||
"Teams needing session recordings to watch user interactions",
|
||||
"UX designers requiring heatmaps (click, scroll, movement)",
|
||||
"UX designers requiring comprehensive heatmaps (click, scroll, movement)",
|
||||
"Mobile app crash reports with linked session recordings",
|
||||
"Teams wanting combined analytics and replay in one tool",
|
||||
"Unity game developers (Smartlook supports Unity)"
|
||||
"Teams needing Unity game analytics",
|
||||
"Teams requiring Cisco/AppDynamics ecosystem integration"
|
||||
]
|
||||
},
|
||||
"highlights": {
|
||||
@@ -68,8 +67,8 @@
|
||||
},
|
||||
{
|
||||
"label": "Session replay",
|
||||
"openpanel": "Not available",
|
||||
"competitor": "Yes, full recordings"
|
||||
"openpanel": "Yes",
|
||||
"competitor": "Yes, with heatmaps & friction detection"
|
||||
},
|
||||
{
|
||||
"label": "Heatmaps",
|
||||
@@ -139,9 +138,9 @@
|
||||
"features": [
|
||||
{
|
||||
"name": "Session recordings",
|
||||
"openpanel": false,
|
||||
"openpanel": true,
|
||||
"competitor": true,
|
||||
"notes": null
|
||||
"notes": "Smartlook additionally links recordings to crash reports and heatmaps"
|
||||
},
|
||||
{
|
||||
"name": "Click heatmaps",
|
||||
@@ -311,13 +310,13 @@
|
||||
},
|
||||
"migration": {
|
||||
"title": "Migrating from Smartlook to OpenPanel",
|
||||
"intro": "Moving from Smartlook to OpenPanel involves transitioning from combined session replay and analytics to event-based product analytics.",
|
||||
"intro": "Moving from Smartlook to OpenPanel means keeping session replay and product analytics while gaining self-hosting, open source, and simpler pricing.",
|
||||
"difficulty": "moderate",
|
||||
"estimated_time": "2-4 hours",
|
||||
"steps": [
|
||||
{
|
||||
"title": "Understand feature differences",
|
||||
"description": "OpenPanel focuses on event-based product analytics. If you rely on session recordings and heatmaps, consider using complementary tools like Microsoft Clarity."
|
||||
"description": "OpenPanel includes session replay and event-based product analytics. If you rely on heatmaps or Unity analytics, consider using complementary tools like Microsoft Clarity for heatmaps."
|
||||
},
|
||||
{
|
||||
"title": "Create OpenPanel account or self-host",
|
||||
@@ -382,11 +381,11 @@
|
||||
"items": [
|
||||
{
|
||||
"question": "Can OpenPanel replace Smartlook's session recordings?",
|
||||
"answer": "No, OpenPanel does not provide session recordings or heatmaps. If you need visual behavior analytics, consider using Microsoft Clarity (free) or Hotjar alongside OpenPanel, or continue using Smartlook for recordings while using OpenPanel for deeper product analytics."
|
||||
"answer": "Yes for session replay — OpenPanel now includes session recording. However, if you need heatmaps (click, scroll, movement), frustration signals, or Unity game analytics, Smartlook still has the edge in those areas."
|
||||
},
|
||||
{
|
||||
"question": "Which tool has better funnel analysis?",
|
||||
"answer": "Both tools offer funnel analysis. Smartlook's advantage is the ability to watch session recordings of users who dropped off. OpenPanel offers more advanced funnel customization and cohort breakdowns."
|
||||
"answer": "Both tools offer funnel analysis. With OpenPanel you can also watch session recordings of users who dropped off, and OpenPanel offers more advanced funnel customization and cohort breakdowns."
|
||||
},
|
||||
{
|
||||
"question": "Can I self-host Smartlook?",
|
||||
|
||||
@@ -68,6 +68,34 @@ app.listen(3000, () => {
|
||||
- `trackRequest` - A function that returns `true` if the request should be tracked.
|
||||
- `getProfileId` - A function that returns the profile ID of the user making the request.
|
||||
|
||||
## Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. Since Express is a backend SDK, you can upsert groups and assign users from your route handlers.
|
||||
|
||||
See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
```ts
|
||||
app.post('/login', async (req, res) => {
|
||||
const user = await loginUser(req.body);
|
||||
|
||||
// Identify the user
|
||||
req.op.identify({ profileId: user.id, email: user.email });
|
||||
|
||||
// Create/update the group entity
|
||||
req.op.upsertGroup({
|
||||
id: user.organizationId,
|
||||
type: 'company',
|
||||
name: user.organizationName,
|
||||
properties: { plan: user.plan },
|
||||
});
|
||||
|
||||
// Assign the user to the group
|
||||
req.op.setGroup(user.organizationId);
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
## Typescript
|
||||
|
||||
If `req.op` is not typed you can extend the `Request` interface.
|
||||
|
||||
@@ -116,9 +116,38 @@ op.decrement({
|
||||
});
|
||||
```
|
||||
|
||||
### Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
**Create or update a group:**
|
||||
|
||||
```js title="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.upsertGroup({
|
||||
id: 'org_acme',
|
||||
type: 'company',
|
||||
name: 'Acme Inc',
|
||||
properties: { plan: 'enterprise' },
|
||||
});
|
||||
```
|
||||
|
||||
**Assign the current user to a group** (call after `identify`):
|
||||
|
||||
```js title="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
op.setGroup('org_acme');
|
||||
// or multiple groups:
|
||||
op.setGroups(['org_acme', 'team_eng']);
|
||||
```
|
||||
|
||||
Once set, all subsequent `track()` calls will automatically include the group IDs.
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
To clear the current user's data (including groups):
|
||||
|
||||
```js title="index.js"
|
||||
import { op } from './op.ts'
|
||||
|
||||
@@ -227,9 +227,32 @@ useOpenPanel().decrement({
|
||||
});
|
||||
```
|
||||
|
||||
### Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
**Create or update a group:**
|
||||
|
||||
```tsx title="app/login/page.tsx"
|
||||
useOpenPanel().upsertGroup({
|
||||
id: 'org_acme',
|
||||
type: 'company',
|
||||
name: 'Acme Inc',
|
||||
properties: { plan: 'enterprise' },
|
||||
});
|
||||
```
|
||||
|
||||
**Assign the current user to a group** (call after `identify`):
|
||||
|
||||
```tsx title="app/login/page.tsx"
|
||||
useOpenPanel().setGroup('org_acme');
|
||||
```
|
||||
|
||||
Once set, all subsequent `track()` calls will automatically include the group IDs.
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
To clear the current user's data (including groups):
|
||||
|
||||
```js title="index.js"
|
||||
useOpenPanel().clear()
|
||||
|
||||
@@ -120,3 +120,35 @@ op.track('my_event', { foo: 'bar' });
|
||||
</Tabs>
|
||||
|
||||
For more information on how to use the SDK, check out the [Javascript SDK](/docs/sdks/javascript#usage).
|
||||
|
||||
## Offline support
|
||||
|
||||
The SDK can buffer events when the device is offline and flush them once connectivity is restored. Events are stamped with a `__timestamp` at the time they are fired so they are recorded with the correct time even if they are delivered later.
|
||||
|
||||
Two optional peer dependencies enable this feature:
|
||||
|
||||
```npm
|
||||
npm install @react-native-async-storage/async-storage @react-native-community/netinfo
|
||||
```
|
||||
|
||||
Pass them to the constructor:
|
||||
|
||||
```typescript
|
||||
import { OpenPanel } from '@openpanel/react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
|
||||
const op = new OpenPanel({
|
||||
clientId: '{YOUR_CLIENT_ID}',
|
||||
clientSecret: '{YOUR_CLIENT_SECRET}',
|
||||
// Persist the event queue across app restarts
|
||||
storage: AsyncStorage,
|
||||
// Automatically flush the queue when the device comes back online
|
||||
networkInfo: NetInfo,
|
||||
});
|
||||
```
|
||||
|
||||
Both options are independent — you can use either one or both:
|
||||
|
||||
- **`storage`** — persists the queue to disk so events survive app restarts while offline.
|
||||
- **`networkInfo`** — flushes the queue automatically when connectivity is restored. Without this, the queue is flushed the next time the app becomes active.
|
||||
|
||||
@@ -174,9 +174,37 @@ function MyComponent() {
|
||||
}
|
||||
```
|
||||
|
||||
### Working with Groups
|
||||
|
||||
Groups let you track analytics at the account or company level. See the [Groups guide](/docs/get-started/groups) for the full walkthrough.
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function LoginComponent() {
|
||||
const handleLogin = async (user: User) => {
|
||||
// 1. Identify the user
|
||||
op.identify({ profileId: user.id, email: user.email });
|
||||
|
||||
// 2. Create/update the group entity (only when data changes)
|
||||
op.upsertGroup({
|
||||
id: user.organizationId,
|
||||
type: 'company',
|
||||
name: user.organizationName,
|
||||
properties: { plan: user.plan },
|
||||
});
|
||||
|
||||
// 3. Link the user to their group — tags all future events
|
||||
op.setGroup(user.organizationId);
|
||||
};
|
||||
|
||||
return <button onClick={() => handleLogin(user)}>Login</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
To clear the current user's data (including groups):
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
@@ -106,6 +106,81 @@ curl -X POST https://api.openpanel.dev/track \
|
||||
}'
|
||||
```
|
||||
|
||||
### Creating or updating a group
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "group",
|
||||
"payload": {
|
||||
"id": "org_acme",
|
||||
"type": "company",
|
||||
"name": "Acme Inc",
|
||||
"properties": {
|
||||
"plan": "enterprise",
|
||||
"seats": 25
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | `string` | Yes | Unique identifier for the group |
|
||||
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
|
||||
| `name` | `string` | Yes | Display name |
|
||||
| `properties` | `object` | No | Custom metadata |
|
||||
|
||||
### Assigning a user to a group
|
||||
|
||||
Links a profile to one or more groups. This updates the profile record but does not auto-attach groups to future events — you still need to pass `groups` explicitly on each track call.
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "assign_group",
|
||||
"payload": {
|
||||
"profileId": "user_123",
|
||||
"groupIds": ["org_acme"]
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `profileId` | `string` | No | Profile to assign. Falls back to the device ID if omitted |
|
||||
| `groupIds` | `string[]` | Yes | Group IDs to link to the profile |
|
||||
|
||||
### Tracking events with groups
|
||||
|
||||
Groups are never auto-populated on events — even if the profile has been assigned to a group via `assign_group`. Pass `groups` on every track event where you want group data.
|
||||
|
||||
```bash
|
||||
curl -X POST https://api.openpanel.dev/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "openpanel-client-id: YOUR_CLIENT_ID" \
|
||||
-H "openpanel-client-secret: YOUR_CLIENT_SECRET" \
|
||||
-d '{
|
||||
"type": "track",
|
||||
"payload": {
|
||||
"name": "report_exported",
|
||||
"profileId": "user_123",
|
||||
"groups": ["org_acme"],
|
||||
"properties": {
|
||||
"format": "pdf"
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Unlike the SDK, where `setGroup()` stores group IDs on the instance and attaches them to every subsequent `track()` call, the API has no such state. You must pass `groups` on each event.
|
||||
|
||||
### Error Handling
|
||||
The API uses standard HTTP response codes to indicate the success or failure of requests. In case of an error, the response body will contain more information about the error.
|
||||
Example error response:
|
||||
|
||||
@@ -59,7 +59,16 @@ The trailing edge of the line (the current, incomplete interval) is shown as a d
|
||||
|
||||
## Insights
|
||||
|
||||
If you have configured insights for your project, a scrollable row of insight cards appears below the chart. Each card shows a pre-configured metric with its current value and trend. Clicking a card applies that insight's filter to the entire overview page. Insights are optional—this section is hidden when none have been configured.
|
||||
A scrollable row of insight cards appears below the chart once your project has at least 30 days of data. OpenPanel automatically detects significant trends across pageviews, entry pages, referrers, and countries—no configuration needed.
|
||||
|
||||
Each card shows:
|
||||
- **Share**: The percentage of total traffic that property represents (e.g., "United States: 42% of all sessions")
|
||||
- **Absolute change**: The raw increase or decrease in sessions compared to the previous period
|
||||
- **Percentage change**: How much that property grew or declined relative to its own previous value
|
||||
|
||||
For example, if the US had 1,000 sessions last week and 1,200 this week, the card shows "+200 sessions (+20%)".
|
||||
|
||||
Clicking any insight card filters the entire overview page to show only data matching that property—letting you drill into what's driving the trend.
|
||||
|
||||
---
|
||||
|
||||
|
||||
208
apps/public/content/docs/get-started/groups.mdx
Normal file
208
apps/public/content/docs/get-started/groups.mdx
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: Groups
|
||||
description: Track analytics at the account, company, or team level — not just individual users.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
Groups let you associate users with a shared entity — like a company, workspace, or team — and analyze behavior at that level. Instead of asking "what did Jane do?", you can ask "what is Acme Inc doing?"
|
||||
|
||||
This is especially useful for B2B SaaS products where a single paying account has many users.
|
||||
|
||||
## How Groups work
|
||||
|
||||
There are two separate concepts:
|
||||
|
||||
1. **The group entity** — created/updated with `upsertGroup()`. Stores metadata about the group (name, plan, etc.).
|
||||
2. **Group membership** — set with `setGroup()` / `setGroups()`. Links a user profile to one or more groups, and automatically attaches those group IDs to every subsequent `track()` call.
|
||||
|
||||
## Creating or updating a group
|
||||
|
||||
Call `upsertGroup()` to create a group or update its properties. The group is identified by its `id` and `type`.
|
||||
|
||||
```typescript
|
||||
op.upsertGroup({
|
||||
id: 'org_acme', // Your group's unique ID
|
||||
type: 'company', // Group type (company, workspace, team, etc.)
|
||||
name: 'Acme Inc', // Display name
|
||||
properties: {
|
||||
plan: 'enterprise',
|
||||
seats: 25,
|
||||
industry: 'logistics',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Group payload
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `id` | `string` | Yes | Unique identifier for the group |
|
||||
| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) |
|
||||
| `name` | `string` | Yes | Human-readable display name |
|
||||
| `properties` | `object` | No | Custom metadata about the group |
|
||||
|
||||
## Managing groups in the dashboard
|
||||
|
||||
The easiest way to create, edit, and delete groups is directly in the OpenPanel dashboard. Navigate to your project and open the **Groups** section — from there you can manage group names, types, and properties without touching any code.
|
||||
|
||||
`upsertGroup()` is the right tool when your group properties are **dynamic and driven by your own data** — for example, syncing a customer's current plan, seat count, or MRR from your backend at login time.
|
||||
|
||||
<Callout>
|
||||
A good rule of thumb: call `upsertGroup()` on login or when group properties change — not on every request or page view. If you find yourself calling it frequently with the same data, the dashboard is probably the better place to manage that group.
|
||||
</Callout>
|
||||
|
||||
## Assigning a user to a group
|
||||
|
||||
After identifying a user, call `setGroup()` to link them to a group. This also attaches the group ID to all future `track()` calls for the current session.
|
||||
|
||||
```typescript
|
||||
// After login
|
||||
op.identify({ profileId: 'user_123' });
|
||||
|
||||
// Link the user to their organization
|
||||
op.setGroup('org_acme');
|
||||
```
|
||||
|
||||
For users that belong to multiple groups:
|
||||
|
||||
```typescript
|
||||
op.setGroups(['org_acme', 'team_engineering']);
|
||||
```
|
||||
|
||||
<Callout>
|
||||
`setGroup()` and `setGroups()` persist group IDs on the SDK instance. All subsequent `track()` calls will automatically include these group IDs until `clear()` is called.
|
||||
</Callout>
|
||||
|
||||
## Full login flow example
|
||||
|
||||
`setGroup()` doesn't require the group to exist first. You can call it with just an ID — events will be tagged with that group ID, and you can create the group later in the dashboard or via `upsertGroup()`.
|
||||
|
||||
```typescript
|
||||
// 1. Identify the user
|
||||
op.identify({
|
||||
profileId: 'user_123',
|
||||
firstName: 'Jane',
|
||||
email: 'jane@acme.com',
|
||||
});
|
||||
|
||||
// 2. Assign the user to the group — the group doesn't need to exist yet
|
||||
op.setGroup('org_acme');
|
||||
|
||||
// 3. All subsequent events are now tagged with the group
|
||||
op.track('dashboard_viewed'); // → includes groups: ['org_acme']
|
||||
op.track('report_exported'); // → includes groups: ['org_acme']
|
||||
```
|
||||
|
||||
If you want to sync dynamic group properties from your own data (plan, seats, MRR), add `upsertGroup()` to the flow:
|
||||
|
||||
```typescript
|
||||
op.identify({ profileId: 'user_123', email: 'jane@acme.com' });
|
||||
|
||||
// Sync group metadata from your backend
|
||||
op.upsertGroup({
|
||||
id: 'org_acme',
|
||||
type: 'company',
|
||||
name: 'Acme Inc',
|
||||
properties: { plan: 'pro' },
|
||||
});
|
||||
|
||||
op.setGroup('org_acme');
|
||||
```
|
||||
|
||||
## Per-event group override
|
||||
|
||||
You can attach group IDs to a specific event without affecting the SDK's persistent group state:
|
||||
|
||||
```typescript
|
||||
op.track('file_shared', {
|
||||
filename: 'q4-report.pdf',
|
||||
groups: ['org_acme', 'org_partner'], // Only applies to this event
|
||||
});
|
||||
```
|
||||
|
||||
Groups passed in `track()` are **merged** with any groups already set on the SDK instance.
|
||||
|
||||
## Clearing groups on logout
|
||||
|
||||
`clear()` resets the profile, device, session, and all groups. Always call it on logout.
|
||||
|
||||
```typescript
|
||||
function handleLogout() {
|
||||
op.clear();
|
||||
// redirect to login...
|
||||
}
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
### B2B SaaS — company accounts
|
||||
|
||||
```typescript
|
||||
// On login
|
||||
op.identify({ profileId: user.id, email: user.email });
|
||||
op.upsertGroup({
|
||||
id: user.organizationId,
|
||||
type: 'company',
|
||||
name: user.organizationName,
|
||||
properties: { plan: user.plan, mrr: user.mrr },
|
||||
});
|
||||
op.setGroup(user.organizationId);
|
||||
```
|
||||
|
||||
### Multi-tenant — workspaces
|
||||
|
||||
```typescript
|
||||
// When user switches workspace
|
||||
op.upsertGroup({
|
||||
id: workspace.id,
|
||||
type: 'workspace',
|
||||
name: workspace.name,
|
||||
});
|
||||
op.setGroup(workspace.id);
|
||||
```
|
||||
|
||||
### Teams within a company
|
||||
|
||||
```typescript
|
||||
// User belongs to a company and a specific team
|
||||
op.setGroups([user.organizationId, user.teamId]);
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
### `upsertGroup(payload)`
|
||||
|
||||
Creates the group if it doesn't exist, or merges properties into the existing group.
|
||||
|
||||
```typescript
|
||||
op.upsertGroup({
|
||||
id: string; // Required
|
||||
type: string; // Required
|
||||
name: string; // Required
|
||||
properties?: Record<string, unknown>;
|
||||
});
|
||||
```
|
||||
|
||||
### `setGroup(groupId)`
|
||||
|
||||
Adds a single group ID to the SDK's internal group list and sends an `assign_group` event to link the current profile to that group.
|
||||
|
||||
```typescript
|
||||
op.setGroup('org_acme');
|
||||
```
|
||||
|
||||
### `setGroups(groupIds)`
|
||||
|
||||
Same as `setGroup()` but for multiple group IDs at once.
|
||||
|
||||
```typescript
|
||||
op.setGroups(['org_acme', 'team_engineering']);
|
||||
```
|
||||
|
||||
## What to avoid
|
||||
|
||||
- **Calling `upsertGroup()` on every event or page view** — call it on login or when group properties actually change. For static group management, use the dashboard instead.
|
||||
- **Not calling `setGroup()` after `identify()`** — without it, events won't be tagged with the group and you won't see group-level data in the dashboard.
|
||||
- **Forgetting `clear()` on logout** — groups persist on the SDK instance, so a new user logging in on the same session could inherit the previous user's groups.
|
||||
- **Using `upsertGroup()` to link a user to a group** — `upsertGroup()` manages the group entity only. Use `setGroup()` to link a user profile to it.
|
||||
@@ -3,6 +3,7 @@
|
||||
"install-openpanel",
|
||||
"track-events",
|
||||
"identify-users",
|
||||
"groups",
|
||||
"revenue-tracking"
|
||||
]
|
||||
}
|
||||
|
||||
251
apps/public/content/docs/self-hosting/high-volume.mdx
Normal file
251
apps/public/content/docs/self-hosting/high-volume.mdx
Normal file
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: High volume setup
|
||||
description: Tuning OpenPanel for high event throughput
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
|
||||
The default Docker Compose setup works well for most deployments. When you start seeing high event throughput — thousands of events per second or dozens of worker replicas — a few things need adjusting.
|
||||
|
||||
## Connection pooling with PGBouncer
|
||||
|
||||
PostgreSQL has a hard limit on the number of open connections. Each worker and API replica opens its own pool of connections, so the total can grow fast. Without pooling, you will start seeing `too many connections` errors under load.
|
||||
|
||||
PGBouncer sits in front of PostgreSQL and maintains a small pool of real database connections, multiplexing many application connections on top of them.
|
||||
|
||||
### Add PGBouncer to docker-compose.yml
|
||||
|
||||
Add the `op-pgbouncer` service and update the `op-api` and `op-worker` dependencies:
|
||||
|
||||
```yaml
|
||||
op-pgbouncer:
|
||||
image: edoburu/pgbouncer:v1.25.1-p0
|
||||
restart: always
|
||||
depends_on:
|
||||
op-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DB_HOST=op-db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_NAME=postgres
|
||||
- AUTH_TYPE=scram-sha-256
|
||||
- POOL_MODE=transaction
|
||||
- MAX_CLIENT_CONN=1000
|
||||
- DEFAULT_POOL_SIZE=20
|
||||
- MIN_POOL_SIZE=5
|
||||
- RESERVE_POOL_SIZE=5
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "PGPASSWORD=postgres psql -h 127.0.0.1 -p 5432 -U postgres pgbouncer -c 'SHOW VERSION;' -q || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
```
|
||||
|
||||
Then update `op-api` and `op-worker` to depend on `op-pgbouncer` instead of `op-db`:
|
||||
|
||||
```yaml
|
||||
op-api:
|
||||
depends_on:
|
||||
op-pgbouncer:
|
||||
condition: service_healthy
|
||||
op-ch:
|
||||
condition: service_healthy
|
||||
op-kv:
|
||||
condition: service_healthy
|
||||
|
||||
op-worker:
|
||||
depends_on:
|
||||
op-pgbouncer:
|
||||
condition: service_healthy
|
||||
op-api:
|
||||
condition: service_healthy
|
||||
```
|
||||
|
||||
### Update DATABASE_URL
|
||||
|
||||
Prisma needs to know it is talking to a pooler. Point `DATABASE_URL` at `op-pgbouncer` and add `&pgbouncer=true`:
|
||||
|
||||
```bash
|
||||
# Before
|
||||
DATABASE_URL=postgresql://postgres:postgres@op-db:5432/postgres?schema=public
|
||||
|
||||
# After
|
||||
DATABASE_URL=postgresql://postgres:postgres@op-pgbouncer:5432/postgres?schema=public&pgbouncer=true
|
||||
```
|
||||
|
||||
Leave `DATABASE_URL_DIRECT` pointing at `op-db` directly, without the `pgbouncer=true` flag. Migrations use the direct connection and will not work through a transaction-mode pooler.
|
||||
|
||||
```bash
|
||||
DATABASE_URL_DIRECT=postgresql://postgres:postgres@op-db:5432/postgres?schema=public
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
PGBouncer runs in transaction mode. Prisma migrations and interactive transactions require a direct connection. Always set `DATABASE_URL_DIRECT` to the `op-db` address.
|
||||
</Callout>
|
||||
|
||||
### Tuning the pool size
|
||||
|
||||
A rough rule: `DEFAULT_POOL_SIZE` should not exceed your PostgreSQL `max_connections` divided by the number of distinct database/user pairs. The PostgreSQL default is 100. If you raise `max_connections` in Postgres, you can raise `DEFAULT_POOL_SIZE` proportionally.
|
||||
|
||||
---
|
||||
|
||||
## Buffer tuning
|
||||
|
||||
Events, sessions, and profiles flow through in-memory Redis buffers before being written to ClickHouse in batches. The defaults are conservative. Under high load you want larger batches to reduce the number of ClickHouse inserts and improve throughput.
|
||||
|
||||
### Event buffer
|
||||
|
||||
The event buffer collects incoming events in Redis and flushes them to ClickHouse on a cron schedule.
|
||||
|
||||
| Variable | Default | What it controls |
|
||||
|---|---|---|
|
||||
| `EVENT_BUFFER_BATCH_SIZE` | `4000` | How many events are read from Redis and sent to ClickHouse per flush |
|
||||
| `EVENT_BUFFER_CHUNK_SIZE` | `1000` | How many events are sent in a single ClickHouse insert call |
|
||||
| `EVENT_BUFFER_MICRO_BATCH_MS` | `10` | How long (ms) to accumulate events in memory before writing to Redis |
|
||||
| `EVENT_BUFFER_MICRO_BATCH_SIZE` | `100` | Max events to accumulate before forcing a Redis write |
|
||||
|
||||
For high throughput, increase `EVENT_BUFFER_BATCH_SIZE` so each flush processes more events. Keep `EVENT_BUFFER_CHUNK_SIZE` at or below `EVENT_BUFFER_BATCH_SIZE`.
|
||||
|
||||
```bash
|
||||
EVENT_BUFFER_BATCH_SIZE=10000
|
||||
EVENT_BUFFER_CHUNK_SIZE=2000
|
||||
```
|
||||
|
||||
### Session buffer
|
||||
|
||||
Sessions are updated on each event and flushed to ClickHouse separately.
|
||||
|
||||
| Variable | Default | What it controls |
|
||||
|---|---|---|
|
||||
| `SESSION_BUFFER_BATCH_SIZE` | `1000` | Events read per flush |
|
||||
| `SESSION_BUFFER_CHUNK_SIZE` | `1000` | Events per ClickHouse insert |
|
||||
|
||||
```bash
|
||||
SESSION_BUFFER_BATCH_SIZE=5000
|
||||
SESSION_BUFFER_CHUNK_SIZE=2000
|
||||
```
|
||||
|
||||
### Profile buffer
|
||||
|
||||
Profiles are merged with existing data before writing. The default batch size is small because each profile may require a ClickHouse lookup.
|
||||
|
||||
| Variable | Default | What it controls |
|
||||
|---|---|---|
|
||||
| `PROFILE_BUFFER_BATCH_SIZE` | `200` | Profiles processed per flush |
|
||||
| `PROFILE_BUFFER_CHUNK_SIZE` | `1000` | Profiles per ClickHouse insert |
|
||||
| `PROFILE_BUFFER_TTL_IN_SECONDS` | `3600` | How long a profile stays cached in Redis |
|
||||
|
||||
Raise `PROFILE_BUFFER_BATCH_SIZE` if profile processing is a bottleneck. Higher values mean fewer flushes but more memory used per flush.
|
||||
|
||||
```bash
|
||||
PROFILE_BUFFER_BATCH_SIZE=500
|
||||
PROFILE_BUFFER_CHUNK_SIZE=1000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scaling ingestion
|
||||
|
||||
If the event queue is growing faster than workers can drain it, you have a few options.
|
||||
|
||||
Start vertical before going horizontal. Each worker replica adds overhead: more Redis connections, more ClickHouse connections, more memory. Increasing concurrency on an existing replica is almost always cheaper and more effective than adding another one.
|
||||
|
||||
### Increase job concurrency (do this first)
|
||||
|
||||
Each worker processes multiple jobs in parallel. The default is `10` per replica.
|
||||
|
||||
```bash
|
||||
EVENT_JOB_CONCURRENCY=20
|
||||
```
|
||||
|
||||
Raise this in steps and watch your queue depth. The limit is memory, not logic — values of `500`, `1000`, or even `2000+` are possible on hardware with enough RAM. Each concurrent job holds event data in memory, so monitor usage as you increase the value. Only add more replicas once concurrency alone stops helping.
|
||||
|
||||
### Add more worker replicas
|
||||
|
||||
If you have maxed out concurrency and the queue is still falling behind, add more replicas.
|
||||
|
||||
In `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
op-worker:
|
||||
deploy:
|
||||
replicas: 8
|
||||
```
|
||||
|
||||
Or at runtime:
|
||||
|
||||
```bash
|
||||
docker compose up -d --scale op-worker=8
|
||||
```
|
||||
|
||||
### Shard the events queue
|
||||
|
||||
<Callout type="warn">
|
||||
**Experimental.** Queue sharding requires either a Redis Cluster or Dragonfly. Dragonfly has seen minimal testing and Redis Cluster has not been tested at all. Do not use this in production without validating it in your environment first.
|
||||
</Callout>
|
||||
|
||||
Redis is single-threaded, so a single queue instance can become the bottleneck at very high event rates. Queue sharding works around this by splitting the queue across multiple independent shards. Each shard can be backed by its own Redis instance, so the throughput scales with the number of instances rather than being capped by one core.
|
||||
|
||||
Events are distributed across shards by project ID, so ordering within a project is preserved.
|
||||
|
||||
```bash
|
||||
EVENTS_GROUP_QUEUES_SHARDS=4
|
||||
QUEUE_CLUSTER=true
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
Set `EVENTS_GROUP_QUEUES_SHARDS` before you have live traffic on the queue. Changing it while jobs are pending will cause those jobs to be looked up on the wrong shard and they will not be processed until the shard count is restored.
|
||||
</Callout>
|
||||
|
||||
### Tune the ordering delay
|
||||
|
||||
Events arriving out of order are held briefly before processing. The default is `100ms`.
|
||||
|
||||
```bash
|
||||
ORDERING_DELAY_MS=100
|
||||
```
|
||||
|
||||
Lowering this reduces latency but increases the chance of out-of-order writes to ClickHouse. The value should not exceed `500ms`.
|
||||
|
||||
---
|
||||
|
||||
## Putting it together
|
||||
|
||||
A starting point for a high-volume `.env`:
|
||||
|
||||
```bash
|
||||
# Route app traffic through PGBouncer
|
||||
DATABASE_URL=postgresql://postgres:postgres@op-pgbouncer:5432/postgres?schema=public&pgbouncer=true
|
||||
# Keep direct connection for migrations
|
||||
DATABASE_URL_DIRECT=postgresql://postgres:postgres@op-db:5432/postgres?schema=public
|
||||
|
||||
# Event buffer
|
||||
EVENT_BUFFER_BATCH_SIZE=10000
|
||||
EVENT_BUFFER_CHUNK_SIZE=2000
|
||||
|
||||
# Session buffer
|
||||
SESSION_BUFFER_BATCH_SIZE=5000
|
||||
SESSION_BUFFER_CHUNK_SIZE=2000
|
||||
|
||||
# Profile buffer
|
||||
PROFILE_BUFFER_BATCH_SIZE=500
|
||||
|
||||
# Queue
|
||||
EVENTS_GROUP_QUEUES_SHARDS=4
|
||||
EVENT_JOB_CONCURRENCY=20
|
||||
```
|
||||
|
||||
Then start with more workers:
|
||||
|
||||
```bash
|
||||
docker compose up -d --scale op-worker=8
|
||||
```
|
||||
|
||||
Monitor the Redis queue depth and ClickHouse insert latency as you tune. The right values depend on your hardware, event shape, and traffic pattern.
|
||||
@@ -8,6 +8,7 @@
|
||||
"[Deploy with Dokploy](/docs/self-hosting/deploy-dokploy)",
|
||||
"[Deploy on Kubernetes](/docs/self-hosting/deploy-kubernetes)",
|
||||
"[Environment Variables](/docs/self-hosting/environment-variables)",
|
||||
"[High volume setup](/docs/self-hosting/high-volume)",
|
||||
"supporter-access-latest-docker-images",
|
||||
"changelog"
|
||||
]
|
||||
|
||||
155
apps/public/content/for/nextjs.json
Normal file
155
apps/public/content/for/nextjs.json
Normal file
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"slug": "nextjs",
|
||||
"audience": "nextjs",
|
||||
"seo": {
|
||||
"title": "Next.js Analytics — OpenPanel SDK for App Router & Pages Router",
|
||||
"description": "Add analytics to your Next.js app in minutes. OpenPanel's Next.js SDK supports App Router, Pages Router, server-side events, and automatic route change tracking. Open source, cookieless, from $2.50/month.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Analytics Built for Next.js",
|
||||
"subheading": "Most analytics tools treat Next.js like a static site. OpenPanel was built for how Next.js actually works — App Router, server components, API routes, and route changes all tracked correctly. Install in 5 minutes, get events, funnels, and user profiles that work with SSR.",
|
||||
"badges": [
|
||||
"App Router support",
|
||||
"Server-side events",
|
||||
"Auto route tracking",
|
||||
"2.3 KB client bundle"
|
||||
]
|
||||
},
|
||||
"problem": {
|
||||
"title": "Why standard analytics breaks in Next.js",
|
||||
"intro": "Next.js is not a traditional website. Most analytics tools were built before App Router existed and show it.",
|
||||
"items": [
|
||||
{
|
||||
"title": "GA4 breaks with App Router route changes",
|
||||
"description": "Google Analytics was designed for traditional page loads. In Next.js App Router, client-side navigation doesn't trigger GA4's page view events unless you add custom workarounds. Your analytics misses a significant portion of page views."
|
||||
},
|
||||
{
|
||||
"title": "Cookie consent is painful in Next.js",
|
||||
"description": "Implementing a GDPR-compliant cookie consent flow in a Next.js app requires next/script, consent state management, and conditional script loading. It's a non-trivial engineering task just to run analytics."
|
||||
},
|
||||
{
|
||||
"title": "No server-side tracking",
|
||||
"description": "Most analytics SDKs are client-only. Server-side events — API route completions, background jobs, server actions — are invisible. You're missing half the picture."
|
||||
},
|
||||
{
|
||||
"title": "Analytics libraries bloat your bundle",
|
||||
"description": "Many analytics SDKs add 20–50 KB to your JavaScript bundle. In a performance-conscious Next.js app, that overhead matters for Core Web Vitals."
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"title": "Analytics that works the way Next.js works",
|
||||
"intro": "OpenPanel's Next.js SDK was designed for modern Next.js patterns — not adapted from a legacy client-only library.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Next.js SDK with App Router support",
|
||||
"description": "Install @openpanel/nextjs, add the <OpenPanelComponent> to your root layout, and automatic route change tracking works immediately across App Router and Pages Router."
|
||||
},
|
||||
{
|
||||
"title": "Automatic route change tracking",
|
||||
"description": "Every router.push(), <Link> navigation, and back/forward browser action is captured as a page view. No custom useEffect or router event listeners needed."
|
||||
},
|
||||
{
|
||||
"title": "Server-side event tracking",
|
||||
"description": "Import openPanel in any server component, API route, or server action to track events server-side. Track form submissions, payment completions, and background jobs without any client involvement."
|
||||
},
|
||||
{
|
||||
"title": "Cookieless by default",
|
||||
"description": "The OpenPanel SDK uses no cookies. No consent management library, no conditional script loading, no GDPR consent modal needed — just install and track."
|
||||
},
|
||||
{
|
||||
"title": "Identify users across sessions",
|
||||
"description": "Call op.identify({ profileId, name, email }) after authentication to tie anonymous events to known users. Works with any auth solution including NextAuth.js, Clerk, and custom implementations."
|
||||
},
|
||||
{
|
||||
"title": "TypeScript-first SDK",
|
||||
"description": "Full TypeScript types for all methods. Autocomplete for event names and properties. Zero runtime errors from mistyped event calls."
|
||||
},
|
||||
{
|
||||
"title": "2.3 KB gzipped client bundle",
|
||||
"description": "The smallest full-featured analytics SDK for Next.js. No impact on Lighthouse scores or Core Web Vitals."
|
||||
}
|
||||
]
|
||||
},
|
||||
"benefits": {
|
||||
"title": "Why Next.js developers choose OpenPanel",
|
||||
"intro": "OpenPanel fits naturally into a modern Next.js codebase — not as an afterthought.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Works exactly how Next.js works",
|
||||
"description": "Built for App Router, server components, and modern Next.js patterns. Not bolted onto a client-only library."
|
||||
},
|
||||
{
|
||||
"title": "No consent infrastructure needed",
|
||||
"description": "Cookieless tracking means no consent modal, no next/script loading gymnastics, no conditional initialization. Install once, works everywhere."
|
||||
},
|
||||
{
|
||||
"title": "Track server and client events in the same dashboard",
|
||||
"description": "Server actions, API endpoints, and client interactions all show up together. Full picture of what your app is doing."
|
||||
},
|
||||
{
|
||||
"title": "Open source and self-hostable",
|
||||
"description": "Run OpenPanel on your own infrastructure. Your Next.js app's analytics data never leaves your servers."
|
||||
},
|
||||
{
|
||||
"title": "From $2.50/month",
|
||||
"description": "No enterprise contract, no per-seat fees. Pay for events, get all features. Start free with 30-day trial."
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions from Next.js developers evaluating OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "How do I install OpenPanel in a Next.js app?",
|
||||
"answer": "Install @openpanel/nextjs, add <OpenPanelComponent clientId=\"...\" /> to your root layout.tsx, and you're tracking page views. For custom events, import and call op.track('event_name', { properties }) anywhere. See the full step-by-step guide at /guides/nextjs-analytics."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel support Next.js App Router?",
|
||||
"answer": "Yes. The <OpenPanelComponent> handles App Router route changes automatically using the usePathname hook internally. No custom setup is needed for client-side navigation tracking."
|
||||
},
|
||||
{
|
||||
"question": "Can I track events in Server Components and API Routes?",
|
||||
"answer": "Yes. Import { openPanel } from @openpanel/nextjs/server and call openPanel.track() in any server context including Server Components, Route Handlers, and Server Actions."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel work with NextAuth.js or Clerk?",
|
||||
"answer": "Yes. Call op.identify({ profileId: session.user.id, ... }) after authentication to link events to user identities. Works with any auth solution that gives you a user ID."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel add significant bundle size to my Next.js app?",
|
||||
"answer": "No. The client-side SDK is 2.3 KB gzipped. For reference, GA4 adds 50+ KB. OpenPanel has negligible impact on your bundle size or Core Web Vitals."
|
||||
},
|
||||
{
|
||||
"question": "Can I self-host OpenPanel for my Next.js app?",
|
||||
"answer": "Yes. OpenPanel is fully open source and can be self-hosted with Docker. Your Next.js app sends events to your own server. The self-hosted version has no event limits and is free to run."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"guides": [
|
||||
{ "title": "Next.js analytics setup", "url": "/guides/nextjs-analytics" },
|
||||
{ "title": "React analytics setup", "url": "/guides/react-analytics" },
|
||||
{ "title": "Track custom events", "url": "/guides/track-custom-events" }
|
||||
],
|
||||
"articles": [
|
||||
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
|
||||
],
|
||||
"comparisons": [
|
||||
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
|
||||
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" }
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Try OpenPanel Free",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View Source on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
165
apps/public/content/for/saas.json
Normal file
165
apps/public/content/for/saas.json
Normal file
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"slug": "saas",
|
||||
"audience": "saas",
|
||||
"seo": {
|
||||
"title": "SaaS Analytics — Track Events, Funnels & Retention Without Enterprise Pricing",
|
||||
"description": "OpenPanel gives SaaS teams the product analytics they need to reduce churn and grow — events, funnels, retention, cohorts, and user profiles. Open source, from $2.50/month.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Product Analytics for SaaS Teams",
|
||||
"subheading": "Understanding why users churn, where trials drop off, and which features drive retention is the difference between a SaaS that grows and one that plateaus. OpenPanel gives you events, funnels, retention analysis, and user profiles — the core analytics stack for SaaS — without Mixpanel or Amplitude's enterprise pricing.",
|
||||
"badges": [
|
||||
"Funnel analysis",
|
||||
"Retention tracking",
|
||||
"User profiles",
|
||||
"From $2.50/month"
|
||||
]
|
||||
},
|
||||
"problem": {
|
||||
"title": "Why SaaS teams outgrow their analytics tools",
|
||||
"intro": "The analytics tools most SaaS teams start with either can't answer the right questions or become unaffordable as you grow.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Mixpanel and Amplitude become unaffordable at scale",
|
||||
"description": "Event-based pricing sounds cheap at 10K events, but SaaS products are event-heavy. At 1M events/month you're paying $300–800/month. At 10M, it's thousands. Your analytics bill grows faster than your revenue."
|
||||
},
|
||||
{
|
||||
"title": "GA4 doesn't answer SaaS questions",
|
||||
"description": "Google Analytics tells you pageviews. It can't tell you which users completed your onboarding flow, how feature adoption correlates with retention, or why your trial-to-paid conversion dropped last month."
|
||||
},
|
||||
{
|
||||
"title": "Complex tools slow down your team",
|
||||
"description": "Mixpanel's interface is powerful but has a steep learning curve. When it takes 30 minutes to build a simple funnel, your team stops using analytics to make decisions."
|
||||
},
|
||||
{
|
||||
"title": "Cloud-only means full vendor dependence",
|
||||
"description": "If Mixpanel raises prices, your data is hostage. No export path, no self-hosting option, and a pricing model that punishes growth."
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"title": "The full product analytics stack for SaaS",
|
||||
"intro": "Everything your team needs to understand users, reduce churn, and grow — without the enterprise pricing.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Event tracking",
|
||||
"description": "Track any user action — feature used, button clicked, settings changed, plan upgraded. One line of code. Works across web, mobile, and your backend."
|
||||
},
|
||||
{
|
||||
"title": "Funnel analysis",
|
||||
"description": "Build conversion funnels for trial signup, onboarding, activation, and upgrade flows. See the exact step losing you the most conversions and fix it."
|
||||
},
|
||||
{
|
||||
"title": "Retention analysis",
|
||||
"description": "Cohort-based retention charts show you whether users activated in week 1 are still active in week 8. Identify which actions predict long-term retention."
|
||||
},
|
||||
{
|
||||
"title": "User profiles",
|
||||
"description": "See every event a specific user triggered since they signed up. Walk through their session to diagnose support issues or understand power user behavior."
|
||||
},
|
||||
{
|
||||
"title": "Cohort analysis",
|
||||
"description": "Group users by signup date, plan, or any property and compare their behavior over time. Understand how product changes affect different user segments."
|
||||
},
|
||||
{
|
||||
"title": "Real-time dashboard",
|
||||
"description": "See new signups, trial activations, and feature usage as they happen. Know immediately when a new deployment changes user behavior."
|
||||
},
|
||||
{
|
||||
"title": "Revenue tracking",
|
||||
"description": "Send MRR, payment events, and plan upgrade data to OpenPanel. Correlate feature usage with revenue without a separate BI tool."
|
||||
},
|
||||
{
|
||||
"title": "Multi-platform SDKs",
|
||||
"description": "Track across web app, marketing site, iOS, Android, and backend in one unified analytics view. Events from all platforms share the same user profiles."
|
||||
}
|
||||
]
|
||||
},
|
||||
"benefits": {
|
||||
"title": "Why SaaS teams choose OpenPanel",
|
||||
"intro": "OpenPanel gives you the analytics depth of Mixpanel at a price that makes sense for growing SaaS products.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Predictable pricing as you scale",
|
||||
"description": "Flat event tiers from $2.50 to $900/month. No per-seat fees, no MTU limits. Know exactly what you'll pay as your user base grows."
|
||||
},
|
||||
{
|
||||
"title": "Answer the questions SaaS teams actually ask",
|
||||
"description": "Which onboarding step has the highest drop-off? Which features are used by retained users vs churned users? OpenPanel is built for these questions."
|
||||
},
|
||||
{
|
||||
"title": "Self-host to eliminate per-event costs",
|
||||
"description": "Large SaaS products generate millions of events. Self-hosting eliminates the cost entirely — pay only for your server infrastructure."
|
||||
},
|
||||
{
|
||||
"title": "All features from day one",
|
||||
"description": "Funnels, retention, cohorts, user profiles, and dashboards are available at every pricing tier. No feature gating that forces upgrades."
|
||||
},
|
||||
{
|
||||
"title": "Open source and auditable",
|
||||
"description": "Your product data is sensitive. OpenPanel's open source codebase means you can audit exactly what's tracked and how it's stored."
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions from SaaS founders and product teams evaluating OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "How is OpenPanel different from Mixpanel for SaaS?",
|
||||
"answer": "OpenPanel covers the same core analytics (events, funnels, retention, cohorts, user profiles) at a fraction of the cost. Mixpanel adds features like Metric Trees and advanced experimentation, but most SaaS teams don't use them. OpenPanel focuses on the analytics that actually drive decisions."
|
||||
},
|
||||
{
|
||||
"question": "Can OpenPanel replace both GA4 and Mixpanel?",
|
||||
"answer": "Yes. OpenPanel includes web analytics (pageviews, referrers, UTM campaigns) alongside product analytics. Most SaaS teams can replace both tools with one OpenPanel deployment."
|
||||
},
|
||||
{
|
||||
"question": "How do I track trial-to-paid conversions?",
|
||||
"answer": "Track a trial_started event on signup and a plan_upgraded event on conversion. Build a funnel in OpenPanel between those two events. See conversion rate by cohort, traffic source, or any user property."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel support multi-product or multi-tenant SaaS?",
|
||||
"answer": "Yes. Use the profileId to identify individual users and pass organization or workspace IDs as properties. You can filter analytics by any property you send."
|
||||
},
|
||||
{
|
||||
"question": "What happens to my data if I stop paying for OpenPanel cloud?",
|
||||
"answer": "You can export all your event data via the API at any time. Alternatively, migrate to self-hosting — OpenPanel has no lock-in and no proprietary data format."
|
||||
},
|
||||
{
|
||||
"question": "Can I track backend events from my SaaS API?",
|
||||
"answer": "Yes. OpenPanel has server-side SDKs for Node.js, Python, and a REST API. Track subscription webhooks, background jobs, and server-side logic alongside client events."
|
||||
},
|
||||
{
|
||||
"question": "Is OpenPanel GDPR compliant for SaaS?",
|
||||
"answer": "Yes. Cookieless tracking by default, EU-only cloud hosting, and the option to self-host for complete data sovereignty. No consent banner required for product analytics."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"guides": [
|
||||
{ "title": "Track custom events", "url": "/guides/track-custom-events" },
|
||||
{ "title": "Next.js analytics setup", "url": "/guides/nextjs-analytics" },
|
||||
{ "title": "React analytics setup", "url": "/guides/react-analytics" }
|
||||
],
|
||||
"articles": [
|
||||
{ "title": "How to create a funnel", "url": "/articles/how-to-create-a-funnel" },
|
||||
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
|
||||
],
|
||||
"comparisons": [
|
||||
{ "title": "OpenPanel vs Mixpanel", "url": "/compare/mixpanel-alternative" },
|
||||
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" },
|
||||
{ "title": "OpenPanel vs Amplitude", "url": "/compare/amplitude-alternative" }
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Try OpenPanel Free",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View Source on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
158
apps/public/content/for/shopify.json
Normal file
158
apps/public/content/for/shopify.json
Normal file
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"slug": "shopify",
|
||||
"audience": "shopify",
|
||||
"seo": {
|
||||
"title": "Shopify Analytics — Cookie-Free Tracking Without Consent Banners",
|
||||
"description": "Add product analytics to your Shopify store without GDPR consent banners. OpenPanel is cookieless by default — track events, funnels, and revenue from $2.50/month or free to self-host.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "Shopify Analytics That Actually Works",
|
||||
"subheading": "Most analytics tools break on Shopify — cookie consent blocks tracking, GA4 loses attribution, and Shopify's built-in reports can't answer the questions that matter. OpenPanel tracks every event without cookies, so your data is complete and your checkout conversion doesn't suffer from a consent banner.",
|
||||
"badges": [
|
||||
"Cookie-free tracking",
|
||||
"No consent banner needed",
|
||||
"Revenue tracking",
|
||||
"From $2.50/month"
|
||||
]
|
||||
},
|
||||
"problem": {
|
||||
"title": "Why analytics breaks on Shopify",
|
||||
"intro": "Every major analytics tool creates a different problem for Shopify stores. Here's what you're actually dealing with.",
|
||||
"items": [
|
||||
{
|
||||
"title": "GA4 consent mode destroys your data",
|
||||
"description": "Cookie consent requirements mean 30-50% of visitors opt out. GA4's \"consent mode\" fills gaps with modeled data — you're making decisions on estimates, not reality."
|
||||
},
|
||||
{
|
||||
"title": "Shopify's built-in analytics is shallow",
|
||||
"description": "Pageviews and sales are there, but you can't build funnels, analyze drop-off by product page, or see which acquisition channel retains customers best."
|
||||
},
|
||||
{
|
||||
"title": "Event tracking requires a developer",
|
||||
"description": "Installing GA4 on Shopify correctly, with purchase events, add-to-cart, and checkout steps, requires custom code or expensive third-party apps."
|
||||
},
|
||||
{
|
||||
"title": "Cookie banners hurt conversion",
|
||||
"description": "A consent popup before checkout is a conversion killer. Every analytics tool that uses cookies forces you to choose between data and revenue."
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"title": "Everything you need to understand your Shopify store",
|
||||
"intro": "OpenPanel is designed to give you complete visibility into your store's performance — without the privacy headaches.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Script tag install — no developer needed",
|
||||
"description": "Add one script tag in Shopify's theme settings. Automatic page view tracking starts immediately. No Shopify app or coding required."
|
||||
},
|
||||
{
|
||||
"title": "Cookieless tracking by default",
|
||||
"description": "No cookies means no consent banner. Your analytics data is 100% complete — not modeled, not estimated, not filtered by opt-outs."
|
||||
},
|
||||
{
|
||||
"title": "Purchase and revenue tracking",
|
||||
"description": "Track add-to-cart, checkout started, and purchase events with revenue values. See exactly which campaigns and pages drive revenue, not just clicks."
|
||||
},
|
||||
{
|
||||
"title": "Funnel analysis",
|
||||
"description": "Build funnels from product page → add to cart → checkout → purchase. See exactly where you're losing customers and fix it."
|
||||
},
|
||||
{
|
||||
"title": "UTM campaign attribution",
|
||||
"description": "Track every paid ad, email campaign, and influencer link. See which traffic source actually converts to buyers, not just visitors."
|
||||
},
|
||||
{
|
||||
"title": "User profiles",
|
||||
"description": "See the complete journey of any customer — pages visited, products viewed, purchase history. Identify high-value customer behavior patterns."
|
||||
},
|
||||
{
|
||||
"title": "Real-time dashboard",
|
||||
"description": "Watch your store live. Launch a sale or send an email and see the traffic surge in real time."
|
||||
},
|
||||
{
|
||||
"title": "EU data residency",
|
||||
"description": "All data stored in the EU. No transfers to US servers. GDPR compliance without the legal complexity."
|
||||
}
|
||||
]
|
||||
},
|
||||
"benefits": {
|
||||
"title": "Why Shopify store owners choose OpenPanel",
|
||||
"intro": "OpenPanel was built to solve the exact problems that plague analytics on Shopify.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Complete data, no consent required",
|
||||
"description": "Cookieless tracking means every visitor is counted. No consent mode models, no sampling, no gaps from opt-outs."
|
||||
},
|
||||
{
|
||||
"title": "Cheaper than Shopify analytics apps",
|
||||
"description": "Most Shopify analytics apps charge $50–200/month. OpenPanel starts at $2.50/month with all features included."
|
||||
},
|
||||
{
|
||||
"title": "Replace GA4 and your analytics app",
|
||||
"description": "One tool covers web analytics (traffic, referrers, UTM) and product analytics (funnels, retention, revenue). Cancel two subscriptions."
|
||||
},
|
||||
{
|
||||
"title": "Privacy-first out of the box",
|
||||
"description": "EU hosting, no cookies, no fingerprinting. No compliance team required to use OpenPanel on your store."
|
||||
},
|
||||
{
|
||||
"title": "Self-host for free",
|
||||
"description": "For high-volume stores, self-hosting eliminates per-event costs entirely. One Docker deployment, unlimited events."
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions from Shopify store owners evaluating OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "How do I install OpenPanel on Shopify?",
|
||||
"answer": "Add the OpenPanel script tag via Shopify Admin → Online Store → Themes → Edit Code → theme.liquid. Paste the snippet before </head>. Automatic page views track immediately. For purchase events, add a few lines to your order confirmation page."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel work without a cookie consent banner?",
|
||||
"answer": "Yes. OpenPanel is cookieless by default and doesn't collect personal data. No consent banner is required under GDPR or CCPA for analytics-only use."
|
||||
},
|
||||
{
|
||||
"question": "Can I track Shopify purchase events?",
|
||||
"answer": "Yes. Use a simple script on your order confirmation page to send purchase amount, order ID, and product data to OpenPanel. No Shopify app required."
|
||||
},
|
||||
{
|
||||
"question": "How does OpenPanel compare to Lucky Orange or Hotjar for Shopify?",
|
||||
"answer": "Lucky Orange and Hotjar focus on heatmaps and session replay. OpenPanel focuses on event analytics, funnels, and retention — understanding what users do, not watching recordings. If you need both, they complement each other."
|
||||
},
|
||||
{
|
||||
"question": "Will OpenPanel affect my Shopify store's page speed?",
|
||||
"answer": "OpenPanel's JavaScript SDK is 2.3 KB gzipped — one of the lightest analytics trackers available. GA4 is 50+ KB. The performance impact is negligible."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel work with Shopify Plus?",
|
||||
"answer": "Yes. OpenPanel works on any Shopify plan including Plus. The script tag installation method works identically across all plans."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"guides": [
|
||||
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" }
|
||||
],
|
||||
"articles": [
|
||||
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
|
||||
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" }
|
||||
],
|
||||
"comparisons": [
|
||||
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
|
||||
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" }
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Try OpenPanel Free",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View Source on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
157
apps/public/content/for/wordpress.json
Normal file
157
apps/public/content/for/wordpress.json
Normal file
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"slug": "wordpress",
|
||||
"audience": "wordpress",
|
||||
"seo": {
|
||||
"title": "WordPress Analytics Without Cookies — A Google Analytics Alternative",
|
||||
"description": "Add privacy-first analytics to your WordPress site without cookie consent banners. OpenPanel is cookieless, open source, and starts at $2.50/month — a better alternative to GA4 for WordPress.",
|
||||
"noindex": false
|
||||
},
|
||||
"hero": {
|
||||
"heading": "WordPress Analytics Without the Cookie Banner",
|
||||
"subheading": "Google Analytics on WordPress means cookie consent popups, GDPR complexity, and sending your visitors' data to US servers. OpenPanel is a cookieless WordPress analytics plugin that gives you pageviews, events, and user behavior — with full GDPR compliance out of the box and none of the privacy headaches.",
|
||||
"badges": [
|
||||
"No cookie banner needed",
|
||||
"GDPR compliant",
|
||||
"Open source",
|
||||
"Lightweight — 2.3 KB"
|
||||
]
|
||||
},
|
||||
"problem": {
|
||||
"title": "Why Google Analytics on WordPress creates problems",
|
||||
"intro": "WordPress powers 40% of the web, but GA4 was not designed with WordPress privacy requirements in mind.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Google Analytics requires cookie consent on WordPress",
|
||||
"description": "Installing GA4 on a WordPress site means a consent popup for every visitor. Under GDPR, you can't set GA4 cookies without explicit opt-in. That banner costs you conversions and corrupts your data."
|
||||
},
|
||||
{
|
||||
"title": "GA4 sends data to US servers",
|
||||
"description": "Google Analytics transfers visitor data to US servers. For European sites, this creates legal exposure under SCHREMS II — a risk WordPress site owners increasingly can't ignore."
|
||||
},
|
||||
{
|
||||
"title": "WordPress analytics plugins are bloated",
|
||||
"description": "Most GA4 WordPress plugins add significant weight to your site. Page speed suffers, Core Web Vitals scores drop, and you're still dealing with cookies."
|
||||
},
|
||||
{
|
||||
"title": "You can't see user behavior, only pageviews",
|
||||
"description": "GA4 tells you how many people visited. OpenPanel tells you what they did: which CTAs they clicked, where they dropped off, which blog posts convert to signups."
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"title": "Privacy-first analytics for WordPress",
|
||||
"intro": "OpenPanel gives WordPress site owners complete visibility without the cookie compliance headaches.",
|
||||
"items": [
|
||||
{
|
||||
"title": "Official WordPress plugin",
|
||||
"description": "Install the OpenPanel WordPress plugin directly from the WordPress plugin directory. Activate it, paste your Client ID, and page view tracking starts immediately — no code required."
|
||||
},
|
||||
{
|
||||
"title": "Automatic page view tracking",
|
||||
"description": "Every WordPress page, post, and custom post type is tracked automatically. No configuration needed for standard pageview analytics."
|
||||
},
|
||||
{
|
||||
"title": "Cookie-free by default",
|
||||
"description": "No cookies means no consent banner. Compliant with GDPR, CCPA, and ePrivacy without any configuration."
|
||||
},
|
||||
{
|
||||
"title": "Custom event tracking",
|
||||
"description": "Track clicks, form submissions, and conversions with a one-line JavaScript call. Know which CTAs and forms drive the most leads."
|
||||
},
|
||||
{
|
||||
"title": "UTM campaign tracking",
|
||||
"description": "See exactly which emails, ads, and social posts drive traffic to your WordPress site — and which ones actually convert."
|
||||
},
|
||||
{
|
||||
"title": "Lightweight script — 2.3 KB",
|
||||
"description": "OpenPanel's tracker is 20x smaller than GA4. No impact on Core Web Vitals or PageSpeed scores."
|
||||
},
|
||||
{
|
||||
"title": "Self-hostable on your own server",
|
||||
"description": "Run OpenPanel on your own infrastructure. Your WordPress visitor data never leaves your servers."
|
||||
}
|
||||
]
|
||||
},
|
||||
"benefits": {
|
||||
"title": "Why WordPress site owners switch to OpenPanel",
|
||||
"intro": "OpenPanel replaces Google Analytics without sacrificing features — and fixes the privacy problems GA4 created.",
|
||||
"items": [
|
||||
{
|
||||
"title": "No consent banner required",
|
||||
"description": "Cookieless tracking means you're GDPR-compliant without a popup. No lost conversions, no fragmented data from opt-outs."
|
||||
},
|
||||
{
|
||||
"title": "Replace Google Analytics without losing features",
|
||||
"description": "Pageviews, referrers, countries, devices, UTM campaigns — all there. Plus events and funnels that GA4 requires complex configuration to support."
|
||||
},
|
||||
{
|
||||
"title": "Your data stays out of Google's hands",
|
||||
"description": "OpenPanel is EU-hosted with no data sharing. Ideal for European sites, publishers, and anyone avoiding Google's data monopoly."
|
||||
},
|
||||
{
|
||||
"title": "Open source and auditable",
|
||||
"description": "Unlike GA4, OpenPanel's code is public. You can see exactly what data is collected and how it's processed."
|
||||
},
|
||||
{
|
||||
"title": "Affordable pricing",
|
||||
"description": "From $2.50/month for small WordPress sites. Self-host for free if you prefer — full feature parity, no event limits."
|
||||
}
|
||||
]
|
||||
},
|
||||
"faqs": {
|
||||
"title": "Frequently asked questions",
|
||||
"intro": "Common questions from WordPress site owners evaluating OpenPanel.",
|
||||
"items": [
|
||||
{
|
||||
"question": "How do I install OpenPanel on WordPress?",
|
||||
"answer": "Add the OpenPanel script tag to your WordPress site via your theme's header.php, a child theme, or any \"header scripts\" plugin. Paste the snippet before </head>. Automatic page view tracking starts immediately — no additional configuration needed."
|
||||
},
|
||||
{
|
||||
"question": "Do I need a cookie consent banner with OpenPanel?",
|
||||
"answer": "No. OpenPanel uses cookieless tracking and doesn't collect personal data by default. Under GDPR, cookie consent is only required when you actually use cookies. OpenPanel doesn't, so no banner is needed."
|
||||
},
|
||||
{
|
||||
"question": "Is there an OpenPanel WordPress plugin?",
|
||||
"answer": "Yes. The official OpenPanel plugin is available in the WordPress plugin directory. Search for \"OpenPanel\" in Plugins → Add New, install it, and paste your Client ID. You can also add the script tag manually via your theme's header.php or any \"add code to header\" plugin if you prefer."
|
||||
},
|
||||
{
|
||||
"question": "How does OpenPanel compare to MonsterInsights or Analytify?",
|
||||
"answer": "Those plugins are wrappers around Google Analytics — they still send data to Google and still require cookies. OpenPanel is an independent analytics platform that's cookieless by default and stores data in the EU."
|
||||
},
|
||||
{
|
||||
"question": "Can I track WooCommerce events?",
|
||||
"answer": "Yes. You can track add-to-cart, checkout, and purchase events by adding a few lines of JavaScript to your WooCommerce templates or using WordPress hooks. No dedicated WooCommerce plugin required."
|
||||
},
|
||||
{
|
||||
"question": "Does OpenPanel affect WordPress site speed?",
|
||||
"answer": "OpenPanel's script is 2.3 KB gzipped. By comparison, GA4 adds 50+ KB. Switching from GA4 to OpenPanel will likely improve your Core Web Vitals scores."
|
||||
}
|
||||
]
|
||||
},
|
||||
"related_links": {
|
||||
"guides": [
|
||||
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" },
|
||||
{ "title": "Website analytics setup", "url": "/guides/website-analytics-setup" },
|
||||
{ "title": "OpenPanel WordPress plugin", "url": "https://sv.wordpress.org/plugins/openpanel/" }
|
||||
],
|
||||
"articles": [
|
||||
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
|
||||
{ "title": "How to self-host OpenPanel", "url": "/articles/self-hosted-web-analytics" }
|
||||
],
|
||||
"comparisons": [
|
||||
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
|
||||
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" },
|
||||
{ "title": "OpenPanel vs Matomo", "url": "/compare/matomo-alternative" }
|
||||
]
|
||||
},
|
||||
"ctas": {
|
||||
"primary": {
|
||||
"label": "Try OpenPanel Free",
|
||||
"href": "https://dashboard.openpanel.dev/onboarding"
|
||||
},
|
||||
"secondary": {
|
||||
"label": "View Source on GitHub",
|
||||
"href": "https://github.com/Openpanel-dev/openpanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ description: Learn about OpenPanel, the open-source web and product analytics pl
|
||||
|
||||
**OpenPanel** is an open-source web and product analytics platform - a modern alternative to Mixpanel, Google Analytics, and Plausible. We're NOT a server control panel or hosting panel like other software that shares our name.
|
||||
|
||||
If you were looking for a server administration panel (like cPanel or Plesk), you might be looking for [OpenPanel](https://openpanel.com) - that's a different product for managing web servers. **OpenPanel.dev** is all about analytics.
|
||||
If you were looking for a server administration panel (like cPanel or Plesk), you might be looking for [OpenPanel](https://openpanel.dev) - that's a different product for managing web servers. **OpenPanel.dev** is all about analytics.
|
||||
|
||||
## Introduction
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -34,9 +34,9 @@ const faqData = [
|
||||
'We have a dedicated compare page where you can see how OpenPanel compares to other analytics tools. You can find it [here](/compare). You can also read our comprehensive guide on the [best open source web analytics tools](/articles/open-source-web-analytics).',
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Mixpanel?',
|
||||
question: 'Is OpenPanel a good Mixpanel alternative?',
|
||||
answer:
|
||||
"OpenPanel offers similar powerful product analytics features as Mixpanel, but with the added benefits of being open-source, more affordable, and including web analytics capabilities.\n\nYou get Mixpanel's power with Plausible's simplicity.",
|
||||
"Yes — OpenPanel covers the core features most teams use in Mixpanel: event tracking, funnels, retention, cohorts, and user profiles. The key differences are pricing, privacy, and self-hosting.\n\nOpenPanel starts at $2.50/month and can be self-hosted for free, while Mixpanel is cloud-only and scales to hundreds or thousands per month. OpenPanel is also cookie-free by default with EU-only hosting, so no consent banners required — something Mixpanel can't offer.\n\nSee the full [OpenPanel vs Mixpanel comparison](/compare/mixpanel-alternative) for a side-by-side breakdown.",
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Plausible?',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { BarChart2Icon, CoinsIcon, GithubIcon, ServerIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { GetStartedButton } from '@/components/get-started-button';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const reasons = [
|
||||
{
|
||||
icon: CoinsIcon,
|
||||
title: 'Fraction of the cost',
|
||||
description:
|
||||
"Mixpanel's pricing scales to hundreds or thousands per month as your event volume grows. OpenPanel starts at $2.50/month — or self-host for free with no event limits.",
|
||||
},
|
||||
{
|
||||
icon: BarChart2Icon,
|
||||
title: 'The features you actually use',
|
||||
description:
|
||||
'Events, funnels, retention, cohorts, user profiles, custom dashboards, and A/B testing — all there. OpenPanel covers every core analytics workflow from Mixpanel without the learning curve.',
|
||||
},
|
||||
{
|
||||
icon: ServerIcon,
|
||||
title: 'Actually self-hostable',
|
||||
description:
|
||||
'Mixpanel is cloud-only. OpenPanel runs on your own infrastructure with a simple Docker setup. Full data ownership, zero vendor lock-in.',
|
||||
},
|
||||
{
|
||||
icon: GithubIcon,
|
||||
title: 'Open source & transparent',
|
||||
description:
|
||||
"Mixpanel is a black box. OpenPanel's code is public on GitHub — audit it, contribute to it, or fork it. No surprises, no hidden data processing.",
|
||||
},
|
||||
];
|
||||
|
||||
export function MixpanelAlternative() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
description="OpenPanel covers the product analytics features teams actually use — events, funnels, retention, cohorts, and user profiles — without Mixpanel's pricing, privacy trade-offs, or vendor lock-in."
|
||||
label="Mixpanel Alternative"
|
||||
title="Why teams switch from Mixpanel to OpenPanel"
|
||||
/>
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{reasons.map((reason) => (
|
||||
<FeatureCard
|
||||
description={reason.description}
|
||||
icon={reason.icon}
|
||||
key={reason.title}
|
||||
title={reason.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="row mt-8 gap-4">
|
||||
<GetStartedButton />
|
||||
<Button asChild className="px-6" size="lg" variant="outline">
|
||||
<Link href="/compare/mixpanel-alternative">
|
||||
OpenPanel vs Mixpanel →
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { FeatureSpotlight } from './_sections/feature-spotlight';
|
||||
import { CtaBanner } from './_sections/cta-banner';
|
||||
import { DataPrivacy } from './_sections/data-privacy';
|
||||
import { Faq } from './_sections/faq';
|
||||
import { MixpanelAlternative } from './_sections/mixpanel-alternative';
|
||||
import { Hero } from './_sections/hero';
|
||||
import { Pricing } from './_sections/pricing';
|
||||
import { Sdks } from './_sections/sdks';
|
||||
@@ -63,6 +64,7 @@ export default function HomePage() {
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
<DataPrivacy />
|
||||
<MixpanelAlternative />
|
||||
<Sdks />
|
||||
<Faq />
|
||||
<CtaBanner />
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = getPageMetadata({
|
||||
url: '/tools/ip-lookup',
|
||||
title: 'IP Lookup - Free IP Address Geolocation Tool',
|
||||
title: 'Free IP Address Lookup — Geolocation, ISP & ASN',
|
||||
description:
|
||||
'Find your IP address and get detailed geolocation information including country, city, ISP, ASN, and coordinates. Free IP lookup tool with map preview.',
|
||||
'Instantly look up any IP address. Get country, city, region, ISP, ASN, and coordinates in seconds. Free tool, no signup required, powered by MaxMind GeoLite2.',
|
||||
});
|
||||
|
||||
export default function IPLookupLayout({
|
||||
|
||||
@@ -26,11 +26,11 @@ export function baseOptions(): BaseLayoutProps {
|
||||
export const authors = [
|
||||
{
|
||||
name: 'OpenPanel Team',
|
||||
url: 'https://openpanel.com',
|
||||
url: 'https://openpanel.dev',
|
||||
},
|
||||
{
|
||||
name: 'Carl-Gerhard Lindesvärd',
|
||||
url: 'https://openpanel.com',
|
||||
url: 'https://openpanel.dev',
|
||||
image: '/twitter-carl.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { EventIcon } from './event-icon';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
||||
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { EventIcon } from './event-icon';
|
||||
|
||||
type EventListItemProps = IServiceEventMinimal | IServiceEvent;
|
||||
|
||||
export function EventListItem(props: EventListItemProps) {
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const { createdAt, name, path, duration, meta } = props;
|
||||
const { createdAt, name, path, meta } = props;
|
||||
const profile = 'profile' in props ? props.profile : null;
|
||||
|
||||
const number = useNumber();
|
||||
|
||||
const renderName = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
@@ -32,83 +27,65 @@ export function EventListItem(props: EventListItemProps) {
|
||||
return name.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const renderDuration = () => {
|
||||
if (name === 'screen_view') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{number.shortWithUnit(duration / 1000, 'min')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isMinimal = 'minimal' in props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isMinimal) {
|
||||
pushModal('EventDetails', {
|
||||
id: props.id,
|
||||
projectId,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'card hover:bg-light-background flex w-full items-center justify-between rounded-lg p-4 transition-colors',
|
||||
meta?.conversion &&
|
||||
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-left ">
|
||||
<EventIcon size="sm" name={name} meta={meta} />
|
||||
<span>
|
||||
<span className="font-medium">{renderName()}</span>
|
||||
{' '}
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-10">
|
||||
<div className="flex origin-left scale-75 gap-1">
|
||||
<SerieIcon name={props.country} />
|
||||
<SerieIcon name={props.os} />
|
||||
<SerieIcon name={props.browser} />
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
'card flex w-full items-center justify-between rounded-lg p-4 transition-colors hover:bg-light-background',
|
||||
meta?.conversion &&
|
||||
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isMinimal) {
|
||||
pushModal('EventDetails', {
|
||||
id: props.id,
|
||||
projectId,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-4 text-left">
|
||||
<EventIcon meta={meta} name={name} size="sm" />
|
||||
<span className="font-medium">{renderName()}</span>
|
||||
</div>
|
||||
<div className="pl-10">
|
||||
<div className="flex origin-left scale-75 gap-1">
|
||||
<SerieIcon name={props.country} />
|
||||
<SerieIcon name={props.os} />
|
||||
<SerieIcon name={props.browser} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{profile && (
|
||||
<Tooltiper asChild content={getProfileName(profile)}>
|
||||
<Link
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
to={'/$organizationId/$projectId/profiles/$profileId'}
|
||||
params={{
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId: profile.id,
|
||||
}}
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
</Tooltiper>
|
||||
)}
|
||||
|
||||
<Tooltiper asChild content={createdAt.toLocaleString()}>
|
||||
<div className=" text-muted-foreground">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{profile && (
|
||||
<Tooltiper asChild content={getProfileName(profile)}>
|
||||
<Link
|
||||
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
params={{
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId: profile.id,
|
||||
}}
|
||||
to={'/$organizationId/$projectId/profiles/$profileId'}
|
||||
>
|
||||
{getProfileName(profile)}
|
||||
</Link>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltiper asChild content={createdAt.toLocaleString()}>
|
||||
<div className="text-muted-foreground">
|
||||
{createdAt.toLocaleTimeString()}
|
||||
</div>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -8,71 +9,53 @@ import { useDebounceState } from '@/hooks/use-debounce-state';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
|
||||
export default function EventListener({
|
||||
onRefresh,
|
||||
}: {
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const params = useParams({
|
||||
strict: false,
|
||||
});
|
||||
const { projectId } = useAppParams();
|
||||
const counter = useDebounceState(0, 1000);
|
||||
useWS<IServiceEventMinimal | IServiceEvent>(
|
||||
useWS<{ count: number }>(
|
||||
`/live/events/${projectId}`,
|
||||
(event) => {
|
||||
if (event) {
|
||||
const isProfilePage = !!params?.profileId;
|
||||
if (isProfilePage) {
|
||||
const profile = 'profile' in event ? event.profile : null;
|
||||
if (profile?.id === params?.profileId) {
|
||||
counter.set((prev) => prev + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
counter.set((prev) => prev + 1);
|
||||
}
|
||||
({ count }) => {
|
||||
counter.set((prev) => prev + count);
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
|
||||
onClick={() => {
|
||||
counter.set(0);
|
||||
onRefresh();
|
||||
}}
|
||||
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
|
||||
type="button"
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
|
||||
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
|
||||
'absolute top-0 left-0 h-3 w-3 rounded-full bg-emerald-500 transition-all'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{counter.debounced === 0 ? (
|
||||
'Listening'
|
||||
) : (
|
||||
<AnimatedNumber value={counter.debounced} suffix=" new events" />
|
||||
<AnimatedNumber suffix=" new events" value={counter.debounced} />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { EventIcon } from '@/components/events/event-icon';
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { pushModal } from '@/modals';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
|
||||
export function useColumns() {
|
||||
const number = useNumber();
|
||||
@@ -28,17 +27,24 @@ export function useColumns() {
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell({ row }) {
|
||||
const { name, path, duration, properties, revenue } = row.original;
|
||||
const { name, path, revenue } = row.original;
|
||||
const fullTitle =
|
||||
name === 'screen_view'
|
||||
? path
|
||||
: name === 'revenue' && revenue
|
||||
? `${name} (${number.currency(revenue / 100)})`
|
||||
: name.replace(/_/g, ' ');
|
||||
|
||||
const renderName = () => {
|
||||
if (name === 'screen_view') {
|
||||
if (path.includes('/')) {
|
||||
return <span className="max-w-md truncate">{path}</span>;
|
||||
return path;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">Screen: </span>
|
||||
<span className="max-w-md truncate">{path}</span>
|
||||
{path}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -50,38 +56,26 @@ export function useColumns() {
|
||||
return name.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
const renderDuration = () => {
|
||||
if (name === 'screen_view') {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{number.shortWithUnit(duration / 1000, 'min')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="transition-transform hover:scale-105"
|
||||
className="shrink-0 transition-transform hover:scale-105"
|
||||
onClick={() => {
|
||||
pushModal('EditEvent', {
|
||||
id: row.original.id,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<EventIcon
|
||||
size="sm"
|
||||
name={row.original.name}
|
||||
meta={row.original.meta}
|
||||
name={row.original.name}
|
||||
size="sm"
|
||||
/>
|
||||
</button>
|
||||
<span className="flex gap-2">
|
||||
<span className="flex min-w-0 flex-1 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 max-w-full truncate text-left font-medium hover:underline"
|
||||
onClick={() => {
|
||||
pushModal('EventDetails', {
|
||||
id: row.original.id,
|
||||
@@ -89,11 +83,11 @@ export function useColumns() {
|
||||
projectId: row.original.projectId,
|
||||
});
|
||||
}}
|
||||
className="font-medium hover:underline"
|
||||
title={fullTitle}
|
||||
type="button"
|
||||
>
|
||||
{renderName()}
|
||||
<span className="block truncate">{renderName()}</span>
|
||||
</button>
|
||||
{renderDuration()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -107,8 +101,8 @@ export function useColumns() {
|
||||
if (profile) {
|
||||
return (
|
||||
<ProjectLink
|
||||
className="group row items-center gap-2 whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
||||
className="group whitespace-nowrap font-medium hover:underline row items-center gap-2"
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
{getProfileName(profile)}
|
||||
@@ -119,8 +113,8 @@ export function useColumns() {
|
||||
if (profileId && profileId !== deviceId) {
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${encodeURIComponent(profileId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(profileId)}`}
|
||||
>
|
||||
Unknown
|
||||
</ProjectLink>
|
||||
@@ -130,8 +124,8 @@ export function useColumns() {
|
||||
if (deviceId) {
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${encodeURIComponent(deviceId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(deviceId)}`}
|
||||
>
|
||||
Anonymous
|
||||
</ProjectLink>
|
||||
@@ -152,10 +146,10 @@ export function useColumns() {
|
||||
const { sessionId } = row.original;
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||
className="whitespace-nowrap font-medium hover:underline"
|
||||
href={`/sessions/${encodeURIComponent(sessionId)}`}
|
||||
>
|
||||
{sessionId.slice(0,6)}
|
||||
{sessionId.slice(0, 6)}
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
@@ -175,7 +169,7 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { country, city } = row.original;
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<div className="row min-w-0 items-center gap-2">
|
||||
<SerieIcon name={country} />
|
||||
<span className="truncate">{city}</span>
|
||||
</div>
|
||||
@@ -189,7 +183,7 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { os } = row.original;
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<div className="row min-w-0 items-center gap-2">
|
||||
<SerieIcon name={os} />
|
||||
<span className="truncate">{os}</span>
|
||||
</div>
|
||||
@@ -203,13 +197,39 @@ export function useColumns() {
|
||||
cell({ row }) {
|
||||
const { browser } = row.original;
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0">
|
||||
<div className="row min-w-0 items-center gap-2">
|
||||
<SerieIcon name={browser} />
|
||||
<span className="truncate">{browser}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'groups',
|
||||
header: 'Groups',
|
||||
size: 200,
|
||||
meta: {
|
||||
hidden: true,
|
||||
},
|
||||
cell({ row }) {
|
||||
const { groups } = row.original;
|
||||
if (!groups?.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{groups.map((g) => (
|
||||
<span
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
key={g}
|
||||
>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'properties',
|
||||
header: 'Properties',
|
||||
@@ -221,14 +241,14 @@ export function useColumns() {
|
||||
const { properties } = row.original;
|
||||
const filteredProperties = Object.fromEntries(
|
||||
Object.entries(properties || {}).filter(
|
||||
([key]) => !key.startsWith('__'),
|
||||
),
|
||||
([key]) => !key.startsWith('__')
|
||||
)
|
||||
);
|
||||
const items = Object.entries(filteredProperties);
|
||||
const limit = 2;
|
||||
const data = items.slice(0, limit).map(([key, value]) => ({
|
||||
name: key,
|
||||
value: value,
|
||||
value,
|
||||
}));
|
||||
if (items.length > limit) {
|
||||
data.push({
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, Loader2Icon } from 'lucide-react';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
import { last } from 'ramda';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
import EventListener from '../event-listener';
|
||||
import { useColumns } from './columns';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import {
|
||||
OverviewFilterButton,
|
||||
@@ -12,20 +26,6 @@ import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, FilterIcon, Loader2Icon } from 'lucide-react';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
import { last } from 'ramda';
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
import EventListener from '../event-listener';
|
||||
import { useColumns } from './columns';
|
||||
|
||||
type Props = {
|
||||
query: UseInfiniteQueryResult<
|
||||
@@ -35,6 +35,7 @@ type Props = {
|
||||
>,
|
||||
unknown
|
||||
>;
|
||||
showEventListener?: boolean;
|
||||
};
|
||||
|
||||
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
|
||||
@@ -53,6 +54,7 @@ interface VirtualRowProps {
|
||||
scrollMargin: number;
|
||||
isLoading: boolean;
|
||||
headerColumnsHash: string;
|
||||
onRowClick?: (row: any) => void;
|
||||
}
|
||||
|
||||
const VirtualRow = memo(
|
||||
@@ -62,12 +64,26 @@ const VirtualRow = memo(
|
||||
headerColumns,
|
||||
scrollMargin,
|
||||
isLoading,
|
||||
onRowClick,
|
||||
}: VirtualRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row absolute top-0 left-0 w-full border-b transition-colors hover:bg-muted/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
data-index={virtualRow.index}
|
||||
onClick={
|
||||
onRowClick
|
||||
? (e) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) {
|
||||
return;
|
||||
}
|
||||
onRowClick(row);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
ref={virtualRow.measureElement}
|
||||
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||
display: 'grid',
|
||||
@@ -82,8 +98,8 @@ const VirtualRow = memo(
|
||||
const width = `${cell.column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center whitespace-nowrap p-2 px-4 align-middle"
|
||||
key={cell.id}
|
||||
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
overflow: 'hidden',
|
||||
@@ -113,16 +129,18 @@ const VirtualRow = memo(
|
||||
prevProps.virtualRow.start === nextProps.virtualRow.start &&
|
||||
prevProps.virtualRow.size === nextProps.virtualRow.size &&
|
||||
prevProps.isLoading === nextProps.isLoading &&
|
||||
prevProps.headerColumnsHash === nextProps.headerColumnsHash
|
||||
prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
|
||||
prevProps.onRowClick === nextProps.onRowClick
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const VirtualizedEventsTable = ({
|
||||
table,
|
||||
data,
|
||||
isLoading,
|
||||
}: VirtualizedEventsTableProps) => {
|
||||
onRowClick,
|
||||
}: VirtualizedEventsTableProps & { onRowClick?: (row: any) => void }) => {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const headerColumns = table.getAllLeafColumns().filter((col) => {
|
||||
@@ -144,12 +162,12 @@ const VirtualizedEventsTable = ({
|
||||
const headerColumnsHash = headerColumns.map((col) => col.id).join(',');
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-x-auto rounded-md border bg-card"
|
||||
ref={parentRef}
|
||||
className="w-full overflow-x-auto border rounded-md bg-card"
|
||||
>
|
||||
{/* Table Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-card border-b"
|
||||
className="sticky top-0 z-10 border-b bg-card"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
@@ -163,8 +181,8 @@ const VirtualizedEventsTable = ({
|
||||
const width = `${column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
className="flex h-10 items-center whitespace-nowrap px-4 text-left font-semibold text-[10px] text-foreground uppercase"
|
||||
key={column.id}
|
||||
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
@@ -177,8 +195,8 @@ const VirtualizedEventsTable = ({
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No events"
|
||||
description={"Start sending events and you'll see them here"}
|
||||
title="No events"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -193,20 +211,23 @@ const VirtualizedEventsTable = ({
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = table.getRowModel().rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualRow
|
||||
headerColumns={headerColumns}
|
||||
headerColumnsHash={headerColumnsHash}
|
||||
isLoading={isLoading}
|
||||
key={row.id}
|
||||
onRowClick={onRowClick}
|
||||
row={row}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
virtualRow={{
|
||||
...virtualRow,
|
||||
measureElement: rowVirtualizer.measureElement,
|
||||
}}
|
||||
headerColumns={headerColumns}
|
||||
headerColumnsHash={headerColumnsHash}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -215,10 +236,18 @@ const VirtualizedEventsTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const EventsTable = ({ query }: Props) => {
|
||||
export const EventsTable = ({ query, showEventListener = false }: Props) => {
|
||||
const { isLoading } = query;
|
||||
const columns = useColumns();
|
||||
|
||||
const handleRowClick = useCallback((row: any) => {
|
||||
pushModal('EventDetails', {
|
||||
id: row.original.id,
|
||||
createdAt: row.original.createdAt,
|
||||
projectId: row.original.projectId,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return LOADING_DATA;
|
||||
@@ -272,13 +301,22 @@ export const EventsTable = ({ query }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventsTableToolbar query={query} table={table} />
|
||||
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
|
||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||
<EventsTableToolbar
|
||||
query={query}
|
||||
showEventListener={showEventListener}
|
||||
table={table}
|
||||
/>
|
||||
<VirtualizedEventsTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
table={table}
|
||||
/>
|
||||
<div className="center-center h-10 w-full pt-4" ref={inViewportRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100',
|
||||
'center-center size-8 rounded-full border bg-background opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
@@ -291,24 +329,26 @@ export const EventsTable = ({ query }: Props) => {
|
||||
function EventsTableToolbar({
|
||||
query,
|
||||
table,
|
||||
showEventListener,
|
||||
}: {
|
||||
query: Props['query'];
|
||||
table: Table<IServiceEvent>;
|
||||
showEventListener: boolean;
|
||||
}) {
|
||||
const { projectId } = useAppParams();
|
||||
const [startDate, setStartDate] = useQueryState(
|
||||
'startDate',
|
||||
parseAsIsoDateTime,
|
||||
parseAsIsoDateTime
|
||||
);
|
||||
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<EventListener onRefresh={() => query.refetch()} />
|
||||
{showEventListener && (
|
||||
<EventListener onRefresh={() => query.refetch()} />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={CalendarIcon}
|
||||
onClick={() => {
|
||||
pushModal('DateRangerPicker', {
|
||||
@@ -320,6 +360,8 @@ function EventsTableToolbar({
|
||||
endDate: endDate || undefined,
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{startDate && endDate
|
||||
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`
|
||||
|
||||
130
apps/start/src/components/groups/group-member-growth.tsx
Normal file
130
apps/start/src/components/groups/group-member-growth.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { TrendingUpIcon } from 'lucide-react';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartTooltip,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||
import {
|
||||
useXAxisProps,
|
||||
useYAxisProps,
|
||||
} from '@/components/report-chart/common/axis';
|
||||
import { Widget, WidgetBody } from '@/components/widget';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
type Props = {
|
||||
data: { date: string; count: number }[];
|
||||
};
|
||||
|
||||
function Tooltip(props: any) {
|
||||
const number = useNumber();
|
||||
const formatDate = useFormatDateInterval({ interval: 'day', short: false });
|
||||
const payload = props.payload?.[0]?.payload;
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[160px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDate(new Date(payload.timestamp))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-10 w-1 rounded-full"
|
||||
style={{ background: getChartColor(0) }}
|
||||
/>
|
||||
<div className="col gap-1">
|
||||
<div className="text-muted-foreground text-sm">Total members</div>
|
||||
<div
|
||||
className="font-semibold text-lg"
|
||||
style={{ color: getChartColor(0) }}
|
||||
>
|
||||
{number.format(payload.cumulative)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{payload.count > 0 && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
+{number.format(payload.count)} new
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupMemberGrowth({ data }: Props) {
|
||||
const xAxisProps = useXAxisProps({ interval: 'day' });
|
||||
const yAxisProps = useYAxisProps({});
|
||||
const color = getChartColor(0);
|
||||
|
||||
let cumulative = 0;
|
||||
const chartData = data.map((item) => {
|
||||
cumulative += item.count;
|
||||
return {
|
||||
date: item.date,
|
||||
timestamp: new Date(item.date).getTime(),
|
||||
count: item.count,
|
||||
cumulative,
|
||||
};
|
||||
});
|
||||
|
||||
const gradientId = 'memberGrowthGradient';
|
||||
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle icon={TrendingUpIcon}>New members last 30 days</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
{data.length === 0 ? (
|
||||
<p className="py-4 text-center text-muted-foreground text-sm">
|
||||
No data yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-[200px] w-full">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor={color} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<RechartTooltip
|
||||
content={<Tooltip />}
|
||||
cursor={{ stroke: color, strokeOpacity: 0.3 }}
|
||||
/>
|
||||
<Area
|
||||
dataKey="cumulative"
|
||||
dot={false}
|
||||
fill={`url(#${gradientId})`}
|
||||
isAnimationActive={false}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
<YAxis {...yAxisProps} />
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal={true}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
vertical={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
76
apps/start/src/components/groups/table/columns.tsx
Normal file
76
apps/start/src/components/groups/table/columns.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { IServiceGroup } from '@openpanel/db';
|
||||
|
||||
export type IServiceGroupWithStats = IServiceGroup & {
|
||||
memberCount: number;
|
||||
lastActiveAt: Date | null;
|
||||
};
|
||||
|
||||
export function useGroupColumns(): ColumnDef<IServiceGroupWithStats>[] {
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
return (
|
||||
<Link
|
||||
className="font-medium hover:underline"
|
||||
params={{ organizationId, projectId, groupId: group.id }}
|
||||
to="/$organizationId/$projectId/groups/$groupId"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{row.original.id}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">{row.original.type}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'memberCount',
|
||||
header: 'Members',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.memberCount}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'lastActiveAt',
|
||||
header: 'Last active',
|
||||
size: ColumnCreatedAt.size,
|
||||
cell: ({ row }) =>
|
||||
row.original.lastActiveAt ? (
|
||||
<ColumnCreatedAt>{row.original.lastActiveAt}</ColumnCreatedAt>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
size: ColumnCreatedAt.size,
|
||||
cell: ({ row }) => (
|
||||
<ColumnCreatedAt>{row.original.createdAt}</ColumnCreatedAt>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
114
apps/start/src/components/groups/table/index.tsx
Normal file
114
apps/start/src/components/groups/table/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { IServiceGroup } from '@openpanel/db';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { memo } from 'react';
|
||||
import { type IServiceGroupWithStats, useGroupColumns } from './columns';
|
||||
import { DataTable } from '@/components/ui/data-table/data-table';
|
||||
import {
|
||||
useDataTableColumnVisibility,
|
||||
useDataTablePagination,
|
||||
} from '@/components/ui/data-table/data-table-hooks';
|
||||
import {
|
||||
AnimatedSearchInput,
|
||||
DataTableToolbarContainer,
|
||||
} from '@/components/ui/data-table/data-table-toolbar';
|
||||
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { arePropsEqual } from '@/utils/are-props-equal';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface Props {
|
||||
query: UseQueryResult<RouterOutputs['group']['list'], unknown>;
|
||||
pageSize?: number;
|
||||
toolbarLeft?: React.ReactNode;
|
||||
}
|
||||
|
||||
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceGroupWithStats[];
|
||||
|
||||
export const GroupsTable = memo(
|
||||
({ query, pageSize = PAGE_SIZE, toolbarLeft }: Props) => {
|
||||
const { data, isLoading } = query;
|
||||
const columns = useGroupColumns();
|
||||
|
||||
const { setPage, state: pagination } = useDataTablePagination(pageSize);
|
||||
const {
|
||||
columnVisibility,
|
||||
setColumnVisibility,
|
||||
columnOrder,
|
||||
setColumnOrder,
|
||||
} = useDataTableColumnVisibility(columns, 'groups');
|
||||
|
||||
const table = useReactTable({
|
||||
data: isLoading ? LOADING_DATA : (data?.data ?? []),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
manualSorting: true,
|
||||
columns,
|
||||
rowCount: data?.meta.count,
|
||||
pageCount: Math.ceil(
|
||||
(data?.meta.count || 0) / (pagination.pageSize || 1)
|
||||
),
|
||||
filterFns: {
|
||||
isWithinRange: () => true,
|
||||
},
|
||||
state: {
|
||||
pagination,
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
|
||||
const nextPagination =
|
||||
typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(pagination)
|
||||
: updaterOrValue;
|
||||
setPage(nextPagination.pageIndex + 1);
|
||||
},
|
||||
getRowId: (row, index) => row.id ?? `loading-${index}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<GroupsTableToolbar table={table} toolbarLeft={toolbarLeft} />
|
||||
<DataTable
|
||||
empty={{
|
||||
title: 'No groups found',
|
||||
description:
|
||||
'Groups represent companies, teams, or other entities that events belong to.',
|
||||
}}
|
||||
loading={isLoading}
|
||||
table={table}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
arePropsEqual(['query.isLoading', 'query.data', 'pageSize', 'toolbarLeft'])
|
||||
);
|
||||
|
||||
function GroupsTableToolbar({
|
||||
table,
|
||||
toolbarLeft,
|
||||
}: {
|
||||
table: Table<IServiceGroupWithStats>;
|
||||
toolbarLeft?: React.ReactNode;
|
||||
}) {
|
||||
const { search, setSearch } = useSearchQueryState();
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{toolbarLeft}
|
||||
<AnimatedSearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search groups..."
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
<DataTableViewOptions table={table} />
|
||||
</DataTableToolbarContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,13 @@
|
||||
import type {
|
||||
IServiceClient,
|
||||
IServiceEvent,
|
||||
IServiceProject,
|
||||
} from '@openpanel/db';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { timeAgo } from '@/utils/date';
|
||||
|
||||
interface Props {
|
||||
project: IServiceProject;
|
||||
client: IServiceClient | null;
|
||||
events: IServiceEvent[];
|
||||
onVerified: (verified: boolean) => void;
|
||||
}
|
||||
|
||||
const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
|
||||
useWS<IServiceEvent>(
|
||||
`/live/events/${client?.projectId}?type=received`,
|
||||
(data) => {
|
||||
setEvents((prev) => [...prev, data]);
|
||||
onVerified(true);
|
||||
}
|
||||
);
|
||||
|
||||
const VerifyListener = ({ events }: Props) => {
|
||||
const isConnected = events.length > 0;
|
||||
|
||||
const renderIcon = () => {
|
||||
@@ -49,16 +31,18 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-6 rounded-xl p-4 md:p-6',
|
||||
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10'
|
||||
isConnected
|
||||
? 'bg-emerald-100 dark:bg-emerald-700/10'
|
||||
: 'bg-blue-500/10'
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-foreground/90 text-lg leading-normal">
|
||||
{isConnected ? 'Success' : 'Waiting for events'}
|
||||
{isConnected ? 'Successfully connected' : 'Waiting for events'}
|
||||
</div>
|
||||
{isConnected ? (
|
||||
<div className="flex flex-col-reverse">
|
||||
<div className="mt-2 flex flex-col-reverse gap-1">
|
||||
{events.length > 5 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckIcon size={14} />{' '}
|
||||
@@ -69,7 +53,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||
<div className="flex items-center gap-2" key={event.id}>
|
||||
<CheckIcon size={14} />{' '}
|
||||
<span className="font-medium">{event.name}</span>{' '}
|
||||
<span className="ml-auto text-emerald-800">
|
||||
<span className="ml-auto text-foreground/50 text-sm">
|
||||
{timeAgo(event.createdAt, 'round')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -34,13 +34,13 @@ const questions = [
|
||||
{
|
||||
question: 'How do I change my billing information?',
|
||||
answer: [
|
||||
'You can change your billing information by clicking the "Manage your subscription" button in the billing section.',
|
||||
'You can change your billing information by clicking the "Customer portal" button in the billing section.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'We need a custom plan, can you help us?',
|
||||
answer: [
|
||||
'Yes, we can help you with that. Please contact us at hello@openpanel.com to request a quote.',
|
||||
'Yes, we can help you with that. Please contact us at hello@openpanel.dev to request a quote.',
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -52,13 +52,13 @@ export function BillingFaq() {
|
||||
<span className="title">Frequently asked questions</span>
|
||||
</WidgetHead>
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full max-w-screen-md self-center"
|
||||
collapsible
|
||||
type="single"
|
||||
>
|
||||
{questions.map((q) => (
|
||||
<AccordionItem value={q.question} key={q.question}>
|
||||
<AccordionTrigger className="text-left px-4">
|
||||
<AccordionItem key={q.question} value={q.question}>
|
||||
<AccordionTrigger className="px-4 text-left">
|
||||
{q.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button, LinkButton } from '@/components/ui/button';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { op } from '@/utils/op';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -11,11 +6,17 @@ import {
|
||||
InfinityIcon,
|
||||
type LucideIcon,
|
||||
MapIcon,
|
||||
SearchIcon,
|
||||
ShieldCheckIcon,
|
||||
TrendingUpIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button, LinkButton } from '@/components/ui/button';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { op } from '@/utils/op';
|
||||
|
||||
const COPY = {
|
||||
expired: {
|
||||
@@ -59,7 +60,7 @@ export default function BillingPrompt({
|
||||
const { data: products, isLoading: isLoadingProducts } = useQuery(
|
||||
trpc.subscription.products.queryOptions({
|
||||
organizationId: organization.id,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const checkout = useMutation(
|
||||
trpc.subscription.checkout.mutationOptions({
|
||||
@@ -72,15 +73,14 @@ export default function BillingPrompt({
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
const { title, description, body } = COPY[type];
|
||||
|
||||
const bestProductFit = products?.find(
|
||||
(product) =>
|
||||
typeof product.metadata.eventsLimit === 'number' &&
|
||||
product.metadata.eventsLimit >=
|
||||
organization.subscriptionPeriodEventsCount,
|
||||
product.metadata.eventsLimit >= organization.subscriptionPeriodEventsCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,32 +98,30 @@ export default function BillingPrompt({
|
||||
}).format(
|
||||
bestProductFit.prices[0] && 'priceAmount' in bestProductFit.prices[0]
|
||||
? bestProductFit.prices[0].priceAmount / 100
|
||||
: 0,
|
||||
: 0
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-20 max-w-7xl mx-auto">
|
||||
<div className="border rounded-lg overflow-hidden bg-def-200 p-2 items-center">
|
||||
<div className="mx-auto max-w-7xl p-4 md:p-20">
|
||||
<div className="items-center overflow-hidden rounded-lg border bg-def-200 p-2">
|
||||
<div className="md:row">
|
||||
<div className="p-6 bg-background rounded-md border col gap-4 flex-1">
|
||||
<PageHeader title={title} description={description} />
|
||||
<div className="col flex-1 gap-4 rounded-md border bg-background p-6">
|
||||
<PageHeader description={description} title={title} />
|
||||
{body.map((paragraph) => (
|
||||
<p key={paragraph}>
|
||||
{paragraph.replace(
|
||||
'{{events}}',
|
||||
number.format(
|
||||
organization.subscriptionPeriodEventsCount ?? 0,
|
||||
),
|
||||
number.format(organization.subscriptionPeriodEventsCount ?? 0)
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
<div className="col gap-2 mt-auto">
|
||||
<div className="col mt-auto gap-2">
|
||||
{bestProductFit && (
|
||||
<div className="text-sm text-muted-foreground leading-normal">
|
||||
<div className="text-muted-foreground text-sm leading-normal">
|
||||
Based on your usage (
|
||||
{number.format(
|
||||
organization.subscriptionPeriodEventsCount ?? 0,
|
||||
organization.subscriptionPeriodEventsCount ?? 0
|
||||
)}{' '}
|
||||
events) we recommend upgrading <br />
|
||||
to the <strong>{bestProductFit.name}</strong> plan for{' '}
|
||||
@@ -132,9 +130,8 @@ export default function BillingPrompt({
|
||||
)}
|
||||
<div className="col md:row gap-2">
|
||||
<Button
|
||||
size="lg"
|
||||
loading={isLoadingProducts}
|
||||
disabled={!bestProductFit}
|
||||
loading={isLoadingProducts}
|
||||
onClick={() => {
|
||||
if (bestProductFit) {
|
||||
op.track('billing_prompt_upgrade_clicked', {
|
||||
@@ -152,33 +149,34 @@ export default function BillingPrompt({
|
||||
});
|
||||
}
|
||||
}}
|
||||
size="lg"
|
||||
>
|
||||
Upgrade to {price}
|
||||
</Button>
|
||||
<LinkButton
|
||||
size="lg"
|
||||
variant="outline"
|
||||
to="/$organizationId/billing"
|
||||
params={{ organizationId: organization.id }}
|
||||
size="lg"
|
||||
to="/$organizationId/billing"
|
||||
variant="outline"
|
||||
>
|
||||
View pricing
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex-1 p-6 gap-4 col min-w-[200px] max-w-[300px]">
|
||||
<div className="col min-w-[200px] max-w-[300px] flex-1 shrink-0 gap-4 p-6">
|
||||
<Point icon={DollarSignIcon}>Plans start at just $2.5/month</Point>
|
||||
<Point icon={InfinityIcon}>
|
||||
Unlimited reports, members and projects
|
||||
</Point>
|
||||
<Point icon={BarChart3Icon}>Advanced funnels and conversions</Point>
|
||||
<Point icon={MapIcon}>Real-time analytics</Point>
|
||||
<Point icon={TrendingUpIcon}>
|
||||
Track KPIs and custom events (revenue soon)
|
||||
</Point>
|
||||
<Point icon={TrendingUpIcon}>Track KPIs and custom events</Point>
|
||||
<Point icon={ShieldCheckIcon}>
|
||||
Privacy-focused and GDPR compliant
|
||||
</Point>
|
||||
<Point icon={DollarSignIcon}>Revenue tracking</Point>
|
||||
<Point icon={SearchIcon}>Google Search Console integration</Point>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,13 +187,16 @@ export default function BillingPrompt({
|
||||
function Point({
|
||||
icon: Icon,
|
||||
children,
|
||||
}: { icon: LucideIcon; children: React.ReactNode }) {
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="row gap-2">
|
||||
<div className="size-6 shrink-0 center-center rounded-full bg-amber-500 text-white">
|
||||
<div className="center-center size-6 shrink-0 rounded-full bg-amber-500 text-white">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<h3 className="font-medium mt-[1.5px]">{children}</h3>
|
||||
<h3 className="mt-[1.5px] font-medium">{children}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ export default function BillingUsage({ organization }: Props) {
|
||||
<XAxis {...xAxisProps} dataKey="date" />
|
||||
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
|
||||
<CartesianGrid
|
||||
className="stroke-border"
|
||||
horizontal={true}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
|
||||
@@ -1,61 +1,25 @@
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { useDebounceState } from '@/hooks/use-debounce-state';
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||
import { useLiveCounter } from '@/hooks/use-live-counter';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export interface LiveCounterProps {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
}
|
||||
|
||||
const FIFTEEN_SECONDS = 1000 * 30;
|
||||
|
||||
export function LiveCounter({ projectId, shareId }: LiveCounterProps) {
|
||||
const trpc = useTRPC();
|
||||
const client = useQueryClient();
|
||||
const counter = useDebounceState(0, 1000);
|
||||
const lastRefresh = useRef(Date.now());
|
||||
const query = useQuery(
|
||||
trpc.overview.liveVisitors.queryOptions({
|
||||
projectId,
|
||||
shareId,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
counter.set(query.data);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
useWS<number>(
|
||||
`/live/visitors/${projectId}`,
|
||||
(value) => {
|
||||
if (!Number.isNaN(value)) {
|
||||
counter.set(value);
|
||||
if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
|
||||
lastRefresh.current = Date.now();
|
||||
if (!document.hidden) {
|
||||
toast('Refreshed data');
|
||||
client.refetchQueries({
|
||||
type: 'active',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 5000,
|
||||
},
|
||||
},
|
||||
);
|
||||
const onRefresh = useCallback(() => {
|
||||
toast('Refreshed data');
|
||||
client.refetchQueries({
|
||||
type: 'active',
|
||||
});
|
||||
}, [client]);
|
||||
const counter = useLiveCounter({ projectId, shareId, onRefresh });
|
||||
|
||||
return (
|
||||
<TooltipComplete
|
||||
@@ -66,13 +30,13 @@ export function LiveCounter({ projectId, shareId }: LiveCounterProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
|
||||
counter.debounced === 0 && 'bg-destructive opacity-0',
|
||||
counter.debounced === 0 && 'bg-destructive opacity-0'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
|
||||
counter.debounced === 0 && 'bg-destructive',
|
||||
'absolute top-0 left-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
|
||||
counter.debounced === 0 && 'bg-destructive'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Widget } from '@/components/widget';
|
||||
import { ZapIcon } from 'lucide-react';
|
||||
import { Widget, WidgetEmptyState } from '@/components/widget';
|
||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
@@ -6,28 +7,32 @@ type Props = {
|
||||
};
|
||||
|
||||
export const MostEvents = ({ data }: Props) => {
|
||||
const max = Math.max(...data.map((item) => item.count));
|
||||
const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Popular events</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.name} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.name}</div>
|
||||
<div>{item.count}</div>
|
||||
{data.length === 0 ? (
|
||||
<WidgetEmptyState icon={ZapIcon} text="No events yet" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.name} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.name}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Widget } from '@/components/widget';
|
||||
import { RouteIcon } from 'lucide-react';
|
||||
import { Widget, WidgetEmptyState } from '@/components/widget';
|
||||
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
@@ -6,28 +7,32 @@ type Props = {
|
||||
};
|
||||
|
||||
export const PopularRoutes = ({ data }: Props) => {
|
||||
const max = Math.max(...data.map((item) => item.count));
|
||||
const max = data.length > 0 ? Math.max(...data.map((item) => item.count)) : 0;
|
||||
return (
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<WidgetTitle>Most visted pages</WidgetTitle>
|
||||
</WidgetHead>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.path} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.path}</div>
|
||||
<div>{item.count}</div>
|
||||
{data.length === 0 ? (
|
||||
<WidgetEmptyState icon={RouteIcon} text="No pages visited yet" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{data.slice(0, 5).map((item) => (
|
||||
<div key={item.path} className="relative px-3 py-2">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
|
||||
style={{
|
||||
width: `${(item.count / max) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between ">
|
||||
<div>{item.path}</div>
|
||||
<div>{item.count}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
43
apps/start/src/components/profiles/profile-groups.tsx
Normal file
43
apps/start/src/components/profiles/profile-groups.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
profileId: string;
|
||||
projectId: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export const ProfileGroups = ({ projectId, groups }: Props) => {
|
||||
const trpc = useTRPC();
|
||||
const query = useQuery(
|
||||
trpc.group.listByIds.queryOptions({
|
||||
projectId,
|
||||
ids: groups,
|
||||
}),
|
||||
);
|
||||
|
||||
if (groups.length === 0 || !query.data?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<UsersIcon className="size-3.5" />
|
||||
Groups
|
||||
</span>
|
||||
{query.data.map((group) => (
|
||||
<ProjectLink
|
||||
key={group.id}
|
||||
href={`/groups/${encodeURIComponent(group.id)}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/50 px-2.5 py-1 text-xs transition-colors hover:bg-muted"
|
||||
>
|
||||
<span className="font-medium">{group.name}</span>
|
||||
<span className="text-muted-foreground">{group.type}</span>
|
||||
</ProjectLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { ProfileAvatar } from '../profile-avatar';
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { formatDateTime, formatTime } from '@/utils/date';
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { isToday } from 'date-fns';
|
||||
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
|
||||
import { ColumnCreatedAt } from '@/components/column-created-at';
|
||||
import { ProfileAvatar } from '../profile-avatar';
|
||||
|
||||
export function useColumns(type: 'profiles' | 'power-users') {
|
||||
const columns: ColumnDef<IServiceProfile>[] = [
|
||||
@@ -20,8 +15,8 @@ export function useColumns(type: 'profiles' | 'power-users') {
|
||||
const profile = row.original;
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
||||
title={getProfileName(profile, false)}
|
||||
>
|
||||
<ProfileAvatar size="sm" {...profile} />
|
||||
@@ -100,13 +95,40 @@ export function useColumns(type: 'profiles' | 'power-users') {
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Last seen',
|
||||
header: 'First seen',
|
||||
size: ColumnCreatedAt.size,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return <ColumnCreatedAt>{item.createdAt}</ColumnCreatedAt>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'groups',
|
||||
header: 'Groups',
|
||||
size: 200,
|
||||
meta: {
|
||||
hidden: true,
|
||||
},
|
||||
cell({ row }) {
|
||||
const { groups } = row.original;
|
||||
if (!groups?.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{groups.map((g) => (
|
||||
<ProjectLink
|
||||
className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs hover:underline"
|
||||
href={`/groups/${encodeURIComponent(g)}`}
|
||||
key={g}
|
||||
>
|
||||
{g}
|
||||
</ProjectLink>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (type === 'power-users') {
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useColumns } from './columns';
|
||||
|
||||
import { DataTable } from '@/components/ui/data-table/data-table';
|
||||
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
|
||||
import {
|
||||
useDataTableColumnVisibility,
|
||||
useDataTablePagination,
|
||||
} from '@/components/ui/data-table/data-table-hooks';
|
||||
import {
|
||||
AnimatedSearchInput,
|
||||
DataTableToolbarContainer,
|
||||
} from '@/components/ui/data-table/data-table-toolbar';
|
||||
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { arePropsEqual } from '@/utils/are-props-equal';
|
||||
import type { IServiceProfile } from '@openpanel/db';
|
||||
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { memo } from 'react';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -32,6 +34,22 @@ export const ProfilesTable = memo(
|
||||
({ type, query, pageSize = PAGE_SIZE }: Props) => {
|
||||
const { data, isLoading } = query;
|
||||
const columns = useColumns(type);
|
||||
const navigate = useNavigate();
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: any) => {
|
||||
navigate({
|
||||
to: '/$organizationId/$projectId/profiles/$profileId',
|
||||
params: {
|
||||
organizationId,
|
||||
projectId,
|
||||
profileId: encodeURIComponent(row.original.id),
|
||||
},
|
||||
});
|
||||
},
|
||||
[navigate, organizationId, projectId]
|
||||
);
|
||||
|
||||
const { setPage, state: pagination } = useDataTablePagination(pageSize);
|
||||
const {
|
||||
@@ -50,7 +68,7 @@ export const ProfilesTable = memo(
|
||||
columns,
|
||||
rowCount: data?.meta.count,
|
||||
pageCount: Math.ceil(
|
||||
(data?.meta.count || 0) / (pagination.pageSize || 1),
|
||||
(data?.meta.count || 0) / (pagination.pageSize || 1)
|
||||
),
|
||||
filterFns: {
|
||||
isWithinRange: () => true,
|
||||
@@ -76,17 +94,18 @@ export const ProfilesTable = memo(
|
||||
<>
|
||||
<ProfileTableToolbar table={table} />
|
||||
<DataTable
|
||||
table={table}
|
||||
loading={isLoading}
|
||||
empty={{
|
||||
title: 'No profiles',
|
||||
description: "Looks like you haven't identified any profiles yet.",
|
||||
}}
|
||||
loading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
table={table}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']),
|
||||
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize'])
|
||||
);
|
||||
|
||||
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
|
||||
@@ -94,9 +113,9 @@ function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<AnimatedSearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search profiles"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<DataTableViewOptions table={table} />
|
||||
</DataTableToolbarContainer>
|
||||
|
||||
@@ -1,13 +1,133 @@
|
||||
export type Coordinate = {
|
||||
export interface Coordinate {
|
||||
lat: number;
|
||||
long: number;
|
||||
city?: string;
|
||||
country?: string;
|
||||
};
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export type ClusterDetailLevel = 'country' | 'city' | 'coordinate';
|
||||
|
||||
export interface CoordinateCluster {
|
||||
center: Coordinate;
|
||||
count: number;
|
||||
members: Coordinate[];
|
||||
location: {
|
||||
city?: string;
|
||||
country?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const COUNTRY_GROUP_MAX_ZOOM = 2;
|
||||
const CITY_GROUP_MAX_ZOOM = 4.5;
|
||||
|
||||
function normalizeLocationValue(value?: string) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function getClusterDetailLevel(zoom: number): ClusterDetailLevel {
|
||||
if (zoom <= COUNTRY_GROUP_MAX_ZOOM) {
|
||||
return 'country';
|
||||
}
|
||||
|
||||
if (zoom <= CITY_GROUP_MAX_ZOOM) {
|
||||
return 'city';
|
||||
}
|
||||
|
||||
return 'coordinate';
|
||||
}
|
||||
|
||||
function getLocationSummary(members: Coordinate[]) {
|
||||
const cityCounts = new Map<string, number>();
|
||||
const countryCounts = new Map<string, number>();
|
||||
|
||||
for (const member of members) {
|
||||
const city = normalizeLocationValue(member.city);
|
||||
const country = normalizeLocationValue(member.country);
|
||||
const weight = member.count ?? 1;
|
||||
|
||||
if (city) {
|
||||
cityCounts.set(city, (cityCounts.get(city) ?? 0) + weight);
|
||||
}
|
||||
|
||||
if (country) {
|
||||
countryCounts.set(country, (countryCounts.get(country) ?? 0) + weight);
|
||||
}
|
||||
}
|
||||
|
||||
const getTopLocation = (counts: Map<string, number>) =>
|
||||
[...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
|
||||
return {
|
||||
city: getTopLocation(cityCounts),
|
||||
country: getTopLocation(countryCounts),
|
||||
};
|
||||
}
|
||||
|
||||
function getAggregationKey(
|
||||
member: Coordinate,
|
||||
detailLevel: Exclude<ClusterDetailLevel, 'coordinate'>
|
||||
) {
|
||||
const city = normalizeLocationValue(member.city);
|
||||
const country = normalizeLocationValue(member.country);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return country ?? city;
|
||||
}
|
||||
|
||||
if (country && city) {
|
||||
return `${country}::${city}`;
|
||||
}
|
||||
|
||||
return city ?? country;
|
||||
}
|
||||
|
||||
function regroupClustersByDetail(
|
||||
clusters: CoordinateCluster[],
|
||||
detailLevel: Exclude<ClusterDetailLevel, 'coordinate'>
|
||||
): CoordinateCluster[] {
|
||||
const grouped = new Map<string, Coordinate[]>();
|
||||
const ungrouped: CoordinateCluster[] = [];
|
||||
|
||||
for (const cluster of clusters) {
|
||||
for (const member of cluster.members) {
|
||||
const key = getAggregationKey(member, detailLevel);
|
||||
|
||||
if (!key) {
|
||||
ungrouped.push({
|
||||
members: [member],
|
||||
center: calculateClusterCenter([member]),
|
||||
count: member.count ?? 1,
|
||||
location: {
|
||||
city: normalizeLocationValue(member.city),
|
||||
country: normalizeLocationValue(member.country),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), member]);
|
||||
}
|
||||
}
|
||||
|
||||
const regrouped = [...grouped.values()].map((members) => {
|
||||
const location = getLocationSummary(members);
|
||||
|
||||
return {
|
||||
members,
|
||||
center: calculateClusterCenter(members),
|
||||
count: members.reduce((sum, member) => sum + (member.count ?? 1), 0),
|
||||
location,
|
||||
};
|
||||
});
|
||||
|
||||
return [...regrouped, ...ungrouped];
|
||||
}
|
||||
|
||||
export function haversineDistance(
|
||||
coord1: Coordinate,
|
||||
coord2: Coordinate,
|
||||
coord2: Coordinate
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in kilometers
|
||||
const lat1Rad = coord1.lat * (Math.PI / 180);
|
||||
@@ -27,7 +147,7 @@ export function haversineDistance(
|
||||
}
|
||||
|
||||
export function findFarthestPoints(
|
||||
coordinates: Coordinate[],
|
||||
coordinates: Coordinate[]
|
||||
): [Coordinate, Coordinate] {
|
||||
if (coordinates.length < 2) {
|
||||
throw new Error('At least two coordinates are required');
|
||||
@@ -58,14 +178,17 @@ export function getAverageCenter(coordinates: Coordinate[]): Coordinate {
|
||||
|
||||
let sumLong = 0;
|
||||
let sumLat = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
for (const coord of coordinates) {
|
||||
sumLong += coord.long;
|
||||
sumLat += coord.lat;
|
||||
const weight = coord.count ?? 1;
|
||||
sumLong += coord.long * weight;
|
||||
sumLat += coord.lat * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
|
||||
const avgLat = sumLat / coordinates.length;
|
||||
const avgLong = sumLong / coordinates.length;
|
||||
const avgLat = sumLat / totalWeight;
|
||||
const avgLong = sumLong / totalWeight;
|
||||
|
||||
return { long: avgLong, lat: avgLat };
|
||||
}
|
||||
@@ -82,15 +205,17 @@ function cross(o: Coordinate, a: Coordinate, b: Coordinate): number {
|
||||
|
||||
// convex hull
|
||||
export function getOuterMarkers(coordinates: Coordinate[]): Coordinate[] {
|
||||
const sorted = coordinates.sort(sortCoordinates);
|
||||
const sorted = [...coordinates].sort(sortCoordinates);
|
||||
|
||||
if (sorted.length <= 3) return sorted;
|
||||
if (sorted.length <= 3) {
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const lower: Coordinate[] = [];
|
||||
for (const coord of sorted) {
|
||||
while (
|
||||
lower.length >= 2 &&
|
||||
cross(lower[lower.length - 2]!, lower[lower.length - 1]!, coord) <= 0
|
||||
cross(lower.at(-2)!, lower.at(-1)!, coord) <= 0
|
||||
) {
|
||||
lower.pop();
|
||||
}
|
||||
@@ -101,7 +226,7 @@ export function getOuterMarkers(coordinates: Coordinate[]): Coordinate[] {
|
||||
for (let i = coordinates.length - 1; i >= 0; i--) {
|
||||
while (
|
||||
upper.length >= 2 &&
|
||||
cross(upper[upper.length - 2]!, upper[upper.length - 1]!, sorted[i]!) <= 0
|
||||
cross(upper.at(-2)!, upper.at(-1)!, sorted[i]!) <= 0
|
||||
) {
|
||||
upper.pop();
|
||||
}
|
||||
@@ -133,7 +258,7 @@ export function calculateCentroid(polygon: Coordinate[]): Coordinate {
|
||||
centroidLat += (y0 + y1) * a;
|
||||
}
|
||||
|
||||
area = area / 2;
|
||||
area /= 2;
|
||||
if (area === 0) {
|
||||
// This should not happen for a proper convex hull
|
||||
throw new Error('Area of the polygon is zero, check the coordinates.');
|
||||
@@ -146,7 +271,7 @@ export function calculateCentroid(polygon: Coordinate[]): Coordinate {
|
||||
}
|
||||
|
||||
export function calculateGeographicMidpoint(
|
||||
coordinate: Coordinate[],
|
||||
coordinate: Coordinate[]
|
||||
): Coordinate {
|
||||
let minLat = Number.POSITIVE_INFINITY;
|
||||
let maxLat = Number.NEGATIVE_INFINITY;
|
||||
@@ -154,10 +279,18 @@ export function calculateGeographicMidpoint(
|
||||
let maxLong = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const { lat, long } of coordinate) {
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (long < minLong) minLong = long;
|
||||
if (long > maxLong) maxLong = long;
|
||||
if (lat < minLat) {
|
||||
minLat = lat;
|
||||
}
|
||||
if (lat > maxLat) {
|
||||
maxLat = lat;
|
||||
}
|
||||
if (long < minLong) {
|
||||
minLong = long;
|
||||
}
|
||||
if (long > maxLong) {
|
||||
maxLong = long;
|
||||
}
|
||||
}
|
||||
|
||||
// Handling the wrap around the international date line
|
||||
@@ -191,9 +324,10 @@ export function clusterCoordinates(
|
||||
maxLong: number;
|
||||
};
|
||||
};
|
||||
} = {},
|
||||
} = {}
|
||||
) {
|
||||
const { zoom = 1, adaptiveRadius = true, viewport } = options;
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
// Calculate adaptive radius based on zoom level and coordinate density
|
||||
let adjustedRadius = radius;
|
||||
@@ -214,7 +348,7 @@ export function clusterCoordinates(
|
||||
coord.lat >= viewport.bounds.minLat &&
|
||||
coord.lat <= viewport.bounds.maxLat &&
|
||||
coord.long >= viewport.bounds.minLong &&
|
||||
coord.long <= viewport.bounds.maxLong,
|
||||
coord.long <= viewport.bounds.maxLong
|
||||
);
|
||||
|
||||
if (viewportCoords.length > 0) {
|
||||
@@ -227,7 +361,7 @@ export function clusterCoordinates(
|
||||
// Adjust radius based on density - higher density = larger radius for more aggressive clustering
|
||||
const densityFactor = Math.max(
|
||||
0.5,
|
||||
Math.min(5, Math.sqrt(density * 1000) + 1),
|
||||
Math.min(5, Math.sqrt(density * 1000) + 1)
|
||||
);
|
||||
adjustedRadius *= densityFactor;
|
||||
}
|
||||
@@ -241,44 +375,44 @@ export function clusterCoordinates(
|
||||
// TODO: Re-enable optimized clustering after thorough testing
|
||||
const result = basicClusterCoordinates(coordinates, adjustedRadius);
|
||||
|
||||
// Debug: Log clustering results
|
||||
if (coordinates.length > 0) {
|
||||
console.log(
|
||||
`Clustering ${coordinates.length} coordinates with radius ${adjustedRadius.toFixed(2)}km resulted in ${result.length} clusters`,
|
||||
);
|
||||
if (detailLevel === 'coordinate') {
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
return regroupClustersByDetail(result, detailLevel);
|
||||
}
|
||||
|
||||
// Aggressive clustering algorithm with iterative expansion
|
||||
function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
|
||||
if (coordinates.length === 0) return [];
|
||||
if (coordinates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const clusters: {
|
||||
center: Coordinate;
|
||||
count: number;
|
||||
members: Coordinate[];
|
||||
}[] = [];
|
||||
const clusters: CoordinateCluster[] = [];
|
||||
const visited = new Set<number>();
|
||||
|
||||
// Sort coordinates by density (coordinates near others first)
|
||||
const coordinatesWithDensity = coordinates
|
||||
.map((coord, idx) => {
|
||||
const nearbyCount = coordinates.filter(
|
||||
(other) => haversineDistance(coord, other) <= radius * 0.5,
|
||||
(other) => haversineDistance(coord, other) <= radius * 0.5
|
||||
).length;
|
||||
return { ...coord, originalIdx: idx, nearbyCount };
|
||||
})
|
||||
.sort((a, b) => b.nearbyCount - a.nearbyCount);
|
||||
|
||||
coordinatesWithDensity.forEach(
|
||||
({ lat, long, city, country, originalIdx }) => {
|
||||
({ lat, long, city, country, count, originalIdx }) => {
|
||||
if (!visited.has(originalIdx)) {
|
||||
const initialCount = count ?? 1;
|
||||
const cluster = {
|
||||
members: [{ lat, long, city, country }],
|
||||
members: [{ lat, long, city, country, count: initialCount }],
|
||||
center: { lat, long },
|
||||
count: 1,
|
||||
count: initialCount,
|
||||
location: {
|
||||
city: normalizeLocationValue(city),
|
||||
country: normalizeLocationValue(country),
|
||||
},
|
||||
};
|
||||
|
||||
// Mark the initial coordinate as visited
|
||||
@@ -297,6 +431,7 @@ function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
|
||||
long: otherLong,
|
||||
city: otherCity,
|
||||
country: otherCountry,
|
||||
count: otherCount,
|
||||
originalIdx: otherIdx,
|
||||
}) => {
|
||||
if (!visited.has(otherIdx)) {
|
||||
@@ -306,28 +441,31 @@ function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
|
||||
});
|
||||
|
||||
if (distance <= radius) {
|
||||
const memberCount = otherCount ?? 1;
|
||||
cluster.members.push({
|
||||
lat: otherLat,
|
||||
long: otherLong,
|
||||
city: otherCity,
|
||||
country: otherCountry,
|
||||
count: memberCount,
|
||||
});
|
||||
visited.add(otherIdx);
|
||||
cluster.count++;
|
||||
cluster.count += memberCount;
|
||||
expandedInLastIteration = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the proper center for the cluster
|
||||
cluster.center = calculateClusterCenter(cluster.members);
|
||||
cluster.location = getLocationSummary(cluster.members);
|
||||
|
||||
clusters.push(cluster);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return clusters;
|
||||
@@ -339,9 +477,12 @@ function basicClusterCoordinates(coordinates: Coordinate[], radius: number) {
|
||||
// Utility function to get clustering statistics for debugging
|
||||
export function getClusteringStats(
|
||||
coordinates: Coordinate[],
|
||||
clusters: ReturnType<typeof clusterCoordinates>,
|
||||
clusters: ReturnType<typeof clusterCoordinates>
|
||||
) {
|
||||
const totalPoints = coordinates.length;
|
||||
const totalPoints = coordinates.reduce(
|
||||
(sum, coordinate) => sum + (coordinate.count ?? 1),
|
||||
0
|
||||
);
|
||||
const totalClusters = clusters.length;
|
||||
const singletonClusters = clusters.filter((c) => c.count === 1).length;
|
||||
const avgClusterSize = totalPoints > 0 ? totalPoints / totalClusters : 0;
|
||||
@@ -371,26 +512,33 @@ function calculateClusterCenter(members: Coordinate[]): Coordinate {
|
||||
|
||||
let avgLat = 0;
|
||||
let avgLong = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
if (maxLong - minLong > 180) {
|
||||
// Handle dateline crossing
|
||||
let adjustedLongSum = 0;
|
||||
for (const member of members) {
|
||||
avgLat += member.lat;
|
||||
const weight = member.count ?? 1;
|
||||
avgLat += member.lat * weight;
|
||||
const adjustedLong = member.long < 0 ? member.long + 360 : member.long;
|
||||
adjustedLongSum += adjustedLong;
|
||||
adjustedLongSum += adjustedLong * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
avgLat /= totalWeight;
|
||||
avgLong = (adjustedLongSum / totalWeight) % 360;
|
||||
if (avgLong > 180) {
|
||||
avgLong -= 360;
|
||||
}
|
||||
avgLat /= members.length;
|
||||
avgLong = (adjustedLongSum / members.length) % 360;
|
||||
if (avgLong > 180) avgLong -= 360;
|
||||
} else {
|
||||
// Normal case - no dateline crossing
|
||||
for (const member of members) {
|
||||
avgLat += member.lat;
|
||||
avgLong += member.long;
|
||||
const weight = member.count ?? 1;
|
||||
avgLat += member.lat * weight;
|
||||
avgLong += member.long * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
avgLat /= members.length;
|
||||
avgLong /= members.length;
|
||||
avgLat /= totalWeight;
|
||||
avgLong /= totalWeight;
|
||||
}
|
||||
|
||||
return { lat: avgLat, long: avgLong };
|
||||
|
||||
@@ -1,350 +1,20 @@
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Marker,
|
||||
ZoomableGroup,
|
||||
} from 'react-simple-maps';
|
||||
import { useRef } from 'react';
|
||||
import { MapBadgeDetails } from './map-badge-details';
|
||||
import { MapCanvas } from './map-canvas';
|
||||
import type { RealtimeMapProps } from './map-types';
|
||||
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import type { Coordinate } from './coordinates';
|
||||
|
||||
// Interpolate function similar to React Native Reanimated
|
||||
const interpolate = (
|
||||
value: number,
|
||||
inputRange: [number, number],
|
||||
outputRange: [number, number],
|
||||
extrapolate?: 'clamp' | 'extend' | 'identity',
|
||||
): number => {
|
||||
const [inputMin, inputMax] = inputRange;
|
||||
const [outputMin, outputMax] = outputRange;
|
||||
|
||||
// Handle edge cases
|
||||
if (inputMin === inputMax) return outputMin;
|
||||
|
||||
const progress = (value - inputMin) / (inputMax - inputMin);
|
||||
|
||||
// Apply extrapolation
|
||||
if (extrapolate === 'clamp') {
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress));
|
||||
return outputMin + clampedProgress * (outputMax - outputMin);
|
||||
}
|
||||
|
||||
return outputMin + progress * (outputMax - outputMin);
|
||||
};
|
||||
import {
|
||||
calculateGeographicMidpoint,
|
||||
clusterCoordinates,
|
||||
getAverageCenter,
|
||||
getOuterMarkers,
|
||||
} from './coordinates';
|
||||
import { GEO_MAP_URL, determineZoom, getBoundingBox } from './map.helpers';
|
||||
import { calculateMarkerSize } from './markers';
|
||||
|
||||
type Props = {
|
||||
markers: Coordinate[];
|
||||
sidebarConfig?: {
|
||||
width: number;
|
||||
position: 'left' | 'right';
|
||||
};
|
||||
};
|
||||
const Map = ({ markers, sidebarConfig }: Props) => {
|
||||
const showCenterMarker = false;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [currentZoom, setCurrentZoom] = useState(1);
|
||||
const [debouncedZoom, setDebouncedZoom] = useState(1);
|
||||
const zoomTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Memoize expensive calculations
|
||||
const { hull, center, initialZoom } = useMemo(() => {
|
||||
const hull = getOuterMarkers(markers);
|
||||
const center =
|
||||
hull.length < 2
|
||||
? getAverageCenter(markers)
|
||||
: calculateGeographicMidpoint(hull);
|
||||
|
||||
// Calculate initial zoom based on markers distribution
|
||||
const boundingBox = getBoundingBox(hull.length > 0 ? hull : markers);
|
||||
const minZoom = 1;
|
||||
const maxZoom = 20;
|
||||
|
||||
const aspectRatio = size ? size.width / size.height : 1;
|
||||
const autoZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(maxZoom, determineZoom(boundingBox, aspectRatio) * 0.4),
|
||||
);
|
||||
|
||||
// Use calculated zoom if we have markers, otherwise default to 1
|
||||
const initialZoom = markers.length > 0 ? autoZoom : 1;
|
||||
|
||||
return { hull, center, initialZoom };
|
||||
}, [markers, size]);
|
||||
|
||||
// Update current zoom when initial zoom changes (when new markers are loaded)
|
||||
useEffect(() => {
|
||||
setCurrentZoom(initialZoom);
|
||||
setDebouncedZoom(initialZoom);
|
||||
}, [initialZoom]);
|
||||
|
||||
// Debounced zoom update for marker clustering
|
||||
const updateDebouncedZoom = useCallback((newZoom: number) => {
|
||||
if (zoomTimeoutRef.current) {
|
||||
clearTimeout(zoomTimeoutRef.current);
|
||||
}
|
||||
|
||||
zoomTimeoutRef.current = setTimeout(() => {
|
||||
setDebouncedZoom(newZoom);
|
||||
}, 100); // 100ms debounce delay
|
||||
}, []);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (zoomTimeoutRef.current) {
|
||||
clearTimeout(zoomTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Memoize center coordinates adjustment for sidebar
|
||||
const { long, lat } = useMemo(() => {
|
||||
let adjustedLong = center.long;
|
||||
|
||||
if (sidebarConfig && size) {
|
||||
// Calculate how much to shift the map to center content in visible area
|
||||
const sidebarOffset =
|
||||
sidebarConfig.position === 'left'
|
||||
? sidebarConfig.width / 2
|
||||
: -sidebarConfig.width / 2;
|
||||
|
||||
// Convert pixel offset to longitude degrees
|
||||
// This is a rough approximation - degrees per pixel at current zoom
|
||||
const longitudePerPixel = 360 / (size.width * initialZoom);
|
||||
const longitudeOffset = sidebarOffset * longitudePerPixel;
|
||||
|
||||
adjustedLong = center.long - longitudeOffset; // Subtract to shift map right for left sidebar
|
||||
}
|
||||
|
||||
return { long: adjustedLong, lat: center.lat };
|
||||
}, [center.long, center.lat, sidebarConfig, size, initialZoom]);
|
||||
|
||||
const minZoom = 1;
|
||||
const maxZoom = 20;
|
||||
|
||||
useEffect(() => {
|
||||
return bind(window, {
|
||||
type: 'resize',
|
||||
listener() {
|
||||
if (ref.current) {
|
||||
const parentRect = ref.current.parentElement?.getBoundingClientRect();
|
||||
setSize({
|
||||
width: parentRect?.width ?? 0,
|
||||
height: parentRect?.height ?? 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const parentRect = ref.current.parentElement?.getBoundingClientRect();
|
||||
setSize({
|
||||
width: parentRect?.width ?? 0,
|
||||
height: parentRect?.height ?? 0,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Dynamic marker size based on zoom level - balanced scaling for new size range
|
||||
const getMarkerSize = useCallback(
|
||||
(baseSize: number) => {
|
||||
// Interpolate the adjustment value from zoom 1 to 20
|
||||
// At zoom 1: adjustThisValue = 1
|
||||
// At zoom 20: adjustThisValue = 0.5
|
||||
const adjustThisValue = interpolate(
|
||||
currentZoom,
|
||||
[1, 20],
|
||||
[1.5, 0.6],
|
||||
'clamp',
|
||||
);
|
||||
const scaleFactor = (1 / Math.sqrt(currentZoom)) * adjustThisValue;
|
||||
|
||||
// Ensure minimum size for visibility, but allow smaller sizes for precision
|
||||
const minSize = baseSize * 0.05;
|
||||
const scaledSize = baseSize * scaleFactor;
|
||||
|
||||
return Math.max(minSize, scaledSize);
|
||||
},
|
||||
[currentZoom],
|
||||
);
|
||||
|
||||
const getBorderWidth = useCallback(() => {
|
||||
const map = {
|
||||
0.1: [15, 20],
|
||||
0.15: [10, 15],
|
||||
0.25: [5, 10],
|
||||
0.5: [0, 5],
|
||||
};
|
||||
const found = Object.entries(map).find(([, value]) => {
|
||||
if (currentZoom >= value[0] && currentZoom <= value[1]) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return found ? Number.parseFloat(found[0]) : 0.1;
|
||||
}, [currentZoom]);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
// Memoize clustered markers
|
||||
const clusteredMarkers = useMemo(() => {
|
||||
return clusterCoordinates(markers, 150, {
|
||||
zoom: debouncedZoom,
|
||||
adaptiveRadius: true,
|
||||
});
|
||||
}, [markers, debouncedZoom]);
|
||||
const Map = ({ projectId, markers, sidebarConfig }: RealtimeMapProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div className="bg-gradient-to-t from-def-100 to-transparent h-1/10 absolute bottom-0 left-0 right-0" />
|
||||
{size === null ? (
|
||||
<></>
|
||||
) : (
|
||||
<>
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
width={size?.width || 800}
|
||||
height={size?.height || 400}
|
||||
>
|
||||
<ZoomableGroup
|
||||
center={[long, lat]}
|
||||
zoom={initialZoom}
|
||||
minZoom={minZoom}
|
||||
maxZoom={maxZoom}
|
||||
onMove={(event) => {
|
||||
if (currentZoom !== event.zoom) {
|
||||
setCurrentZoom(event.zoom);
|
||||
updateDebouncedZoom(event.zoom);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Geographies geography={GEO_MAP_URL}>
|
||||
{({ geographies }) =>
|
||||
geographies
|
||||
.filter((geo) => {
|
||||
return geo.properties.name !== 'Antarctica';
|
||||
})
|
||||
.map((geo) => (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={theme.theme === 'dark' ? '#000' : '#f0f0f0'}
|
||||
stroke={theme.theme === 'dark' ? '#333' : '#999'}
|
||||
strokeWidth={getBorderWidth()}
|
||||
pointerEvents={'none'}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Geographies>
|
||||
{showCenterMarker && (
|
||||
<Marker coordinates={[center.long, center.lat]}>
|
||||
<circle r={getMarkerSize(10)} fill="green" stroke="#fff" />
|
||||
</Marker>
|
||||
)}
|
||||
{clusteredMarkers.map((marker, index) => {
|
||||
const size = getMarkerSize(calculateMarkerSize(marker.count));
|
||||
const coordinates: [number, number] = [
|
||||
marker.center.long,
|
||||
marker.center.lat,
|
||||
];
|
||||
<div className="relative h-full w-full" ref={containerRef}>
|
||||
<MapCanvas
|
||||
markers={markers}
|
||||
projectId={projectId}
|
||||
sidebarConfig={sidebarConfig}
|
||||
/>
|
||||
|
||||
return (
|
||||
<Fragment
|
||||
key={`cluster-${index}-${marker.center.long}-${marker.center.lat}`}
|
||||
>
|
||||
{/* Animated ping effect */}
|
||||
<Marker coordinates={coordinates}>
|
||||
<circle
|
||||
r={size}
|
||||
fill={theme.theme === 'dark' ? '#3d79ff' : '#2266ec'}
|
||||
className="animate-ping opacity-20"
|
||||
/>
|
||||
</Marker>
|
||||
{/* Main marker with tooltip */}
|
||||
<Tooltiper
|
||||
asChild
|
||||
content={
|
||||
<div className="flex min-w-[200px] flex-col gap-2">
|
||||
<h3 className="font-semibold capitalize">
|
||||
{`${marker.count} visitor${marker.count !== 1 ? 's' : ''}`}
|
||||
</h3>
|
||||
|
||||
{marker.members
|
||||
.slice(0, 5)
|
||||
.filter((item) => item.country || item.city)
|
||||
.map((item) => (
|
||||
<div
|
||||
className="row items-center gap-2"
|
||||
key={`${item.long}-${item.lat}`}
|
||||
>
|
||||
<SerieIcon
|
||||
name={
|
||||
item.country || `${item.lat}, ${item.long}`
|
||||
}
|
||||
/>
|
||||
{item.city || 'Unknown'}
|
||||
</div>
|
||||
))}
|
||||
{marker.members.length > 5 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
+ {marker.members.length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Marker coordinates={coordinates}>
|
||||
<circle
|
||||
r={size}
|
||||
fill={theme.theme === 'dark' ? '#3d79ff' : '#2266ec'}
|
||||
fillOpacity={0.8}
|
||||
stroke="#fff"
|
||||
strokeWidth={getBorderWidth() * 0.5}
|
||||
/>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fill="#fff"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={size * 0.6}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{marker.count}
|
||||
</text>
|
||||
</Marker>
|
||||
</Tooltiper>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</>
|
||||
)}
|
||||
<MapBadgeDetails containerRef={containerRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
267
apps/start/src/components/realtime/map/map-badge-detail-card.tsx
Normal file
267
apps/start/src/components/realtime/map/map-badge-detail-card.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion } from 'framer-motion';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { DisplayMarker } from './map-types';
|
||||
import {
|
||||
getBadgeOverlayPosition,
|
||||
getProfileDisplayName,
|
||||
getUniqueCoordinateDetailLocations,
|
||||
getUniquePlaceDetailLocations,
|
||||
} from './map-utils';
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
export function MapBadgeDetailCard({
|
||||
marker,
|
||||
onClose,
|
||||
panelRef,
|
||||
projectId,
|
||||
size,
|
||||
}: {
|
||||
marker: DisplayMarker;
|
||||
onClose: () => void;
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
projectId: string;
|
||||
size: { width: number; height: number };
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const input = {
|
||||
detailScope: marker.detailScope,
|
||||
projectId,
|
||||
locations:
|
||||
marker.detailScope === 'coordinate'
|
||||
? getUniqueCoordinateDetailLocations(marker.members)
|
||||
: getUniquePlaceDetailLocations(marker.members),
|
||||
};
|
||||
const query = useQuery(
|
||||
trpc.realtime.mapBadgeDetails.queryOptions(input, {
|
||||
enabled: input.locations.length > 0,
|
||||
})
|
||||
);
|
||||
const position = getBadgeOverlayPosition(marker, size);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="absolute z-[90]"
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
width: position.overlayWidth,
|
||||
}}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="overflow-hidden rounded-2xl border border-white/10 bg-background shadow-2xl"
|
||||
initial={{ opacity: 0.98 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Realtime cluster
|
||||
</div>
|
||||
<div className="truncate text-lg" style={{ fontWeight: 600 }}>
|
||||
{marker.label}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-muted-foreground"
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
{query.data?.summary.totalSessions ?? marker.count} sessions
|
||||
{query.data?.summary.totalProfiles
|
||||
? ` • ${query.data.summary.totalProfiles} profiles`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 border-b p-4 text-sm">
|
||||
<div className="col gap-1 rounded-lg bg-def-200 p-3">
|
||||
<div className="text-muted-foreground text-xs">Locations</div>
|
||||
<div className="font-semibold">
|
||||
{query.data?.summary.totalLocations ?? marker.members.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-1 rounded-lg bg-def-200 p-3">
|
||||
<div className="text-muted-foreground text-xs">Countries</div>
|
||||
<div className="font-semibold">
|
||||
{query.data?.summary.totalCountries ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-1 rounded-lg bg-def-200 p-3">
|
||||
<div className="text-muted-foreground text-xs">Cities</div>
|
||||
<div className="font-semibold">
|
||||
{query.data?.summary.totalCities ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[420px] space-y-4 overflow-y-auto p-4">
|
||||
{query.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-16 animate-pulse rounded-xl bg-def-200" />
|
||||
<div className="h-24 animate-pulse rounded-xl bg-def-200" />
|
||||
<div className="h-24 animate-pulse rounded-xl bg-def-200" />
|
||||
</div>
|
||||
) : query.data ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 font-medium text-sm">Top referrers</div>
|
||||
<div className="space-y-2">
|
||||
{query.data.topReferrers.length > 0 ? (
|
||||
query.data.topReferrers.map((item) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
key={item.referrerName || '(not set)'}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={item.referrerName} />
|
||||
<span className="truncate">
|
||||
{item.referrerName
|
||||
.replaceAll('https://', '')
|
||||
.replaceAll('http://', '')
|
||||
.replaceAll('www.', '') || '(Not set)'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{item.count}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-2 font-medium text-sm">Top events</div>
|
||||
<div className="space-y-2">
|
||||
{query.data.topEvents.length > 0 ? (
|
||||
query.data.topEvents.map((item) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
key={item.name}
|
||||
>
|
||||
<span className="truncate">{item.name}</span>
|
||||
<span className="font-mono">{item.count}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 rounded-xl border p-3">
|
||||
<div className="mb-2 font-medium text-sm">Top paths</div>
|
||||
<div className="space-y-2">
|
||||
{query.data.topPaths.length > 0 ? (
|
||||
query.data.topPaths.map((item) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
key={`${item.origin}${item.path}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{item.path || '(Not set)'}
|
||||
</span>
|
||||
<span className="font-mono">{item.count}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-3">
|
||||
<div className="mb-3 font-medium text-sm">Recent sessions</div>
|
||||
<div className="space-y-3">
|
||||
{query.data.recentProfiles.length > 0 ? (
|
||||
query.data.recentProfiles.map((profile) => {
|
||||
const href = profile.profileId
|
||||
? `/profiles/${encodeURIComponent(profile.profileId)}`
|
||||
: `/sessions/${encodeURIComponent(profile.sessionId)}`;
|
||||
return (
|
||||
<ProjectLink
|
||||
className="-mx-1 flex items-center gap-3 rounded-lg px-1 py-0.5 transition-colors hover:bg-def-200"
|
||||
href={href}
|
||||
key={
|
||||
profile.profileId
|
||||
? `p:${profile.profileId}`
|
||||
: `s:${profile.sessionId}`
|
||||
}
|
||||
>
|
||||
<ProfileAvatar
|
||||
avatar={profile.avatar}
|
||||
email={profile.email}
|
||||
firstName={profile.firstName}
|
||||
lastName={profile.lastName}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="truncate"
|
||||
style={{ fontSize: 14, fontWeight: 500 }}
|
||||
>
|
||||
{getProfileDisplayName(profile)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate text-muted-foreground"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{profile.latestPath || profile.latestEvent}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-right text-muted-foreground"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<div>
|
||||
{[profile.city, profile.country]
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
</ProjectLink>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No recent sessions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Could not load badge details.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
92
apps/start/src/components/realtime/map/map-badge-details.tsx
Normal file
92
apps/start/src/components/realtime/map/map-badge-details.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MapBadgeDetailCard } from './map-badge-detail-card';
|
||||
import { closeMapBadgeDetails } from './realtime-map-badge-slice';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
export function MapBadgeDetails({
|
||||
containerRef,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const { open, marker, projectId } = useSelector(
|
||||
(state) => state.realtimeMapBadge
|
||||
);
|
||||
const [overlaySize, setOverlaySize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(open && marker)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!panelRef.current?.contains(event.target as Node)) {
|
||||
dispatch(closeMapBadgeDetails());
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
dispatch(closeMapBadgeDetails());
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mousedown', onPointerDown);
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', onPointerDown);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [dispatch, marker, open]);
|
||||
|
||||
useEffect(() => {
|
||||
const measure = () => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOverlaySize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
return bind(window, {
|
||||
type: 'resize',
|
||||
listener: measure,
|
||||
});
|
||||
}, [containerRef]);
|
||||
|
||||
if (!(open && marker && projectId && overlaySize)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.button
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed inset-0 z-[80] bg-black/10"
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="map-badge-backdrop"
|
||||
onClick={() => dispatch(closeMapBadgeDetails())}
|
||||
type="button"
|
||||
/>
|
||||
<MapBadgeDetailCard
|
||||
key="map-badge-panel"
|
||||
marker={marker}
|
||||
onClose={() => dispatch(closeMapBadgeDetails())}
|
||||
panelRef={panelRef}
|
||||
projectId={projectId}
|
||||
size={overlaySize}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
314
apps/start/src/components/realtime/map/map-canvas.tsx
Normal file
314
apps/start/src/components/realtime/map/map-canvas.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Marker,
|
||||
ZoomableGroup,
|
||||
} from 'react-simple-maps';
|
||||
import {
|
||||
calculateGeographicMidpoint,
|
||||
clusterCoordinates,
|
||||
getAverageCenter,
|
||||
getOuterMarkers,
|
||||
} from './coordinates';
|
||||
import { determineZoom, GEO_MAP_URL, getBoundingBox } from './map.helpers';
|
||||
import { createDisplayMarkers } from './map-display-markers';
|
||||
import { MapMarkerPill } from './map-marker-pill';
|
||||
import type {
|
||||
DisplayMarkerCache,
|
||||
GeographyFeature,
|
||||
MapCanvasProps,
|
||||
MapProjection,
|
||||
ZoomMoveEndPosition,
|
||||
ZoomMovePosition,
|
||||
} from './map-types';
|
||||
import {
|
||||
ANCHOR_R,
|
||||
isValidCoordinate,
|
||||
PILL_GAP,
|
||||
PILL_H,
|
||||
PILL_W,
|
||||
} from './map-utils';
|
||||
import {
|
||||
closeMapBadgeDetails,
|
||||
openMapBadgeDetails,
|
||||
} from './realtime-map-badge-slice';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useDispatch } from '@/redux';
|
||||
|
||||
export const MapCanvas = memo(function MapCanvas({
|
||||
projectId,
|
||||
markers,
|
||||
sidebarConfig,
|
||||
}: MapCanvasProps) {
|
||||
const dispatch = useDispatch();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(
|
||||
null
|
||||
);
|
||||
const [currentZoom, setCurrentZoom] = useState(1);
|
||||
const [debouncedZoom, setDebouncedZoom] = useState(1);
|
||||
const [viewCenter, setViewCenter] = useState<[number, number]>([0, 20]);
|
||||
const zoomTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const displayMarkersCacheRef = useRef<DisplayMarkerCache>({
|
||||
markers: [],
|
||||
projection: null,
|
||||
viewportCenter: [0, 20],
|
||||
zoom: 1,
|
||||
size: null,
|
||||
result: [],
|
||||
});
|
||||
|
||||
const { center, initialZoom } = useMemo(() => {
|
||||
const hull = getOuterMarkers(markers);
|
||||
const center =
|
||||
hull.length < 2
|
||||
? getAverageCenter(markers)
|
||||
: calculateGeographicMidpoint(hull);
|
||||
|
||||
const boundingBox = getBoundingBox(hull.length > 0 ? hull : markers);
|
||||
const aspectRatio = size ? size.width / size.height : 1;
|
||||
const autoZoom = Math.max(
|
||||
1,
|
||||
Math.min(20, determineZoom(boundingBox, aspectRatio) * 0.4)
|
||||
);
|
||||
const initialZoom = markers.length > 0 ? autoZoom : 1;
|
||||
|
||||
return { center, initialZoom };
|
||||
}, [markers, size]);
|
||||
|
||||
const updateDebouncedZoom = useCallback((newZoom: number) => {
|
||||
if (zoomTimeoutRef.current) {
|
||||
clearTimeout(zoomTimeoutRef.current);
|
||||
}
|
||||
zoomTimeoutRef.current = setTimeout(() => {
|
||||
setDebouncedZoom(newZoom);
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (zoomTimeoutRef.current) {
|
||||
clearTimeout(zoomTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { long, lat } = useMemo(() => {
|
||||
let adjustedLong = center.long;
|
||||
if (sidebarConfig && size) {
|
||||
const sidebarOffset =
|
||||
sidebarConfig.position === 'left'
|
||||
? sidebarConfig.width / 2
|
||||
: -sidebarConfig.width / 2;
|
||||
const longitudePerPixel = 360 / (size.width * initialZoom);
|
||||
const longitudeOffset = sidebarOffset * longitudePerPixel;
|
||||
adjustedLong = center.long - longitudeOffset;
|
||||
}
|
||||
return { long: adjustedLong, lat: center.lat };
|
||||
}, [center.long, center.lat, sidebarConfig, size, initialZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
setViewCenter([long, lat]);
|
||||
setCurrentZoom(initialZoom);
|
||||
setDebouncedZoom(initialZoom);
|
||||
}, [long, lat, initialZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
return bind(window, {
|
||||
type: 'resize',
|
||||
listener() {
|
||||
if (ref.current) {
|
||||
const parentRect = ref.current.getBoundingClientRect();
|
||||
setSize({
|
||||
width: parentRect.width ?? 0,
|
||||
height: parentRect.height ?? 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const parentRect = ref.current.getBoundingClientRect();
|
||||
setSize({
|
||||
width: parentRect.width ?? 0,
|
||||
height: parentRect.height ?? 0,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const clusteredMarkers = useMemo(() => {
|
||||
return clusterCoordinates(markers, 150, {
|
||||
zoom: debouncedZoom,
|
||||
adaptiveRadius: true,
|
||||
});
|
||||
}, [markers, debouncedZoom]);
|
||||
|
||||
const invScale = Number.isNaN(1 / currentZoom) ? 1 : 1 / currentZoom;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full" ref={ref}>
|
||||
<div className="absolute inset-x-0 bottom-0 h-1/10 bg-gradient-to-t from-def-100 to-transparent" />
|
||||
{size !== null && (
|
||||
<ComposableMap
|
||||
height={size.height}
|
||||
projection="geoMercator"
|
||||
width={size.width}
|
||||
>
|
||||
<ZoomableGroup
|
||||
center={[long, lat]}
|
||||
// key={`${long}-${lat}-${initialZoom}`}
|
||||
maxZoom={20}
|
||||
minZoom={1}
|
||||
onMove={(position: ZoomMovePosition) => {
|
||||
dispatch(closeMapBadgeDetails());
|
||||
if (currentZoom !== position.zoom) {
|
||||
setCurrentZoom(position.zoom);
|
||||
updateDebouncedZoom(position.zoom);
|
||||
}
|
||||
}}
|
||||
onMoveEnd={(position: ZoomMoveEndPosition) => {
|
||||
setViewCenter(position.coordinates);
|
||||
|
||||
if (currentZoom !== position.zoom) {
|
||||
setCurrentZoom(position.zoom);
|
||||
updateDebouncedZoom(position.zoom);
|
||||
}
|
||||
}}
|
||||
zoom={initialZoom}
|
||||
>
|
||||
<Geographies geography={GEO_MAP_URL}>
|
||||
{({
|
||||
geographies,
|
||||
projection,
|
||||
}: {
|
||||
geographies: GeographyFeature[];
|
||||
projection: MapProjection;
|
||||
}) => {
|
||||
const cachedDisplayMarkers = displayMarkersCacheRef.current;
|
||||
const cacheMatches =
|
||||
cachedDisplayMarkers.markers === clusteredMarkers &&
|
||||
cachedDisplayMarkers.projection === projection &&
|
||||
cachedDisplayMarkers.viewportCenter[0] === viewCenter[0] &&
|
||||
cachedDisplayMarkers.viewportCenter[1] === viewCenter[1] &&
|
||||
cachedDisplayMarkers.zoom === debouncedZoom &&
|
||||
cachedDisplayMarkers.size?.width === size.width &&
|
||||
cachedDisplayMarkers.size?.height === size.height;
|
||||
|
||||
const displayMarkers = cacheMatches
|
||||
? cachedDisplayMarkers.result
|
||||
: createDisplayMarkers({
|
||||
markers: clusteredMarkers,
|
||||
projection,
|
||||
viewportCenter: viewCenter,
|
||||
zoom: debouncedZoom,
|
||||
labelZoom: debouncedZoom,
|
||||
size,
|
||||
});
|
||||
|
||||
if (!cacheMatches) {
|
||||
displayMarkersCacheRef.current = {
|
||||
markers: clusteredMarkers,
|
||||
projection,
|
||||
viewportCenter: viewCenter,
|
||||
zoom: debouncedZoom,
|
||||
size,
|
||||
result: displayMarkers,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{geographies
|
||||
.filter(
|
||||
(geo: GeographyFeature) =>
|
||||
geo.properties.name !== 'Antarctica'
|
||||
)
|
||||
.map((geo: GeographyFeature) => (
|
||||
<Geography
|
||||
fill={theme.theme === 'dark' ? '#000' : '#f0f0f0'}
|
||||
geography={geo}
|
||||
key={geo.rsmKey}
|
||||
pointerEvents="none"
|
||||
stroke={theme.theme === 'dark' ? '#333' : '#999'}
|
||||
strokeWidth={0.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
|
||||
{markers.filter(isValidCoordinate).map((marker, index) => (
|
||||
<Marker
|
||||
coordinates={[marker.long, marker.lat]}
|
||||
key={`point-${index}-${marker.long}-${marker.lat}`}
|
||||
>
|
||||
<g transform={`scale(${invScale})`}>
|
||||
<circle
|
||||
fill="var(--primary)"
|
||||
fillOpacity={0.9}
|
||||
pointerEvents="none"
|
||||
r={ANCHOR_R}
|
||||
/>
|
||||
</g>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{displayMarkers.map((marker, index) => {
|
||||
const coordinates: [number, number] = [
|
||||
marker.center.long,
|
||||
marker.center.lat,
|
||||
];
|
||||
|
||||
return (
|
||||
<Marker
|
||||
coordinates={coordinates}
|
||||
key={`cluster-${index}-${marker.center.long}-${marker.center.lat}-${marker.mergedVisualClusters}`}
|
||||
>
|
||||
<g transform={`scale(${invScale})`}>
|
||||
<foreignObject
|
||||
height={PILL_H}
|
||||
overflow="visible"
|
||||
width={PILL_W}
|
||||
x={-PILL_W / 2}
|
||||
y={-(PILL_H + ANCHOR_R + PILL_GAP)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<MapMarkerPill
|
||||
marker={marker}
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
openMapBadgeDetails({
|
||||
marker,
|
||||
projectId,
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
309
apps/start/src/components/realtime/map/map-display-markers.ts
Normal file
309
apps/start/src/components/realtime/map/map-display-markers.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type { Coordinate, CoordinateCluster } from './coordinates';
|
||||
import {
|
||||
getAverageCenter,
|
||||
getClusterDetailLevel,
|
||||
haversineDistance,
|
||||
} from './coordinates';
|
||||
import type {
|
||||
ContinentBucket,
|
||||
DisplayMarker,
|
||||
MapProjection,
|
||||
} from './map-types';
|
||||
import {
|
||||
ANCHOR_R,
|
||||
createDisplayLabel,
|
||||
createMergedDisplayLabel,
|
||||
getDetailQueryScope,
|
||||
getDisplayMarkerId,
|
||||
getMergedDetailQueryScope,
|
||||
getWeightedScreenPoint,
|
||||
isValidCoordinate,
|
||||
normalizeLocationValue,
|
||||
PILL_GAP,
|
||||
PILL_H,
|
||||
PILL_W,
|
||||
} from './map-utils';
|
||||
|
||||
function projectToScreen(
|
||||
projection: MapProjection,
|
||||
coordinate: Coordinate,
|
||||
viewportCenter: [number, number],
|
||||
zoom: number,
|
||||
size: { width: number; height: number }
|
||||
) {
|
||||
const projectedPoint = projection([coordinate.long, coordinate.lat]);
|
||||
const projectedCenter = projection(viewportCenter);
|
||||
|
||||
if (!(projectedPoint && projectedCenter)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (projectedPoint[0] - projectedCenter[0]) * zoom + size.width / 2,
|
||||
y: (projectedPoint[1] - projectedCenter[1]) * zoom + size.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function isOffscreen(
|
||||
point: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
) {
|
||||
const margin = PILL_W;
|
||||
|
||||
return (
|
||||
point.x < -margin ||
|
||||
point.x > size.width + margin ||
|
||||
point.y < -margin ||
|
||||
point.y > size.height + margin
|
||||
);
|
||||
}
|
||||
|
||||
function doPillsOverlap(
|
||||
left: { x: number; y: number },
|
||||
right: { x: number; y: number },
|
||||
padding: number
|
||||
) {
|
||||
const leftBox = {
|
||||
left: left.x - PILL_W / 2 - padding,
|
||||
right: left.x + PILL_W / 2 + padding,
|
||||
top: left.y - (PILL_H + ANCHOR_R + PILL_GAP) - padding,
|
||||
};
|
||||
const rightBox = {
|
||||
left: right.x - PILL_W / 2 - padding,
|
||||
right: right.x + PILL_W / 2 + padding,
|
||||
top: right.y - (PILL_H + ANCHOR_R + PILL_GAP) - padding,
|
||||
};
|
||||
|
||||
const leftBottom = leftBox.top + PILL_H + padding * 2;
|
||||
const rightBottom = rightBox.top + PILL_H + padding * 2;
|
||||
|
||||
return !(
|
||||
leftBox.right < rightBox.left ||
|
||||
leftBox.left > rightBox.right ||
|
||||
leftBottom < rightBox.top ||
|
||||
leftBox.top > rightBottom
|
||||
);
|
||||
}
|
||||
|
||||
function getVisualMergePadding(zoom: number) {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return 8;
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
return 4;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
function getContinentBucket(coordinate: Coordinate): ContinentBucket {
|
||||
const { lat, long } = coordinate;
|
||||
|
||||
if (lat >= 15 && long >= -170 && long <= -20) {
|
||||
return 'north-america';
|
||||
}
|
||||
|
||||
if (lat < 15 && lat >= -60 && long >= -95 && long <= -30) {
|
||||
return 'south-america';
|
||||
}
|
||||
|
||||
if (lat >= 35 && long >= -25 && long <= 45) {
|
||||
return 'europe';
|
||||
}
|
||||
|
||||
if (lat >= -40 && lat <= 38 && long >= -20 && long <= 55) {
|
||||
return 'africa';
|
||||
}
|
||||
|
||||
if (lat >= -10 && long >= 110 && long <= 180) {
|
||||
return 'oceania';
|
||||
}
|
||||
|
||||
if (lat >= -10 && long >= 55 && long <= 180) {
|
||||
return 'asia';
|
||||
}
|
||||
|
||||
if (lat >= 0 && long >= 45 && long <= 180) {
|
||||
return 'asia';
|
||||
}
|
||||
|
||||
if (lat >= -10 && long >= 30 && long < 55) {
|
||||
return 'asia';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function getMaxVisualMergeDistanceKm(zoom: number) {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return 2200;
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
return 900;
|
||||
}
|
||||
|
||||
return 500;
|
||||
}
|
||||
|
||||
function canVisuallyMergeMarkers(
|
||||
left: CoordinateCluster,
|
||||
right: CoordinateCluster,
|
||||
zoom: number
|
||||
) {
|
||||
const sameContinent =
|
||||
getContinentBucket(left.center) === getContinentBucket(right.center);
|
||||
|
||||
if (!sameContinent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
haversineDistance(left.center, right.center) <=
|
||||
getMaxVisualMergeDistanceKm(zoom)
|
||||
);
|
||||
}
|
||||
|
||||
export function createDisplayMarkers({
|
||||
markers,
|
||||
projection,
|
||||
viewportCenter,
|
||||
zoom,
|
||||
labelZoom,
|
||||
size,
|
||||
}: {
|
||||
markers: CoordinateCluster[];
|
||||
projection: MapProjection;
|
||||
viewportCenter: [number, number];
|
||||
zoom: number;
|
||||
labelZoom: number;
|
||||
size: { width: number; height: number };
|
||||
}): DisplayMarker[] {
|
||||
const positionedMarkers = markers
|
||||
.map((marker) => {
|
||||
if (!isValidCoordinate(marker.center)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const point = projectToScreen(
|
||||
projection,
|
||||
marker.center,
|
||||
viewportCenter,
|
||||
zoom,
|
||||
size
|
||||
);
|
||||
|
||||
if (!point || isOffscreen(point, size)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { marker, point };
|
||||
})
|
||||
.filter((entry) => entry !== null);
|
||||
|
||||
const entries = positionedMarkers.sort(
|
||||
(left, right) => right.marker.count - left.marker.count
|
||||
);
|
||||
const consumed = new Set<number>();
|
||||
const mergedMarkers: DisplayMarker[] = [];
|
||||
const overlapPadding = getVisualMergePadding(labelZoom);
|
||||
|
||||
for (let index = 0; index < entries.length; index++) {
|
||||
if (consumed.has(index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queue = [index];
|
||||
const componentIndices: number[] = [];
|
||||
consumed.add(index);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentIndex = queue.shift()!;
|
||||
componentIndices.push(currentIndex);
|
||||
|
||||
for (
|
||||
let candidateIndex = currentIndex + 1;
|
||||
candidateIndex < entries.length;
|
||||
candidateIndex++
|
||||
) {
|
||||
if (consumed.has(candidateIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
doPillsOverlap(
|
||||
entries[currentIndex]!.point,
|
||||
entries[candidateIndex]!.point,
|
||||
overlapPadding
|
||||
) &&
|
||||
canVisuallyMergeMarkers(
|
||||
entries[currentIndex]!.marker,
|
||||
entries[candidateIndex]!.marker,
|
||||
labelZoom
|
||||
)
|
||||
) {
|
||||
consumed.add(candidateIndex);
|
||||
queue.push(candidateIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const componentEntries = componentIndices.map(
|
||||
(componentIndex) => entries[componentIndex]!
|
||||
);
|
||||
const componentMarkers = componentEntries.map((entry) => entry.marker);
|
||||
|
||||
if (componentMarkers.length === 1) {
|
||||
const marker = componentMarkers[0]!;
|
||||
mergedMarkers.push({
|
||||
...marker,
|
||||
detailScope: getDetailQueryScope(marker, labelZoom),
|
||||
id: getDisplayMarkerId(marker.members),
|
||||
label: createDisplayLabel(marker, labelZoom),
|
||||
mergedVisualClusters: 1,
|
||||
screenPoint: entries[index]!.point,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const members = componentMarkers.flatMap((marker) => marker.members);
|
||||
const center = getAverageCenter(members);
|
||||
const representativeCountry = normalizeLocationValue(
|
||||
componentMarkers[0]?.location.country
|
||||
);
|
||||
const representativeCity = normalizeLocationValue(
|
||||
componentMarkers[0]?.location.city
|
||||
);
|
||||
|
||||
const mergedMarker: CoordinateCluster = {
|
||||
center,
|
||||
count: componentMarkers.reduce((sum, marker) => sum + marker.count, 0),
|
||||
members,
|
||||
location: {
|
||||
city: representativeCity,
|
||||
country: representativeCountry,
|
||||
},
|
||||
};
|
||||
|
||||
mergedMarkers.push({
|
||||
...mergedMarker,
|
||||
detailScope: getMergedDetailQueryScope(labelZoom),
|
||||
id: getDisplayMarkerId(mergedMarker.members),
|
||||
label: createMergedDisplayLabel(mergedMarker, labelZoom),
|
||||
mergedVisualClusters: componentMarkers.length,
|
||||
screenPoint: getWeightedScreenPoint(
|
||||
componentEntries.map((entry) => ({
|
||||
count: entry.marker.count,
|
||||
screenPoint: entry.point,
|
||||
}))
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return mergedMarkers;
|
||||
}
|
||||
35
apps/start/src/components/realtime/map/map-marker-pill.tsx
Normal file
35
apps/start/src/components/realtime/map/map-marker-pill.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { DisplayMarker } from './map-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function MapMarkerPill({
|
||||
marker,
|
||||
onClick,
|
||||
}: {
|
||||
marker: DisplayMarker;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex select-none items-center gap-1.5 whitespace-nowrap rounded-lg border border-border/10 bg-background px-[10px] py-[5px] font-medium text-[11px] text-foreground shadow-[0_4px_16px] shadow-background/20',
|
||||
onClick ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="relative flex size-[7px] shrink-0">
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-300 opacity-75" />
|
||||
<span className="relative inline-flex size-[7px] rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
|
||||
<span className="tabular-nums">{marker.count.toLocaleString()}</span>
|
||||
|
||||
{marker.label ? (
|
||||
<>
|
||||
<span className="h-4 w-px shrink-0 bg-foreground/20" />
|
||||
<span className="max-w-[110px] truncate">{marker.label}</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
55
apps/start/src/components/realtime/map/map-types.ts
Normal file
55
apps/start/src/components/realtime/map/map-types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Coordinate, CoordinateCluster } from './coordinates';
|
||||
import type { MapBadgeDisplayMarker } from './realtime-map-badge-slice';
|
||||
|
||||
export type DisplayMarker = MapBadgeDisplayMarker;
|
||||
|
||||
export type ContinentBucket =
|
||||
| 'north-america'
|
||||
| 'south-america'
|
||||
| 'europe'
|
||||
| 'africa'
|
||||
| 'asia'
|
||||
| 'oceania'
|
||||
| 'unknown';
|
||||
|
||||
export type MapProjection = (
|
||||
point: [number, number]
|
||||
) => [number, number] | null;
|
||||
|
||||
export interface ZoomMovePosition {
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface ZoomMoveEndPosition {
|
||||
coordinates: [number, number];
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface GeographyFeature {
|
||||
rsmKey: string;
|
||||
properties: {
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DisplayMarkerCache {
|
||||
markers: CoordinateCluster[];
|
||||
projection: MapProjection | null;
|
||||
viewportCenter: [number, number];
|
||||
zoom: number;
|
||||
size: { width: number; height: number } | null;
|
||||
result: DisplayMarker[];
|
||||
}
|
||||
|
||||
export interface MapSidebarConfig {
|
||||
width: number;
|
||||
position: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface RealtimeMapProps {
|
||||
projectId: string;
|
||||
markers: Coordinate[];
|
||||
sidebarConfig?: MapSidebarConfig;
|
||||
}
|
||||
|
||||
export interface MapCanvasProps extends RealtimeMapProps {}
|
||||
298
apps/start/src/components/realtime/map/map-utils.ts
Normal file
298
apps/start/src/components/realtime/map/map-utils.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import type { Coordinate, CoordinateCluster } from './coordinates';
|
||||
import { getClusterDetailLevel } from './coordinates';
|
||||
import type { DisplayMarker } from './map-types';
|
||||
|
||||
export const PILL_W = 220;
|
||||
export const PILL_H = 32;
|
||||
export const ANCHOR_R = 3;
|
||||
export const PILL_GAP = 6;
|
||||
|
||||
const COUNTRY_CODE_PATTERN = /^[A-Z]{2}$/;
|
||||
|
||||
const regionDisplayNames =
|
||||
typeof Intl !== 'undefined'
|
||||
? new Intl.DisplayNames(['en'], { type: 'region' })
|
||||
: null;
|
||||
|
||||
export function normalizeLocationValue(value?: string) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function isValidCoordinate(coordinate: Coordinate) {
|
||||
return Number.isFinite(coordinate.lat) && Number.isFinite(coordinate.long);
|
||||
}
|
||||
|
||||
export function getCoordinateIdentity(coordinate: Coordinate) {
|
||||
return [
|
||||
normalizeLocationValue(coordinate.country) ?? '',
|
||||
normalizeLocationValue(coordinate.city) ?? '',
|
||||
isValidCoordinate(coordinate) ? coordinate.long.toFixed(4) : 'invalid-long',
|
||||
isValidCoordinate(coordinate) ? coordinate.lat.toFixed(4) : 'invalid-lat',
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function getDisplayMarkerId(members: Coordinate[]) {
|
||||
const validMembers = members.filter(isValidCoordinate);
|
||||
|
||||
if (validMembers.length === 0) {
|
||||
return 'invalid-cluster';
|
||||
}
|
||||
|
||||
return validMembers.map(getCoordinateIdentity).sort().join('|');
|
||||
}
|
||||
|
||||
export function getWeightedScreenPoint(
|
||||
markers: Array<{
|
||||
count: number;
|
||||
screenPoint: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}>
|
||||
) {
|
||||
let weightedX = 0;
|
||||
let weightedY = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
for (const marker of markers) {
|
||||
weightedX += marker.screenPoint.x * marker.count;
|
||||
weightedY += marker.screenPoint.y * marker.count;
|
||||
totalWeight += marker.count;
|
||||
}
|
||||
|
||||
return {
|
||||
x: weightedX / totalWeight,
|
||||
y: weightedY / totalWeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCountryLabel(country?: string) {
|
||||
const normalized = normalizeLocationValue(country);
|
||||
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!COUNTRY_CODE_PATTERN.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return regionDisplayNames?.of(normalized) ?? normalized;
|
||||
}
|
||||
|
||||
export function summarizeLocation(members: Coordinate[]) {
|
||||
const cities = new Set<string>();
|
||||
const countries = new Set<string>();
|
||||
|
||||
for (const member of members) {
|
||||
const city = normalizeLocationValue(member.city);
|
||||
const country = normalizeLocationValue(member.country);
|
||||
|
||||
if (city) {
|
||||
cities.add(city);
|
||||
}
|
||||
|
||||
if (country) {
|
||||
countries.add(country);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cityCount: cities.size,
|
||||
countryCount: countries.size,
|
||||
firstCity: [...cities][0],
|
||||
firstCountry: [...countries][0],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDisplayLabel(
|
||||
marker: CoordinateCluster,
|
||||
zoom: number
|
||||
): string {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return (
|
||||
formatCountryLabel(marker.location.country) ?? marker.location.city ?? '?'
|
||||
);
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
return (
|
||||
marker.location.city ?? formatCountryLabel(marker.location.country) ?? '?'
|
||||
);
|
||||
}
|
||||
|
||||
const cityMember = marker.members.find((member) => member.city?.trim());
|
||||
return (
|
||||
cityMember?.city?.trim() ??
|
||||
formatCountryLabel(marker.location.country) ??
|
||||
'?'
|
||||
);
|
||||
}
|
||||
|
||||
export function getDetailQueryScope(
|
||||
marker: CoordinateCluster,
|
||||
zoom: number
|
||||
): DisplayMarker['detailScope'] {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
return 'country';
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
return marker.location.city ? 'city' : 'country';
|
||||
}
|
||||
|
||||
return 'coordinate';
|
||||
}
|
||||
|
||||
export function getMergedDetailQueryScope(
|
||||
zoom: number
|
||||
): DisplayMarker['detailScope'] {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
|
||||
return detailLevel === 'country' ? 'country' : 'city';
|
||||
}
|
||||
|
||||
export function createMergedDisplayLabel(
|
||||
marker: CoordinateCluster,
|
||||
zoom: number
|
||||
): string {
|
||||
const detailLevel = getClusterDetailLevel(zoom);
|
||||
const summary = summarizeLocation(marker.members);
|
||||
|
||||
if (detailLevel === 'country') {
|
||||
if (summary.countryCount <= 1) {
|
||||
return (
|
||||
formatCountryLabel(summary.firstCountry) ?? summary.firstCity ?? '?'
|
||||
);
|
||||
}
|
||||
|
||||
return `${summary.countryCount} countries`;
|
||||
}
|
||||
|
||||
if (detailLevel === 'city') {
|
||||
if (summary.cityCount === 1 && summary.firstCity) {
|
||||
return summary.firstCity;
|
||||
}
|
||||
|
||||
if (summary.countryCount === 1) {
|
||||
const country = formatCountryLabel(summary.firstCountry);
|
||||
|
||||
if (country && summary.cityCount > 1) {
|
||||
return `${country}, ${summary.cityCount} cities`;
|
||||
}
|
||||
|
||||
return country ?? `${summary.cityCount} places`;
|
||||
}
|
||||
|
||||
if (summary.countryCount > 1) {
|
||||
return `${summary.countryCount} countries`;
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.cityCount === 1 && summary.firstCity) {
|
||||
return summary.firstCity;
|
||||
}
|
||||
|
||||
if (summary.countryCount === 1) {
|
||||
const country = formatCountryLabel(summary.firstCountry);
|
||||
|
||||
if (country && summary.cityCount > 1) {
|
||||
return `${country}, ${summary.cityCount} places`;
|
||||
}
|
||||
|
||||
return country ?? `${marker.members.length} places`;
|
||||
}
|
||||
|
||||
return `${Math.max(summary.countryCount, summary.cityCount, 2)} places`;
|
||||
}
|
||||
|
||||
export function getBadgeOverlayPosition(
|
||||
marker: DisplayMarker,
|
||||
size: { width: number; height: number }
|
||||
) {
|
||||
const overlayWidth = Math.min(380, size.width - 24);
|
||||
const preferredLeft = marker.screenPoint.x - overlayWidth / 2;
|
||||
const left = Math.max(
|
||||
12,
|
||||
Math.min(preferredLeft, size.width - overlayWidth - 12)
|
||||
);
|
||||
const top = Math.max(
|
||||
12,
|
||||
Math.min(marker.screenPoint.y + 16, size.height - 340)
|
||||
);
|
||||
|
||||
return { left, overlayWidth, top };
|
||||
}
|
||||
|
||||
export function getProfileDisplayName(profile: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
id: string;
|
||||
}) {
|
||||
const name = [profile.firstName, profile.lastName].filter(Boolean).join(' ');
|
||||
return name || profile.email || profile.id;
|
||||
}
|
||||
|
||||
export function getUniqueCoordinateDetailLocations(members: Coordinate[]) {
|
||||
const locationsByKey: Record<
|
||||
string,
|
||||
{
|
||||
city?: string;
|
||||
country?: string;
|
||||
lat: number;
|
||||
long: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const member of members) {
|
||||
if (!isValidCoordinate(member)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = [
|
||||
normalizeLocationValue(member.country) ?? '',
|
||||
normalizeLocationValue(member.city) ?? '',
|
||||
member.long.toFixed(4),
|
||||
member.lat.toFixed(4),
|
||||
].join(':');
|
||||
|
||||
locationsByKey[key] = {
|
||||
city: member.city,
|
||||
country: member.country,
|
||||
lat: member.lat,
|
||||
long: member.long,
|
||||
};
|
||||
}
|
||||
|
||||
return Object.values(locationsByKey);
|
||||
}
|
||||
|
||||
export function getUniquePlaceDetailLocations(members: Coordinate[]) {
|
||||
const locationsByKey: Record<
|
||||
string,
|
||||
{
|
||||
city?: string;
|
||||
country?: string;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const member of members) {
|
||||
const key = [
|
||||
normalizeLocationValue(member.country) ?? '',
|
||||
normalizeLocationValue(member.city) ?? '',
|
||||
].join(':');
|
||||
|
||||
locationsByKey[key] = {
|
||||
city: member.city,
|
||||
country: member.country,
|
||||
};
|
||||
}
|
||||
|
||||
return Object.values(locationsByKey);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useZoomPan } from 'react-simple-maps';
|
||||
|
||||
import type { Coordinate } from './coordinates';
|
||||
|
||||
export const GEO_MAP_URL =
|
||||
@@ -49,7 +48,7 @@ export const getBoundingBox = (coordinates: Coordinate[]) => {
|
||||
|
||||
export const determineZoom = (
|
||||
bbox: ReturnType<typeof getBoundingBox>,
|
||||
aspectRatio = 1.0,
|
||||
aspectRatio = 1.0
|
||||
): number => {
|
||||
const latDiff = bbox.maxLat - bbox.minLat;
|
||||
const longDiff = bbox.maxLong - bbox.minLong;
|
||||
@@ -80,7 +79,7 @@ export function CustomZoomableGroup({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { mapRef, transformString } = useZoomPan({
|
||||
center: center,
|
||||
center,
|
||||
zoom,
|
||||
filterZoomEvent: () => false,
|
||||
});
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { Coordinate } from './coordinates';
|
||||
|
||||
const useActiveMarkers = (initialMarkers: Coordinate[]) => {
|
||||
const [activeMarkers, setActiveMarkers] = useState(initialMarkers);
|
||||
|
||||
const toggleActiveMarkers = useCallback(() => {
|
||||
// Shuffle array function
|
||||
const shuffled = [...initialMarkers].sort(() => 0.5 - Math.random());
|
||||
// Cut the array in half randomly to simulate changes in active markers
|
||||
const selected = shuffled.slice(
|
||||
0,
|
||||
Math.floor(Math.random() * shuffled.length) + 1,
|
||||
);
|
||||
setActiveMarkers(selected);
|
||||
}, [activeMarkers]);
|
||||
|
||||
return { markers: activeMarkers, toggle: toggleActiveMarkers };
|
||||
};
|
||||
|
||||
export default useActiveMarkers;
|
||||
|
||||
export function calculateMarkerSize(count: number) {
|
||||
const minSize = 3; // Minimum size for single visitor (reduced from 4)
|
||||
const maxSize = 14; // Maximum size for very large clusters (reduced from 20)
|
||||
|
||||
if (count <= 1) return minSize;
|
||||
|
||||
// Use square root scaling for better visual differentiation
|
||||
// This creates more noticeable size differences for common visitor counts
|
||||
// Examples:
|
||||
// 1 visitor: 3px
|
||||
// 2 visitors: ~5px
|
||||
// 5 visitors: ~7px
|
||||
// 10 visitors: ~9px
|
||||
// 25 visitors: ~12px
|
||||
// 50+ visitors: ~14px (max)
|
||||
const scaledSize = minSize + Math.sqrt(count - 1) * 1.8;
|
||||
|
||||
// Ensure size does not exceed maxSize or fall below minSize
|
||||
return Math.max(minSize, Math.min(scaledSize, maxSize));
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { CoordinateCluster } from './coordinates';
|
||||
|
||||
/** Serializable marker payload for the realtime map badge detail panel */
|
||||
export interface MapBadgeDisplayMarker extends CoordinateCluster {
|
||||
detailScope: 'city' | 'coordinate' | 'country' | 'merged';
|
||||
id: string;
|
||||
label: string;
|
||||
mergedVisualClusters: number;
|
||||
screenPoint: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface RealtimeMapBadgeState {
|
||||
open: boolean;
|
||||
marker: MapBadgeDisplayMarker | null;
|
||||
projectId: string | null;
|
||||
}
|
||||
|
||||
const initialState: RealtimeMapBadgeState = {
|
||||
open: false,
|
||||
marker: null,
|
||||
projectId: null,
|
||||
};
|
||||
|
||||
const realtimeMapBadgeSlice = createSlice({
|
||||
name: 'realtimeMapBadge',
|
||||
initialState,
|
||||
reducers: {
|
||||
openMapBadgeDetails(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
marker: MapBadgeDisplayMarker;
|
||||
projectId: string;
|
||||
}>
|
||||
) {
|
||||
state.open = true;
|
||||
state.marker = action.payload.marker;
|
||||
state.projectId = action.payload.projectId;
|
||||
},
|
||||
closeMapBadgeDetails(state) {
|
||||
if (!state.open) {
|
||||
return;
|
||||
}
|
||||
state.open = false;
|
||||
state.marker = null;
|
||||
state.projectId = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { openMapBadgeDetails, closeMapBadgeDetails } =
|
||||
realtimeMapBadgeSlice.actions;
|
||||
|
||||
export default realtimeMapBadgeSlice.reducer;
|
||||
@@ -1,80 +1,69 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import useWS from '@/hooks/use-ws';
|
||||
import type { IServiceEvent } from '@openpanel/db';
|
||||
import { EventItem } from '../events/table/item';
|
||||
import { ProjectLink } from '../links';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { formatTimeAgoOrDateTime } from '@/utils/date';
|
||||
|
||||
interface RealtimeActiveSessionsProps {
|
||||
projectId: string;
|
||||
limit?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RealtimeActiveSessions({
|
||||
projectId,
|
||||
limit = 10,
|
||||
className,
|
||||
}: RealtimeActiveSessionsProps) {
|
||||
const trpc = useTRPC();
|
||||
const activeSessionsQuery = useQuery(
|
||||
trpc.realtime.activeSessions.queryOptions({
|
||||
projectId,
|
||||
}),
|
||||
const { data: sessions = [] } = useQuery(
|
||||
trpc.realtime.activeSessions.queryOptions(
|
||||
{ projectId },
|
||||
{ refetchInterval: 5000 }
|
||||
)
|
||||
);
|
||||
|
||||
const [state, setState] = useState<IServiceEvent[]>([]);
|
||||
|
||||
// Update state when initial data loads
|
||||
useEffect(() => {
|
||||
if (activeSessionsQuery.data && state.length === 0) {
|
||||
setState(activeSessionsQuery.data);
|
||||
}
|
||||
}, [activeSessionsQuery.data, state]);
|
||||
|
||||
// Set up WebSocket connection for real-time updates
|
||||
useWS<IServiceEvent>(
|
||||
`/live/events/${projectId}`,
|
||||
(session) => {
|
||||
setState((prev) => {
|
||||
// Add new session and remove duplicates, keeping most recent
|
||||
const filtered = prev.filter((s) => s.id !== session.id);
|
||||
return [session, ...filtered].slice(0, limit);
|
||||
});
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 5000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sessions = state.length > 0 ? state : (activeSessionsQuery.data ?? []);
|
||||
|
||||
return (
|
||||
<div className="col h-full max-md:hidden">
|
||||
<div className="hide-scrollbar h-full overflow-y-auto pb-10">
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<div className="col gap-4">
|
||||
{sessions.map((session) => (
|
||||
<div className={cn('col card h-full', className)}>
|
||||
<div className="hide-scrollbar h-full overflow-y-auto">
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
<div className="col divide-y">
|
||||
{sessions.slice(0, limit).map((session) => (
|
||||
<motion.div
|
||||
key={session.id}
|
||||
layout
|
||||
// initial={{ opacity: 0, x: -200, scale: 0.8 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 200, scale: 0.8 }}
|
||||
key={session.id}
|
||||
layout
|
||||
transition={{ duration: 0.4, type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<EventItem
|
||||
event={session}
|
||||
viewOptions={{
|
||||
properties: false,
|
||||
origin: false,
|
||||
queryString: false,
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<ProjectLink
|
||||
className="relative block p-4 py-3 pr-14"
|
||||
href={`/sessions/${session.sessionId}`}
|
||||
>
|
||||
<div className="col flex-1 gap-1">
|
||||
{session.name === 'screen_view' && (
|
||||
<span className="text-muted-foreground text-xs leading-normal/80">
|
||||
{session.origin}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate font-medium text-sm leading-normal">
|
||||
{session.name === 'screen_view'
|
||||
? session.path
|
||||
: session.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatTimeAgoOrDateTime(session.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row absolute top-1/2 right-4 origin-right -translate-y-1/2 scale-50 gap-2">
|
||||
<SerieIcon name={session.referrerName} />
|
||||
<SerieIcon name={session.os} />
|
||||
<SerieIcon name={session.browser} />
|
||||
<SerieIcon name={session.device} />
|
||||
</div>
|
||||
</ProjectLink>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import throttle from 'lodash.throttle';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -17,6 +13,9 @@ import {
|
||||
} from 'recharts';
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
interface RealtimeLiveHistogramProps {
|
||||
projectId: string;
|
||||
@@ -26,10 +25,11 @@ export function RealtimeLiveHistogram({
|
||||
projectId,
|
||||
}: RealtimeLiveHistogramProps) {
|
||||
const trpc = useTRPC();
|
||||
const number = useNumber();
|
||||
|
||||
// Use the same liveData endpoint as overview
|
||||
const { data: liveData, isLoading } = useQuery(
|
||||
trpc.overview.liveData.queryOptions({ projectId }),
|
||||
trpc.overview.liveData.queryOptions({ projectId })
|
||||
);
|
||||
|
||||
const chartData = liveData?.minuteCounts ?? [];
|
||||
@@ -40,7 +40,7 @@ export function RealtimeLiveHistogram({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Wrapper count={0}>
|
||||
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
||||
<div className="h-full w-full animate-pulse rounded bg-def-200" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -55,23 +55,23 @@ export function RealtimeLiveHistogram({
|
||||
return (
|
||||
<Wrapper
|
||||
count={totalVisitors}
|
||||
icons={
|
||||
liveData.referrers && liveData.referrers.length > 0 ? (
|
||||
<div className="row gap-2 shrink-0">
|
||||
{liveData.referrers.slice(0, 3).map((ref, index) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="font-bold text-xs row gap-1 items-center"
|
||||
>
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span>{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
// icons={
|
||||
// liveData.referrers && liveData.referrers.length > 0 ? (
|
||||
// <div className="row shrink-0 gap-2">
|
||||
// {liveData.referrers.slice(0, 3).map((ref, index) => (
|
||||
// <div
|
||||
// className="row items-center gap-1 font-bold text-xs"
|
||||
// key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
// >
|
||||
// <SerieIcon name={ref.referrer} />
|
||||
// <span>{number.short(ref.count)}</span>
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// ) : null
|
||||
// }
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
@@ -82,11 +82,11 @@ export function RealtimeLiveHistogram({
|
||||
fill: 'var(--def-200)',
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<XAxis axisLine={false} dataKey="time" hide tickLine={false} />
|
||||
<YAxis domain={[0, maxDomain]} hide />
|
||||
<Bar
|
||||
dataKey="visitorCount"
|
||||
className="fill-chart-0"
|
||||
dataKey="visitorCount"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
@@ -104,19 +104,18 @@ interface WrapperProps {
|
||||
function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="row gap-2 justify-between mb-2">
|
||||
<div className="relative text-sm font-medium text-muted-foreground leading-normal">
|
||||
Unique visitors {icons ? <br /> : null}
|
||||
last 30 min
|
||||
<div className="row justify-between gap-2">
|
||||
<div className="relative font-medium text-muted-foreground text-sm leading-normal">
|
||||
Unique visitors last 30 min
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
<div className="col gap-2 mb-4">
|
||||
<div className="font-mono text-6xl font-bold">
|
||||
<div className="col -mt-1 gap-2">
|
||||
<div className="font-bold font-mono text-6xl">
|
||||
<AnimatedNumber value={count} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative aspect-[6/1] w-full">{children}</div>
|
||||
<div className="relative -mt-2 aspect-[6/1] w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -125,10 +124,10 @@ function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
const number = useNumber();
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
const inactive = !active || !payload?.length;
|
||||
const inactive = !(active && payload?.length);
|
||||
useEffect(() => {
|
||||
const setPositionThrottled = throttle(setPosition, 50);
|
||||
const unsubMouseMove = bind(window, {
|
||||
@@ -156,7 +155,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!active || !payload || !payload.length) {
|
||||
if (!(active && payload && payload.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -179,6 +178,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
|
||||
return (
|
||||
<Portal.Portal
|
||||
className="rounded-md border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position?.y,
|
||||
@@ -186,7 +186,6 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
zIndex: 1000,
|
||||
width: tooltipWidth,
|
||||
}}
|
||||
className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{data.time}</div>
|
||||
@@ -199,7 +198,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Active users</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="flex justify-between gap-8 font-medium font-mono">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.visitorCount)}
|
||||
</div>
|
||||
@@ -207,18 +206,18 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
</div>
|
||||
</div>
|
||||
{data.referrers && data.referrers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
|
||||
<div className="mt-2 border-border border-t pt-2">
|
||||
<div className="mb-2 text-muted-foreground text-xs">Referrers:</div>
|
||||
<div className="space-y-1">
|
||||
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="row items-center justify-between text-xs"
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
>
|
||||
<div className="row items-center gap-1">
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span
|
||||
className="truncate max-w-[120px]"
|
||||
className="max-w-[120px] truncate"
|
||||
title={ref.referrer}
|
||||
>
|
||||
{ref.referrer}
|
||||
@@ -228,7 +227,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
</div>
|
||||
))}
|
||||
{data.referrers.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
+{data.referrers.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,17 +14,11 @@ const RealtimeReloader = ({ projectId }: Props) => {
|
||||
`/live/events/${projectId}`,
|
||||
() => {
|
||||
if (!document.hidden) {
|
||||
// pathFilter() covers all realtime.* queries for this project
|
||||
client.refetchQueries(trpc.realtime.pathFilter());
|
||||
client.refetchQueries(
|
||||
trpc.overview.liveData.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.realtime.activeSessions.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(
|
||||
trpc.realtime.referrals.queryFilter({ projectId }),
|
||||
);
|
||||
client.refetchQueries(trpc.realtime.paths.queryFilter({ projectId }));
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -166,7 +166,8 @@ export function Tables({
|
||||
metric: 'sum',
|
||||
options: funnelOptions,
|
||||
},
|
||||
stepIndex, // Pass the step index for funnel queries
|
||||
stepIndex,
|
||||
breakdownValues: breakdowns,
|
||||
});
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { chartSegments } from '@openpanel/constants';
|
||||
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
|
||||
import {
|
||||
ActivityIcon,
|
||||
Building2Icon,
|
||||
ClockIcon,
|
||||
EqualApproximatelyIcon,
|
||||
type LucideIcon,
|
||||
@@ -10,10 +13,7 @@ import {
|
||||
UserCheckIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { chartSegments } from '@openpanel/constants';
|
||||
import { type IChartEventSegment, mapKeys } from '@openpanel/validation';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface ReportChartTypeProps {
|
||||
className?: string;
|
||||
@@ -46,6 +45,7 @@ export function ReportSegment({
|
||||
event: ActivityIcon,
|
||||
user: UsersIcon,
|
||||
session: ClockIcon,
|
||||
group: Building2Icon,
|
||||
user_average: UserCheck2Icon,
|
||||
one_event_per_user: UserCheckIcon,
|
||||
property_sum: SigmaIcon,
|
||||
@@ -58,9 +58,9 @@ export function ReportSegment({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={Icons[value]}
|
||||
className={cn('justify-start text-sm', className)}
|
||||
icon={Icons[value]}
|
||||
variant="outline"
|
||||
>
|
||||
{items.find((item) => item.value === value)?.label}
|
||||
</Button>
|
||||
@@ -74,13 +74,13 @@ export function ReportSegment({
|
||||
const Icon = Icons[item.value];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="group"
|
||||
key={item.value}
|
||||
onClick={() => onChange(item.value)}
|
||||
className="group"
|
||||
>
|
||||
{item.label}
|
||||
<DropdownMenuShortcut>
|
||||
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||
<Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
Building2Icon,
|
||||
DatabaseIcon,
|
||||
UserIcon,
|
||||
} from 'lucide-react';
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,11 +21,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventProperties } from '@/hooks/use-event-properties';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ArrowLeftIcon, DatabaseIcon, UserIcon } from 'lucide-react';
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
interface PropertiesComboboxProps {
|
||||
event?: IChartEvent;
|
||||
@@ -40,15 +47,15 @@ function SearchHeader({
|
||||
return (
|
||||
<div className="row items-center gap-1">
|
||||
{!!onBack && (
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<Button onClick={onBack} size="icon" variant="ghost">
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search"
|
||||
value={value}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -62,18 +69,24 @@ export function PropertiesCombobox({
|
||||
exclude = [],
|
||||
}: PropertiesComboboxProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const trpc = useTRPC();
|
||||
const [open, setOpen] = useState(false);
|
||||
const properties = useEventProperties({
|
||||
event: event?.name,
|
||||
projectId,
|
||||
});
|
||||
const [state, setState] = useState<'index' | 'event' | 'profile'>('index');
|
||||
const groupPropertiesQuery = useQuery(
|
||||
trpc.group.properties.queryOptions({ projectId })
|
||||
);
|
||||
const [state, setState] = useState<'index' | 'event' | 'profile' | 'group'>(
|
||||
'index'
|
||||
);
|
||||
const [search, setSearch] = useState('');
|
||||
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setState(!mode ? 'index' : mode === 'events' ? 'event' : 'profile');
|
||||
setState(mode ? (mode === 'events' ? 'event' : 'profile') : 'index');
|
||||
}
|
||||
}, [open, mode]);
|
||||
|
||||
@@ -86,11 +99,21 @@ export function PropertiesCombobox({
|
||||
});
|
||||
};
|
||||
|
||||
// Mock data for the lists
|
||||
// Fixed group properties: name, type, plus dynamic property keys
|
||||
const groupActions = [
|
||||
{ value: 'group.name', label: 'name', description: 'group' },
|
||||
{ value: 'group.type', label: 'type', description: 'group' },
|
||||
...(groupPropertiesQuery.data ?? []).map((key) => ({
|
||||
value: `group.properties.${key}`,
|
||||
label: key,
|
||||
description: 'group.properties',
|
||||
})),
|
||||
].filter((a) => shouldShowProperty(a.value));
|
||||
|
||||
const profileActions = properties
|
||||
.filter(
|
||||
(property) =>
|
||||
property.startsWith('profile') && shouldShowProperty(property),
|
||||
property.startsWith('profile') && shouldShowProperty(property)
|
||||
)
|
||||
.map((property) => ({
|
||||
value: property,
|
||||
@@ -100,7 +123,7 @@ export function PropertiesCombobox({
|
||||
const eventActions = properties
|
||||
.filter(
|
||||
(property) =>
|
||||
!property.startsWith('profile') && shouldShowProperty(property),
|
||||
!property.startsWith('profile') && shouldShowProperty(property)
|
||||
)
|
||||
.map((property) => ({
|
||||
value: property,
|
||||
@@ -108,7 +131,9 @@ export function PropertiesCombobox({
|
||||
description: property.split('.').slice(0, -1).join('.'),
|
||||
}));
|
||||
|
||||
const handleStateChange = (newState: 'index' | 'event' | 'profile') => {
|
||||
const handleStateChange = (
|
||||
newState: 'index' | 'event' | 'profile' | 'group'
|
||||
) => {
|
||||
setDirection(newState === 'index' ? 'backward' : 'forward');
|
||||
setState(newState);
|
||||
};
|
||||
@@ -135,7 +160,7 @@ export function PropertiesCombobox({
|
||||
}}
|
||||
>
|
||||
Event properties
|
||||
<DatabaseIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||
<DatabaseIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="group justify-between gap-2"
|
||||
@@ -145,7 +170,17 @@ export function PropertiesCombobox({
|
||||
}}
|
||||
>
|
||||
Profile properties
|
||||
<UserIcon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||
<UserIcon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="group justify-between gap-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleStateChange('group');
|
||||
}}
|
||||
>
|
||||
Group properties
|
||||
<Building2Icon className="size-4 transition-all group-hover:rotate-12 group-hover:scale-125 group-hover:text-blue-500" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
);
|
||||
@@ -155,7 +190,7 @@ export function PropertiesCombobox({
|
||||
const filteredActions = eventActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
||||
action.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -169,20 +204,20 @@ export function PropertiesCombobox({
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
height={300}
|
||||
data={filteredActions}
|
||||
height={300}
|
||||
itemHeight={40}
|
||||
itemKey="id"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
|
||||
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
onClick={() => handleSelect(action)}
|
||||
>
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{action.description}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -196,7 +231,7 @@ export function PropertiesCombobox({
|
||||
const filteredActions = profileActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase()),
|
||||
action.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -208,20 +243,59 @@ export function PropertiesCombobox({
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
height={300}
|
||||
data={filteredActions}
|
||||
height={300}
|
||||
itemHeight={40}
|
||||
itemKey="id"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-2 hover:bg-accent cursor-pointer rounded-md col gap-px"
|
||||
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
onClick={() => handleSelect(action)}
|
||||
>
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{action.description}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</VirtualList>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderGroup = () => {
|
||||
const filteredActions = groupActions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
action.description.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<SearchHeader
|
||||
onBack={() => handleStateChange('index')}
|
||||
onSearch={setSearch}
|
||||
value={search}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<VirtualList
|
||||
data={filteredActions}
|
||||
height={Math.min(300, filteredActions.length * 40 + 8)}
|
||||
itemHeight={40}
|
||||
itemKey="value"
|
||||
>
|
||||
{(action) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="col cursor-pointer gap-px rounded-md p-2 hover:bg-accent"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
onClick={() => handleSelect(action)}
|
||||
>
|
||||
<div className="font-medium">{action.label}</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{action.description}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -233,20 +307,20 @@ export function PropertiesCombobox({
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>{children(setOpen)}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="max-w-80" align="start">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<DropdownMenuContent align="start" className="max-w-80">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{state === 'index' && (
|
||||
<motion.div
|
||||
key="index"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="index"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderIndex()}
|
||||
@@ -254,10 +328,10 @@ export function PropertiesCombobox({
|
||||
)}
|
||||
{state === 'event' && (
|
||||
<motion.div
|
||||
key="event"
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key="event"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderEvent()}
|
||||
@@ -265,15 +339,26 @@ export function PropertiesCombobox({
|
||||
)}
|
||||
{state === 'profile' && (
|
||||
<motion.div
|
||||
key="profile"
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key="profile"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderProfile()}
|
||||
</motion.div>
|
||||
)}
|
||||
{state === 'group' && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key="group"
|
||||
transition={{ duration: 0.05 }}
|
||||
>
|
||||
{renderGroup()}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function useColumns() {
|
||||
if (session.profile) {
|
||||
return (
|
||||
<ProjectLink
|
||||
className="row items-center gap-2 font-medium"
|
||||
className="row items-center gap-2 font-medium hover:underline"
|
||||
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
|
||||
>
|
||||
<ProfileAvatar size="sm" {...session.profile} />
|
||||
@@ -270,6 +270,27 @@ export function useColumns() {
|
||||
header: 'Device ID',
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
accessorKey: 'groups',
|
||||
header: 'Groups',
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const { groups } = row.original;
|
||||
if (!groups?.length) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{groups.map((g) => (
|
||||
<span
|
||||
key={g}
|
||||
className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono"
|
||||
>
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return columns;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import { useColumns } from './columns';
|
||||
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
||||
|
||||
// Custom hook for persistent column visibility
|
||||
const usePersistentColumnVisibility = (columns: any[]) => {
|
||||
@@ -21,7 +19,7 @@ const usePersistentColumnVisibility = (columns: any[]) => {
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, boolean>,
|
||||
{} as Record<string, boolean>
|
||||
);
|
||||
}, [columns, savedVisibility]);
|
||||
|
||||
@@ -37,25 +35,33 @@ const usePersistentColumnVisibility = (columns: any[]) => {
|
||||
};
|
||||
};
|
||||
|
||||
import type { IServiceSession } from '@openpanel/db';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
||||
import { Loader2Icon, SlidersHorizontalIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AnimatedSearchInput,
|
||||
DataTableToolbarContainer,
|
||||
} from '@/components/ui/data-table/data-table-toolbar';
|
||||
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
|
||||
import type { FilterDefinition } from '@/components/ui/filter-dropdown';
|
||||
import { FilterDropdown } from '@/components/ui/filter-dropdown';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { arePropsEqual } from '@/utils/are-props-equal';
|
||||
import { useSessionFilters } from '@/hooks/use-session-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { IServiceSession } from '@openpanel/db';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
type Props = {
|
||||
query: UseInfiniteQueryResult<
|
||||
@@ -83,6 +89,7 @@ interface VirtualRowProps {
|
||||
scrollMargin: number;
|
||||
isLoading: boolean;
|
||||
headerColumnsHash: string;
|
||||
onRowClick?: (row: any) => void;
|
||||
}
|
||||
|
||||
const VirtualRow = memo(
|
||||
@@ -92,12 +99,26 @@ const VirtualRow = memo(
|
||||
headerColumns,
|
||||
scrollMargin,
|
||||
isLoading,
|
||||
onRowClick,
|
||||
}: VirtualRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row absolute top-0 left-0 w-full border-b transition-colors hover:bg-muted/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
data-index={virtualRow.index}
|
||||
onClick={
|
||||
onRowClick
|
||||
? (e) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) {
|
||||
return;
|
||||
}
|
||||
onRowClick(row);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
ref={virtualRow.measureElement}
|
||||
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||
display: 'grid',
|
||||
@@ -112,8 +133,8 @@ const VirtualRow = memo(
|
||||
const width = `${cell.column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
className="flex items-center whitespace-nowrap p-2 px-4 align-middle"
|
||||
key={cell.id}
|
||||
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
overflow: 'hidden',
|
||||
@@ -143,16 +164,18 @@ const VirtualRow = memo(
|
||||
prevProps.virtualRow.start === nextProps.virtualRow.start &&
|
||||
prevProps.virtualRow.size === nextProps.virtualRow.size &&
|
||||
prevProps.isLoading === nextProps.isLoading &&
|
||||
prevProps.headerColumnsHash === nextProps.headerColumnsHash
|
||||
prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
|
||||
prevProps.onRowClick === nextProps.onRowClick
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const VirtualizedSessionsTable = ({
|
||||
table,
|
||||
data,
|
||||
isLoading,
|
||||
}: VirtualizedSessionsTableProps) => {
|
||||
onRowClick,
|
||||
}: VirtualizedSessionsTableProps & { onRowClick?: (row: any) => void }) => {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const headerColumns = table.getAllLeafColumns().filter((col) => {
|
||||
@@ -171,12 +194,12 @@ const VirtualizedSessionsTable = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-x-auto rounded-md border bg-card"
|
||||
ref={parentRef}
|
||||
className="w-full overflow-x-auto border rounded-md bg-card"
|
||||
>
|
||||
{/* Table Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-card border-b"
|
||||
className="sticky top-0 z-10 border-b bg-card"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
@@ -190,8 +213,8 @@ const VirtualizedSessionsTable = ({
|
||||
const width = `${column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
className="flex h-10 items-center whitespace-nowrap px-4 text-left font-semibold text-[10px] text-foreground uppercase"
|
||||
key={column.id}
|
||||
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
@@ -204,8 +227,8 @@ const VirtualizedSessionsTable = ({
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No sessions found"
|
||||
description="Looks like you haven't inserted any events yet."
|
||||
title="No sessions found"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -220,20 +243,23 @@ const VirtualizedSessionsTable = ({
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = table.getRowModel().rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VirtualRow
|
||||
headerColumns={headerColumns}
|
||||
headerColumnsHash={headerColumnsHash}
|
||||
isLoading={isLoading}
|
||||
key={row.id}
|
||||
onRowClick={onRowClick}
|
||||
row={row}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
virtualRow={{
|
||||
...virtualRow,
|
||||
measureElement: rowVirtualizer.measureElement,
|
||||
}}
|
||||
headerColumns={headerColumns}
|
||||
headerColumnsHash={headerColumnsHash}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -245,13 +271,25 @@ const VirtualizedSessionsTable = ({
|
||||
export const SessionsTable = ({ query }: Props) => {
|
||||
const { isLoading } = query;
|
||||
const columns = useColumns();
|
||||
const navigate = useNavigate();
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: any) => {
|
||||
navigate({
|
||||
to: '/$organizationId/$projectId/sessions/$sessionId',
|
||||
params: { organizationId, projectId, sessionId: row.original.id },
|
||||
});
|
||||
},
|
||||
[navigate, organizationId, projectId]
|
||||
);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return LOADING_DATA;
|
||||
}
|
||||
|
||||
return query.data?.pages?.flatMap((p) => p.data) ?? [];
|
||||
return query.data?.pages?.flatMap((p) => p.items) ?? [];
|
||||
}, [query.data]);
|
||||
|
||||
// const { setPage, state: pagination } = useDataTablePagination();
|
||||
@@ -292,7 +330,6 @@ export const SessionsTable = ({ query }: Props) => {
|
||||
enterCount > 0 &&
|
||||
query.isFetchingNextPage === false
|
||||
) {
|
||||
console.log('fetching next page');
|
||||
query.fetchNextPage();
|
||||
}
|
||||
}, [inViewport, enterCount, hasNextPage]);
|
||||
@@ -301,15 +338,16 @@ export const SessionsTable = ({ query }: Props) => {
|
||||
<>
|
||||
<SessionTableToolbar table={table} />
|
||||
<VirtualizedSessionsTable
|
||||
table={table}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
table={table}
|
||||
/>
|
||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||
<div className="center-center h-10 w-full pt-4" ref={inViewportRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100',
|
||||
'center-center size-8 rounded-full border bg-background opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
@@ -319,15 +357,88 @@ export const SessionsTable = ({ query }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SESSION_FILTER_KEY_TO_FIELD: Record<string, string> = {
|
||||
referrer: 'referrer_name',
|
||||
country: 'country',
|
||||
os: 'os',
|
||||
browser: 'browser',
|
||||
device: 'device',
|
||||
};
|
||||
|
||||
const SESSION_FILTER_DEFINITIONS: FilterDefinition[] = [
|
||||
{ key: 'referrer', label: 'Referrer', type: 'select' },
|
||||
{ key: 'country', label: 'Country', type: 'select' },
|
||||
{ key: 'os', label: 'OS', type: 'select' },
|
||||
{ key: 'browser', label: 'Browser', type: 'select' },
|
||||
{ key: 'device', label: 'Device', type: 'select' },
|
||||
{ key: 'entryPage', label: 'Entry page', type: 'string' },
|
||||
{ key: 'exitPage', label: 'Exit page', type: 'string' },
|
||||
{ key: 'minPageViews', label: 'Min page views', type: 'number' },
|
||||
{ key: 'maxPageViews', label: 'Max page views', type: 'number' },
|
||||
{ key: 'minEvents', label: 'Min events', type: 'number' },
|
||||
{ key: 'maxEvents', label: 'Max events', type: 'number' },
|
||||
];
|
||||
|
||||
function SessionTableToolbar({ table }: { table: Table<IServiceSession> }) {
|
||||
const { projectId } = useAppParams();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { search, setSearch } = useSearchQueryState();
|
||||
const { values, setValue, activeCount } = useSessionFilters();
|
||||
|
||||
const loadOptions = useCallback(
|
||||
(key: string) => {
|
||||
const field = SESSION_FILTER_KEY_TO_FIELD[key];
|
||||
if (!field) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return queryClient.fetchQuery(
|
||||
trpc.session.distinctValues.queryOptions({
|
||||
projectId,
|
||||
field: field as
|
||||
| 'referrer_name'
|
||||
| 'country'
|
||||
| 'os'
|
||||
| 'browser'
|
||||
| 'device',
|
||||
})
|
||||
);
|
||||
},
|
||||
[trpc, queryClient, projectId]
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<AnimatedSearchInput
|
||||
placeholder="Search sessions by path, referrer..."
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<AnimatedSearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search sessions by path, referrer..."
|
||||
value={search}
|
||||
/>
|
||||
<FilterDropdown
|
||||
definitions={SESSION_FILTER_DEFINITIONS}
|
||||
loadOptions={loadOptions}
|
||||
onChange={setValue}
|
||||
values={values}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'border-dashed',
|
||||
activeCount > 0 && 'border-primary border-solid'
|
||||
)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<SlidersHorizontalIcon className="mr-2 size-4" />
|
||||
Filters
|
||||
{activeCount > 0 && (
|
||||
<Badge className="ml-2 rounded-full px-1.5 py-0 text-xs">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</FilterDropdown>
|
||||
</div>
|
||||
<DataTableViewOptions table={table} />
|
||||
</DataTableToolbarContainer>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
BellIcon,
|
||||
BookOpenIcon,
|
||||
Building2Icon,
|
||||
ChartLineIcon,
|
||||
ChevronDownIcon,
|
||||
CogIcon,
|
||||
@@ -62,6 +63,7 @@ export default function SidebarProjectMenu({
|
||||
<SidebarLink href={'/events'} icon={GanttChartIcon} label="Events" />
|
||||
<SidebarLink href={'/sessions'} icon={UsersIcon} label="Sessions" />
|
||||
<SidebarLink href={'/profiles'} icon={UserCircleIcon} label="Profiles" />
|
||||
<SidebarLink href={'/groups'} icon={Building2Icon} label="Groups" />
|
||||
<div className="mt-4 mb-2 font-medium text-muted-foreground text-sm">
|
||||
Manage
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Column, Table } from '@tanstack/react-table';
|
||||
import { SearchIcon, X, XIcon } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DataTableDateFilter } from '@/components/ui/data-table/data-table-date-filter';
|
||||
import { DataTableFacetedFilter } from '@/components/ui/data-table/data-table-faceted-filter';
|
||||
@@ -23,12 +22,12 @@ export function DataTableToolbarContainer({
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
className={cn(
|
||||
'flex flex-1 items-start justify-between gap-2 mb-2',
|
||||
className,
|
||||
'mb-2 flex flex-1 items-start justify-between gap-2',
|
||||
className
|
||||
)}
|
||||
role="toolbar"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -47,12 +46,12 @@ export function DataTableToolbar<TData>({
|
||||
});
|
||||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
|
||||
const columns = React.useMemo(
|
||||
const columns = useMemo(
|
||||
() => table.getAllColumns().filter((column) => column.getCanFilter()),
|
||||
[table],
|
||||
[table]
|
||||
);
|
||||
|
||||
const onReset = React.useCallback(() => {
|
||||
const onReset = useCallback(() => {
|
||||
table.resetColumnFilters();
|
||||
}, [table]);
|
||||
|
||||
@@ -61,23 +60,23 @@ export function DataTableToolbar<TData>({
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
{globalSearchKey && (
|
||||
<AnimatedSearchInput
|
||||
onChange={setSearch}
|
||||
placeholder={globalSearchPlaceholder ?? 'Search'}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
)}
|
||||
{columns.map((column) => (
|
||||
<DataTableToolbarFilter key={column.id} column={column} />
|
||||
<DataTableToolbarFilter column={column} key={column.id} />
|
||||
))}
|
||||
{isFiltered && (
|
||||
<Button
|
||||
aria-label="Reset filters"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-dashed"
|
||||
onClick={onReset}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<XIcon className="size-4 mr-2" />
|
||||
<XIcon className="mr-2 size-4" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
@@ -99,20 +98,22 @@ function DataTableToolbarFilter<TData>({
|
||||
{
|
||||
const columnMeta = column.columnDef.meta;
|
||||
|
||||
const getTitle = React.useCallback(() => {
|
||||
const getTitle = useCallback(() => {
|
||||
return columnMeta?.label ?? columnMeta?.placeholder ?? column.id;
|
||||
}, [columnMeta, column]);
|
||||
|
||||
const onFilterRender = React.useCallback(() => {
|
||||
if (!columnMeta?.variant) return null;
|
||||
const onFilterRender = useCallback(() => {
|
||||
if (!columnMeta?.variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (columnMeta.variant) {
|
||||
case 'text':
|
||||
return (
|
||||
<AnimatedSearchInput
|
||||
onChange={(value) => column.setFilterValue(value)}
|
||||
placeholder={columnMeta.placeholder ?? columnMeta.label}
|
||||
value={(column.getFilterValue() as string) ?? ''}
|
||||
onChange={(value) => column.setFilterValue(value)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -120,12 +121,12 @@ function DataTableToolbarFilter<TData>({
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={getTitle()}
|
||||
value={(column.getFilterValue() as string) ?? ''}
|
||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||
className={cn('h-8 w-[120px]', columnMeta.unit && 'pr-8')}
|
||||
inputMode="numeric"
|
||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||
placeholder={getTitle()}
|
||||
type="number"
|
||||
value={(column.getFilterValue() as string) ?? ''}
|
||||
/>
|
||||
{columnMeta.unit && (
|
||||
<span className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm">
|
||||
@@ -143,8 +144,8 @@ function DataTableToolbarFilter<TData>({
|
||||
return (
|
||||
<DataTableDateFilter
|
||||
column={column}
|
||||
title={getTitle()}
|
||||
multiple={columnMeta.variant === 'dateRange'}
|
||||
title={getTitle()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -153,9 +154,9 @@ function DataTableToolbarFilter<TData>({
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
column={column}
|
||||
title={getTitle()}
|
||||
options={columnMeta.options ?? []}
|
||||
multiple={columnMeta.variant === 'multiSelect'}
|
||||
options={columnMeta.options ?? []}
|
||||
title={getTitle()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -179,11 +180,11 @@ export function AnimatedSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
}: AnimatedSearchInputProps) {
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const isExpanded = isFocused || (value?.length ?? 0) > 0;
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
const handleClear = useCallback(() => {
|
||||
onChange('');
|
||||
// Re-focus after clearing
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
@@ -191,34 +192,35 @@ export function AnimatedSearchInput({
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={placeholder ?? 'Search'}
|
||||
className={cn(
|
||||
'relative flex h-8 items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
|
||||
'relative flex items-center rounded-md border border-input bg-background text-sm transition-[width] duration-300 ease-out',
|
||||
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
|
||||
isExpanded ? 'w-56 lg:w-72' : 'w-32',
|
||||
'h-8 min-h-8',
|
||||
isExpanded ? 'w-56 lg:w-72' : 'w-32'
|
||||
)}
|
||||
role="search"
|
||||
aria-label={placeholder ?? 'Search'}
|
||||
>
|
||||
<SearchIcon className="size-4 ml-2 shrink-0" />
|
||||
<SearchIcon className="ml-2 size-4 shrink-0" />
|
||||
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'absolute inset-0 -top-px h-8 w-full rounded-md border-0 bg-transparent pl-7 pr-7 shadow-none',
|
||||
'absolute inset-0 h-full w-full rounded-md border-0 bg-transparent py-2 pr-7 pl-7 shadow-none',
|
||||
'focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'transition-opacity duration-200',
|
||||
'font-medium text-[14px] truncate align-baseline',
|
||||
'truncate align-baseline font-medium text-[14px]'
|
||||
)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
size="sm"
|
||||
value={value}
|
||||
/>
|
||||
|
||||
{isExpanded && value && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
className="absolute right-1 flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
@@ -226,6 +228,7 @@ export function AnimatedSearchInput({
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
328
apps/start/src/components/ui/filter-dropdown.tsx
Normal file
328
apps/start/src/components/ui/filter-dropdown.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2Icon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import VirtualList from 'rc-virtual-list';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
export type FilterType = 'select' | 'string' | 'number';
|
||||
|
||||
export interface FilterDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
type: FilterType;
|
||||
/** For 'select' type: show SerieIcon next to options (default true) */
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
interface FilterDropdownProps {
|
||||
definitions: FilterDefinition[];
|
||||
values: Record<string, string | number | null | undefined>;
|
||||
onChange: (key: string, value: string | number | null) => void;
|
||||
loadOptions: (key: string) => Promise<string[]>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FilterDropdown({
|
||||
definitions,
|
||||
values,
|
||||
onChange,
|
||||
loadOptions,
|
||||
children,
|
||||
}: FilterDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
||||
const [search, setSearch] = useState('');
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveKey(null);
|
||||
setSearch('');
|
||||
setOptions([]);
|
||||
setInputValue('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeKey) {
|
||||
return;
|
||||
}
|
||||
const def = definitions.find((d) => d.key === activeKey);
|
||||
if (!def || def.type !== 'select') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
loadOptions(activeKey)
|
||||
.then((opts) => {
|
||||
setOptions(opts);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => setIsLoading(false));
|
||||
}, [activeKey]);
|
||||
|
||||
const currentDef = activeKey
|
||||
? definitions.find((d) => d.key === activeKey)
|
||||
: null;
|
||||
|
||||
const goToFilter = (key: string) => {
|
||||
setDirection('forward');
|
||||
setSearch('');
|
||||
setOptions([]);
|
||||
const current = values[key];
|
||||
setInputValue(current != null ? String(current) : '');
|
||||
setActiveKey(key);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
setDirection('backward');
|
||||
setActiveKey(null);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const applyValue = (key: string, value: string | number | null) => {
|
||||
onChange(key, value);
|
||||
goBack();
|
||||
};
|
||||
|
||||
const renderIndex = () => (
|
||||
<div className="min-w-52">
|
||||
{definitions.map((def) => {
|
||||
const currentValue = values[def.key];
|
||||
const isActive = currentValue != null && currentValue !== '';
|
||||
return (
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent"
|
||||
key={def.key}
|
||||
onClick={() => goToFilter(def.key)}
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium text-sm">{def.label}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isActive && (
|
||||
<>
|
||||
<span className="max-w-24 truncate text-muted-foreground text-xs">
|
||||
{String(currentValue)}
|
||||
</span>
|
||||
<button
|
||||
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(def.key, null);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectFilter = () => {
|
||||
const showIcon = currentDef?.showIcon !== false;
|
||||
const filteredOptions = options.filter((opt) =>
|
||||
opt.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const currentValue = activeKey ? values[activeKey] : undefined;
|
||||
|
||||
return (
|
||||
<div className="min-w-52">
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<Button
|
||||
className="size-7 shrink-0"
|
||||
onClick={goBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-6 text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
</div>
|
||||
) : filteredOptions.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
No options found
|
||||
</div>
|
||||
) : (
|
||||
<VirtualList
|
||||
data={filteredOptions}
|
||||
height={Math.min(filteredOptions.length * 36, 250)}
|
||||
itemHeight={36}
|
||||
itemKey={(item) => item}
|
||||
>
|
||||
{(option) => (
|
||||
<button
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 hover:bg-accent"
|
||||
onClick={() => applyValue(activeKey!, option)}
|
||||
type="button"
|
||||
>
|
||||
{showIcon && <SerieIcon name={option} />}
|
||||
<span className="truncate text-sm">{option || 'Direct'}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto size-4 shrink-0',
|
||||
currentValue === option ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</VirtualList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStringFilter = () => (
|
||||
<div className="min-w-52">
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<Button
|
||||
className="size-7 shrink-0"
|
||||
onClick={goBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 text-sm"
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
applyValue(activeKey!, inputValue || null);
|
||||
}
|
||||
}}
|
||||
placeholder={`Filter by ${currentDef?.label.toLowerCase()}...`}
|
||||
value={inputValue}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => applyValue(activeKey!, inputValue || null)}
|
||||
size="sm"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderNumberFilter = () => (
|
||||
<div className="min-w-52">
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<Button
|
||||
className="size-7 shrink-0"
|
||||
onClick={goBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 text-sm"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
applyValue(
|
||||
activeKey!,
|
||||
inputValue === '' ? null : Number(inputValue)
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Enter value..."
|
||||
type="number"
|
||||
value={inputValue}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
applyValue(
|
||||
activeKey!,
|
||||
inputValue === '' ? null : Number(inputValue)
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!(activeKey && currentDef)) {
|
||||
return renderIndex();
|
||||
}
|
||||
switch (currentDef.type) {
|
||||
case 'select':
|
||||
return renderSelectFilter();
|
||||
case 'string':
|
||||
return renderStringFilter();
|
||||
case 'number':
|
||||
return renderNumberFilter();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-1">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key={activeKey ?? 'index'}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{renderContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Select({
|
||||
@@ -32,12 +31,12 @@ function SelectTrigger({
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
"flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[size=default]:h-8 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
data-size={size}
|
||||
data-slot="select-trigger"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -57,13 +56,13 @@ function SelectContent({
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
'data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
data-slot="select-content"
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
@@ -72,7 +71,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -89,8 +88,8 @@ function SelectLabel({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
className={cn('px-2 py-1.5 text-muted-foreground text-xs', className)}
|
||||
data-slot="select-label"
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -103,11 +102,11 @@ function SelectItem({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
data-slot="select-item"
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
@@ -126,8 +125,8 @@ function SelectSeparator({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
|
||||
data-slot="select-separator"
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -139,11 +138,11 @@ function SelectScrollUpButton({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
data-slot="select-scroll-up-button"
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
@@ -157,11 +156,11 @@ function SelectScrollDownButton({
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
'flex cursor-default items-center justify-center py-1',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
data-slot="select-scroll-down-button"
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
|
||||
@@ -54,6 +54,19 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
|
||||
return <div className={cn('p-4', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export interface WidgetEmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
text: string;
|
||||
}
|
||||
export function WidgetEmptyState({ icon: Icon, text }: WidgetEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Icon size={28} strokeWidth={1.5} />
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WidgetProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
|
||||
81
apps/start/src/hooks/use-live-counter.ts
Normal file
81
apps/start/src/hooks/use-live-counter.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDebounceState } from './use-debounce-state';
|
||||
import useWS from './use-ws';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
const FIFTEEN_SECONDS = 1000 * 15;
|
||||
/** Refetch from API when WS-only updates may be stale (e.g. visitors left). */
|
||||
const FALLBACK_STALE_MS = 1000 * 60;
|
||||
|
||||
export function useLiveCounter({
|
||||
projectId,
|
||||
shareId,
|
||||
onRefresh,
|
||||
}: {
|
||||
projectId: string;
|
||||
shareId?: string;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const counter = useDebounceState(0, 1000);
|
||||
const lastRefresh = useRef(Date.now());
|
||||
const query = useQuery(
|
||||
trpc.overview.liveVisitors.queryOptions({
|
||||
projectId,
|
||||
shareId: shareId ?? undefined,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
counter.set(query.data);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
useWS<number>(
|
||||
`/live/visitors/${projectId}`,
|
||||
(value) => {
|
||||
if (!Number.isNaN(value)) {
|
||||
counter.set(value);
|
||||
if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
|
||||
lastRefresh.current = Date.now();
|
||||
if (!document.hidden) {
|
||||
onRefresh?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: {
|
||||
delay: 1000,
|
||||
maxWait: 5000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(async () => {
|
||||
if (Date.now() - lastRefresh.current < FALLBACK_STALE_MS) {
|
||||
return;
|
||||
}
|
||||
const data = await queryClient.fetchQuery(
|
||||
trpc.overview.liveVisitors.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
shareId: shareId ?? undefined,
|
||||
},
|
||||
// Default query staleTime is 5m; bypass cache so this reconciliation always hits the API.
|
||||
{ staleTime: 0 }
|
||||
)
|
||||
);
|
||||
counter.set(data);
|
||||
lastRefresh.current = Date.now();
|
||||
}, FALLBACK_STALE_MS);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [projectId, shareId, trpc, queryClient, counter.set]);
|
||||
|
||||
return counter;
|
||||
}
|
||||
229
apps/start/src/hooks/use-session-filters.ts
Normal file
229
apps/start/src/hooks/use-session-filters.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { IChartEventFilter } from '@openpanel/validation';
|
||||
import { parseAsInteger, parseAsString, useQueryState } from 'nuqs';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
const debounceOpts = {
|
||||
clearOnDefault: true,
|
||||
limitUrlUpdates: { method: 'debounce' as const, timeMs: DEBOUNCE_MS },
|
||||
};
|
||||
|
||||
export function useSessionFilters() {
|
||||
const [referrer, setReferrer] = useQueryState(
|
||||
'referrer',
|
||||
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||
);
|
||||
const [country, setCountry] = useQueryState(
|
||||
'country',
|
||||
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||
);
|
||||
const [os, setOs] = useQueryState(
|
||||
'os',
|
||||
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||
);
|
||||
const [browser, setBrowser] = useQueryState(
|
||||
'browser',
|
||||
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||
);
|
||||
const [device, setDevice] = useQueryState(
|
||||
'device',
|
||||
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||
);
|
||||
const [entryPage, setEntryPage] = useQueryState(
|
||||
'entryPage',
|
||||
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||
);
|
||||
const [exitPage, setExitPage] = useQueryState(
|
||||
'exitPage',
|
||||
parseAsString.withDefault('').withOptions(debounceOpts),
|
||||
);
|
||||
const [minPageViews, setMinPageViews] = useQueryState(
|
||||
'minPageViews',
|
||||
parseAsInteger,
|
||||
);
|
||||
const [maxPageViews, setMaxPageViews] = useQueryState(
|
||||
'maxPageViews',
|
||||
parseAsInteger,
|
||||
);
|
||||
const [minEvents, setMinEvents] = useQueryState('minEvents', parseAsInteger);
|
||||
const [maxEvents, setMaxEvents] = useQueryState('maxEvents', parseAsInteger);
|
||||
|
||||
const filters = useMemo<IChartEventFilter[]>(() => {
|
||||
const result: IChartEventFilter[] = [];
|
||||
if (referrer) {
|
||||
result.push({ name: 'referrer_name', operator: 'is', value: [referrer] });
|
||||
}
|
||||
if (country) {
|
||||
result.push({ name: 'country', operator: 'is', value: [country] });
|
||||
}
|
||||
if (os) {
|
||||
result.push({ name: 'os', operator: 'is', value: [os] });
|
||||
}
|
||||
if (browser) {
|
||||
result.push({ name: 'browser', operator: 'is', value: [browser] });
|
||||
}
|
||||
if (device) {
|
||||
result.push({ name: 'device', operator: 'is', value: [device] });
|
||||
}
|
||||
if (entryPage) {
|
||||
result.push({
|
||||
name: 'entry_path',
|
||||
operator: 'contains',
|
||||
value: [entryPage],
|
||||
});
|
||||
}
|
||||
if (exitPage) {
|
||||
result.push({
|
||||
name: 'exit_path',
|
||||
operator: 'contains',
|
||||
value: [exitPage],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [referrer, country, os, browser, device, entryPage, exitPage]);
|
||||
|
||||
const values = useMemo(
|
||||
() => ({
|
||||
referrer,
|
||||
country,
|
||||
os,
|
||||
browser,
|
||||
device,
|
||||
entryPage,
|
||||
exitPage,
|
||||
minPageViews,
|
||||
maxPageViews,
|
||||
minEvents,
|
||||
maxEvents,
|
||||
}),
|
||||
[
|
||||
referrer,
|
||||
country,
|
||||
os,
|
||||
browser,
|
||||
device,
|
||||
entryPage,
|
||||
exitPage,
|
||||
minPageViews,
|
||||
maxPageViews,
|
||||
minEvents,
|
||||
maxEvents,
|
||||
],
|
||||
);
|
||||
|
||||
const setValue = useCallback(
|
||||
(key: string, value: string | number | null) => {
|
||||
switch (key) {
|
||||
case 'referrer':
|
||||
setReferrer(String(value ?? ''));
|
||||
break;
|
||||
case 'country':
|
||||
setCountry(String(value ?? ''));
|
||||
break;
|
||||
case 'os':
|
||||
setOs(String(value ?? ''));
|
||||
break;
|
||||
case 'browser':
|
||||
setBrowser(String(value ?? ''));
|
||||
break;
|
||||
case 'device':
|
||||
setDevice(String(value ?? ''));
|
||||
break;
|
||||
case 'entryPage':
|
||||
setEntryPage(String(value ?? ''));
|
||||
break;
|
||||
case 'exitPage':
|
||||
setExitPage(String(value ?? ''));
|
||||
break;
|
||||
case 'minPageViews':
|
||||
setMinPageViews(value != null ? Number(value) : null);
|
||||
break;
|
||||
case 'maxPageViews':
|
||||
setMaxPageViews(value != null ? Number(value) : null);
|
||||
break;
|
||||
case 'minEvents':
|
||||
setMinEvents(value != null ? Number(value) : null);
|
||||
break;
|
||||
case 'maxEvents':
|
||||
setMaxEvents(value != null ? Number(value) : null);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
setReferrer,
|
||||
setCountry,
|
||||
setOs,
|
||||
setBrowser,
|
||||
setDevice,
|
||||
setEntryPage,
|
||||
setExitPage,
|
||||
setMinPageViews,
|
||||
setMaxPageViews,
|
||||
setMinEvents,
|
||||
setMaxEvents,
|
||||
],
|
||||
);
|
||||
|
||||
const activeCount =
|
||||
filters.length +
|
||||
(minPageViews != null ? 1 : 0) +
|
||||
(maxPageViews != null ? 1 : 0) +
|
||||
(minEvents != null ? 1 : 0) +
|
||||
(maxEvents != null ? 1 : 0);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setReferrer('');
|
||||
setCountry('');
|
||||
setOs('');
|
||||
setBrowser('');
|
||||
setDevice('');
|
||||
setEntryPage('');
|
||||
setExitPage('');
|
||||
setMinPageViews(null);
|
||||
setMaxPageViews(null);
|
||||
setMinEvents(null);
|
||||
setMaxEvents(null);
|
||||
}, [
|
||||
setReferrer,
|
||||
setCountry,
|
||||
setOs,
|
||||
setBrowser,
|
||||
setDevice,
|
||||
setEntryPage,
|
||||
setExitPage,
|
||||
setMinPageViews,
|
||||
setMaxPageViews,
|
||||
setMinEvents,
|
||||
setMaxEvents,
|
||||
]);
|
||||
|
||||
return {
|
||||
referrer,
|
||||
setReferrer,
|
||||
country,
|
||||
setCountry,
|
||||
os,
|
||||
setOs,
|
||||
browser,
|
||||
setBrowser,
|
||||
device,
|
||||
setDevice,
|
||||
entryPage,
|
||||
setEntryPage,
|
||||
exitPage,
|
||||
setExitPage,
|
||||
minPageViews,
|
||||
setMinPageViews,
|
||||
maxPageViews,
|
||||
setMaxPageViews,
|
||||
minEvents,
|
||||
setMinEvents,
|
||||
maxEvents,
|
||||
setMaxEvents,
|
||||
filters,
|
||||
values,
|
||||
setValue,
|
||||
activeCount,
|
||||
clearAll,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { createTRPCClient, httpLink } from '@trpc/client';
|
||||
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { TRPCProvider } from '@/integrations/trpc/react';
|
||||
import type { AppRouter } from '@openpanel/trpc';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { createIsomorphicFn } from '@tanstack/react-start';
|
||||
import { getRequestHeaders } from '@tanstack/react-start/server';
|
||||
import { createTRPCClient, httpLink } from '@trpc/client';
|
||||
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query';
|
||||
import { useMemo } from 'react';
|
||||
import superjson from 'superjson';
|
||||
import { TRPCProvider } from '@/integrations/trpc/react';
|
||||
|
||||
export const getIsomorphicHeaders = createIsomorphicFn()
|
||||
.server(() => {
|
||||
return getRequestHeaders();
|
||||
const headers = getRequestHeaders();
|
||||
const result: Record<string, string> = {};
|
||||
// Only forward the cookie header so the API can validate the session.
|
||||
// Forwarding all headers causes problems with hop-by-hop headers like
|
||||
// `Connection: upgrade` (common in NGINX WebSocket configs) which makes
|
||||
// Node.js undici throw UND_ERR_INVALID_ARG ("fetch failed").
|
||||
const cookie = headers.get('Cookie');
|
||||
if (cookie) {
|
||||
result.cookie = cookie;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.client(() => {
|
||||
return {};
|
||||
@@ -27,7 +36,6 @@ export function createTRPCClientWithHeaders(apiUrl: string) {
|
||||
headers: () => getIsomorphicHeaders(),
|
||||
fetch: async (url, options) => {
|
||||
try {
|
||||
console.log('fetching', url, options);
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
mode: 'cors',
|
||||
@@ -82,8 +90,8 @@ export function getContext(apiUrl: string) {
|
||||
const client = createTRPCClientWithHeaders(apiUrl);
|
||||
|
||||
const serverHelpers = createTRPCOptionsProxy({
|
||||
client: client,
|
||||
queryClient: queryClient,
|
||||
client,
|
||||
queryClient,
|
||||
});
|
||||
return {
|
||||
queryClient,
|
||||
@@ -102,10 +110,10 @@ export function Provider({
|
||||
}) {
|
||||
const trpcClient = useMemo(
|
||||
() => createTRPCClientWithHeaders(apiUrl),
|
||||
[apiUrl],
|
||||
[apiUrl]
|
||||
);
|
||||
return (
|
||||
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
|
||||
<TRPCProvider queryClient={queryClient} trpcClient={trpcClient}>
|
||||
{children}
|
||||
</TRPCProvider>
|
||||
);
|
||||
|
||||
139
apps/start/src/modals/add-group.tsx
Normal file
139
apps/start/src/modals/add-group.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { zCreateGroup } from '@openpanel/validation';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
import { ButtonContainer } from '@/components/button-container';
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
const zForm = zCreateGroup.omit({ projectId: true, properties: true }).extend({
|
||||
properties: z.array(z.object({ key: z.string(), value: z.string() })),
|
||||
});
|
||||
type IForm = z.infer<typeof zForm>;
|
||||
|
||||
export default function AddGroup() {
|
||||
const { projectId } = useAppParams();
|
||||
const queryClient = useQueryClient();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { register, handleSubmit, control, formState } = useForm<IForm>({
|
||||
resolver: zodResolver(zForm),
|
||||
defaultValues: {
|
||||
id: '',
|
||||
type: '',
|
||||
name: '',
|
||||
properties: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'properties',
|
||||
});
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.group.create.mutationOptions({
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(trpc.group.list.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.group.types.pathFilter());
|
||||
toast('Success', { description: 'Group created.' });
|
||||
popModal();
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader title="Add group" />
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={handleSubmit(({ properties, ...values }) => {
|
||||
const props = Object.fromEntries(
|
||||
properties
|
||||
.filter((p) => p.key.trim() !== '')
|
||||
.map((p) => [p.key.trim(), String(p.value)])
|
||||
);
|
||||
mutation.mutate({ projectId, ...values, properties: props });
|
||||
})}
|
||||
>
|
||||
<InputWithLabel
|
||||
label="ID"
|
||||
placeholder="acme-corp"
|
||||
{...register('id')}
|
||||
autoFocus
|
||||
error={formState.errors.id?.message}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
placeholder="Acme Corp"
|
||||
{...register('name')}
|
||||
error={formState.errors.name?.message}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Type"
|
||||
placeholder="company"
|
||||
{...register('type')}
|
||||
error={formState.errors.type?.message}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Properties</span>
|
||||
<Button
|
||||
onClick={() => append({ key: '', value: '' })}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="mr-1 size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field, index) => (
|
||||
<div className="flex gap-2" key={field.id}>
|
||||
<input
|
||||
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="key"
|
||||
{...register(`properties.${index}.key`)}
|
||||
/>
|
||||
<input
|
||||
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="value"
|
||||
{...register(`properties.${index}.value`)}
|
||||
/>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={() => remove(index)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ButtonContainer>
|
||||
<Button onClick={() => popModal()} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!formState.isDirty || mutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
147
apps/start/src/modals/edit-group.tsx
Normal file
147
apps/start/src/modals/edit-group.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { IServiceGroup } from '@openpanel/db';
|
||||
import { zUpdateGroup } from '@openpanel/validation';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
import { ButtonContainer } from '@/components/button-container';
|
||||
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||
|
||||
const zForm = zUpdateGroup
|
||||
.omit({ id: true, projectId: true, properties: true })
|
||||
.extend({
|
||||
properties: z.array(z.object({ key: z.string(), value: z.string() })),
|
||||
});
|
||||
type IForm = z.infer<typeof zForm>;
|
||||
|
||||
type EditGroupProps = Pick<
|
||||
IServiceGroup,
|
||||
'id' | 'projectId' | 'name' | 'type' | 'properties'
|
||||
>;
|
||||
|
||||
export default function EditGroup({
|
||||
id,
|
||||
projectId,
|
||||
name,
|
||||
type,
|
||||
properties,
|
||||
}: EditGroupProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const { register, handleSubmit, control, formState } = useForm<IForm>({
|
||||
resolver: zodResolver(zForm),
|
||||
defaultValues: {
|
||||
type,
|
||||
name,
|
||||
properties: Object.entries(properties as Record<string, string>).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'properties',
|
||||
});
|
||||
|
||||
const mutation = useMutation(
|
||||
trpc.group.update.mutationOptions({
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(trpc.group.list.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.group.byId.pathFilter());
|
||||
queryClient.invalidateQueries(trpc.group.types.pathFilter());
|
||||
toast('Success', { description: 'Group updated.' });
|
||||
popModal();
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader title="Edit group" />
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={handleSubmit(({ properties: formProps, ...values }) => {
|
||||
const props = Object.fromEntries(
|
||||
formProps
|
||||
.filter((p) => p.key.trim() !== '')
|
||||
.map((p) => [p.key.trim(), String(p.value)])
|
||||
);
|
||||
mutation.mutate({ id, projectId, ...values, properties: props });
|
||||
})}
|
||||
>
|
||||
<InputWithLabel
|
||||
label="Name"
|
||||
{...register('name')}
|
||||
error={formState.errors.name?.message}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Type"
|
||||
{...register('type')}
|
||||
error={formState.errors.type?.message}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Properties</span>
|
||||
<Button
|
||||
onClick={() => append({ key: '', value: '' })}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="mr-1 size-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field, index) => (
|
||||
<div className="flex gap-2" key={field.id}>
|
||||
<input
|
||||
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="key"
|
||||
{...register(`properties.${index}.key`)}
|
||||
/>
|
||||
<input
|
||||
className="h-9 flex-1 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="value"
|
||||
{...register(`properties.${index}.value`)}
|
||||
/>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onClick={() => remove(index)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ButtonContainer>
|
||||
<Button onClick={() => popModal()} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!formState.isDirty || mutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import PageDetails from './page-details';
|
||||
import { createPushModal } from 'pushmodal';
|
||||
import AddClient from './add-client';
|
||||
import AddDashboard from './add-dashboard';
|
||||
import AddGroup from './add-group';
|
||||
import AddImport from './add-import';
|
||||
import AddIntegration from './add-integration';
|
||||
import AddNotificationRule from './add-notification-rule';
|
||||
@@ -16,6 +16,7 @@ import DateTimePicker from './date-time-picker';
|
||||
import EditClient from './edit-client';
|
||||
import EditDashboard from './edit-dashboard';
|
||||
import EditEvent from './edit-event';
|
||||
import EditGroup from './edit-group';
|
||||
import EditMember from './edit-member';
|
||||
import EditReference from './edit-reference';
|
||||
import EditReport from './edit-report';
|
||||
@@ -23,6 +24,7 @@ import EventDetails from './event-details';
|
||||
import Instructions from './Instructions';
|
||||
import OverviewChartDetails from './overview-chart-details';
|
||||
import OverviewFilters from './overview-filters';
|
||||
import PageDetails from './page-details';
|
||||
import RequestPasswordReset from './request-reset-password';
|
||||
import SaveReport from './save-report';
|
||||
import SelectBillingPlan from './select-billing-plan';
|
||||
@@ -36,6 +38,8 @@ import { op } from '@/utils/op';
|
||||
|
||||
const modals = {
|
||||
PageDetails,
|
||||
AddGroup,
|
||||
EditGroup,
|
||||
OverviewTopPagesModal,
|
||||
OverviewTopGenericModal,
|
||||
RequestPasswordReset,
|
||||
|
||||
@@ -281,9 +281,10 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
||||
interface FunnelUsersViewProps {
|
||||
report: IReportInput;
|
||||
stepIndex: number;
|
||||
breakdownValues?: string[];
|
||||
}
|
||||
|
||||
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
||||
function FunnelUsersView({ report, stepIndex, breakdownValues }: FunnelUsersViewProps) {
|
||||
const trpc = useTRPC();
|
||||
const [showDropoffs, setShowDropoffs] = useState(false);
|
||||
|
||||
@@ -306,6 +307,7 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
||||
? report.options.funnelGroup
|
||||
: undefined,
|
||||
breakdowns: report.breakdowns,
|
||||
breakdownValues: breakdownValues,
|
||||
},
|
||||
{
|
||||
enabled: stepIndex !== undefined,
|
||||
@@ -384,13 +386,14 @@ type ViewChartUsersProps =
|
||||
type: 'funnel';
|
||||
report: IReportInput;
|
||||
stepIndex: number;
|
||||
breakdownValues?: string[];
|
||||
};
|
||||
|
||||
// Main component that routes to the appropriate view
|
||||
export default function ViewChartUsers(props: ViewChartUsersProps) {
|
||||
if (props.type === 'funnel') {
|
||||
return (
|
||||
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
|
||||
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} breakdownValues={props.breakdownValues} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import reportSlice from '@/components/report/reportSlice';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import type { TypedUseSelectorHook } from 'react-redux';
|
||||
import {
|
||||
useDispatch as useBaseDispatch,
|
||||
useSelector as useBaseSelector,
|
||||
} from 'react-redux';
|
||||
import type { TypedUseSelectorHook } from 'react-redux';
|
||||
import realtimeMapBadgeReducer from '@/components/realtime/map/realtime-map-badge-slice';
|
||||
import reportSlice from '@/components/report/reportSlice';
|
||||
|
||||
const makeStore = () =>
|
||||
configureStore({
|
||||
reducer: {
|
||||
report: reportSlice,
|
||||
realtimeMapBadge: realtimeMapBadgeReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { Route as AppOrganizationIdProjectIdReferencesRouteImport } from './rout
|
||||
import { Route as AppOrganizationIdProjectIdRealtimeRouteImport } from './routes/_app.$organizationId.$projectId.realtime'
|
||||
import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_app.$organizationId.$projectId.pages'
|
||||
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
|
||||
import { Route as AppOrganizationIdProjectIdGroupsRouteImport } from './routes/_app.$organizationId.$projectId.groups'
|
||||
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
|
||||
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
|
||||
import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index'
|
||||
@@ -82,12 +83,16 @@ import { Route as AppOrganizationIdProjectIdProfilesTabsAnonymousRouteImport } f
|
||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs'
|
||||
import { Route as AppOrganizationIdProjectIdNotificationsTabsRulesRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.rules'
|
||||
import { Route as AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.notifications'
|
||||
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs'
|
||||
import { Route as AppOrganizationIdProjectIdEventsTabsStatsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.stats'
|
||||
import { Route as AppOrganizationIdProjectIdEventsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.events'
|
||||
import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.conversions'
|
||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
|
||||
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.index'
|
||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.sessions'
|
||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
||||
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.members'
|
||||
import { Route as AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.groups_.$groupId._tabs.events'
|
||||
|
||||
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
||||
'/_app/$organizationId/profile',
|
||||
@@ -113,6 +118,9 @@ const AppOrganizationIdProjectIdEventsRouteImport = createFileRoute(
|
||||
const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId',
|
||||
)()
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdRouteImport = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId',
|
||||
)()
|
||||
|
||||
const UnsubscribeRoute = UnsubscribeRouteImport.update({
|
||||
id: '/unsubscribe',
|
||||
@@ -350,6 +358,12 @@ const AppOrganizationIdProjectIdInsightsRoute =
|
||||
path: '/insights',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdGroupsRoute =
|
||||
AppOrganizationIdProjectIdGroupsRouteImport.update({
|
||||
id: '/groups',
|
||||
path: '/groups',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdDashboardsRoute =
|
||||
AppOrganizationIdProjectIdDashboardsRouteImport.update({
|
||||
id: '/dashboards',
|
||||
@@ -368,6 +382,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute =
|
||||
path: '/$profileId',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdRoute =
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRouteImport.update({
|
||||
id: '/groups_/$groupId',
|
||||
path: '/groups/$groupId',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProfileTabsIndexRoute =
|
||||
AppOrganizationIdProfileTabsIndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -555,6 +575,11 @@ const AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute =
|
||||
path: '/notifications',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdNotificationsTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdTabsRoute =
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport.update({
|
||||
id: '/_tabs',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdEventsTabsStatsRoute =
|
||||
AppOrganizationIdProjectIdEventsTabsStatsRouteImport.update({
|
||||
id: '/stats',
|
||||
@@ -579,6 +604,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute =
|
||||
path: '/',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute =
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute =
|
||||
AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRouteImport.update({
|
||||
id: '/sessions',
|
||||
@@ -591,6 +622,18 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
||||
path: '/events',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesProfileIdTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute =
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport.update({
|
||||
id: '/members',
|
||||
path: '/members',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
|
||||
} as any)
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute =
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport.update({
|
||||
id: '/events',
|
||||
path: '/events',
|
||||
getParentRoute: () => AppOrganizationIdProjectIdGroupsGroupIdTabsRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
@@ -615,6 +658,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -646,6 +690,7 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
|
||||
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
|
||||
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRouteWithChildren
|
||||
@@ -663,8 +708,11 @@ export interface FileRoutesByFullPath {
|
||||
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
||||
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||
'/$organizationId/$projectId/settings/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
|
||||
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||
'/$organizationId/$projectId/groups/$groupId/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -688,6 +736,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId': typeof AppOrganizationIdIndexRoute
|
||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -716,6 +765,7 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||
'/$organizationId/$projectId/groups/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
|
||||
'/$organizationId/$projectId/notifications/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
|
||||
'/$organizationId/$projectId/notifications/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||
@@ -729,6 +779,8 @@ export interface FileRoutesByTo {
|
||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||
'/$organizationId/$projectId/settings/tracking': typeof AppOrganizationIdProjectIdSettingsTabsTrackingRoute
|
||||
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||
'/$organizationId/$projectId/groups/$groupId/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
|
||||
'/$organizationId/$projectId/groups/$groupId/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||
'/$organizationId/$projectId/profiles/$profileId/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||
}
|
||||
@@ -760,6 +812,7 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
'/_app/$organizationId/$projectId/groups': typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
'/_app/$organizationId/$projectId/insights': typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
'/_app/$organizationId/$projectId/pages': typeof AppOrganizationIdProjectIdPagesRoute
|
||||
'/_app/$organizationId/$projectId/realtime': typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -798,6 +851,8 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId': typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
|
||||
'/_app/$organizationId/$projectId/notifications/_tabs/notifications': typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRoute
|
||||
'/_app/$organizationId/$projectId/notifications/_tabs/rules': typeof AppOrganizationIdProjectIdNotificationsTabsRulesRoute
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId': typeof AppOrganizationIdProjectIdProfilesProfileIdRouteWithChildren
|
||||
@@ -816,8 +871,11 @@ export interface FileRoutesById {
|
||||
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
||||
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||
'/_app/$organizationId/$projectId/settings/_tabs/': typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsSessionsRoute
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -845,6 +903,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/'
|
||||
| '/$organizationId/$projectId/chat'
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
| '/$organizationId/$projectId/groups'
|
||||
| '/$organizationId/$projectId/insights'
|
||||
| '/$organizationId/$projectId/pages'
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
@@ -876,6 +935,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/events/conversions'
|
||||
| '/$organizationId/$projectId/events/events'
|
||||
| '/$organizationId/$projectId/events/stats'
|
||||
| '/$organizationId/$projectId/groups/$groupId'
|
||||
| '/$organizationId/$projectId/notifications/notifications'
|
||||
| '/$organizationId/$projectId/notifications/rules'
|
||||
| '/$organizationId/$projectId/profiles/$profileId'
|
||||
@@ -893,8 +953,11 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/notifications/'
|
||||
| '/$organizationId/$projectId/profiles/'
|
||||
| '/$organizationId/$projectId/settings/'
|
||||
| '/$organizationId/$projectId/groups/$groupId/events'
|
||||
| '/$organizationId/$projectId/groups/$groupId/members'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||
| '/$organizationId/$projectId/groups/$groupId/'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -918,6 +981,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId'
|
||||
| '/$organizationId/$projectId/chat'
|
||||
| '/$organizationId/$projectId/dashboards'
|
||||
| '/$organizationId/$projectId/groups'
|
||||
| '/$organizationId/$projectId/insights'
|
||||
| '/$organizationId/$projectId/pages'
|
||||
| '/$organizationId/$projectId/realtime'
|
||||
@@ -946,6 +1010,7 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/events/conversions'
|
||||
| '/$organizationId/$projectId/events/events'
|
||||
| '/$organizationId/$projectId/events/stats'
|
||||
| '/$organizationId/$projectId/groups/$groupId'
|
||||
| '/$organizationId/$projectId/notifications/notifications'
|
||||
| '/$organizationId/$projectId/notifications/rules'
|
||||
| '/$organizationId/$projectId/profiles/$profileId'
|
||||
@@ -959,6 +1024,8 @@ export interface FileRouteTypes {
|
||||
| '/$organizationId/$projectId/settings/imports'
|
||||
| '/$organizationId/$projectId/settings/tracking'
|
||||
| '/$organizationId/$projectId/settings/widgets'
|
||||
| '/$organizationId/$projectId/groups/$groupId/events'
|
||||
| '/$organizationId/$projectId/groups/$groupId/members'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||
| '/$organizationId/$projectId/profiles/$profileId/sessions'
|
||||
id:
|
||||
@@ -989,6 +1056,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/'
|
||||
| '/_app/$organizationId/$projectId/chat'
|
||||
| '/_app/$organizationId/$projectId/dashboards'
|
||||
| '/_app/$organizationId/$projectId/groups'
|
||||
| '/_app/$organizationId/$projectId/insights'
|
||||
| '/_app/$organizationId/$projectId/pages'
|
||||
| '/_app/$organizationId/$projectId/realtime'
|
||||
@@ -1027,6 +1095,8 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
|
||||
| '/_app/$organizationId/$projectId/events/_tabs/events'
|
||||
| '/_app/$organizationId/$projectId/events/_tabs/stats'
|
||||
| '/_app/$organizationId/$projectId/groups_/$groupId'
|
||||
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
|
||||
| '/_app/$organizationId/$projectId/notifications/_tabs/notifications'
|
||||
| '/_app/$organizationId/$projectId/notifications/_tabs/rules'
|
||||
| '/_app/$organizationId/$projectId/profiles/$profileId'
|
||||
@@ -1045,8 +1115,11 @@ export interface FileRouteTypes {
|
||||
| '/_app/$organizationId/$projectId/notifications/_tabs/'
|
||||
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
||||
| '/_app/$organizationId/$projectId/settings/_tabs/'
|
||||
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
|
||||
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
|
||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events'
|
||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
||||
| '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
|
||||
| '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -1378,6 +1451,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdInsightsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/groups': {
|
||||
id: '/_app/$organizationId/$projectId/groups'
|
||||
path: '/groups'
|
||||
fullPath: '/$organizationId/$projectId/groups'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/dashboards': {
|
||||
id: '/_app/$organizationId/$projectId/dashboards'
|
||||
path: '/dashboards'
|
||||
@@ -1399,6 +1479,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId': {
|
||||
id: '/_app/$organizationId/$projectId/groups_/$groupId'
|
||||
path: '/groups/$groupId'
|
||||
fullPath: '/$organizationId/$projectId/groups/$groupId'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdRoute
|
||||
}
|
||||
'/_app/$organizationId/profile/_tabs/': {
|
||||
id: '/_app/$organizationId/profile/_tabs/'
|
||||
path: '/'
|
||||
@@ -1623,6 +1710,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdNotificationsTabsNotificationsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdNotificationsTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs': {
|
||||
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
|
||||
path: '/groups/$groupId'
|
||||
fullPath: '/$organizationId/$projectId/groups/$groupId'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/events/_tabs/stats': {
|
||||
id: '/_app/$organizationId/$projectId/events/_tabs/stats'
|
||||
path: '/stats'
|
||||
@@ -1651,6 +1745,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/': {
|
||||
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
|
||||
path: '/'
|
||||
fullPath: '/$organizationId/$projectId/groups/$groupId/'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions': {
|
||||
id: '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/sessions'
|
||||
path: '/sessions'
|
||||
@@ -1665,6 +1766,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members': {
|
||||
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
|
||||
path: '/members'
|
||||
fullPath: '/$organizationId/$projectId/groups/$groupId/members'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
|
||||
}
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events': {
|
||||
id: '/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
|
||||
path: '/events'
|
||||
fullPath: '/$organizationId/$projectId/groups/$groupId/events'
|
||||
preLoaderRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRouteImport
|
||||
parentRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1872,9 +1987,46 @@ const AppOrganizationIdProjectIdSettingsRouteWithChildren =
|
||||
AppOrganizationIdProjectIdSettingsRouteChildren,
|
||||
)
|
||||
|
||||
interface AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren {
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute
|
||||
}
|
||||
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren =
|
||||
{
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute:
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsEventsRoute,
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute:
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsMembersRoute,
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute:
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsIndexRoute,
|
||||
}
|
||||
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren =
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute._addFileChildren(
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteChildren,
|
||||
)
|
||||
|
||||
interface AppOrganizationIdProjectIdGroupsGroupIdRouteChildren {
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren
|
||||
}
|
||||
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdRouteChildren: AppOrganizationIdProjectIdGroupsGroupIdRouteChildren =
|
||||
{
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsRoute:
|
||||
AppOrganizationIdProjectIdGroupsGroupIdTabsRouteWithChildren,
|
||||
}
|
||||
|
||||
const AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren =
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRoute._addFileChildren(
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRouteChildren,
|
||||
)
|
||||
|
||||
interface AppOrganizationIdProjectIdRouteChildren {
|
||||
AppOrganizationIdProjectIdChatRoute: typeof AppOrganizationIdProjectIdChatRoute
|
||||
AppOrganizationIdProjectIdDashboardsRoute: typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||
AppOrganizationIdProjectIdGroupsRoute: typeof AppOrganizationIdProjectIdGroupsRoute
|
||||
AppOrganizationIdProjectIdInsightsRoute: typeof AppOrganizationIdProjectIdInsightsRoute
|
||||
AppOrganizationIdProjectIdPagesRoute: typeof AppOrganizationIdProjectIdPagesRoute
|
||||
AppOrganizationIdProjectIdRealtimeRoute: typeof AppOrganizationIdProjectIdRealtimeRoute
|
||||
@@ -1890,6 +2042,7 @@ interface AppOrganizationIdProjectIdRouteChildren {
|
||||
AppOrganizationIdProjectIdReportsReportIdRoute: typeof AppOrganizationIdProjectIdReportsReportIdRoute
|
||||
AppOrganizationIdProjectIdSessionsSessionIdRoute: typeof AppOrganizationIdProjectIdSessionsSessionIdRoute
|
||||
AppOrganizationIdProjectIdSettingsRoute: typeof AppOrganizationIdProjectIdSettingsRouteWithChildren
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRoute: typeof AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren
|
||||
}
|
||||
|
||||
const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteChildren =
|
||||
@@ -1897,6 +2050,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
||||
AppOrganizationIdProjectIdChatRoute: AppOrganizationIdProjectIdChatRoute,
|
||||
AppOrganizationIdProjectIdDashboardsRoute:
|
||||
AppOrganizationIdProjectIdDashboardsRoute,
|
||||
AppOrganizationIdProjectIdGroupsRoute:
|
||||
AppOrganizationIdProjectIdGroupsRoute,
|
||||
AppOrganizationIdProjectIdInsightsRoute:
|
||||
AppOrganizationIdProjectIdInsightsRoute,
|
||||
AppOrganizationIdProjectIdPagesRoute: AppOrganizationIdProjectIdPagesRoute,
|
||||
@@ -1924,6 +2079,8 @@ const AppOrganizationIdProjectIdRouteChildren: AppOrganizationIdProjectIdRouteCh
|
||||
AppOrganizationIdProjectIdSessionsSessionIdRoute,
|
||||
AppOrganizationIdProjectIdSettingsRoute:
|
||||
AppOrganizationIdProjectIdSettingsRouteWithChildren,
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRoute:
|
||||
AppOrganizationIdProjectIdGroupsGroupIdRouteWithChildren,
|
||||
}
|
||||
|
||||
const AppOrganizationIdProjectIdRouteWithChildren =
|
||||
|
||||
@@ -42,5 +42,5 @@ function Component() {
|
||||
),
|
||||
);
|
||||
|
||||
return <EventsTable query={query} />;
|
||||
return <EventsTable query={query} showEventListener />;
|
||||
}
|
||||
|
||||
100
apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
Normal file
100
apps/start/src/routes/_app.$organizationId.$projectId.groups.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { GroupsTable } from '@/components/groups/table';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/groups')(
|
||||
{
|
||||
component: Component,
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('Groups') }],
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
function Component() {
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { debouncedSearch } = useSearchQueryState();
|
||||
const [typeFilter, setTypeFilter] = useQueryState(
|
||||
'type',
|
||||
parseAsString.withDefault('')
|
||||
);
|
||||
const { page } = useDataTablePagination(PAGE_SIZE);
|
||||
|
||||
const typesQuery = useQuery(trpc.group.types.queryOptions({ projectId }));
|
||||
|
||||
const groupsQuery = useQuery(
|
||||
trpc.group.list.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
search: debouncedSearch || undefined,
|
||||
type: typeFilter || undefined,
|
||||
take: PAGE_SIZE,
|
||||
cursor: (page - 1) * PAGE_SIZE,
|
||||
},
|
||||
{ placeholderData: keepPreviousData }
|
||||
)
|
||||
);
|
||||
|
||||
const types = typesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
actions={
|
||||
<Button onClick={() => pushModal('AddGroup')}>
|
||||
<PlusIcon className="mr-2 size-4" />
|
||||
Add group
|
||||
</Button>
|
||||
}
|
||||
className="mb-8"
|
||||
description="Groups represent companies, teams, or other entities that events belong to."
|
||||
title="Groups"
|
||||
/>
|
||||
|
||||
<GroupsTable
|
||||
pageSize={PAGE_SIZE}
|
||||
query={groupsQuery}
|
||||
toolbarLeft={
|
||||
types.length > 0 ? (
|
||||
<Select
|
||||
onValueChange={(v) => setTypeFilter(v === 'all' ? '' : v)}
|
||||
value={typeFilter || 'all'}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{types.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { EventsTable } from '@/components/events/table';
|
||||
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/events'
|
||||
)({
|
||||
component: Component,
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('Group events') }],
|
||||
}),
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, groupId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
|
||||
const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||
const [eventNames] = useEventQueryNamesFilter();
|
||||
const columnVisibility = useReadColumnVisibility('events');
|
||||
|
||||
const query = useInfiniteQuery(
|
||||
trpc.event.events.infiniteQueryOptions(
|
||||
{
|
||||
projectId,
|
||||
groupId,
|
||||
filters: [], // Always scope to group only; date + event names from toolbar still apply
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
events: eventNames,
|
||||
columnVisibility: columnVisibility ?? {},
|
||||
},
|
||||
{
|
||||
enabled: columnVisibility !== null,
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return <EventsTable query={query} />;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { GroupMemberGrowth } from '@/components/groups/group-member-growth';
|
||||
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
|
||||
import { WidgetHead } from '@/components/overview/overview-widget';
|
||||
import { MostEvents } from '@/components/profiles/most-events';
|
||||
import { PopularRoutes } from '@/components/profiles/popular-routes';
|
||||
import { ProfileActivity } from '@/components/profiles/profile-activity';
|
||||
import { KeyValueGrid } from '@/components/ui/key-value-grid';
|
||||
import { Widget } from '@/components/widget';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { formatDateTime } from '@/utils/date';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/'
|
||||
)({
|
||||
component: Component,
|
||||
loader: async ({ context, params }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.prefetchQuery(
|
||||
context.trpc.group.activity.queryOptions({
|
||||
id: params.groupId,
|
||||
projectId: params.projectId,
|
||||
})
|
||||
),
|
||||
]);
|
||||
},
|
||||
pendingComponent: FullPageLoadingState,
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('Group') }],
|
||||
}),
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, groupId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const group = useSuspenseQuery(
|
||||
trpc.group.byId.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
const metrics = useSuspenseQuery(
|
||||
trpc.group.metrics.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
const activity = useSuspenseQuery(
|
||||
trpc.group.activity.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
const mostEvents = useSuspenseQuery(
|
||||
trpc.group.mostEvents.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
const popularRoutes = useSuspenseQuery(
|
||||
trpc.group.popularRoutes.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
const memberGrowth = useSuspenseQuery(
|
||||
trpc.group.memberGrowth.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
|
||||
const g = group.data;
|
||||
const m = metrics.data;
|
||||
|
||||
if (!g) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const properties = g.properties as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Metrics */}
|
||||
{m && (
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4">
|
||||
<OverviewMetricCard
|
||||
data={[]}
|
||||
id="totalEvents"
|
||||
isLoading={false}
|
||||
label="Total Events"
|
||||
metric={{ current: m.totalEvents, previous: null }}
|
||||
unit=""
|
||||
/>
|
||||
<OverviewMetricCard
|
||||
data={[]}
|
||||
id="uniqueMembers"
|
||||
isLoading={false}
|
||||
label="Unique Members"
|
||||
metric={{ current: m.uniqueProfiles, previous: null }}
|
||||
unit=""
|
||||
/>
|
||||
<OverviewMetricCard
|
||||
data={[]}
|
||||
id="firstSeen"
|
||||
isLoading={false}
|
||||
label="First Seen"
|
||||
metric={{
|
||||
current: m.firstSeen ? new Date(m.firstSeen).getTime() : 0,
|
||||
previous: null,
|
||||
}}
|
||||
unit="timeAgo"
|
||||
/>
|
||||
<OverviewMetricCard
|
||||
data={[]}
|
||||
id="lastSeen"
|
||||
isLoading={false}
|
||||
label="Last Seen"
|
||||
metric={{
|
||||
current: m.lastSeen ? new Date(m.lastSeen).getTime() : 0,
|
||||
previous: null,
|
||||
}}
|
||||
unit="timeAgo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<Widget className="w-full">
|
||||
<WidgetHead>
|
||||
<div className="title">Group Information</div>
|
||||
</WidgetHead>
|
||||
<KeyValueGrid
|
||||
className="border-0"
|
||||
columns={3}
|
||||
copyable
|
||||
data={[
|
||||
{ name: 'id', value: g.id },
|
||||
{ name: 'name', value: g.name },
|
||||
{ name: 'type', value: g.type },
|
||||
{
|
||||
name: 'createdAt',
|
||||
value: formatDateTime(new Date(g.createdAt)),
|
||||
},
|
||||
...Object.entries(properties)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => ({
|
||||
name: k,
|
||||
value: String(v),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Widget>
|
||||
</div>
|
||||
|
||||
{/* Activity heatmap */}
|
||||
<div className="col-span-1">
|
||||
<ProfileActivity data={activity.data} />
|
||||
</div>
|
||||
|
||||
{/* New members last 30 days */}
|
||||
<div className="col-span-1">
|
||||
<GroupMemberGrowth data={memberGrowth.data} />
|
||||
</div>
|
||||
|
||||
{/* Top events */}
|
||||
<div className="col-span-1">
|
||||
<MostEvents data={mostEvents.data} />
|
||||
</div>
|
||||
|
||||
{/* Popular routes */}
|
||||
<div className="col-span-1">
|
||||
<PopularRoutes data={popularRoutes.data} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ProfilesTable } from '@/components/profiles/table';
|
||||
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs/members'
|
||||
)({
|
||||
component: Component,
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('Group members') }],
|
||||
}),
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const { projectId, groupId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { debouncedSearch } = useSearchQueryState();
|
||||
const { page } = useDataTablePagination(50);
|
||||
|
||||
const query = useQuery({
|
||||
...trpc.group.listProfiles.queryOptions({
|
||||
projectId,
|
||||
groupId,
|
||||
cursor: (page - 1) * 50,
|
||||
take: 50,
|
||||
search: debouncedSearch || undefined,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
return (
|
||||
<ProfilesTable
|
||||
pageSize={50}
|
||||
query={query as Parameters<typeof ProfilesTable>[0]['query']}
|
||||
type="profiles"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
useSuspenseQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import {
|
||||
createFileRoute,
|
||||
Outlet,
|
||||
useNavigate,
|
||||
useRouter,
|
||||
} from '@tanstack/react-router';
|
||||
import { Building2Icon, PencilIcon, Trash2Icon } from 'lucide-react';
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { usePageTabs } from '@/hooks/use-page-tabs';
|
||||
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal, showConfirm } from '@/modals';
|
||||
import { createProjectTitle } from '@/utils/title';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/groups_/$groupId/_tabs'
|
||||
)({
|
||||
component: Component,
|
||||
loader: async ({ context, params }) => {
|
||||
await Promise.all([
|
||||
context.queryClient.prefetchQuery(
|
||||
context.trpc.group.byId.queryOptions({
|
||||
id: params.groupId,
|
||||
projectId: params.projectId,
|
||||
})
|
||||
),
|
||||
context.queryClient.prefetchQuery(
|
||||
context.trpc.group.metrics.queryOptions({
|
||||
id: params.groupId,
|
||||
projectId: params.projectId,
|
||||
})
|
||||
),
|
||||
]);
|
||||
},
|
||||
pendingComponent: FullPageLoadingState,
|
||||
head: () => ({
|
||||
meta: [{ title: createProjectTitle('Group') }],
|
||||
}),
|
||||
});
|
||||
|
||||
function Component() {
|
||||
const router = useRouter();
|
||||
const { projectId, organizationId, groupId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const group = useSuspenseQuery(
|
||||
trpc.group.byId.queryOptions({ id: groupId, projectId })
|
||||
);
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
trpc.group.delete.mutationOptions({
|
||||
onSuccess() {
|
||||
queryClient.invalidateQueries(trpc.group.list.pathFilter());
|
||||
navigate({
|
||||
to: '/$organizationId/$projectId/groups',
|
||||
params: { organizationId, projectId },
|
||||
});
|
||||
},
|
||||
onError: handleError,
|
||||
})
|
||||
);
|
||||
|
||||
const { activeTab, tabs } = usePageTabs([
|
||||
{ id: '/$organizationId/$projectId/groups/$groupId', label: 'Overview' },
|
||||
{ id: 'members', label: 'Members' },
|
||||
{ id: 'events', label: 'Events' },
|
||||
]);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
router.navigate({
|
||||
from: Route.fullPath,
|
||||
to: tabId,
|
||||
});
|
||||
};
|
||||
|
||||
const g = group.data;
|
||||
|
||||
if (!g) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-muted-foreground">
|
||||
<Building2Icon className="size-10 opacity-30" />
|
||||
<p className="text-sm">Group not found</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer className="col">
|
||||
<PageHeader
|
||||
actions={
|
||||
<div className="row gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
pushModal('EditGroup', {
|
||||
id: g.id,
|
||||
projectId: g.projectId,
|
||||
name: g.name,
|
||||
type: g.type,
|
||||
properties: g.properties,
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<PencilIcon className="mr-2 size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
showConfirm({
|
||||
title: 'Delete group',
|
||||
text: `Are you sure you want to delete "${g.name}"? This action cannot be undone.`,
|
||||
onConfirm: () =>
|
||||
deleteMutation.mutate({ id: g.id, projectId }),
|
||||
})
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2Icon className="mr-2 size-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div className="row min-w-0 items-center gap-3">
|
||||
<Building2Icon className="size-6 shrink-0" />
|
||||
<span className="truncate">{g.name}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
className="mt-2 mb-8"
|
||||
onValueChange={handleTabChange}
|
||||
value={activeTab}
|
||||
>
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { MostEvents } from '@/components/profiles/most-events';
|
||||
import { PopularRoutes } from '@/components/profiles/popular-routes';
|
||||
import { ProfileActivity } from '@/components/profiles/profile-activity';
|
||||
import { ProfileCharts } from '@/components/profiles/profile-charts';
|
||||
import { ProfileGroups } from '@/components/profiles/profile-groups';
|
||||
import { ProfileMetrics } from '@/components/profiles/profile-metrics';
|
||||
import { ProfileProperties } from '@/components/profiles/profile-properties';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
@@ -103,8 +104,15 @@ function Component() {
|
||||
<ProfileMetrics data={metrics.data} />
|
||||
</div>
|
||||
{/* Profile properties - full width */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<div className="col-span-1 flex flex-col gap-3 md:col-span-2">
|
||||
<ProfileProperties profile={profile.data!} />
|
||||
{profile.data?.groups?.length ? (
|
||||
<ProfileGroups
|
||||
profileId={profileId}
|
||||
projectId={projectId}
|
||||
groups={profile.data.groups}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Heatmap / Activity */}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { Fullscreen, FullscreenClose } from '@/components/fullscreen-toggle';
|
||||
import RealtimeMap from '@/components/realtime/map';
|
||||
import { RealtimeActiveSessions } from '@/components/realtime/realtime-active-sessions';
|
||||
@@ -7,12 +9,10 @@ import { RealtimePaths } from '@/components/realtime/realtime-paths';
|
||||
import { RealtimeReferrals } from '@/components/realtime/realtime-referrals';
|
||||
import RealtimeReloader from '@/components/realtime/realtime-reloader';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/realtime',
|
||||
'/_app/$organizationId/$projectId/realtime'
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -36,38 +36,70 @@ function Component() {
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fullscreen>
|
||||
<FullscreenClose />
|
||||
<RealtimeReloader projectId={projectId} />
|
||||
<Fullscreen>
|
||||
<FullscreenClose />
|
||||
<RealtimeReloader projectId={projectId} />
|
||||
|
||||
<div className="flex flex-col gap-4 p-4 md:hidden">
|
||||
<div className="card bg-background/90 p-4">
|
||||
<RealtimeLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div className="-mx-4 aspect-square">
|
||||
<RealtimeMap
|
||||
markers={coordinatesQuery.data ?? []}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[320px]">
|
||||
<RealtimeActiveSessions projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<RealtimeGeo projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<RealtimeReferrals projectId={projectId} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<RealtimePaths projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<div className="row relative">
|
||||
<div className="overflow-hidden aspect-[4/2] w-full">
|
||||
<div className="aspect-[4/2] w-full">
|
||||
<RealtimeMap
|
||||
markers={coordinatesQuery.data ?? []}
|
||||
projectId={projectId}
|
||||
sidebarConfig={{
|
||||
width: 280, // w-96 = 384px
|
||||
position: 'left',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-8 left-8 bottom-0 col gap-4">
|
||||
<div className="card p-4 w-72 bg-background/90">
|
||||
<div className="col absolute top-8 bottom-4 left-8 gap-4">
|
||||
<div className="card w-72 bg-background/90 p-4">
|
||||
<RealtimeLiveHistogram projectId={projectId} />
|
||||
</div>
|
||||
<div className="w-72 flex-1 min-h-0 relative">
|
||||
<RealtimeActiveSessions projectId={projectId} />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-def-100 to-transparent" />
|
||||
<div className="relative min-h-0 w-72 flex-1">
|
||||
<RealtimeActiveSessions
|
||||
className="max-md:hidden"
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4 pt-4 md:p-8 md:pt-0">
|
||||
<div className="grid grid-cols-1 gap-4 p-4 pt-4 md:grid-cols-2 md:p-8 md:pt-0 xl:grid-cols-3">
|
||||
<div>
|
||||
<RealtimeGeo projectId={projectId} />
|
||||
</div>
|
||||
@@ -78,7 +110,7 @@ function Component() {
|
||||
<RealtimePaths projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
</Fullscreen>
|
||||
</>
|
||||
</div>
|
||||
</Fullscreen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { PageContainer } from '@/components/page-container';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { SessionsTable } from '@/components/sessions/table';
|
||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||
import { useSessionFilters } from '@/hooks/use-session-filters';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
||||
import {
|
||||
keepPreviousData,
|
||||
useInfiniteQuery,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
|
||||
|
||||
export const Route = createFileRoute(
|
||||
'/_app/$organizationId/$projectId/sessions',
|
||||
'/_app/$organizationId/$projectId/sessions'
|
||||
)({
|
||||
component: Component,
|
||||
head: () => {
|
||||
@@ -31,6 +27,8 @@ function Component() {
|
||||
const { projectId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const { debouncedSearch } = useSearchQueryState();
|
||||
const { filters, minPageViews, maxPageViews, minEvents, maxEvents } =
|
||||
useSessionFilters();
|
||||
|
||||
const query = useInfiniteQuery(
|
||||
trpc.session.list.infiniteQueryOptions(
|
||||
@@ -38,19 +36,24 @@ function Component() {
|
||||
projectId,
|
||||
take: 50,
|
||||
search: debouncedSearch,
|
||||
filters,
|
||||
minPageViews,
|
||||
maxPageViews,
|
||||
minEvents,
|
||||
maxEvents,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.meta.next,
|
||||
},
|
||||
),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Sessions"
|
||||
description="Access all your sessions here"
|
||||
className="mb-8"
|
||||
description="Access all your sessions here"
|
||||
title="Sessions"
|
||||
/>
|
||||
<SessionsTable query={query} />
|
||||
</PageContainer>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user