Compare commits
6 Commits
feature/on
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00d1bd256 | ||
|
|
5757cb2fac | ||
|
|
ccff90829b | ||
|
|
bc84404235 | ||
|
|
dad9baa581 | ||
|
|
ea6b69d3ec |
1
.github/workflows/docker-build.yml
vendored
@@ -8,7 +8,6 @@ on:
|
||||
- "apps/api/**"
|
||||
- "apps/worker/**"
|
||||
- "apps/public/**"
|
||||
- "apps/start/**"
|
||||
- "packages/**"
|
||||
- "!packages/sdks/**"
|
||||
- "**Dockerfile"
|
||||
|
||||
@@ -38,9 +38,11 @@ COPY packages/redis/package.json packages/redis/
|
||||
COPY packages/logger/package.json packages/logger/
|
||||
COPY packages/common/package.json packages/common/
|
||||
COPY packages/payments/package.json packages/payments/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/constants/package.json packages/constants/
|
||||
COPY packages/validation/package.json packages/validation/
|
||||
COPY packages/integrations/package.json packages/integrations/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY patches ./patches
|
||||
|
||||
# BUILD
|
||||
@@ -105,6 +107,7 @@ COPY --from=build /app/packages/redis ./packages/redis
|
||||
COPY --from=build /app/packages/logger ./packages/logger
|
||||
COPY --from=build /app/packages/common ./packages/common
|
||||
COPY --from=build /app/packages/payments ./packages/payments
|
||||
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
|
||||
COPY --from=build /app/packages/constants ./packages/constants
|
||||
COPY --from=build /app/packages/validation ./packages/validation
|
||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"start": "dotenv -e ../../.env node dist/index.js",
|
||||
"build": "rm -rf dist && tsdown",
|
||||
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
|
||||
"test:manage": "jiti scripts/test-manage-api.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -53,6 +52,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@openpanel/sdk": "workspace:*",
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* One-off script to test all /manage/ API endpoints
|
||||
*
|
||||
* Usage:
|
||||
* pnpm test:manage
|
||||
* or
|
||||
* pnpm jiti scripts/test-manage-api.ts
|
||||
*
|
||||
* Set API_URL environment variable to test against a different server:
|
||||
* API_URL=http://localhost:3000 pnpm test:manage
|
||||
*/
|
||||
|
||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
|
||||
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
|
||||
|
||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||
console.error('CLIENT_ID and CLIENT_SECRET must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
method: string;
|
||||
url: string;
|
||||
status: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function makeRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: any,
|
||||
): Promise<TestResult> {
|
||||
const url = `${API_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'openpanel-client-id': CLIENT_ID,
|
||||
'openpanel-client-secret': CLIENT_SECRET,
|
||||
};
|
||||
|
||||
// Only set Content-Type if there's a body
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
success: response.ok,
|
||||
error: response.ok ? undefined : data.message || 'Request failed',
|
||||
data: response.ok ? data : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: 0,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function testProjects() {
|
||||
console.log('\n📁 Testing Projects endpoints...\n');
|
||||
|
||||
// Create project
|
||||
const createResult = await makeRequest('POST', '/manage/projects', {
|
||||
name: `Test Project ${Date.now()}`,
|
||||
domain: 'https://example.com',
|
||||
cors: ['https://example.com', 'https://www.example.com'],
|
||||
crossDomain: false,
|
||||
types: ['website'],
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const projectId = createResult.data?.data?.id;
|
||||
const clientId = createResult.data?.data?.client?.id;
|
||||
const clientSecret = createResult.data?.data?.client?.secret;
|
||||
|
||||
if (projectId) {
|
||||
console.log(` Created project: ${projectId}`);
|
||||
if (clientId) console.log(` Created client: ${clientId}`);
|
||||
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
|
||||
}
|
||||
|
||||
// List projects
|
||||
const listResult = await makeRequest('GET', '/manage/projects');
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} projects`);
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
// Get project
|
||||
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update project
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/projects/${projectId}`,
|
||||
{
|
||||
name: 'Updated Test Project',
|
||||
crossDomain: true,
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete project (soft delete)
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/projects/${projectId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { projectId, clientId };
|
||||
}
|
||||
|
||||
async function testClients(projectId?: string) {
|
||||
console.log('\n🔑 Testing Clients endpoints...\n');
|
||||
|
||||
// Create client
|
||||
const createResult = await makeRequest('POST', '/manage/clients', {
|
||||
name: `Test Client ${Date.now()}`,
|
||||
projectId: projectId || undefined,
|
||||
type: 'read',
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const clientId = createResult.data?.data?.id;
|
||||
const clientSecret = createResult.data?.data?.secret;
|
||||
|
||||
if (clientId) {
|
||||
console.log(` Created client: ${clientId}`);
|
||||
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
|
||||
}
|
||||
|
||||
// List clients
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients',
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} clients`);
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
// Get client
|
||||
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update client
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/clients/${clientId}`,
|
||||
{
|
||||
name: 'Updated Test Client',
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete client
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/clients/${clientId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testReferences(projectId?: string) {
|
||||
console.log('\n📚 Testing References endpoints...\n');
|
||||
|
||||
if (!projectId) {
|
||||
console.log(' ⚠️ Skipping references tests - no project ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create reference
|
||||
const createResult = await makeRequest('POST', '/manage/references', {
|
||||
projectId,
|
||||
title: `Test Reference ${Date.now()}`,
|
||||
description: 'This is a test reference',
|
||||
datetime: new Date().toISOString(),
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const referenceId = createResult.data?.data?.id;
|
||||
|
||||
if (referenceId) {
|
||||
console.log(` Created reference: ${referenceId}`);
|
||||
}
|
||||
|
||||
// List references
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references?projectId=${projectId}`,
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} references`);
|
||||
}
|
||||
|
||||
if (referenceId) {
|
||||
// Get reference
|
||||
const getResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references/${referenceId}`,
|
||||
);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update reference
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/references/${referenceId}`,
|
||||
{
|
||||
title: 'Updated Test Reference',
|
||||
description: 'Updated description',
|
||||
datetime: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete reference
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/references/${referenceId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Testing Manage API Endpoints\n');
|
||||
console.log(`API Base URL: ${API_BASE_URL}`);
|
||||
console.log(`Client ID: ${CLIENT_ID}\n`);
|
||||
|
||||
try {
|
||||
// Test projects first (creates a project we can use for other tests)
|
||||
const { projectId } = await testProjects();
|
||||
|
||||
// Test clients
|
||||
await testClients(projectId);
|
||||
|
||||
// Test references (requires a project)
|
||||
await testReferences(projectId);
|
||||
|
||||
// Summary
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log('📊 Test Summary\n');
|
||||
const successful = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
console.log(`Total tests: ${results.length}`);
|
||||
console.log(`✅ Successful: ${successful}`);
|
||||
console.log(`❌ Failed: ${failed}\n`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('Failed tests:');
|
||||
results
|
||||
.filter((r) => !r.success)
|
||||
.forEach((r) => {
|
||||
console.log(` ❌ ${r.name} (${r.status})`);
|
||||
if (r.error) console.log(` Error: ${r.error}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -3,15 +3,15 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { getSalts } from '@openpanel/db';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import type { PostEventPayload } from '@openpanel/sdk';
|
||||
|
||||
import { generateId, slug } from '@openpanel/common';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
|
||||
import { getStringHeaders, getTimestamp } from './track.controller';
|
||||
|
||||
export async function postEvent(
|
||||
request: FastifyRequest<{
|
||||
Body: DeprecatedPostEventPayload;
|
||||
Body: PostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getSettingsForProject,
|
||||
} from '@openpanel/db';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { zChartEvent, zReport } from '@openpanel/validation';
|
||||
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
|
||||
async function getProjectId(
|
||||
@@ -139,7 +139,7 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const chartSchemeFull = zReport
|
||||
const chartSchemeFull = zChartInputBase
|
||||
.pick({
|
||||
breakdowns: true,
|
||||
interval: true,
|
||||
|
||||
@@ -96,6 +96,8 @@ export async function getPages(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,6 +170,8 @@ export function getOverviewGeneric(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,649 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { stripTrailingSlash } from '@openpanel/common';
|
||||
import { hashPassword } from '@openpanel/common/server';
|
||||
import {
|
||||
db,
|
||||
getClientByIdCached,
|
||||
getId,
|
||||
getProjectByIdCached,
|
||||
} from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Validation schemas
|
||||
const zCreateProject = z.object({
|
||||
name: z.string().min(1),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).default([]),
|
||||
crossDomain: z.boolean().optional().default(false),
|
||||
types: z
|
||||
.array(z.enum(['website', 'app', 'backend']))
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const zUpdateProject = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).optional(),
|
||||
crossDomain: z.boolean().optional(),
|
||||
allowUnsafeRevenueTracking: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const zCreateClient = z.object({
|
||||
name: z.string().min(1),
|
||||
projectId: z.string().optional(),
|
||||
type: z.enum(['read', 'write', 'root']).optional().default('write'),
|
||||
});
|
||||
|
||||
const zUpdateClient = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const zCreateReference = z.object({
|
||||
projectId: z.string(),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string(),
|
||||
});
|
||||
|
||||
const zUpdateReference = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string().optional(),
|
||||
});
|
||||
|
||||
// Projects CRUD
|
||||
export async function listProjects(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
deleteAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: projects });
|
||||
}
|
||||
|
||||
export async function getProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, domain, cors, crossDomain, types } = parsed.data;
|
||||
|
||||
// Generate a default client secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
const clientData = {
|
||||
organizationId: request.client!.organizationId,
|
||||
name: 'First client',
|
||||
type: 'write' as const,
|
||||
secret: await hashPassword(secret),
|
||||
};
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
id: await getId('project', name),
|
||||
organizationId: request.client!.organizationId,
|
||||
name,
|
||||
domain: domain ? stripTrailingSlash(domain) : null,
|
||||
cors: cors.map((c) => stripTrailingSlash(c)),
|
||||
crossDomain: crossDomain ?? false,
|
||||
allowUnsafeRevenueTracking: false,
|
||||
filters: [],
|
||||
types,
|
||||
clients: {
|
||||
create: clientData,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
project.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...project,
|
||||
client: project.clients[0]
|
||||
? {
|
||||
id: project.clients[0].id,
|
||||
secret,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateProject>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify project exists and belongs to organization
|
||||
const existing = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
if (parsed.data.domain !== undefined) {
|
||||
updateData.domain = parsed.data.domain
|
||||
? stripTrailingSlash(parsed.data.domain)
|
||||
: null;
|
||||
}
|
||||
if (parsed.data.cors !== undefined) {
|
||||
updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c));
|
||||
}
|
||||
if (parsed.data.crossDomain !== undefined) {
|
||||
updateData.crossDomain = parsed.data.crossDomain;
|
||||
}
|
||||
if (parsed.data.allowUnsafeRevenueTracking !== undefined) {
|
||||
updateData.allowUnsafeRevenueTracking =
|
||||
parsed.data.allowUnsafeRevenueTracking;
|
||||
}
|
||||
|
||||
const project = await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
existing.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: {
|
||||
deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
await getProjectByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// Clients CRUD
|
||||
export async function listClients(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {
|
||||
organizationId: request.client!.organizationId,
|
||||
};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
}
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: clients });
|
||||
}
|
||||
|
||||
export async function getClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function createClient(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, projectId, type } = parsed.data;
|
||||
|
||||
// If projectId is provided, verify it belongs to organization
|
||||
if (projectId) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
|
||||
const client = await db.client.create({
|
||||
data: {
|
||||
organizationId: request.client!.organizationId,
|
||||
projectId: projectId || null,
|
||||
name,
|
||||
type: type || 'write',
|
||||
secret: await hashPassword(secret),
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...client,
|
||||
secret, // Return plain secret only once
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateClient(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateClient>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify client exists and belongs to organization
|
||||
const existing = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
|
||||
const client = await db.client.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function deleteClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.client.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// References CRUD
|
||||
export async function listReferences(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
} else {
|
||||
// If no projectId, get all projects in org and filter references
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
where.projectId = {
|
||||
in: projects.map((p) => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
const references = await db.reference.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: references });
|
||||
}
|
||||
|
||||
export async function getReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function createReference(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { projectId, title, description, datetime } = parsed.data;
|
||||
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const reference = await db.reference.create({
|
||||
data: {
|
||||
projectId,
|
||||
title,
|
||||
description: description || null,
|
||||
date: new Date(datetime),
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function updateReference(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateReference>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reference exists and belongs to organization
|
||||
const existing = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (existing.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.title !== undefined) {
|
||||
updateData.title = parsed.data.title;
|
||||
}
|
||||
if (parsed.data.description !== undefined) {
|
||||
updateData.description = parsed.data.description ?? null;
|
||||
}
|
||||
if (parsed.data.datetime !== undefined) {
|
||||
updateData.date = new Date(parsed.data.datetime);
|
||||
}
|
||||
|
||||
const reference = await db.reference.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function deleteReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.reference.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
@@ -118,11 +118,7 @@ async function fetchImage(
|
||||
|
||||
// Check if URL is an ICO file
|
||||
function isIcoFile(url: string, contentType?: string): boolean {
|
||||
return (
|
||||
url.toLowerCase().endsWith('.ico') ||
|
||||
contentType === 'image/x-icon' ||
|
||||
contentType === 'image/vnd.microsoft.icon'
|
||||
);
|
||||
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
|
||||
}
|
||||
function isSvgFile(url: string, contentType?: string): boolean {
|
||||
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
|
||||
@@ -243,9 +239,7 @@ export async function getFavicon(
|
||||
try {
|
||||
const url = validateUrl(request.query.url);
|
||||
if (!url) {
|
||||
reply.header('Content-Type', 'image/png');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
return reply.send(createFallbackImage());
|
||||
return createFallbackImage();
|
||||
}
|
||||
|
||||
const cacheKey = createCacheKey(url.toString());
|
||||
@@ -266,65 +260,21 @@ export async function getFavicon(
|
||||
} else {
|
||||
// For website URLs, extract favicon from HTML
|
||||
const meta = await parseUrlMeta(url.toString());
|
||||
logger.info('parseUrlMeta result', {
|
||||
url: url.toString(),
|
||||
favicon: meta?.favicon,
|
||||
});
|
||||
if (meta?.favicon) {
|
||||
imageUrl = new URL(meta.favicon);
|
||||
} else {
|
||||
// Try standard favicon location first
|
||||
const { origin } = url;
|
||||
imageUrl = new URL(`${origin}/favicon.ico`);
|
||||
// Fallback to Google's favicon service
|
||||
const { hostname } = url;
|
||||
imageUrl = new URL(
|
||||
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Fetching favicon', {
|
||||
originalUrl: url.toString(),
|
||||
imageUrl: imageUrl.toString(),
|
||||
});
|
||||
|
||||
// Fetch the image
|
||||
let { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||
const { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||
|
||||
logger.info('Favicon fetch result', {
|
||||
originalUrl: url.toString(),
|
||||
imageUrl: imageUrl.toString(),
|
||||
status,
|
||||
bufferLength: buffer.length,
|
||||
contentType,
|
||||
});
|
||||
|
||||
// If the direct favicon fetch failed and it's not from DuckDuckGo's service,
|
||||
// try DuckDuckGo's favicon service as a fallback
|
||||
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
|
||||
const { hostname } = url;
|
||||
const duckduckgoUrl = new URL(
|
||||
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
|
||||
);
|
||||
|
||||
logger.info('Trying DuckDuckGo favicon service', {
|
||||
originalUrl: url.toString(),
|
||||
duckduckgoUrl: duckduckgoUrl.toString(),
|
||||
});
|
||||
|
||||
const duckduckgoResult = await fetchImage(duckduckgoUrl);
|
||||
buffer = duckduckgoResult.buffer;
|
||||
contentType = duckduckgoResult.contentType;
|
||||
status = duckduckgoResult.status;
|
||||
imageUrl = duckduckgoUrl;
|
||||
|
||||
logger.info('DuckDuckGo favicon result', {
|
||||
status,
|
||||
bufferLength: buffer.length,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
// Accept any response as long as we have valid image data
|
||||
if (buffer.length === 0) {
|
||||
reply.header('Content-Type', 'image/png');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
if (status !== 200 || buffer.length === 0) {
|
||||
return reply.send(createFallbackImage());
|
||||
}
|
||||
|
||||
@@ -335,31 +285,9 @@ export async function getFavicon(
|
||||
contentType,
|
||||
);
|
||||
|
||||
logger.info('Favicon processing result', {
|
||||
originalUrl: url.toString(),
|
||||
originalBufferLength: buffer.length,
|
||||
processedBufferLength: processedBuffer.length,
|
||||
});
|
||||
|
||||
// Determine the correct content type for caching and response
|
||||
const isIco = isIcoFile(imageUrl.toString(), contentType);
|
||||
const isSvg = isSvgFile(imageUrl.toString(), contentType);
|
||||
let responseContentType = contentType;
|
||||
|
||||
if (isIco) {
|
||||
responseContentType = 'image/x-icon';
|
||||
} else if (isSvg) {
|
||||
responseContentType = 'image/svg+xml';
|
||||
} else if (
|
||||
processedBuffer.length < 5000 &&
|
||||
buffer.length === processedBuffer.length
|
||||
) {
|
||||
// Image was returned as-is, keep original content type
|
||||
responseContentType = contentType;
|
||||
} else {
|
||||
// Image was processed by Sharp, it's now a PNG
|
||||
responseContentType = 'image/png';
|
||||
}
|
||||
const responseContentType = isIco ? 'image/x-icon' : contentType;
|
||||
|
||||
// Cache the result with correct content type
|
||||
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
|
||||
|
||||
@@ -5,13 +5,13 @@ import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, upsertProfile } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import type {
|
||||
DeprecatedIncrementProfilePayload,
|
||||
DeprecatedUpdateProfilePayload,
|
||||
} from '@openpanel/validation';
|
||||
IncrementProfilePayload,
|
||||
UpdateProfilePayload,
|
||||
} from '@openpanel/sdk';
|
||||
|
||||
export async function updateProfile(
|
||||
request: FastifyRequest<{
|
||||
Body: DeprecatedUpdateProfilePayload;
|
||||
Body: UpdateProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -52,7 +52,7 @@ export async function updateProfile(
|
||||
|
||||
export async function incrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
Body: IncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -94,7 +94,7 @@ export async function incrementProfileProperty(
|
||||
|
||||
export async function decrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
Body: IncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
|
||||
@@ -8,15 +8,13 @@ import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
|
||||
import {
|
||||
type IDecrementPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type ITrackHandlerPayload,
|
||||
type ITrackPayload,
|
||||
zTrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type {
|
||||
DecrementPayload,
|
||||
IdentifyPayload,
|
||||
IncrementPayload,
|
||||
TrackHandlerPayload,
|
||||
TrackPayload,
|
||||
} from '@openpanel/sdk';
|
||||
|
||||
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
return Object.entries(
|
||||
@@ -39,28 +37,25 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
);
|
||||
}
|
||||
|
||||
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
||||
if (body.type === 'track') {
|
||||
const identity = body.payload.properties?.__identify as
|
||||
| IIdentifyPayload
|
||||
| undefined;
|
||||
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
|
||||
const identity =
|
||||
'properties' in body.payload
|
||||
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
identity ||
|
||||
(body.payload.profileId
|
||||
? {
|
||||
profileId: String(body.payload.profileId),
|
||||
}
|
||||
: undefined)
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return (
|
||||
identity ||
|
||||
(body?.payload?.profileId
|
||||
? {
|
||||
profileId: body.payload.profileId,
|
||||
}
|
||||
: undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function getTimestamp(
|
||||
timestamp: FastifyRequest['timestamp'],
|
||||
payload: ITrackHandlerPayload['payload'],
|
||||
payload: TrackHandlerPayload['payload'],
|
||||
) {
|
||||
const safeTimestamp = timestamp || Date.now();
|
||||
const userDefinedTimestamp =
|
||||
@@ -87,7 +82,7 @@ export function getTimestamp(
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
|
||||
// isTimestampFromThePast is true only if timestamp is older than 1 hour
|
||||
const isTimestampFromThePast =
|
||||
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
|
||||
|
||||
@@ -97,113 +92,170 @@ export function getTimestamp(
|
||||
};
|
||||
}
|
||||
|
||||
interface TrackContext {
|
||||
projectId: string;
|
||||
ip: string;
|
||||
ua?: string;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: { value: number; isFromPast: boolean };
|
||||
identity?: IIdentifyPayload;
|
||||
currentDeviceId?: string;
|
||||
previousDeviceId?: string;
|
||||
geo: GeoLocation;
|
||||
}
|
||||
|
||||
async function buildContext(
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
Body: TrackHandlerPayload;
|
||||
}>,
|
||||
validatedBody: ITrackHandlerPayload,
|
||||
): Promise<TrackContext> {
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
throw new HttpError('Missing projectId', { status: 400 });
|
||||
}
|
||||
|
||||
const timestamp = getTimestamp(request.timestamp, validatedBody.payload);
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
||||
const ip =
|
||||
validatedBody.type === 'track' && validatedBody.payload.properties?.__ip
|
||||
? (validatedBody.payload.properties.__ip as string)
|
||||
'properties' in request.body.payload &&
|
||||
request.body.payload.properties?.__ip
|
||||
? (request.body.payload.properties.__ip as string)
|
||||
: request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const headers = getStringHeaders(request.headers);
|
||||
const projectId = request.client?.projectId;
|
||||
|
||||
const identity = getIdentity(validatedBody);
|
||||
if (!projectId) {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Missing projectId',
|
||||
});
|
||||
}
|
||||
|
||||
const identity = getIdentity(request.body);
|
||||
const profileId = identity?.profileId;
|
||||
const overrideDeviceId = (() => {
|
||||
const deviceId =
|
||||
'properties' in request.body.payload
|
||||
? request.body.payload.properties?.__deviceId
|
||||
: undefined;
|
||||
if (typeof deviceId === 'string') {
|
||||
return deviceId;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// We might get a profileId from the alias table
|
||||
// If we do, we should use that instead of the one from the payload
|
||||
if (profileId && validatedBody.type === 'track') {
|
||||
validatedBody.payload.profileId = profileId;
|
||||
if (profileId) {
|
||||
request.body.payload.profileId = profileId;
|
||||
}
|
||||
|
||||
// Get geo location (needed for track and identify)
|
||||
const geo = await getGeoLocation(ip);
|
||||
|
||||
// Generate device IDs if needed (for track)
|
||||
let currentDeviceId: string | undefined;
|
||||
let previousDeviceId: string | undefined;
|
||||
|
||||
if (validatedBody.type === 'track') {
|
||||
const overrideDeviceId =
|
||||
typeof validatedBody.payload.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload.properties.__deviceId
|
||||
: undefined;
|
||||
|
||||
const [salts] = await Promise.all([getSalts()]);
|
||||
currentDeviceId =
|
||||
overrideDeviceId ||
|
||||
(ua
|
||||
switch (request.body.type) {
|
||||
case 'track': {
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId =
|
||||
overrideDeviceId ||
|
||||
(ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '');
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '');
|
||||
previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
: '';
|
||||
|
||||
const promises = [];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
if (identity && Object.keys(identity).length > 1) {
|
||||
promises.push(
|
||||
identify({
|
||||
payload: identity,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
promises.push(
|
||||
track({
|
||||
payload: request.body.payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers: getStringHeaders(request.headers),
|
||||
timestamp: timestamp.timestamp,
|
||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
break;
|
||||
}
|
||||
case 'identify': {
|
||||
const payload = request.body.payload;
|
||||
const geo = await getGeoLocation(ip);
|
||||
if (!payload.profileId) {
|
||||
throw new HttpError('Missing profileId', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await identify({
|
||||
payload,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'alias': {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
}
|
||||
case 'increment': {
|
||||
await increment({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'decrement': {
|
||||
await decrement({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
headers,
|
||||
timestamp: {
|
||||
value: timestamp.timestamp,
|
||||
isFromPast: timestamp.isTimestampFromThePast,
|
||||
},
|
||||
identity,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
geo,
|
||||
};
|
||||
reply.status(200).send();
|
||||
}
|
||||
|
||||
async function handleTrack(
|
||||
payload: ITrackPayload,
|
||||
context: TrackContext,
|
||||
): Promise<void> {
|
||||
const {
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
geo,
|
||||
headers,
|
||||
timestamp,
|
||||
} = context;
|
||||
|
||||
if (!currentDeviceId || !previousDeviceId) {
|
||||
throw new HttpError('Device ID generation failed', { status: 500 });
|
||||
}
|
||||
|
||||
async function track({
|
||||
payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
}: {
|
||||
payload: TrackPayload;
|
||||
currentDeviceId: string;
|
||||
previousDeviceId: string;
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: number;
|
||||
isTimestampFromThePast: boolean;
|
||||
}) {
|
||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
? payload.profileId
|
||||
@@ -212,51 +264,44 @@ async function handleTrack(
|
||||
: currentDeviceId;
|
||||
const jobId = [
|
||||
slug(payload.name),
|
||||
timestamp.value,
|
||||
timestamp,
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
groupId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
|
||||
const promises = [];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
if (context.identity && Object.keys(context.identity).length > 1) {
|
||||
promises.push(handleIdentify(context.identity, context));
|
||||
}
|
||||
|
||||
promises.push(
|
||||
getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp.value,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp: timestamp.value,
|
||||
isTimestampFromThePast: timestamp.isFromPast,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
await getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleIdentify(
|
||||
payload: IIdentifyPayload,
|
||||
context: TrackContext,
|
||||
): Promise<void> {
|
||||
const { projectId, geo, ua } = context;
|
||||
async function identify({
|
||||
payload,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
}: {
|
||||
payload: IdentifyPayload;
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
ua?: string;
|
||||
}) {
|
||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||
await upsertProfile({
|
||||
...payload,
|
||||
@@ -281,15 +326,17 @@ async function handleIdentify(
|
||||
});
|
||||
}
|
||||
|
||||
async function adjustProfileProperty(
|
||||
payload: IIncrementPayload | IDecrementPayload,
|
||||
projectId: string,
|
||||
direction: 1 | -1,
|
||||
): Promise<void> {
|
||||
async function increment({
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
payload: IncrementPayload;
|
||||
projectId: string;
|
||||
}) {
|
||||
const { profileId, property, value } = payload;
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
throw new HttpError('Profile not found', { status: 404 });
|
||||
throw new Error('Not found');
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(
|
||||
@@ -298,12 +345,12 @@ async function adjustProfileProperty(
|
||||
);
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new HttpError('Property value is not a number', { status: 400 });
|
||||
throw new Error('Not number');
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed + direction * (value || 1),
|
||||
parsed + (value || 1),
|
||||
profile.properties,
|
||||
);
|
||||
|
||||
@@ -315,74 +362,40 @@ async function adjustProfileProperty(
|
||||
});
|
||||
}
|
||||
|
||||
async function handleIncrement(
|
||||
payload: IIncrementPayload,
|
||||
context: TrackContext,
|
||||
): Promise<void> {
|
||||
await adjustProfileProperty(payload, context.projectId, 1);
|
||||
}
|
||||
|
||||
async function handleDecrement(
|
||||
payload: IDecrementPayload,
|
||||
context: TrackContext,
|
||||
): Promise<void> {
|
||||
await adjustProfileProperty(payload, context.projectId, -1);
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
// Validate request body with Zod
|
||||
const validationResult = zTrackHandlerPayload.safeParse(request.body);
|
||||
if (!validationResult.success) {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Validation failed',
|
||||
errors: validationResult.error.errors,
|
||||
});
|
||||
async function decrement({
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
payload: DecrementPayload;
|
||||
projectId: string;
|
||||
}) {
|
||||
const { profileId, property, value } = payload;
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
throw new Error('Not found');
|
||||
}
|
||||
|
||||
const validatedBody = validationResult.data;
|
||||
const parsed = Number.parseInt(
|
||||
pathOr<string>('0', property.split('.'), profile.properties),
|
||||
10,
|
||||
);
|
||||
|
||||
// Handle alias (not supported)
|
||||
if (validatedBody.type === 'alias') {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error('Not number');
|
||||
}
|
||||
|
||||
// Build request context
|
||||
const context = await buildContext(request, validatedBody);
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed - (value || 1),
|
||||
profile.properties,
|
||||
);
|
||||
|
||||
// Dispatch to appropriate handler
|
||||
switch (validatedBody.type) {
|
||||
case 'track':
|
||||
await handleTrack(validatedBody.payload, context);
|
||||
break;
|
||||
case 'identify':
|
||||
await handleIdentify(validatedBody.payload, context);
|
||||
break;
|
||||
case 'increment':
|
||||
await handleIncrement(validatedBody.payload, context);
|
||||
break;
|
||||
case 'decrement':
|
||||
await handleDecrement(validatedBody.payload, context);
|
||||
break;
|
||||
default:
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(200).send();
|
||||
await upsertProfile({
|
||||
id: profile.id,
|
||||
projectId,
|
||||
properties: profile.properties,
|
||||
isExternal: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDeviceId(
|
||||
|
||||
@@ -191,9 +191,7 @@ export async function polarWebhook(
|
||||
where: {
|
||||
subscriptionCustomerId: event.data.customer.id,
|
||||
subscriptionId: event.data.id,
|
||||
subscriptionStatus: {
|
||||
in: ['active', 'past_due', 'unpaid'],
|
||||
},
|
||||
subscriptionStatus: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function clientHook(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function duplicateHook(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { isBot } from '@/bots';
|
||||
import { createBotEvent } from '@openpanel/db';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import type { TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
type DeprecatedEventPayload = {
|
||||
name: string;
|
||||
properties: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export async function isBotHook(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
Body: TrackHandlerPayload | DeprecatedEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -44,6 +46,6 @@ export async function isBotHook(
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(202).send();
|
||||
return reply.status(202).send('OK');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import exportRouter from './routes/export.router';
|
||||
import importRouter from './routes/import.router';
|
||||
import insightsRouter from './routes/insights.router';
|
||||
import liveRouter from './routes/live.router';
|
||||
import manageRouter from './routes/manage.router';
|
||||
import miscRouter from './routes/misc.router';
|
||||
import oauthRouter from './routes/oauth-callback.router';
|
||||
import profileRouter from './routes/profile.router';
|
||||
@@ -144,7 +143,7 @@ const startServer = async () => {
|
||||
instance.addHook('onRequest', async (req) => {
|
||||
if (req.cookies?.session) {
|
||||
try {
|
||||
const sessionId = decodeSessionToken(req.cookies?.session);
|
||||
const sessionId = decodeSessionToken(req.cookies.session);
|
||||
const session = await runWithAlsSession(sessionId, () =>
|
||||
validateSessionToken(req.cookies.session),
|
||||
);
|
||||
@@ -152,15 +151,6 @@ const startServer = async () => {
|
||||
} catch (e) {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else if (process.env.DEMO_USER_ID) {
|
||||
try {
|
||||
const session = await runWithAlsSession('1', () =>
|
||||
validateSessionToken(null),
|
||||
);
|
||||
req.session = session;
|
||||
} catch (e) {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
@@ -204,7 +194,6 @@ const startServer = async () => {
|
||||
instance.register(importRouter, { prefix: '/import' });
|
||||
instance.register(insightsRouter, { prefix: '/insights' });
|
||||
instance.register(trackRouter, { prefix: '/track' });
|
||||
instance.register(manageRouter, { prefix: '/manage' });
|
||||
// Keep existing endpoints for backward compatibility
|
||||
instance.get('/healthcheck', healthcheck);
|
||||
// New Kubernetes-style health endpoints
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import * as controller from '@/controllers/manage.controller';
|
||||
import { validateManageRequest } from '@/utils/auth';
|
||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
|
||||
const manageRouter: FastifyPluginCallback = async (fastify) => {
|
||||
await activateRateLimiter({
|
||||
fastify,
|
||||
max: 20,
|
||||
timeWindow: '10 seconds',
|
||||
});
|
||||
|
||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||
try {
|
||||
const client = await validateManageRequest(req.headers);
|
||||
req.client = client;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return reply.status(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Client ID seems to be malformed',
|
||||
});
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: e.message });
|
||||
}
|
||||
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: 'Unexpected error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Projects routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects',
|
||||
handler: controller.listProjects,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects/:id',
|
||||
handler: controller.getProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/projects',
|
||||
handler: controller.createProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/projects/:id',
|
||||
handler: controller.updateProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/projects/:id',
|
||||
handler: controller.deleteProject,
|
||||
});
|
||||
|
||||
// Clients routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients',
|
||||
handler: controller.listClients,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients/:id',
|
||||
handler: controller.getClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/clients',
|
||||
handler: controller.createClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/clients/:id',
|
||||
handler: controller.updateClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/clients/:id',
|
||||
handler: controller.deleteClient,
|
||||
});
|
||||
|
||||
// References routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references',
|
||||
handler: controller.listReferences,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references/:id',
|
||||
handler: controller.getReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/references',
|
||||
handler: controller.createReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/references/:id',
|
||||
handler: controller.updateReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/references/:id',
|
||||
handler: controller.deleteReference,
|
||||
});
|
||||
};
|
||||
|
||||
export default manageRouter;
|
||||
@@ -14,6 +14,22 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
handler: handler,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['type', 'payload'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
|
||||
},
|
||||
payload: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@openpanel/db';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import { zReportInput } from '@openpanel/validation';
|
||||
import { zChartInputAI } from '@openpanel/validation';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -27,10 +27,7 @@ export function getReport({
|
||||
- ${chartTypes.metric}
|
||||
- ${chartTypes.bar}
|
||||
`,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
parameters: zChartInputAI,
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -75,10 +72,7 @@ export function getConversionReport({
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
parameters: zChartInputAI,
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -100,10 +94,7 @@ export function getFunnelReport({
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
parameters: zChartInputAI,
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
|
||||
@@ -4,11 +4,10 @@ import { verifyPassword } from '@openpanel/common/server';
|
||||
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
IProjectFilterIp,
|
||||
IProjectFilterProfileId,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import { path } from 'ramda';
|
||||
|
||||
@@ -42,7 +41,7 @@ export class SdkAuthError extends Error {
|
||||
|
||||
export async function validateSdkRequest(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
}>,
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const { headers, clientIp } = req;
|
||||
@@ -236,40 +235,3 @@ export async function validateImportRequest(
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function validateManageRequest(
|
||||
headers: RawRequestDefaultExpression['headers'],
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const clientId = headers['openpanel-client-id'] as string;
|
||||
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
clientId,
|
||||
)
|
||||
) {
|
||||
throw new Error('Manage: Client ID must be a valid UUIDv4');
|
||||
}
|
||||
|
||||
const client = await getClientByIdCached(clientId);
|
||||
|
||||
if (!client) {
|
||||
throw new Error('Manage: Invalid client id');
|
||||
}
|
||||
|
||||
if (!client.secret) {
|
||||
throw new Error('Manage: Client has no secret');
|
||||
}
|
||||
|
||||
if (client.type !== ClientType.root) {
|
||||
throw new Error(
|
||||
'Manage: Only root clients are allowed to manage resources',
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyPassword(clientSecret, client.secret))) {
|
||||
throw new Error('Manage: Invalid client secret');
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import urlMetadata from 'url-metadata';
|
||||
|
||||
function fallbackFavicon(url: string) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
|
||||
} catch {
|
||||
// If URL parsing fails, use the original string
|
||||
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
|
||||
}
|
||||
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
|
||||
}
|
||||
|
||||
function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,505 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
|
||||
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
|
||||
<meta name="author" content="OpenPanel">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta property="og:image" content="/ogimage.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="twitter:image" content="/ogimage.png">
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.75;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
margin: 0 -4rem 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.screenshot {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-inner {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.window-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.window-dot.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.window-dot.yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.window-dot.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.screenshot-image-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: #131313;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin: 3rem 0;
|
||||
text-align: center;
|
||||
margin: 0 -4rem 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.cta {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta a:hover {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #374151;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: left;
|
||||
margin-top: 4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
color: #8f8f8f;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Just Fucking Use OpenPanel</h1>
|
||||
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
|
||||
|
||||
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
|
||||
|
||||
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
|
||||
<ul>
|
||||
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
|
||||
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
|
||||
</ul>
|
||||
|
||||
<p>"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.</p>
|
||||
|
||||
<p>Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.</p>
|
||||
|
||||
<h2>The Web-Only Analytics Trap</h2>
|
||||
|
||||
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
|
||||
|
||||
<blockquote>
|
||||
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
|
||||
</blockquote>
|
||||
|
||||
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
|
||||
|
||||
<p>Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.</p>
|
||||
|
||||
<p>Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.</p>
|
||||
|
||||
<p>And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a <strong>SECOND tool</strong> on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.</p>
|
||||
|
||||
<h2>Counter One Dollar Stats</h2>
|
||||
|
||||
<p>"$1/month for page views. Adorable."</p>
|
||||
|
||||
<p>Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.</p>
|
||||
|
||||
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
|
||||
|
||||
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>You get:</p>
|
||||
<ul>
|
||||
<li>Funnels to see where users drop off</li>
|
||||
<li>Retention analysis to see who comes back</li>
|
||||
<li>Cohorts to segment your users</li>
|
||||
<li>User profiles to understand individual behavior</li>
|
||||
<li>Custom dashboards to see what matters to YOU</li>
|
||||
<li>Revenue tracking to see what actually makes money</li>
|
||||
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
|
||||
</ul>
|
||||
|
||||
<p>One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.</p>
|
||||
|
||||
<h2>Why OpenPanel is the Answer</h2>
|
||||
|
||||
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
|
||||
|
||||
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
|
||||
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
|
||||
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
|
||||
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
|
||||
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
|
||||
</ul>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
|
||||
|
||||
<p>Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?</p>
|
||||
|
||||
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.</p>
|
||||
|
||||
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
|
||||
|
||||
<p>Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.</p>
|
||||
|
||||
<h2>The Comparison Table (The Brutal Truth)</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tool</th>
|
||||
<th>Price at 20M events</th>
|
||||
<th>What You Get</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Mixpanel</strong></td>
|
||||
<td>$2,300+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>PostHog</strong></td>
|
||||
<td>$1,982+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Plausible</strong></td>
|
||||
<td>Various pricing</td>
|
||||
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>One Dollar Stats</strong></td>
|
||||
<td>$1/month</td>
|
||||
<td>Page views (but cheaper!)</td>
|
||||
</tr>
|
||||
<tr style="background: #131313; border: 2px solid #3b82f6;">
|
||||
<td><strong>OpenPanel</strong></td>
|
||||
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
|
||||
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The Bottom Fucking Line</h2>
|
||||
|
||||
<p>If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.</p>
|
||||
|
||||
<p>You have three choices:</p>
|
||||
|
||||
<ol>
|
||||
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
|
||||
<li>Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from</li>
|
||||
<li>Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do</li>
|
||||
</ol>
|
||||
|
||||
<p>If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.</p>
|
||||
|
||||
<p>But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.</p>
|
||||
|
||||
<div class="cta">
|
||||
<h2>Ready to understand what your users actually do?</h2>
|
||||
<p>Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.</p>
|
||||
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
|
||||
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
|
||||
</figure>
|
||||
|
||||
<footer>
|
||||
<p><strong>Just Fucking Use OpenPanel</strong></p>
|
||||
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
|
||||
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
||||
window.op('init', {
|
||||
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 47 KiB |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"name": "justfuckinguseopenpanel",
|
||||
"compatibility_date": "2025-12-19",
|
||||
"assets": {
|
||||
"directory": "."
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
---
|
||||
title: Nuxt
|
||||
---
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
import { DeviceIdWarning } from '@/components/device-id-warning';
|
||||
import { PersonalDataWarning } from '@/components/personal-data-warning';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
|
||||
import WebSdkConfig from '@/components/web-sdk-config.mdx';
|
||||
|
||||
<Callout>
|
||||
Looking for a step-by-step tutorial? Check out the [Nuxt analytics guide](/guides/nuxt-analytics).
|
||||
</Callout>
|
||||
|
||||
## Good to know
|
||||
|
||||
Keep in mind that all tracking here happens on the client!
|
||||
|
||||
Read more about server side tracking in the [Server Side Tracking](#track-server-events) section.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
pnpm install @openpanel/nuxt
|
||||
```
|
||||
|
||||
### Initialize
|
||||
|
||||
Add the module to your `nuxt.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@openpanel/nuxt'],
|
||||
openpanel: {
|
||||
clientId: 'your-client-id',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
<CommonSdkConfig />
|
||||
<WebSdkConfig />
|
||||
|
||||
##### Nuxt options
|
||||
|
||||
- `clientId` - Your OpenPanel client ID (required)
|
||||
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
|
||||
- `trackScreenViews` - Automatically track screen views (default: `true`)
|
||||
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
|
||||
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
|
||||
- `trackHashChanges` - Track hash changes in URL (default: `false`)
|
||||
- `disabled` - Disable tracking (default: `false`)
|
||||
- `proxy` - Enable server-side proxy to avoid adblockers (default: `false`)
|
||||
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
### Using the composable
|
||||
|
||||
The `useOpenPanel` composable is auto-imported, so you can use it directly in any component:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const op = useOpenPanel(); // Auto-imported!
|
||||
|
||||
function handleClick() {
|
||||
op.track('button_click', { button: 'signup' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="handleClick">Trigger event</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Accessing via useNuxtApp
|
||||
|
||||
You can also access the OpenPanel instance directly via `useNuxtApp()`:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const { $openpanel } = useNuxtApp();
|
||||
|
||||
$openpanel.track('my_event', { foo: 'bar' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### Tracking Events
|
||||
|
||||
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### Identifying Users
|
||||
|
||||
To identify a user, call the `op.identify()` method with a unique identifier.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
|
||||
op.identify({
|
||||
profileId: '123', // Required
|
||||
firstName: 'Joe',
|
||||
lastName: 'Doe',
|
||||
email: 'joe@doe.com',
|
||||
properties: {
|
||||
tier: 'premium',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Setting Global Properties
|
||||
|
||||
To set properties that will be sent with every event:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
|
||||
op.setGlobalProperties({
|
||||
app_version: '1.0.2',
|
||||
environment: 'production',
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Incrementing Properties
|
||||
|
||||
To increment a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
|
||||
op.increment({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1, // optional
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Decrementing Properties
|
||||
|
||||
To decrement a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
|
||||
op.decrement({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1, // optional
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
|
||||
op.clear();
|
||||
</script>
|
||||
```
|
||||
|
||||
## Server side
|
||||
|
||||
If you want to track server-side events, you should create an instance of our Javascript SDK. Import `OpenPanel` from `@openpanel/sdk`
|
||||
|
||||
<Callout>
|
||||
When using server events it's important that you use a secret to authenticate the request. This is to prevent unauthorized requests since we cannot use cors headers.
|
||||
|
||||
You can use the same clientId but you should pass the associated client secret to the SDK.
|
||||
|
||||
</Callout>
|
||||
|
||||
```typescript
|
||||
import { OpenPanel } from '@openpanel/sdk';
|
||||
|
||||
const opServer = new OpenPanel({
|
||||
clientId: '{YOUR_CLIENT_ID}',
|
||||
clientSecret: '{YOUR_CLIENT_SECRET}',
|
||||
});
|
||||
|
||||
opServer.track('my_server_event', { ok: '✅' });
|
||||
|
||||
// Pass `profileId` to track events for a specific user
|
||||
opServer.track('my_server_event', { profileId: '123', ok: '✅' });
|
||||
```
|
||||
|
||||
### Serverless & Edge Functions
|
||||
|
||||
If you log events in a serverless environment, make sure to await the event call to ensure it completes before the function terminates.
|
||||
|
||||
```typescript
|
||||
import { OpenPanel } from '@openpanel/sdk';
|
||||
|
||||
const opServer = new OpenPanel({
|
||||
clientId: '{YOUR_CLIENT_ID}',
|
||||
clientSecret: '{YOUR_CLIENT_SECRET}',
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Await to ensure event is logged before function completes
|
||||
await opServer.track('my_server_event', { foo: 'bar' });
|
||||
return { message: 'Event logged!' };
|
||||
});
|
||||
```
|
||||
|
||||
### Proxy events
|
||||
|
||||
With the `proxy` option enabled, you can proxy your events through your server, which ensures all events are tracked since many adblockers block requests to third-party domains.
|
||||
|
||||
```typescript title="nuxt.config.ts"
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@openpanel/nuxt'],
|
||||
openpanel: {
|
||||
clientId: 'your-client-id',
|
||||
proxy: true, // Enables proxy at /api/openpanel/*
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
When `proxy: true` is set:
|
||||
- The module automatically sets `apiUrl` to `/api/openpanel`
|
||||
- A server handler is registered at `/api/openpanel/**`
|
||||
- All tracking requests route through your server
|
||||
|
||||
This helps bypass adblockers that might block requests to `api.openpanel.dev`.
|
||||
@@ -2,244 +2,4 @@
|
||||
title: React
|
||||
---
|
||||
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
import { PersonalDataWarning } from '@/components/personal-data-warning';
|
||||
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
|
||||
import WebSdkConfig from '@/components/web-sdk-config.mdx';
|
||||
|
||||
## Good to know
|
||||
|
||||
Keep in mind that all tracking here happens on the client!
|
||||
|
||||
For React SPAs, you can use `@openpanel/web` directly - no need for a separate React SDK. Simply create an OpenPanel instance and use it throughout your application.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
### Step 1: Install
|
||||
|
||||
```bash
|
||||
npm install @openpanel/web
|
||||
```
|
||||
|
||||
### Step 2: Initialize
|
||||
|
||||
Create a shared OpenPanel instance in your project:
|
||||
|
||||
```ts title="src/openpanel.ts"
|
||||
import { OpenPanel } from '@openpanel/web';
|
||||
|
||||
export const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
<CommonSdkConfig />
|
||||
<WebSdkConfig />
|
||||
|
||||
- `clientId` - Your OpenPanel client ID (required)
|
||||
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
|
||||
- `trackScreenViews` - Automatically track screen views (default: `true`)
|
||||
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
|
||||
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
|
||||
- `trackHashChanges` - Track hash changes in URL (default: `false`)
|
||||
- `disabled` - Disable tracking (default: `false`)
|
||||
|
||||
### Step 3: Usage
|
||||
|
||||
Import and use the instance in your React components:
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function MyComponent() {
|
||||
const handleClick = () => {
|
||||
op.track('button_click', { button: 'signup' });
|
||||
};
|
||||
|
||||
return <button onClick={handleClick}>Trigger event</button>;
|
||||
}
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
### Tracking Events
|
||||
|
||||
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function MyComponent() {
|
||||
useEffect(() => {
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
}, []);
|
||||
|
||||
return <div>My Component</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Identifying Users
|
||||
|
||||
To identify a user, call the `op.identify()` method with a unique identifier.
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function LoginComponent() {
|
||||
const handleLogin = (user: User) => {
|
||||
op.identify({
|
||||
profileId: user.id, // Required
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
properties: {
|
||||
tier: 'premium',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return <button onClick={() => handleLogin(user)}>Login</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Setting Global Properties
|
||||
|
||||
To set properties that will be sent with every event:
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
op.setGlobalProperties({
|
||||
app_version: '1.0.2',
|
||||
environment: 'production',
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <div>App</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Incrementing Properties
|
||||
|
||||
To increment a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function MyComponent() {
|
||||
const handleAction = () => {
|
||||
op.increment({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1, // optional
|
||||
});
|
||||
};
|
||||
|
||||
return <button onClick={handleAction}>Increment</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Decrementing Properties
|
||||
|
||||
To decrement a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function MyComponent() {
|
||||
const handleAction = () => {
|
||||
op.decrement({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1, // optional
|
||||
});
|
||||
};
|
||||
|
||||
return <button onClick={handleAction}>Decrement</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function LogoutComponent() {
|
||||
const handleLogout = () => {
|
||||
op.clear();
|
||||
// ... logout logic
|
||||
};
|
||||
|
||||
return <button onClick={handleLogout}>Logout</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Revenue Tracking
|
||||
|
||||
Track revenue events:
|
||||
|
||||
```tsx
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function CheckoutComponent() {
|
||||
const handlePurchase = async () => {
|
||||
// Track revenue immediately
|
||||
await op.revenue(29.99, { currency: 'USD' });
|
||||
|
||||
// Or accumulate revenue and flush later
|
||||
op.pendingRevenue(29.99, { currency: 'USD' });
|
||||
op.pendingRevenue(19.99, { currency: 'USD' });
|
||||
await op.flushRevenue(); // Sends both revenue events
|
||||
|
||||
// Clear pending revenue
|
||||
op.clearRevenue();
|
||||
};
|
||||
|
||||
return <button onClick={handlePurchase}>Purchase</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Optional: Create a Hook
|
||||
|
||||
If you prefer using a React hook pattern, you can create your own wrapper:
|
||||
|
||||
```ts title="src/hooks/useOpenPanel.ts"
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
export function useOpenPanel() {
|
||||
return op;
|
||||
}
|
||||
```
|
||||
|
||||
Then use it in your components:
|
||||
|
||||
```tsx
|
||||
import { useOpenPanel } from '@/hooks/useOpenPanel';
|
||||
|
||||
function MyComponent() {
|
||||
const op = useOpenPanel();
|
||||
|
||||
useEffect(() => {
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
}, []);
|
||||
|
||||
return <div>My Component</div>;
|
||||
}
|
||||
```
|
||||
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated react sdk soon.
|
||||
|
||||
@@ -2,219 +2,4 @@
|
||||
title: Vue
|
||||
---
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
|
||||
import { DeviceIdWarning } from '@/components/device-id-warning';
|
||||
import { PersonalDataWarning } from '@/components/personal-data-warning';
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import CommonSdkConfig from '@/components/common-sdk-config.mdx';
|
||||
import WebSdkConfig from '@/components/web-sdk-config.mdx';
|
||||
|
||||
<Callout>
|
||||
Looking for a step-by-step tutorial? Check out the [Vue analytics guide](/guides/vue-analytics).
|
||||
</Callout>
|
||||
|
||||
## Good to know
|
||||
|
||||
Keep in mind that all tracking here happens on the client!
|
||||
|
||||
For Vue SPAs, you can use `@openpanel/web` directly - no need for a separate Vue SDK. Simply create an OpenPanel instance and use it throughout your application.
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
### Step 1: Install
|
||||
|
||||
```bash
|
||||
pnpm install @openpanel/web
|
||||
```
|
||||
|
||||
### Step 2: Initialize
|
||||
|
||||
Create a shared OpenPanel instance in your project:
|
||||
|
||||
```ts title="src/openpanel.ts"
|
||||
import { OpenPanel } from '@openpanel/web';
|
||||
|
||||
export const op = new OpenPanel({
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
<CommonSdkConfig />
|
||||
<WebSdkConfig />
|
||||
|
||||
- `clientId` - Your OpenPanel client ID (required)
|
||||
- `apiUrl` - The API URL to send events to (default: `https://api.openpanel.dev`)
|
||||
- `trackScreenViews` - Automatically track screen views (default: `true`)
|
||||
- `trackOutgoingLinks` - Automatically track outgoing links (default: `true`)
|
||||
- `trackAttributes` - Automatically track elements with `data-track` attributes (default: `true`)
|
||||
- `trackHashChanges` - Track hash changes in URL (default: `false`)
|
||||
- `disabled` - Disable tracking (default: `false`)
|
||||
|
||||
### Step 3: Usage
|
||||
|
||||
Import and use the instance in your Vue components:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
function handleClick() {
|
||||
op.track('button_click', { button: 'signup' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="handleClick">Trigger event</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
### Tracking Events
|
||||
|
||||
You can track events with two different methods: by calling the `op.track()` method directly or by adding `data-track` attributes to your HTML elements.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### Identifying Users
|
||||
|
||||
To identify a user, call the `op.identify()` method with a unique identifier.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
op.identify({
|
||||
profileId: '123', // Required
|
||||
firstName: 'Joe',
|
||||
lastName: 'Doe',
|
||||
email: 'joe@doe.com',
|
||||
properties: {
|
||||
tier: 'premium',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Setting Global Properties
|
||||
|
||||
To set properties that will be sent with every event:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
op.setGlobalProperties({
|
||||
app_version: '1.0.2',
|
||||
environment: 'production',
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Incrementing Properties
|
||||
|
||||
To increment a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to increment the property by. If not provided, the property will be incremented by 1.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
op.increment({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1, // optional
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Decrementing Properties
|
||||
|
||||
To decrement a numeric property on a user profile.
|
||||
|
||||
- `value` is the amount to decrement the property by. If not provided, the property will be decremented by 1.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
op.decrement({
|
||||
profileId: '1',
|
||||
property: 'visits',
|
||||
value: 1, // optional
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Clearing User Data
|
||||
|
||||
To clear the current user's data:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
op.clear();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Revenue Tracking
|
||||
|
||||
Track revenue events:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
// Track revenue immediately
|
||||
await op.revenue(29.99, { currency: 'USD' });
|
||||
|
||||
// Or accumulate revenue and flush later
|
||||
op.pendingRevenue(29.99, { currency: 'USD' });
|
||||
op.pendingRevenue(19.99, { currency: 'USD' });
|
||||
await op.flushRevenue(); // Sends both revenue events
|
||||
|
||||
// Clear pending revenue
|
||||
op.clearRevenue();
|
||||
</script>
|
||||
```
|
||||
|
||||
### Optional: Create a Composable
|
||||
|
||||
If you prefer using a composable pattern, you can create your own wrapper:
|
||||
|
||||
```ts title="src/composables/useOpenPanel.ts"
|
||||
import { op } from '@/openpanel';
|
||||
|
||||
export function useOpenPanel() {
|
||||
return op;
|
||||
}
|
||||
```
|
||||
|
||||
Then use it in your components:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useOpenPanel } from '@/composables/useOpenPanel';
|
||||
|
||||
const op = useOpenPanel();
|
||||
op.track('my_event', { foo: 'bar' });
|
||||
</script>
|
||||
```
|
||||
Use [script tag](/docs/sdks/script) or [Web SDK](/docs/sdks/web) for now. We'll add a dedicated vue sdk soon.
|
||||
|
||||
@@ -7,12 +7,11 @@ description: Learn how to authenticate with the OpenPanel API using client crede
|
||||
|
||||
To authenticate with the OpenPanel API, you need to use your `clientId` and `clientSecret`. Different API endpoints may require different access levels:
|
||||
|
||||
- **Track API**: Default client works with `write` mode
|
||||
- **Track API**: Default client works with `track` mode
|
||||
- **Export API**: Requires `read` or `root` mode
|
||||
- **Insights API**: Requires `read` or `root` mode
|
||||
- **Manage API**: Requires `root` mode only
|
||||
|
||||
The default client (created with a project) has `write` mode and does not have access to the Export, Insights, or Manage APIs. You'll need to create additional clients with appropriate access levels.
|
||||
The default client does not have access to the Export or Insights APIs.
|
||||
|
||||
## Headers
|
||||
|
||||
@@ -49,29 +48,15 @@ If authentication fails, you'll receive a `401 Unauthorized` response:
|
||||
|
||||
Common authentication errors:
|
||||
- Invalid client ID or secret
|
||||
- Client doesn't have required permissions (e.g., trying to access Manage API with a non-root client)
|
||||
- Malformed client ID (must be a valid UUIDv4)
|
||||
- Client type mismatch (e.g., `write` client trying to access Export API)
|
||||
|
||||
## Client Types
|
||||
|
||||
OpenPanel supports three client types with different access levels:
|
||||
|
||||
| Type | Description | Access |
|
||||
|------|-------------|--------|
|
||||
| `write` | Write access | Track API only |
|
||||
| `read` | Read-only access | Export API, Insights API |
|
||||
| `root` | Full access | All APIs including Manage API |
|
||||
|
||||
**Note**: Root clients have organization-wide access and can manage all resources. Use root clients carefully and store their credentials securely.
|
||||
- Client doesn't have required permissions
|
||||
- Malformed client ID
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The API implements rate limiting to prevent abuse. Rate limits vary by endpoint:
|
||||
|
||||
- **Track API**: Higher limits for event tracking
|
||||
- **Export/Insights APIs**: 100 requests per 10 seconds
|
||||
- **Manage API**: 20 requests per 10 seconds
|
||||
- **Export/Insights APIs**: Lower limits for data retrieval
|
||||
|
||||
If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. Implement exponential backoff for retries.
|
||||
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
---
|
||||
title: Clients
|
||||
description: Manage API clients for your OpenPanel projects. Create, read, update, and delete clients with different access levels.
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate with the Clients API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
|
||||
|
||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
||||
|
||||
Include the following headers with your requests:
|
||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
||||
|
||||
## Base URL
|
||||
|
||||
All Clients API requests should be made to:
|
||||
|
||||
```
|
||||
https://api.openpanel.dev/manage/clients
|
||||
```
|
||||
|
||||
## Client Types
|
||||
|
||||
OpenPanel supports three client types with different access levels:
|
||||
|
||||
| Type | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| `read` | Read-only access | Export data, view insights, read-only operations |
|
||||
| `write` | Write access | Track events, send data to OpenPanel |
|
||||
| `root` | Full access | Manage resources, access Manage API |
|
||||
|
||||
**Note**: Only `root` clients can access the Manage API.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List Clients
|
||||
|
||||
Retrieve all clients in your organization, optionally filtered by project.
|
||||
|
||||
```
|
||||
GET /manage/clients
|
||||
```
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `projectId` | string | Optional. Filter clients by project ID |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
# List all clients
|
||||
curl 'https://api.openpanel.dev/manage/clients' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
|
||||
# List clients for a specific project
|
||||
curl 'https://api.openpanel.dev/manage/clients?projectId=my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
|
||||
"name": "First client",
|
||||
"type": "write",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
|
||||
"name": "Read-only Client",
|
||||
"type": "read",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T11:00:00.000Z",
|
||||
"updatedAt": "2024-01-15T11:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Client secrets are never returned in list or get responses for security reasons.
|
||||
|
||||
### Get Client
|
||||
|
||||
Retrieve a specific client by ID.
|
||||
|
||||
```
|
||||
GET /manage/clients/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the client (UUID) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/clients/fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
|
||||
"name": "First client",
|
||||
"type": "write",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Client
|
||||
|
||||
Create a new API client. A secure secret is automatically generated and returned once.
|
||||
|
||||
```
|
||||
POST /manage/clients
|
||||
```
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Client name (minimum 1 character) |
|
||||
| `projectId` | string | No | Associate client with a specific project |
|
||||
| `type` | string | No | Client type: `read`, `write`, or `root` (default: `write`) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.openpanel.dev/manage/clients' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "My API Client",
|
||||
"projectId": "my-project",
|
||||
"type": "read"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
|
||||
"name": "My API Client",
|
||||
"type": "read",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T11:00:00.000Z",
|
||||
"updatedAt": "2024-01-15T11:00:00.000Z",
|
||||
"secret": "sec_b2521ca283bf903b46b3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The `secret` field is only returned once when the client is created. Store it securely immediately. You cannot retrieve the secret later - if lost, you'll need to delete and recreate the client.
|
||||
|
||||
### Update Client
|
||||
|
||||
Update an existing client's name.
|
||||
|
||||
```
|
||||
PATCH /manage/clients/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the client (UUID) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | No | New client name (minimum 1 character) |
|
||||
|
||||
**Note**: Currently, only the `name` field can be updated. To change the client type or project association, delete and recreate the client.
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "Updated Client Name"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
|
||||
"name": "Updated Client Name",
|
||||
"type": "read",
|
||||
"projectId": "my-project",
|
||||
"organizationId": "org_123",
|
||||
"ignoreCorsAndSecret": false,
|
||||
"createdAt": "2024-01-15T11:00:00.000Z",
|
||||
"updatedAt": "2024-01-15T11:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Client
|
||||
|
||||
Permanently delete a client. This action cannot be undone.
|
||||
|
||||
```
|
||||
DELETE /manage/clients/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the client (UUID) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X DELETE 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Warning**: Deleting a client is permanent. Any applications using this client will immediately lose access. Make sure to update your applications before deleting a client.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API uses standard HTTP response codes. Common error responses:
|
||||
|
||||
### 400 Bad Request
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": "Invalid request body",
|
||||
"details": [
|
||||
{
|
||||
"path": ["name"],
|
||||
"message": "String must contain at least 1 character(s)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Manage: Only root clients are allowed to manage resources"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Not Found",
|
||||
"message": "Client not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 429 Too Many Requests
|
||||
|
||||
Rate limiting response includes headers indicating your rate limit status.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The Clients API implements rate limiting:
|
||||
- **20 requests per 10 seconds** per client
|
||||
- Rate limit headers included in responses
|
||||
- Implement exponential backoff for retries
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Store Secrets Securely**: Client secrets are only shown once on creation. Store them in secure credential management systems
|
||||
2. **Use Appropriate Client Types**: Use the minimum required access level for each use case
|
||||
3. **Rotate Secrets Regularly**: Delete old clients and create new ones to rotate secrets
|
||||
4. **Never Expose Secrets**: Never commit client secrets to version control or expose them in client-side code
|
||||
5. **Monitor Client Usage**: Regularly review and remove unused clients
|
||||
|
||||
## Notes
|
||||
|
||||
- Client IDs are UUIDs (Universally Unique Identifiers)
|
||||
- Client secrets are automatically generated with the format `sec_` followed by random hex characters
|
||||
- Secrets are hashed using argon2 before storage
|
||||
- Clients can be associated with a project or exist at the organization level
|
||||
- Clients are scoped to your organization - you can only manage clients in your organization
|
||||
- The `ignoreCorsAndSecret` field is an advanced setting that bypasses CORS and secret validation (use with caution)
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
title: Manage API Overview
|
||||
description: Programmatically manage projects, clients, and references in your OpenPanel organization using the Manage API.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Manage API provides programmatic access to manage your OpenPanel resources including projects, clients, and references. This API is designed for automation, infrastructure-as-code, and administrative tasks.
|
||||
|
||||
## Authentication
|
||||
|
||||
The Manage API requires a **root client** for authentication. Root clients have organization-wide access and can manage all resources within their organization.
|
||||
|
||||
To authenticate with the Manage API, you need:
|
||||
- A client with `type: 'root'`
|
||||
- Your `clientId` and `clientSecret`
|
||||
|
||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
||||
|
||||
Include the following headers with your requests:
|
||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
||||
|
||||
## Base URL
|
||||
|
||||
All Manage API requests should be made to:
|
||||
|
||||
```
|
||||
https://api.openpanel.dev/manage
|
||||
```
|
||||
|
||||
## Available Resources
|
||||
|
||||
The Manage API provides CRUD operations for three resource types:
|
||||
|
||||
### Projects
|
||||
|
||||
Manage your analytics projects programmatically:
|
||||
- **[Projects Documentation](/docs/api/manage/projects)** - Create, read, update, and delete projects
|
||||
- Automatically creates a default write client when creating a project
|
||||
- Supports project configuration including domains, CORS settings, and project types
|
||||
|
||||
### Clients
|
||||
|
||||
Manage API clients for your projects:
|
||||
- **[Clients Documentation](/docs/api/manage/clients)** - Create, read, update, and delete clients
|
||||
- Supports different client types: `read`, `write`, and `root`
|
||||
- Auto-generates secure secrets on creation (returned once)
|
||||
|
||||
### References
|
||||
|
||||
Manage reference points for your analytics:
|
||||
- **[References Documentation](/docs/api/manage/references)** - Create, read, update, and delete references
|
||||
- Useful for marking important dates or events in your analytics timeline
|
||||
- Can be filtered by project
|
||||
|
||||
## Common Features
|
||||
|
||||
All endpoints share these common characteristics:
|
||||
|
||||
### Organization Scope
|
||||
|
||||
All operations are scoped to your organization. You can only manage resources that belong to your organization.
|
||||
|
||||
### Response Format
|
||||
|
||||
Successful responses follow this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
// Resource data
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For list endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
// Array of resources
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The API uses standard HTTP response codes:
|
||||
|
||||
- `200 OK` - Request successful
|
||||
- `400 Bad Request` - Invalid request parameters
|
||||
- `401 Unauthorized` - Authentication failed
|
||||
- `404 Not Found` - Resource not found
|
||||
- `429 Too Many Requests` - Rate limit exceeded
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The Manage API implements rate limiting:
|
||||
- **20 requests per 10 seconds** per client
|
||||
- Rate limit headers included in responses
|
||||
- Implement exponential backoff for retries
|
||||
|
||||
## Use Cases
|
||||
|
||||
The Manage API is ideal for:
|
||||
|
||||
- **Infrastructure as Code**: Manage OpenPanel resources alongside your application infrastructure
|
||||
- **Automation**: Automatically create projects and clients for new deployments
|
||||
- **Bulk Operations**: Programmatically manage multiple resources
|
||||
- **CI/CD Integration**: Set up projects and clients as part of your deployment pipeline
|
||||
- **Administrative Tools**: Build custom admin interfaces
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Root Clients Only**: Only root clients can access the Manage API
|
||||
2. **Store Credentials Securely**: Never expose root client credentials in client-side code
|
||||
3. **Use HTTPS**: Always use HTTPS for API requests
|
||||
4. **Rotate Credentials**: Regularly rotate your root client credentials
|
||||
5. **Limit Access**: Restrict root client creation to trusted administrators
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Create a Root Client**: Use the dashboard to create a root client in your organization
|
||||
2. **Store Credentials**: Securely store your root client ID and secret
|
||||
3. **Make Your First Request**: Start with listing projects to verify authentication
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/projects' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read the [Projects documentation](/docs/api/manage/projects) to manage projects
|
||||
- Read the [Clients documentation](/docs/api/manage/clients) to manage API clients
|
||||
- Read the [References documentation](/docs/api/manage/references) to manage reference points
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"title": "Manage",
|
||||
"pages": ["projects", "clients", "references"],
|
||||
"defaultOpen": false
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
---
|
||||
title: Projects
|
||||
description: Manage your OpenPanel projects programmatically. Create, read, update, and delete projects using the Manage API.
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate with the Projects API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
|
||||
|
||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
||||
|
||||
Include the following headers with your requests:
|
||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
||||
|
||||
## Base URL
|
||||
|
||||
All Projects API requests should be made to:
|
||||
|
||||
```
|
||||
https://api.openpanel.dev/manage/projects
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List Projects
|
||||
|
||||
Retrieve all projects in your organization.
|
||||
|
||||
```
|
||||
GET /manage/projects
|
||||
```
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/projects' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "my-project",
|
||||
"name": "My Project",
|
||||
"organizationId": "org_123",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com", "https://www.example.com"],
|
||||
"crossDomain": false,
|
||||
"allowUnsafeRevenueTracking": false,
|
||||
"filters": [],
|
||||
"types": ["website"],
|
||||
"eventsCount": 0,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z",
|
||||
"deleteAt": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get Project
|
||||
|
||||
Retrieve a specific project by ID.
|
||||
|
||||
```
|
||||
GET /manage/projects/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the project |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/projects/my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "my-project",
|
||||
"name": "My Project",
|
||||
"organizationId": "org_123",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com"],
|
||||
"crossDomain": false,
|
||||
"allowUnsafeRevenueTracking": false,
|
||||
"filters": [],
|
||||
"types": ["website"],
|
||||
"eventsCount": 0,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z",
|
||||
"deleteAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Project
|
||||
|
||||
Create a new project in your organization. A default write client is automatically created with the project.
|
||||
|
||||
```
|
||||
POST /manage/projects
|
||||
```
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Project name (minimum 1 character) |
|
||||
| `domain` | string \| null | No | Primary domain for the project (URL format or empty string) |
|
||||
| `cors` | string[] | No | Array of allowed CORS origins (default: `[]`) |
|
||||
| `crossDomain` | boolean | No | Enable cross-domain tracking (default: `false`) |
|
||||
| `types` | string[] | No | Project types: `website`, `app`, `backend` (default: `[]`) |
|
||||
|
||||
#### Project Types
|
||||
|
||||
- `website`: Web-based project
|
||||
- `app`: Mobile application
|
||||
- `backend`: Backend/server-side project
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.openpanel.dev/manage/projects' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "My New Project",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com", "https://www.example.com"],
|
||||
"crossDomain": false,
|
||||
"types": ["website"]
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "my-new-project",
|
||||
"name": "My New Project",
|
||||
"organizationId": "org_123",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com", "https://www.example.com"],
|
||||
"crossDomain": false,
|
||||
"allowUnsafeRevenueTracking": false,
|
||||
"filters": [],
|
||||
"types": ["website"],
|
||||
"eventsCount": 0,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T10:30:00.000Z",
|
||||
"deleteAt": null,
|
||||
"client": {
|
||||
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
|
||||
"secret": "sec_6c8ae85a092d6c66b242"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The `client.secret` is only returned once when the project is created. Store it securely immediately.
|
||||
|
||||
### Update Project
|
||||
|
||||
Update an existing project's configuration.
|
||||
|
||||
```
|
||||
PATCH /manage/projects/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the project |
|
||||
|
||||
#### Request Body
|
||||
|
||||
All fields are optional. Only include fields you want to update.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `name` | string | Project name (minimum 1 character) |
|
||||
| `domain` | string \| null | Primary domain (URL format, empty string, or null) |
|
||||
| `cors` | string[] | Array of allowed CORS origins |
|
||||
| `crossDomain` | boolean | Enable cross-domain tracking |
|
||||
| `allowUnsafeRevenueTracking` | boolean | Allow revenue tracking without client secret |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'https://api.openpanel.dev/manage/projects/my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "Updated Project Name",
|
||||
"crossDomain": true,
|
||||
"allowUnsafeRevenueTracking": false
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "my-project",
|
||||
"name": "Updated Project Name",
|
||||
"organizationId": "org_123",
|
||||
"domain": "https://example.com",
|
||||
"cors": ["https://example.com"],
|
||||
"crossDomain": true,
|
||||
"allowUnsafeRevenueTracking": false,
|
||||
"filters": [],
|
||||
"types": ["website"],
|
||||
"eventsCount": 0,
|
||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
||||
"updatedAt": "2024-01-15T11:00:00.000Z",
|
||||
"deleteAt": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Project
|
||||
|
||||
Soft delete a project. The project will be scheduled for deletion after 24 hours.
|
||||
|
||||
```
|
||||
DELETE /manage/projects/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the project |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X DELETE 'https://api.openpanel.dev/manage/projects/my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Projects are soft-deleted. The `deleteAt` field is set to 24 hours in the future. You can cancel deletion by updating the project before the deletion time.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API uses standard HTTP response codes. Common error responses:
|
||||
|
||||
### 400 Bad Request
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": "Invalid request body",
|
||||
"details": [
|
||||
{
|
||||
"path": ["name"],
|
||||
"message": "String must contain at least 1 character(s)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Manage: Only root clients are allowed to manage resources"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Not Found",
|
||||
"message": "Project not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 429 Too Many Requests
|
||||
|
||||
Rate limiting response includes headers indicating your rate limit status.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The Projects API implements rate limiting:
|
||||
- **20 requests per 10 seconds** per client
|
||||
- Rate limit headers included in responses
|
||||
- Implement exponential backoff for retries
|
||||
|
||||
## Notes
|
||||
|
||||
- Project IDs are automatically generated from the project name using a slug format
|
||||
- If a project ID already exists, a numeric suffix is added
|
||||
- CORS domains are automatically normalized (trailing slashes removed)
|
||||
- The default client created with a project has `type: 'write'`
|
||||
- Projects are scoped to your organization - you can only manage projects in your organization
|
||||
- Soft-deleted projects are excluded from list endpoints
|
||||
@@ -1,344 +0,0 @@
|
||||
---
|
||||
title: References
|
||||
description: Manage reference points for your OpenPanel projects. References are useful for marking important dates or events in your analytics timeline.
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate with the References API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
|
||||
|
||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
||||
|
||||
Include the following headers with your requests:
|
||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
||||
|
||||
## Base URL
|
||||
|
||||
All References API requests should be made to:
|
||||
|
||||
```
|
||||
https://api.openpanel.dev/manage/references
|
||||
```
|
||||
|
||||
## What are References?
|
||||
|
||||
References are markers you can add to your analytics timeline to track important events such as:
|
||||
- Product launches
|
||||
- Marketing campaign start dates
|
||||
- Feature releases
|
||||
- Website redesigns
|
||||
- Major announcements
|
||||
|
||||
References appear in your analytics charts and help you correlate changes in metrics with specific events.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List References
|
||||
|
||||
Retrieve all references in your organization, optionally filtered by project.
|
||||
|
||||
```
|
||||
GET /manage/references
|
||||
```
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `projectId` | string | Optional. Filter references by project ID |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
# List all references
|
||||
curl 'https://api.openpanel.dev/manage/references' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
|
||||
# List references for a specific project
|
||||
curl 'https://api.openpanel.dev/manage/references?projectId=my-project' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
||||
"title": "Product Launch",
|
||||
"description": "Version 2.0 released",
|
||||
"date": "2024-01-15T10:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
||||
"updatedAt": "2024-01-10T08:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "2bf19738-3ee8-4c48-af6d-7ggb8f561f96",
|
||||
"title": "Marketing Campaign Start",
|
||||
"description": "Q1 2024 campaign launched",
|
||||
"date": "2024-01-20T09:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-18T10:00:00.000Z",
|
||||
"updatedAt": "2024-01-18T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Get Reference
|
||||
|
||||
Retrieve a specific reference by ID.
|
||||
|
||||
```
|
||||
GET /manage/references/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the reference (UUID) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
||||
"title": "Product Launch",
|
||||
"description": "Version 2.0 released",
|
||||
"date": "2024-01-15T10:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
||||
"updatedAt": "2024-01-10T08:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Reference
|
||||
|
||||
Create a new reference point for a project.
|
||||
|
||||
```
|
||||
POST /manage/references
|
||||
```
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `projectId` | string | Yes | The ID of the project this reference belongs to |
|
||||
| `title` | string | Yes | Reference title (minimum 1 character) |
|
||||
| `description` | string | No | Optional description or notes |
|
||||
| `datetime` | string | Yes | Date and time for the reference (ISO 8601 format) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X POST 'https://api.openpanel.dev/manage/references' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"projectId": "my-project",
|
||||
"title": "Product Launch",
|
||||
"description": "Version 2.0 released with new features",
|
||||
"datetime": "2024-01-15T10:00:00.000Z"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
||||
"title": "Product Launch",
|
||||
"description": "Version 2.0 released with new features",
|
||||
"date": "2024-01-15T10:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
||||
"updatedAt": "2024-01-10T08:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `date` field in the response is parsed from the `datetime` string you provided.
|
||||
|
||||
### Update Reference
|
||||
|
||||
Update an existing reference.
|
||||
|
||||
```
|
||||
PATCH /manage/references/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the reference (UUID) |
|
||||
|
||||
#### Request Body
|
||||
|
||||
All fields are optional. Only include fields you want to update.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `title` | string | Reference title (minimum 1 character) |
|
||||
| `description` | string \| null | Description or notes (set to `null` to clear) |
|
||||
| `datetime` | string | Date and time for the reference (ISO 8601 format) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X PATCH 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"title": "Product Launch v2.1",
|
||||
"description": "Updated: Version 2.1 released with bug fixes",
|
||||
"datetime": "2024-01-15T10:00:00.000Z"
|
||||
}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
||||
"title": "Product Launch v2.1",
|
||||
"description": "Updated: Version 2.1 released with bug fixes",
|
||||
"date": "2024-01-15T10:00:00.000Z",
|
||||
"projectId": "my-project",
|
||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
||||
"updatedAt": "2024-01-10T09:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Reference
|
||||
|
||||
Permanently delete a reference. This action cannot be undone.
|
||||
|
||||
```
|
||||
DELETE /manage/references/{id}
|
||||
```
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `id` | string | The ID of the reference (UUID) |
|
||||
|
||||
#### Example Request
|
||||
|
||||
```bash
|
||||
curl -X DELETE 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
|
||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API uses standard HTTP response codes. Common error responses:
|
||||
|
||||
### 400 Bad Request
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Bad Request",
|
||||
"message": "Invalid request body",
|
||||
"details": [
|
||||
{
|
||||
"path": ["title"],
|
||||
"message": "String must contain at least 1 character(s)"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"message": "Manage: Only root clients are allowed to manage resources"
|
||||
}
|
||||
```
|
||||
|
||||
### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Not Found",
|
||||
"message": "Reference not found"
|
||||
}
|
||||
```
|
||||
|
||||
This error can occur if:
|
||||
- The reference ID doesn't exist
|
||||
- The reference belongs to a different organization
|
||||
|
||||
### 429 Too Many Requests
|
||||
|
||||
Rate limiting response includes headers indicating your rate limit status.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The References API implements rate limiting:
|
||||
- **20 requests per 10 seconds** per client
|
||||
- Rate limit headers included in responses
|
||||
- Implement exponential backoff for retries
|
||||
|
||||
## Date Format
|
||||
|
||||
References use ISO 8601 date format. Examples:
|
||||
|
||||
- `2024-01-15T10:00:00.000Z` - UTC timezone
|
||||
- `2024-01-15T10:00:00-05:00` - Eastern Time (UTC-5)
|
||||
- `2024-01-15` - Date only (time defaults to 00:00:00)
|
||||
|
||||
The `datetime` field in requests is converted to a `date` field in responses, stored as a timestamp.
|
||||
|
||||
## Use Cases
|
||||
|
||||
References are useful for:
|
||||
|
||||
- **Product Launches**: Mark when new versions or features are released
|
||||
- **Marketing Campaigns**: Track campaign start and end dates
|
||||
- **Website Changes**: Note when major redesigns or updates occur
|
||||
- **Business Events**: Record important business milestones
|
||||
- **A/B Testing**: Mark when experiments start or end
|
||||
- **Seasonal Events**: Track holidays, sales periods, or seasonal changes
|
||||
|
||||
## Notes
|
||||
|
||||
- Reference IDs are UUIDs (Universally Unique Identifiers)
|
||||
- References are scoped to projects - each reference belongs to a specific project
|
||||
- References are scoped to your organization - you can only manage references for projects in your organization
|
||||
- The `description` field is optional and can be set to `null` to clear it
|
||||
- References appear in analytics charts to help correlate metrics with events
|
||||
- When filtering by `projectId`, the project must exist and belong to your organization
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "API",
|
||||
"pages": ["track", "export", "insights", "manage"]
|
||||
"pages": ["track", "export", "insights"]
|
||||
}
|
||||
|
||||
@@ -175,48 +175,6 @@ COOKIE_SECRET=your-random-secret-here
|
||||
Never use the default value in production! Always generate a unique secret.
|
||||
</Callout>
|
||||
|
||||
### COOKIE_TLDS
|
||||
|
||||
**Type**: `string` (comma-separated)
|
||||
**Required**: No
|
||||
**Default**: None
|
||||
|
||||
Custom multi-part TLDs for cookie domain handling. Use this when deploying on domains with public suffixes that aren't recognized by default (e.g., `.my.id`, `.web.id`, `.co.id`).
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
# For domains like abc.my.id
|
||||
COOKIE_TLDS=my.id
|
||||
|
||||
# Multiple TLDs
|
||||
COOKIE_TLDS=my.id,web.id,co.id
|
||||
```
|
||||
|
||||
<Callout>
|
||||
This is required when using domain suffixes that are public suffixes (like `.co.uk`). Without this, the browser will reject authentication cookies. Common examples include Indonesian domains (`.my.id`, `.web.id`, `.co.id`).
|
||||
</Callout>
|
||||
|
||||
### CUSTOM_COOKIE_DOMAIN
|
||||
|
||||
**Type**: `string`
|
||||
**Required**: No
|
||||
**Default**: None
|
||||
|
||||
Override the automatic cookie domain detection and set a specific domain for authentication cookies. Useful when proxying the API through your main domain or when you need precise control over cookie scope.
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
# Set cookies only on the main domain
|
||||
CUSTOM_COOKIE_DOMAIN=.example.com
|
||||
|
||||
# Set cookies on a specific subdomain
|
||||
CUSTOM_COOKIE_DOMAIN=.app.example.com
|
||||
```
|
||||
|
||||
<Callout>
|
||||
When set, this completely bypasses the automatic domain parsing logic. The cookie will always be set as secure. Include a leading dot (`.`) to allow the cookie to be shared across subdomains.
|
||||
</Callout>
|
||||
|
||||
### DEMO_USER_ID
|
||||
|
||||
**Type**: `string`
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
---
|
||||
title: "How to add analytics to Nuxt"
|
||||
description: "Add privacy-first analytics to your Nuxt app in under 5 minutes with OpenPanel's official Nuxt module."
|
||||
difficulty: beginner
|
||||
timeToComplete: 5
|
||||
date: 2025-01-07
|
||||
cover: /content/cover-default.jpg
|
||||
team: OpenPanel Team
|
||||
steps:
|
||||
- name: "Install the module"
|
||||
anchor: "install"
|
||||
- name: "Configure the module"
|
||||
anchor: "setup"
|
||||
- name: "Track custom events"
|
||||
anchor: "events"
|
||||
- name: "Identify users"
|
||||
anchor: "identify"
|
||||
- name: "Set up server-side tracking"
|
||||
anchor: "server"
|
||||
- name: "Verify your setup"
|
||||
anchor: "verify"
|
||||
---
|
||||
|
||||
# How to add analytics to Nuxt
|
||||
|
||||
This guide walks you through adding OpenPanel to a Nuxt 3 application. The official `@openpanel/nuxt` module makes integration effortless with auto-imported composables, automatic page view tracking, and a built-in proxy option to bypass ad blockers.
|
||||
|
||||
OpenPanel is an open-source alternative to Mixpanel and Google Analytics. It uses cookieless tracking by default, so you won't need cookie consent banners for basic analytics.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Nuxt 3 project
|
||||
- An OpenPanel account ([sign up free](https://dashboard.openpanel.dev/onboarding))
|
||||
- Your Client ID from the OpenPanel dashboard
|
||||
|
||||
## Install the module [#install]
|
||||
|
||||
Start by installing the OpenPanel Nuxt module. This package includes everything you need for client-side tracking, including the auto-imported `useOpenPanel` composable.
|
||||
|
||||
```bash
|
||||
npm install @openpanel/nuxt
|
||||
```
|
||||
|
||||
If you prefer pnpm or yarn, those work too.
|
||||
|
||||
## Configure the module [#setup]
|
||||
|
||||
Add the module to your `nuxt.config.ts` and configure it with your Client ID. The module automatically sets up page view tracking and makes the `useOpenPanel` composable available throughout your app.
|
||||
|
||||
```ts title="nuxt.config.ts"
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@openpanel/nuxt'],
|
||||
openpanel: {
|
||||
clientId: 'your-client-id',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
That's it. Page views are now being tracked automatically as users navigate your app.
|
||||
|
||||
### Configuration options
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `clientId` | — | Your OpenPanel client ID (required) |
|
||||
| `apiUrl` | `https://api.openpanel.dev` | The API URL to send events to |
|
||||
| `trackScreenViews` | `true` | Automatically track page views |
|
||||
| `trackOutgoingLinks` | `true` | Track clicks on external links |
|
||||
| `trackAttributes` | `true` | Track elements with `data-track` attributes |
|
||||
| `trackHashChanges` | `false` | Track hash changes in URL |
|
||||
| `disabled` | `false` | Disable all tracking |
|
||||
| `proxy` | `false` | Route events through your server |
|
||||
|
||||
### Using environment variables
|
||||
|
||||
For production applications, store your Client ID in environment variables.
|
||||
|
||||
```ts title="nuxt.config.ts"
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@openpanel/nuxt'],
|
||||
openpanel: {
|
||||
clientId: process.env.NUXT_PUBLIC_OPENPANEL_CLIENT_ID,
|
||||
trackScreenViews: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```bash title=".env"
|
||||
NUXT_PUBLIC_OPENPANEL_CLIENT_ID=your-client-id
|
||||
```
|
||||
|
||||
## Track custom events [#events]
|
||||
|
||||
Page views only tell part of the story. To understand how users interact with your product, track custom events like button clicks, form submissions, or feature usage.
|
||||
|
||||
### Using the composable
|
||||
|
||||
The `useOpenPanel` composable is auto-imported, so you can use it directly in any component without importing anything.
|
||||
|
||||
```vue title="components/SignupButton.vue"
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
|
||||
function handleClick() {
|
||||
op.track('button_clicked', {
|
||||
button_name: 'signup',
|
||||
button_location: 'hero',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" @click="handleClick">Sign Up</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Accessing via useNuxtApp
|
||||
|
||||
You can also access the OpenPanel instance through `useNuxtApp()` if you prefer.
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
const { $openpanel } = useNuxtApp();
|
||||
|
||||
$openpanel.track('my_event', { foo: 'bar' });
|
||||
</script>
|
||||
```
|
||||
|
||||
### Track form submissions
|
||||
|
||||
Form tracking helps you understand conversion rates and identify where users drop off.
|
||||
|
||||
```vue title="components/ContactForm.vue"
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
const email = ref('');
|
||||
|
||||
async function handleSubmit() {
|
||||
op.track('form_submitted', {
|
||||
form_name: 'contact',
|
||||
form_location: 'homepage',
|
||||
});
|
||||
|
||||
// Your form submission logic
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<input v-model="email" type="email" placeholder="Enter your email" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Use data attributes for declarative tracking
|
||||
|
||||
The SDK supports declarative tracking using `data-track` attributes. This is useful for simple click tracking without writing JavaScript.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<button
|
||||
data-track="button_clicked"
|
||||
data-track-button_name="signup"
|
||||
data-track-button_location="hero"
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
When a user clicks this button, OpenPanel automatically sends a `button_clicked` event with the specified properties. This requires `trackAttributes: true` in your configuration.
|
||||
|
||||
## Identify users [#identify]
|
||||
|
||||
Anonymous tracking is useful, but identifying users unlocks more valuable insights. You can track behavior across sessions, segment users by properties, and build cohort analyses.
|
||||
|
||||
Call `identify` after a user logs in or when you have their information available.
|
||||
|
||||
```vue title="components/UserProfile.vue"
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
const props = defineProps(['user']);
|
||||
|
||||
watch(() => props.user, (user) => {
|
||||
if (user) {
|
||||
op.identify({
|
||||
profileId: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
properties: {
|
||||
plan: user.plan,
|
||||
signupDate: user.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Welcome, {{ user?.firstName }}!</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Set global properties
|
||||
|
||||
Properties set with `setGlobalProperties` are included with every event. This is useful for app version tracking, feature flags, or A/B test variants.
|
||||
|
||||
```vue title="app.vue"
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
|
||||
onMounted(() => {
|
||||
op.setGlobalProperties({
|
||||
app_version: '1.0.0',
|
||||
environment: useRuntimeConfig().public.environment,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Clear user data on logout
|
||||
|
||||
When users log out, clear the stored profile data to ensure subsequent events aren't associated with the previous user.
|
||||
|
||||
```vue title="components/LogoutButton.vue"
|
||||
<script setup>
|
||||
const op = useOpenPanel();
|
||||
|
||||
function handleLogout() {
|
||||
op.clear();
|
||||
navigateTo('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="handleLogout">Logout</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Set up server-side tracking [#server]
|
||||
|
||||
For tracking events in server routes, API endpoints, or server middleware, use the `@openpanel/sdk` package. Server-side tracking requires a client secret for authentication.
|
||||
|
||||
### Install the SDK
|
||||
|
||||
```bash
|
||||
npm install @openpanel/sdk
|
||||
```
|
||||
|
||||
### Create a server instance
|
||||
|
||||
```ts title="server/utils/op.ts"
|
||||
import { OpenPanel } from '@openpanel/sdk';
|
||||
|
||||
export const op = new OpenPanel({
|
||||
clientId: process.env.OPENPANEL_CLIENT_ID!,
|
||||
clientSecret: process.env.OPENPANEL_CLIENT_SECRET!,
|
||||
});
|
||||
```
|
||||
|
||||
### Track events in server routes
|
||||
|
||||
```ts title="server/api/webhook.post.ts"
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
|
||||
await op.track('webhook_received', {
|
||||
source: body.source,
|
||||
event_type: body.type,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
```
|
||||
|
||||
Never expose your client secret on the client side. Keep it in server-only code.
|
||||
|
||||
### Awaiting in serverless environments
|
||||
|
||||
If you're deploying to a serverless platform like Vercel or Netlify, make sure to await the tracking call to ensure it completes before the function terminates.
|
||||
|
||||
```ts
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Always await in serverless environments
|
||||
await op.track('my_server_event', { foo: 'bar' });
|
||||
return { message: 'Event logged!' };
|
||||
});
|
||||
```
|
||||
|
||||
## Bypass ad blockers with proxy [#proxy]
|
||||
|
||||
Many ad blockers block requests to third-party analytics domains. The Nuxt module includes a built-in proxy that routes events through your own server.
|
||||
|
||||
Enable the proxy option in your configuration:
|
||||
|
||||
```ts title="nuxt.config.ts"
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@openpanel/nuxt'],
|
||||
openpanel: {
|
||||
clientId: 'your-client-id',
|
||||
proxy: true, // Routes events through /api/openpanel/*
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
When `proxy: true` is set:
|
||||
- The module automatically sets `apiUrl` to `/api/openpanel`
|
||||
- A server handler is registered at `/api/openpanel/**`
|
||||
- All tracking requests route through your Nuxt server
|
||||
|
||||
This makes tracking requests invisible to browser extensions that block third-party analytics.
|
||||
|
||||
## Verify your setup [#verify]
|
||||
|
||||
Open your Nuxt app in the browser and navigate between a few pages. Interact with elements that trigger custom events. Then open your [OpenPanel dashboard](https://dashboard.openpanel.dev) and check the Real-time view to see events appearing within seconds.
|
||||
|
||||
If events aren't showing up, check the browser console for errors. The most common issues are:
|
||||
- Incorrect client ID
|
||||
- Ad blockers intercepting requests (enable the proxy option)
|
||||
- Client ID exposed in server-only code
|
||||
|
||||
The Network tab in your browser's developer tools can help you confirm that requests are being sent.
|
||||
|
||||
## Next steps
|
||||
|
||||
The [Nuxt SDK reference](/docs/sdks/nuxt) covers additional features like incrementing user properties and event filtering. If you're interested in understanding how OpenPanel handles privacy, read our article on [cookieless analytics](/articles/cookieless-analytics).
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="Does OpenPanel work with Nuxt 3 and Nuxt 4?">
|
||||
Yes. The `@openpanel/nuxt` module supports both Nuxt 3 and Nuxt 4. It uses Nuxt's module system and auto-imports, so everything works seamlessly with either version.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is the useOpenPanel composable auto-imported?">
|
||||
Yes. The module automatically registers the `useOpenPanel` composable, so you can use it in any component without importing it. You can also access the instance via `useNuxtApp().$openpanel`.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Does OpenPanel use cookies?">
|
||||
No. OpenPanel uses cookieless tracking by default. This means you don't need cookie consent banners for basic analytics under most privacy regulations, including GDPR and PECR.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How do I avoid ad blockers?">
|
||||
Enable the `proxy: true` option in your configuration. This routes all tracking requests through your Nuxt server at `/api/openpanel/*`, which ad blockers don't typically block.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is OpenPanel GDPR compliant?">
|
||||
Yes. OpenPanel is designed for GDPR compliance with cookieless tracking, data minimization, and full support for data subject rights. With self-hosting, you also eliminate international data transfer concerns entirely.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
|
||||
BIN
apps/public/public/logos/helpy-ui.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,388 +0,0 @@
|
||||
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
||||
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
||||
import { FaqItem, Faqs } from '@/components/faq';
|
||||
import { FeatureCard } from '@/components/feature-card';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { url } from '@/lib/layout.shared';
|
||||
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
||||
import {
|
||||
BarChartIcon,
|
||||
CheckIcon,
|
||||
CodeIcon,
|
||||
GlobeIcon,
|
||||
HeartHandshakeIcon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
MessageSquareIcon,
|
||||
SparklesIcon,
|
||||
UsersIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Script from 'next/script';
|
||||
|
||||
export const metadata: Metadata = getPageMetadata({
|
||||
title: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
|
||||
description:
|
||||
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
|
||||
url: url('/open-source'),
|
||||
image: getOgImageUrl('/open-source'),
|
||||
});
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
|
||||
description:
|
||||
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
|
||||
url: url('/open-source'),
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntity: {
|
||||
'@type': 'Offer',
|
||||
name: 'Free Analytics for Open Source Projects',
|
||||
description:
|
||||
'Free analytics service for open source projects up to 2.5M events per month',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
};
|
||||
|
||||
export default function OpenSourcePage() {
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
id="open-source-schema"
|
||||
strategy="beforeInteractive"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<HeroContainer>
|
||||
<div className="col center-center flex-1">
|
||||
<SectionHeader
|
||||
as="h1"
|
||||
align="center"
|
||||
className="flex-1"
|
||||
title={
|
||||
<>
|
||||
Free Analytics for
|
||||
<br />
|
||||
Open Source Projects
|
||||
</>
|
||||
}
|
||||
description="Track your users, understand adoption, and grow your project - all without cost. Get free analytics for your open source project with up to 2.5M events per month."
|
||||
/>
|
||||
<div className="col gap-4 justify-center items-center mt-8">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="mailto:oss@openpanel.dev">
|
||||
Apply for Free Access
|
||||
<MailIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Up to 2.5M events/month • No credit card required
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
|
||||
<div className="container">
|
||||
<div className="col gap-16">
|
||||
{/* What You Get Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="What you get"
|
||||
description="Everything you need to understand your users and grow your open source project."
|
||||
/>
|
||||
<div className="grid md:grid-cols-2 gap-6 mt-8">
|
||||
<FeatureCard
|
||||
title="2.5 Million Events/Month"
|
||||
description="More than enough for most open source projects. Track page views, user actions, and custom events without worrying about limits."
|
||||
icon={BarChartIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Full Feature Access"
|
||||
description="Same powerful capabilities as paid plans. Funnels, retention analysis, custom dashboards, and real-time analytics."
|
||||
icon={ZapIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Unlimited Team Members"
|
||||
description="Invite your entire contributor team. Collaborate with maintainers and core contributors on understanding your project's growth."
|
||||
icon={UsersIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Priority Support"
|
||||
description="Dedicated help for open source maintainers. Get faster responses and priority assistance when you need it."
|
||||
icon={MessageSquareIcon}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Why We Do This Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="Why we do this"
|
||||
description="OpenPanel is built by and for the open source community. We believe in giving back."
|
||||
/>
|
||||
<div className="col gap-6 mt-8">
|
||||
<p className="text-muted-foreground">
|
||||
We started OpenPanel because we believed analytics tools
|
||||
shouldn't be complicated or locked behind expensive enterprise
|
||||
subscriptions. As an open source project ourselves, we
|
||||
understand the challenges of building and growing a project
|
||||
without the resources of big corporations.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<FeatureCard
|
||||
title="Built for OSS"
|
||||
description="OpenPanel is open source. We know what it's like to build in the open."
|
||||
icon={CodeIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="No Barriers"
|
||||
description="Analytics shouldn't be a barrier to understanding your users. We're removing that barrier."
|
||||
icon={HeartHandshakeIcon}
|
||||
/>
|
||||
<FeatureCard
|
||||
title="Giving Back"
|
||||
description="We're giving back to the projects that inspire us and the community that supports us."
|
||||
icon={SparklesIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* What We Ask In Return Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="What we ask in return"
|
||||
description="We keep it simple. Just a small way to help us grow and support more projects."
|
||||
/>
|
||||
<div className="row gap-6 mt-8">
|
||||
<div className="col gap-6">
|
||||
<FeatureCard
|
||||
title="Backlink to OpenPanel"
|
||||
description="A simple link on your website or README helps others discover OpenPanel. It's a win-win for the community."
|
||||
icon={LinkIcon}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Example: "Analytics powered by{' '}
|
||||
<Link
|
||||
href="https://openpanel.dev"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
OpenPanel
|
||||
</Link>
|
||||
"
|
||||
</p>
|
||||
</FeatureCard>
|
||||
<FeatureCard
|
||||
title="Display a Widget"
|
||||
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
|
||||
icon={GlobeIcon}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Display real-time visitor counts, page views, or other
|
||||
metrics on your project's website.
|
||||
</p>
|
||||
</FeatureCard>
|
||||
<p className="text-muted-foreground">
|
||||
That's it. No complicated requirements, no hidden fees, no
|
||||
catch. We just want to help open source projects succeed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
<iframe
|
||||
title="Realtime Widget"
|
||||
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
|
||||
width="300"
|
||||
height="400"
|
||||
className="rounded-xl border mb-2"
|
||||
/>
|
||||
Analytics from{' '}
|
||||
<a className="underline" href="https://openpanel.dev">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Eligibility Criteria Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="Eligibility criteria"
|
||||
description="We want to support legitimate open source projects that are making a difference."
|
||||
/>
|
||||
<div className="col gap-4 mt-8">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">OSI-Approved License</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your project must use an OSI-approved open source license
|
||||
(MIT, Apache, GPL, etc.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Public Repository</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your code must be publicly available on GitHub, GitLab, or
|
||||
similar platforms
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">Active Development</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Show evidence of active development and a growing
|
||||
community
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<CheckIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1">
|
||||
Non-Commercial Primary Purpose
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The primary purpose should be non-commercial, though
|
||||
commercial OSS projects may be considered
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* How to Apply Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="How to apply"
|
||||
description="Getting started is simple. Just send us an email with a few details about your project."
|
||||
/>
|
||||
<div className="col gap-6 mt-8">
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="col gap-3">
|
||||
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
|
||||
1
|
||||
</div>
|
||||
<h3 className="font-semibold">Send us an email</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reach out to{' '}
|
||||
<Link
|
||||
href="mailto:oss@openpanel.dev"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
oss@openpanel.dev
|
||||
</Link>{' '}
|
||||
with your project details
|
||||
</p>
|
||||
</div>
|
||||
<div className="col gap-3">
|
||||
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
|
||||
2
|
||||
</div>
|
||||
<h3 className="font-semibold">Include project info</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Share your project URL, license type, and a brief
|
||||
description of what you're building
|
||||
</p>
|
||||
</div>
|
||||
<div className="col gap-3">
|
||||
<div className="size-10 rounded-full bg-primary/10 center-center text-primary font-semibold">
|
||||
3
|
||||
</div>
|
||||
<h3 className="font-semibold">We'll review</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We'll evaluate your project and respond within a few
|
||||
business days
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="mailto:oss@openpanel.dev?subject=Open Source Program Application">
|
||||
Apply Now
|
||||
<MailIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<Section className="my-0">
|
||||
<SectionHeader
|
||||
title="Frequently asked questions"
|
||||
description="Everything you need to know about our open source program."
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<Faqs>
|
||||
<FaqItem question="What counts as an open-source project?">
|
||||
We consider any project with an OSI-approved open source
|
||||
license (MIT, Apache, GPL, BSD, etc.) that is publicly
|
||||
available and actively maintained. The project should have a
|
||||
non-commercial primary purpose, though we may consider
|
||||
commercial open source projects on a case-by-case basis.
|
||||
</FaqItem>
|
||||
<FaqItem question="What happens if I exceed 2.5M events per month?">
|
||||
We understand that successful projects grow. If you
|
||||
consistently exceed 2.5M events, we'll reach out to discuss
|
||||
options. We're flexible and want to support your success. In
|
||||
most cases, we can work out a solution that works for both of
|
||||
us.
|
||||
</FaqItem>
|
||||
<FaqItem question="Can commercial open source projects apply?">
|
||||
Yes, we consider commercial open source projects on a
|
||||
case-by-case basis. If your project is open source but has
|
||||
commercial offerings, please mention this in your application
|
||||
and we'll evaluate accordingly.
|
||||
</FaqItem>
|
||||
<FaqItem question="How long does the free access last?">
|
||||
As long as your project remains eligible and active, your free
|
||||
access continues. We review projects periodically to ensure
|
||||
they still meet our criteria, but we're committed to
|
||||
supporting projects long-term.
|
||||
</FaqItem>
|
||||
<FaqItem question="Do I need to display the widget?">
|
||||
No, displaying the widget is completely optional. We only
|
||||
require a backlink to OpenPanel on your website or README. The
|
||||
widget is just a nice way to showcase your analytics if you
|
||||
want to.
|
||||
</FaqItem>
|
||||
<FaqItem question="What if my project is very small or just starting?">
|
||||
We welcome projects of all sizes! Whether you're just getting
|
||||
started or have a large community, if you meet our eligibility
|
||||
criteria, we'd love to help. Small projects often benefit the
|
||||
most from understanding their users early on.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<CtaBanner
|
||||
title="Ready to get free analytics for your open source project?"
|
||||
description="Join other open source projects using OpenPanel to understand their users and grow their communities. Apply today and get started in minutes."
|
||||
ctaText="Apply for Free Access"
|
||||
ctaLink="mailto:oss@openpanel.dev?subject=Open Source Program Application"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,10 @@ import Image from 'next/image';
|
||||
|
||||
const images = [
|
||||
{
|
||||
name: 'Lucide Animated',
|
||||
url: 'https://lucide-animated.com',
|
||||
logo: '/logos/lucide-animated.png',
|
||||
name: 'Helpy UI',
|
||||
url: 'https://helpy-ui.com',
|
||||
logo: '/logos/helpy-ui.png',
|
||||
className: 'size-12',
|
||||
},
|
||||
{
|
||||
name: 'KiddoKitchen',
|
||||
@@ -66,7 +67,10 @@ export function WhyOpenPanel() {
|
||||
alt={image.name}
|
||||
width={64}
|
||||
height={64}
|
||||
className={cn('size-16 object-contain dark:invert')}
|
||||
className={cn(
|
||||
'size-16 object-contain dark:invert',
|
||||
image.className,
|
||||
)}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -26,13 +26,6 @@ async function getOgData(
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
};
|
||||
}
|
||||
case 'open-source': {
|
||||
return {
|
||||
title: 'Free analytics for open source projects',
|
||||
description:
|
||||
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
|
||||
};
|
||||
}
|
||||
case 'pricing': {
|
||||
return {
|
||||
title: 'Pricing',
|
||||
|
||||
@@ -25,10 +25,6 @@ export async function Footer() {
|
||||
{ title: 'About', url: '/about' },
|
||||
{ title: 'Contact', url: '/contact' },
|
||||
{ title: 'Become a supporter', url: '/supporter' },
|
||||
{
|
||||
title: 'Free analytics for open source projects',
|
||||
url: '/open-source',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ COPY packages/payments/package.json packages/payments/
|
||||
COPY packages/constants/package.json packages/constants/
|
||||
COPY packages/validation/package.json packages/validation/
|
||||
COPY packages/integrations/package.json packages/integrations/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/sdks/_info/package.json packages/sdks/_info/
|
||||
COPY patches ./patches
|
||||
# Copy tracking script to self-hosting dashboard
|
||||
@@ -92,6 +93,7 @@ COPY --from=build /app/packages/payments/package.json ./packages/payments/
|
||||
COPY --from=build /app/packages/constants/package.json ./packages/constants/
|
||||
COPY --from=build /app/packages/validation/package.json ./packages/validation/
|
||||
COPY --from=build /app/packages/integrations/package.json ./packages/integrations/
|
||||
COPY --from=build /app/packages/sdks/sdk/package.json ./packages/sdks/sdk/
|
||||
COPY --from=build /app/packages/sdks/_info/package.json ./packages/sdks/_info/
|
||||
COPY --from=build /app/patches ./patches
|
||||
|
||||
@@ -130,6 +132,7 @@ COPY --from=build /app/packages/payments ./packages/payments
|
||||
COPY --from=build /app/packages/constants ./packages/constants
|
||||
COPY --from=build /app/packages/validation ./packages/validation
|
||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
|
||||
COPY --from=build /app/packages/sdks/_info ./packages/sdks/_info
|
||||
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"cf-typegen": "wrangler types",
|
||||
"build": "pnpm with-env vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest run",
|
||||
"format": "biome format",
|
||||
"lint": "biome lint",
|
||||
"check": "biome check",
|
||||
@@ -24,8 +25,7 @@
|
||||
"@faker-js/faker": "^9.6.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@hyperdx/node-opentelemetry": "^0.8.1",
|
||||
"@nivo/sankey": "^0.99.0",
|
||||
"@number-flow/react": "0.5.10",
|
||||
"@number-flow/react": "0.3.5",
|
||||
"@openpanel/common": "workspace:^",
|
||||
"@openpanel/constants": "workspace:^",
|
||||
"@openpanel/integrations": "workspace:^",
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@cloudflare/vite-plugin": "1.20.3",
|
||||
"@cloudflare/vite-plugin": "^1.13.12",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"@openpanel/trpc": "workspace:*",
|
||||
"@tanstack/devtools-event-client": "^0.3.3",
|
||||
@@ -170,6 +170,6 @@
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.0.5",
|
||||
"web-vitals": "^4.2.4",
|
||||
"wrangler": "4.59.1"
|
||||
"wrangler": "^4.42.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { NumberFlowProps } from '@number-flow/react';
|
||||
import ReactAnimatedNumber from '@number-flow/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// NumberFlow is breaking ssr and forces loaders to fetch twice
|
||||
export function AnimatedNumber(props: NumberFlowProps) {
|
||||
return <ReactAnimatedNumber {...props} />;
|
||||
const [Component, setComponent] =
|
||||
useState<React.ComponentType<NumberFlowProps> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import('@number-flow/react').then(({ default: NumberFlow }) => {
|
||||
setComponent(NumberFlow);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!Component) {
|
||||
return <>{props.value}</>;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
}
|
||||
|
||||
@@ -4,17 +4,11 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { PublicPageCard } from '../public-page-card';
|
||||
import { LogoSquare } from '../logo';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
export function ShareEnterPassword({
|
||||
shareId,
|
||||
shareType = 'overview',
|
||||
}: {
|
||||
shareId: string;
|
||||
shareType?: 'overview' | 'dashboard' | 'report';
|
||||
}) {
|
||||
export function ShareEnterPassword({ shareId }: { shareId: string }) {
|
||||
const trpc = useTRPC();
|
||||
const mutation = useMutation(
|
||||
trpc.auth.signInShare.mutationOptions({
|
||||
@@ -31,7 +25,6 @@ export function ShareEnterPassword({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
shareId,
|
||||
shareType,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,31 +32,46 @@ export function ShareEnterPassword({
|
||||
mutation.mutate({
|
||||
password: data.password,
|
||||
shareId,
|
||||
shareType,
|
||||
});
|
||||
});
|
||||
|
||||
const typeLabel =
|
||||
shareType === 'dashboard'
|
||||
? 'Dashboard'
|
||||
: shareType === 'report'
|
||||
? 'Report'
|
||||
: 'Overview';
|
||||
|
||||
return (
|
||||
<PublicPageCard
|
||||
title={`${typeLabel} is locked`}
|
||||
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
|
||||
>
|
||||
<form onSubmit={onSubmit} className="col gap-4">
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
</PublicPageCard>
|
||||
<div className="center-center h-screen w-screen p-4 col">
|
||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||
<div className="col mt-1 flex-1 gap-2">
|
||||
<LogoSquare className="size-12 mb-4" />
|
||||
<div className="text-xl font-semibold">Overview is locked</div>
|
||||
<div className="text-lg text-muted-foreground leading-normal">
|
||||
Please enter correct password to access this overview
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="col gap-4 mt-6">
|
||||
<Input
|
||||
{...form.register('password')}
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
size="large"
|
||||
/>
|
||||
<Button type="submit">Get access</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-6 text-sm max-w-sm col gap-0.5">
|
||||
<p>
|
||||
Powered by{' '}
|
||||
<a href="https://openpanel.dev" className="font-medium">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
The best web and product analytics tool out there (our honest
|
||||
opinion).
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free today!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,9 @@ import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
|
||||
|
||||
export const ChartTooltipContainer = ({
|
||||
children,
|
||||
className,
|
||||
}: { children: React.ReactNode; className?: string }) => {
|
||||
}: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Markdown } from '@/components/markdown';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { zReport } from '@openpanel/validation';
|
||||
import { z } from 'zod';
|
||||
import { zChartInputAI } from '@openpanel/validation';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { Loader2Icon, UserIcon } from 'lucide-react';
|
||||
import { Fragment, memo } from 'react';
|
||||
@@ -78,10 +77,7 @@ export const ChatMessage = memo(
|
||||
const { result } = p.toolInvocation;
|
||||
|
||||
if (result.type === 'report') {
|
||||
const report = zReport.extend({
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
}).safeParse(result.report);
|
||||
const report = zChartInputAI.safeParse(result.report);
|
||||
if (report.success) {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pushModal } from '@/modals';
|
||||
import type {
|
||||
IReport,
|
||||
IChartInputAi,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
@@ -16,7 +16,7 @@ import { Button } from '../ui/button';
|
||||
export function ChatReport({
|
||||
lazy,
|
||||
...props
|
||||
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
|
||||
}: { report: IChartInputAi; lazy: boolean }) {
|
||||
const [chartType, setChartType] = useState<IChartType>(
|
||||
props.report.chartType,
|
||||
);
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
||||
|
||||
const deltaChipVariants = cva(
|
||||
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
inc: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
dec: 'bg-red-500/10 text-red-600 dark:text-red-400',
|
||||
default: 'bg-muted text-muted-foreground',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type DeltaChipProps = VariantProps<typeof deltaChipVariants> & {
|
||||
children: React.ReactNode;
|
||||
inverted?: boolean;
|
||||
};
|
||||
|
||||
const iconVariants: Record<NonNullable<DeltaChipProps['size']>, number> = {
|
||||
sm: 12,
|
||||
md: 16,
|
||||
lg: 20,
|
||||
};
|
||||
|
||||
const getVariant = (variant: DeltaChipProps['variant'], inverted?: boolean) => {
|
||||
if (inverted) {
|
||||
return variant === 'inc' ? 'dec' : variant === 'dec' ? 'inc' : variant;
|
||||
}
|
||||
return variant;
|
||||
};
|
||||
|
||||
export function DeltaChip({
|
||||
variant,
|
||||
size,
|
||||
inverted,
|
||||
children,
|
||||
}: DeltaChipProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
deltaChipVariants({ variant: getVariant(variant, inverted), size }),
|
||||
)}
|
||||
>
|
||||
{variant === 'inc' ? (
|
||||
<ArrowUpIcon size={iconVariants[size || 'md']} className="shrink-0" />
|
||||
) : variant === 'dec' ? (
|
||||
<ArrowDownIcon size={iconVariants[size || 'md']} className="shrink-0" />
|
||||
) : null}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { IServiceReport } from '@openpanel/db';
|
||||
import { useMemo } from 'react';
|
||||
import { Responsive, WidthProvider } from 'react-grid-layout';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
export type Layout = ReactGridLayout.Layout;
|
||||
|
||||
export const useReportLayouts = (
|
||||
reports: NonNullable<IServiceReport>[],
|
||||
): ReactGridLayout.Layouts => {
|
||||
return useMemo(() => {
|
||||
const baseLayout = reports.map((report, index) => ({
|
||||
i: report.id,
|
||||
x: report.layout?.x ?? (index % 2) * 6,
|
||||
y: report.layout?.y ?? Math.floor(index / 2) * 4,
|
||||
w: report.layout?.w ?? 6,
|
||||
h: report.layout?.h ?? 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
}));
|
||||
|
||||
return {
|
||||
lg: baseLayout,
|
||||
md: baseLayout,
|
||||
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
|
||||
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
|
||||
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
|
||||
};
|
||||
}, [reports]);
|
||||
};
|
||||
|
||||
export function GrafanaGrid({
|
||||
layouts,
|
||||
children,
|
||||
transitions,
|
||||
onLayoutChange,
|
||||
onDragStop,
|
||||
onResizeStop,
|
||||
isDraggable,
|
||||
isResizable,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
transitions?: boolean;
|
||||
} & Pick<
|
||||
ReactGridLayout.ResponsiveProps,
|
||||
| 'layouts'
|
||||
| 'onLayoutChange'
|
||||
| 'onDragStop'
|
||||
| 'onResizeStop'
|
||||
| 'isDraggable'
|
||||
| 'isResizable'
|
||||
>) {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.react-grid-item {
|
||||
transition: ${transitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
|
||||
}
|
||||
.react-grid-item.react-grid-placeholder {
|
||||
background: none !important;
|
||||
opacity: 0.5;
|
||||
transition-duration: 100ms;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed var(--primary);
|
||||
}
|
||||
.react-grid-item.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
`}</style>
|
||||
<div className="-m-4">
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
layouts={layouts}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={100}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
margin={[16, 16]}
|
||||
transformScale={1}
|
||||
useCSSTransforms={true}
|
||||
onLayoutChange={onLayoutChange}
|
||||
onDragStop={onDragStop}
|
||||
onResizeStop={onResizeStop}
|
||||
isDraggable={isDraggable}
|
||||
isResizable={isResizable}
|
||||
>
|
||||
{children}
|
||||
</ResponsiveGridLayout>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import type { InsightPayload } from '@openpanel/validation';
|
||||
import { ArrowDown, ArrowUp, FilterIcon, RotateCcwIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { useState } from 'react';
|
||||
import { DeltaChip } from '../delta-chip';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Badge } from '../ui/badge';
|
||||
|
||||
@@ -189,13 +188,42 @@ export function InsightCard({
|
||||
|
||||
{/* Delta chip */}
|
||||
<DeltaChip
|
||||
variant={isIncrease ? 'inc' : isDecrease ? 'dec' : 'default'}
|
||||
size="sm"
|
||||
>
|
||||
{deltaText}
|
||||
</DeltaChip>
|
||||
isIncrease={isIncrease}
|
||||
isDecrease={isDecrease}
|
||||
deltaText={deltaText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeltaChip({
|
||||
isIncrease,
|
||||
isDecrease,
|
||||
deltaText,
|
||||
}: {
|
||||
isIncrease: boolean;
|
||||
isDecrease: boolean;
|
||||
deltaText: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
|
||||
isIncrease
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||
: isDecrease
|
||||
? 'bg-red-500/10 text-red-600 dark:text-red-400'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{isIncrease ? (
|
||||
<ArrowUp size={16} className="shrink-0" />
|
||||
) : isDecrease ? (
|
||||
<ArrowDown size={16} className="shrink-0" />
|
||||
) : null}
|
||||
<span>{deltaText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function LoginNavbar({ className }: { className?: string }) {
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openpanel.dev/compare/posthog-alternative">
|
||||
<a href="https://openpanel.dev/compare/mixpanel-alternative">
|
||||
Posthog alternative
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { PromptCard } from '@/components/organization/prompt-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
import { op } from '@/utils/op';
|
||||
import { MessageSquareIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
const THIRTY_DAYS_IN_SECONDS = 60 * 60 * 24 * 30;
|
||||
|
||||
export default function FeedbackPrompt() {
|
||||
const { isSelfHosted } = useAppContext();
|
||||
const [feedbackPromptSeen, setFeedbackPromptSeen] = useCookieStore(
|
||||
'feedback-prompt-seen',
|
||||
'',
|
||||
{ maxAge: THIRTY_DAYS_IN_SECONDS },
|
||||
);
|
||||
|
||||
const shouldShow = useMemo(() => {
|
||||
if (isSelfHosted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!feedbackPromptSeen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const lastSeenDate = new Date(feedbackPromptSeen);
|
||||
const now = new Date();
|
||||
const daysSinceLastSeen =
|
||||
(now.getTime() - lastSeenDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
|
||||
return daysSinceLastSeen >= 30;
|
||||
} catch {
|
||||
// If date parsing fails, show the prompt
|
||||
return true;
|
||||
}
|
||||
}, [isSelfHosted, feedbackPromptSeen]);
|
||||
|
||||
const handleGiveFeedback = () => {
|
||||
// Open userjot widget
|
||||
if (typeof window !== 'undefined' && 'uj' in window) {
|
||||
(window.uj as any).showWidget();
|
||||
}
|
||||
// Set cookie with current timestamp
|
||||
setFeedbackPromptSeen(new Date().toISOString());
|
||||
op.track('feedback_prompt_button_clicked');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Set cookie with current timestamp when closed
|
||||
setFeedbackPromptSeen(new Date().toISOString());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShow) {
|
||||
op.track('feedback_prompt_viewed');
|
||||
}
|
||||
}, [shouldShow]);
|
||||
|
||||
return (
|
||||
<PromptCard
|
||||
title="Share Your Feedback"
|
||||
subtitle="Help us improve OpenPanel with your insights"
|
||||
onClose={handleClose}
|
||||
show={shouldShow}
|
||||
gradientColor="rgb(59 130 246)"
|
||||
>
|
||||
<div className="px-6 col gap-4">
|
||||
<p className="text-sm text-foreground leading-normal">
|
||||
Your feedback helps us build features you actually need. Share your
|
||||
thoughts, report bugs, or suggest improvements
|
||||
</p>
|
||||
|
||||
<Button className="self-start" onClick={handleGiveFeedback}>
|
||||
Give Feedback
|
||||
</Button>
|
||||
</div>
|
||||
</PromptCard>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
interface PromptCardProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
gradientColor?: string;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function PromptCard({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
gradientColor = 'rgb(16 185 129)',
|
||||
show,
|
||||
}: PromptCardProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
className="fixed bottom-0 right-0 z-50 p-4 max-w-sm"
|
||||
>
|
||||
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_var(--color-background)] col gap-6 py-6 overflow-hidden">
|
||||
<div className="relative px-6 col gap-1">
|
||||
<div
|
||||
className="absolute -bottom-10 -right-10 h-64 w-64 rounded-full opacity-30 blur-3xl pointer-events-none"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${gradientColor} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="row items-center justify-between">
|
||||
<h2 className="text-xl font-semibold max-w-[200px] leading-snug">
|
||||
{title}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PromptCard } from '@/components/organization/prompt-card';
|
||||
import { LinkButton } from '@/components/ui/button';
|
||||
import { Button, LinkButton } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
AwardIcon,
|
||||
HeartIcon,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
MessageCircleIcon,
|
||||
RocketIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -77,43 +78,70 @@ export default function SupporterPrompt() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PromptCard
|
||||
title="Support OpenPanel"
|
||||
subtitle="Help us build the future of open analytics"
|
||||
onClose={() => setSupporterPromptClosed(true)}
|
||||
show={!supporterPromptClosed}
|
||||
gradientColor="rgb(16 185 129)"
|
||||
>
|
||||
<div className="col gap-3 px-6">
|
||||
{PERKS.map((perk) => (
|
||||
<PerkPoint
|
||||
key={perk.text}
|
||||
icon={perk.icon}
|
||||
text={perk.text}
|
||||
description={perk.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-6">
|
||||
<LinkButton
|
||||
className="w-full"
|
||||
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
|
||||
<AnimatePresence>
|
||||
{!supporterPromptClosed && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 100, scale: 0.95 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
className="fixed bottom-0 right-0 z-50 p-4 max-w-md"
|
||||
>
|
||||
Become a Supporter
|
||||
</LinkButton>
|
||||
<p className="text-xs text-muted-foreground text-center mt-4">
|
||||
Starting at $20/month • Cancel anytime •{' '}
|
||||
<a
|
||||
href="https://openpanel.dev/supporter"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</PromptCard>
|
||||
<div className="bg-card border p-6 rounded-lg shadow-lg col gap-4">
|
||||
<div>
|
||||
<div className="row items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Support OpenPanel</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={() => setSupporterPromptClosed(true)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Help us build the future of open analytics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
{PERKS.map((perk) => (
|
||||
<PerkPoint
|
||||
key={perk.text}
|
||||
icon={perk.icon}
|
||||
text={perk.text}
|
||||
description={perk.description}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<LinkButton
|
||||
className="w-full"
|
||||
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
|
||||
>
|
||||
Become a Supporter
|
||||
</LinkButton>
|
||||
<p className="text-xs text-muted-foreground text-center mt-4">
|
||||
Starting at $20/month • Cancel anytime •{' '}
|
||||
<a
|
||||
href="https://openpanel.dev/supporter"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
createChartTooltip,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
|
||||
type Data = {
|
||||
date: string;
|
||||
timestamp: number;
|
||||
[key: `${string}:sessions`]: number;
|
||||
[key: `${string}:pageviews`]: number;
|
||||
[key: `${string}:revenue`]: number | undefined;
|
||||
[key: `${string}:payload`]: {
|
||||
name: string;
|
||||
prefix?: string;
|
||||
color: string;
|
||||
};
|
||||
};
|
||||
|
||||
type Context = {
|
||||
interval: IInterval;
|
||||
};
|
||||
|
||||
export const OverviewLineChartTooltip = createChartTooltip<Data, Context>(
|
||||
({ context: { interval }, data }) => {
|
||||
const formatDate = useFormatDateInterval({
|
||||
interval,
|
||||
short: false,
|
||||
});
|
||||
const number = useNumber();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstItem = data[0];
|
||||
|
||||
// Get all payload items from the first data point
|
||||
// Keys are in format "prefix:name:payload" or "name:payload"
|
||||
const payloadItems = Object.keys(firstItem)
|
||||
.filter((key) => key.endsWith(':payload'))
|
||||
.map((key) => {
|
||||
const payload = firstItem[key as keyof typeof firstItem] as {
|
||||
name: string;
|
||||
prefix?: string;
|
||||
color: string;
|
||||
};
|
||||
// Extract the base key (without :payload) to access sessions/pageviews/revenue
|
||||
const baseKey = key.replace(':payload', '');
|
||||
return {
|
||||
payload,
|
||||
baseKey,
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(item) =>
|
||||
item.payload &&
|
||||
typeof item.payload === 'object' &&
|
||||
'name' in item.payload,
|
||||
);
|
||||
|
||||
// Sort by sessions (descending)
|
||||
const sorted = payloadItems.sort((a, b) => {
|
||||
const aSessions =
|
||||
(firstItem[
|
||||
`${a.baseKey}:sessions` as keyof typeof firstItem
|
||||
] as number) ?? 0;
|
||||
const bSessions =
|
||||
(firstItem[
|
||||
`${b.baseKey}:sessions` as keyof typeof firstItem
|
||||
] as number) ?? 0;
|
||||
return bSessions - aSessions;
|
||||
});
|
||||
|
||||
const limit = 3;
|
||||
const visible = sorted.slice(0, limit);
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible.map((item, index) => {
|
||||
const sessions =
|
||||
(firstItem[
|
||||
`${item.baseKey}:sessions` as keyof typeof firstItem
|
||||
] as number) ?? 0;
|
||||
const pageviews =
|
||||
(firstItem[
|
||||
`${item.baseKey}:pageviews` as keyof typeof firstItem
|
||||
] as number) ?? 0;
|
||||
const revenue = firstItem[
|
||||
`${item.baseKey}:revenue` as keyof typeof firstItem
|
||||
] as number | undefined;
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.baseKey}>
|
||||
{index === 0 && firstItem.date && (
|
||||
<ChartTooltipHeader>
|
||||
<div>{formatDate(new Date(firstItem.date))}</div>
|
||||
</ChartTooltipHeader>
|
||||
)}
|
||||
<ChartTooltipItem color={item.payload.color}>
|
||||
<div className="flex items-center gap-1">
|
||||
<SerieIcon name={item.payload.prefix || item.payload.name} />
|
||||
<div className="font-medium">
|
||||
{item.payload.prefix && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
{item.payload.prefix}
|
||||
</span>
|
||||
<span className="mx-1">/</span>
|
||||
</>
|
||||
)}
|
||||
{item.payload.name || 'Not set'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-1 text-sm">
|
||||
{revenue !== undefined && revenue > 0 && (
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<span className="text-muted-foreground">Revenue</span>
|
||||
<span style={{ color: '#3ba974' }}>
|
||||
{number.currency(revenue / 100, { short: true })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<span className="text-muted-foreground">Pageviews</span>
|
||||
<span>{number.short(pageviews)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<span className="text-muted-foreground">Sessions</span>
|
||||
<span>{number.short(sessions)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hidden.length > 0 && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
and {hidden.length} more {hidden.length === 1 ? 'item' : 'items'}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,303 +0,0 @@
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { OverviewLineChartTooltip } from './overview-line-chart-tooltip';
|
||||
|
||||
type SeriesData =
|
||||
RouterOutputs['overview']['topGenericSeries']['items'][number];
|
||||
|
||||
interface OverviewLineChartProps {
|
||||
data: RouterOutputs['overview']['topGenericSeries'];
|
||||
interval: IInterval;
|
||||
searchQuery?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function transformDataForRecharts(
|
||||
items: SeriesData[],
|
||||
searchQuery?: string,
|
||||
): Array<{
|
||||
date: string;
|
||||
timestamp: number;
|
||||
[key: `${string}:sessions`]: number;
|
||||
[key: `${string}:pageviews`]: number;
|
||||
[key: `${string}:revenue`]: number | undefined;
|
||||
[key: `${string}:payload`]: {
|
||||
name: string;
|
||||
prefix?: string;
|
||||
color: string;
|
||||
};
|
||||
}> {
|
||||
// Filter items by search query
|
||||
const filteredItems = searchQuery
|
||||
? items.filter((item) => {
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
|
||||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
|
||||
);
|
||||
})
|
||||
: items;
|
||||
|
||||
// Limit to top 15
|
||||
const topItems = filteredItems.slice(0, 15);
|
||||
|
||||
// Get all unique dates from all items
|
||||
const allDates = new Set<string>();
|
||||
topItems.forEach((item) => {
|
||||
item.data.forEach((d) => allDates.add(d.date));
|
||||
});
|
||||
|
||||
const sortedDates = Array.from(allDates).sort();
|
||||
|
||||
// Transform to recharts format
|
||||
return sortedDates.map((date) => {
|
||||
const timestamp = new Date(date).getTime();
|
||||
const result: Record<string, any> = {
|
||||
date,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
topItems.forEach((item, index) => {
|
||||
const dataPoint = item.data.find((d) => d.date === date);
|
||||
if (dataPoint) {
|
||||
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
|
||||
const key = item.prefix ? `${item.prefix}:${item.name}` : item.name;
|
||||
result[`${key}:sessions`] = dataPoint.sessions;
|
||||
result[`${key}:pageviews`] = dataPoint.pageviews;
|
||||
if (dataPoint.revenue !== undefined) {
|
||||
result[`${key}:revenue`] = dataPoint.revenue;
|
||||
}
|
||||
result[`${key}:payload`] = {
|
||||
name: item.name,
|
||||
prefix: item.prefix,
|
||||
color: getChartColor(index),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return result as typeof result & {
|
||||
date: string;
|
||||
timestamp: number;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function OverviewLineChart({
|
||||
data,
|
||||
interval,
|
||||
searchQuery,
|
||||
className,
|
||||
}: OverviewLineChartProps) {
|
||||
const number = useNumber();
|
||||
|
||||
const chartData = useMemo(
|
||||
() => transformDataForRecharts(data.items, searchQuery),
|
||||
[data.items, searchQuery],
|
||||
);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const filtered = searchQuery
|
||||
? data.items.filter((item) => {
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
|
||||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
|
||||
);
|
||||
})
|
||||
: data.items;
|
||||
return filtered.slice(0, 15);
|
||||
}, [data.items, searchQuery]);
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: false });
|
||||
const yAxisProps = useYAxisProps({});
|
||||
|
||||
if (visibleItems.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center h-[358px]', className)}
|
||||
>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{searchQuery ? 'No results found' : 'No data available'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full p-4', className)}>
|
||||
<div className="h-[358px] w-full">
|
||||
<OverviewLineChartTooltip.TooltipProvider interval={interval}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
<YAxis {...yAxisProps} />
|
||||
<Tooltip content={<OverviewLineChartTooltip.Tooltip />} />
|
||||
{visibleItems.map((item, index) => {
|
||||
const color = getChartColor(index);
|
||||
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
|
||||
const key = item.prefix
|
||||
? `${item.prefix}:${item.name}`
|
||||
: item.name;
|
||||
return (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={`${key}:sessions`}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</OverviewLineChartTooltip.TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<LegendScrollable items={visibleItems} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendScrollable({
|
||||
items,
|
||||
}: {
|
||||
items: SeriesData[];
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||
|
||||
const updateGradients = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = el;
|
||||
const hasOverflow = scrollWidth > clientWidth;
|
||||
|
||||
setShowLeftGradient(hasOverflow && scrollLeft > 0);
|
||||
setShowRightGradient(
|
||||
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
updateGradients();
|
||||
|
||||
el.addEventListener('scroll', updateGradients);
|
||||
window.addEventListener('resize', updateGradients);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', updateGradients);
|
||||
window.removeEventListener('resize', updateGradients);
|
||||
};
|
||||
}, [updateGradients]);
|
||||
|
||||
// Update gradients when items change
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(updateGradients);
|
||||
}, [items, updateGradients]);
|
||||
|
||||
return (
|
||||
<div className="relative mt-4 -mb-2">
|
||||
{/* Left gradient */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
|
||||
showLeftGradient ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Scrollable legend */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-x-4 gap-y-1 overflow-x-auto px-2 py-1 hide-scrollbar text-xs"
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const color = getChartColor(index);
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
key={item.prefix ? `${item.prefix}:${item.name}` : item.name}
|
||||
style={{ color }}
|
||||
>
|
||||
<SerieIcon name={item.prefix || item.name} />
|
||||
<span className="font-semibold whitespace-nowrap">
|
||||
{item.prefix && (
|
||||
<>
|
||||
<span className="text-muted-foreground">{item.prefix}</span>
|
||||
<span className="mx-1">/</span>
|
||||
</>
|
||||
)}
|
||||
{item.name || 'Not set'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right gradient */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
|
||||
showRightGradient ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewLineChartLoading({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center h-[358px]', className)}
|
||||
>
|
||||
<div className="text-muted-foreground text-sm">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewLineChartEmpty({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center h-[358px]', className)}
|
||||
>
|
||||
<div className="text-muted-foreground text-sm">No data available</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { ModalContent } from '@/modals/Modal/Container';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { DialogTitle } from '@radix-ui/react-dialog';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
|
||||
// Revenue pie chart component
|
||||
function RevenuePieChart({ percentage }: { percentage: number }) {
|
||||
const size = 16;
|
||||
const strokeWidth = 2;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - percentage * circumference;
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="flex-shrink-0">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
className="text-def-200"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#3ba974"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Base data type that all items must conform to
|
||||
export interface OverviewListItem {
|
||||
sessions: number;
|
||||
pageviews: number;
|
||||
revenue?: number;
|
||||
}
|
||||
|
||||
interface OverviewListModalProps<T extends OverviewListItem> {
|
||||
/** Modal title */
|
||||
title: string;
|
||||
/** Search placeholder text */
|
||||
searchPlaceholder?: string;
|
||||
/** The data to display */
|
||||
data: T[];
|
||||
/** Extract a unique key for each item */
|
||||
keyExtractor: (item: T) => string;
|
||||
/** Filter function for search - receives item and lowercase search query */
|
||||
searchFilter: (item: T, query: string) => boolean;
|
||||
/** Render the main content cell (first column) */
|
||||
renderItem: (item: T) => React.ReactNode;
|
||||
/** Optional footer content */
|
||||
footer?: React.ReactNode;
|
||||
/** Optional header content (appears below title/search) */
|
||||
headerContent?: React.ReactNode;
|
||||
/** Column name for the first column */
|
||||
columnName?: string;
|
||||
/** Whether to show pageviews column */
|
||||
showPageviews?: boolean;
|
||||
/** Whether to show sessions column */
|
||||
showSessions?: boolean;
|
||||
}
|
||||
|
||||
export function OverviewListModal<T extends OverviewListItem>({
|
||||
title,
|
||||
searchPlaceholder = 'Search...',
|
||||
data,
|
||||
keyExtractor,
|
||||
searchFilter,
|
||||
renderItem,
|
||||
footer,
|
||||
headerContent,
|
||||
columnName = 'Name',
|
||||
showPageviews = true,
|
||||
showSessions = true,
|
||||
}: OverviewListModalProps<T>) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const number = useNumber();
|
||||
|
||||
// Filter data based on search query
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return data;
|
||||
}
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return data.filter((item) => searchFilter(item, queryLower));
|
||||
}, [data, searchQuery, searchFilter]);
|
||||
|
||||
// Calculate totals and check for revenue
|
||||
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
|
||||
useMemo(() => {
|
||||
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
|
||||
const totalRevenue = filteredData.reduce(
|
||||
(sum, item) => sum + (item.revenue ?? 0),
|
||||
0,
|
||||
);
|
||||
const hasRevenue = filteredData.some((item) => (item.revenue ?? 0) > 0);
|
||||
const hasPageviews =
|
||||
showPageviews && filteredData.some((item) => item.pageviews > 0);
|
||||
return { maxSessions, totalRevenue, hasRevenue, hasPageviews };
|
||||
}, [filteredData, showPageviews]);
|
||||
|
||||
// Virtual list setup
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredData.length,
|
||||
getScrollElement: () => scrollAreaRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0 sm:max-w-2xl">
|
||||
{/* Sticky Header */}
|
||||
<div className="flex-shrink-0 border-b border-border">
|
||||
<div className="p-6 pb-4">
|
||||
<DialogTitle className="text-lg font-semibold mb-4">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{headerContent}
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div
|
||||
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
|
||||
style={{
|
||||
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||
}}
|
||||
>
|
||||
<div className="text-left truncate">{columnName}</div>
|
||||
{hasRevenue && <div className="text-right">Revenue</div>}
|
||||
{hasPageviews && <div className="text-right">Views</div>}
|
||||
{showSessions && <div className="text-right">Sessions</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Virtualized Scrollable Body */}
|
||||
<div
|
||||
ref={scrollAreaRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto"
|
||||
style={{ maxHeight: '60vh' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const item = filteredData[virtualRow.index];
|
||||
if (!item) return null;
|
||||
|
||||
const percentage = item.sessions / maxSessions;
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? (item.revenue ?? 0) / totalRevenue : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={keyExtractor(item)}
|
||||
className="absolute top-0 left-0 w-full group/row"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Background bar */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors"
|
||||
style={{ width: `${percentage * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row content */}
|
||||
<div
|
||||
className="relative grid h-full items-center px-4 border-b border-border"
|
||||
style={{
|
||||
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||
}}
|
||||
>
|
||||
{/* Main content cell */}
|
||||
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
|
||||
|
||||
{/* Revenue cell */}
|
||||
{hasRevenue && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span
|
||||
className="font-semibold font-mono text-sm"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{(item.revenue ?? 0) > 0
|
||||
? number.currency((item.revenue ?? 0) / 100, {
|
||||
short: true,
|
||||
})
|
||||
: '-'}
|
||||
</span>
|
||||
<RevenuePieChart percentage={revenuePercentage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pageviews cell */}
|
||||
{hasPageviews && (
|
||||
<div className="text-right font-semibold font-mono text-sm">
|
||||
{number.short(item.pageviews)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions cell */}
|
||||
{showSessions && (
|
||||
<div className="text-right font-semibold font-mono text-sm">
|
||||
{number.short(item.sessions)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{filteredData.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||
{searchQuery ? 'No results found' : 'No data available'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed Footer */}
|
||||
{footer && (
|
||||
<div className="flex-shrink-0 border-t border-border p-4">{footer}</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
@@ -7,14 +8,18 @@ import * as Portal from '@radix-ui/react-portal';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import throttle from 'lodash.throttle';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Customized,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { BarShapeBlue } from '../charts/common-bar';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
interface OverviewLiveHistogramProps {
|
||||
projectId: string;
|
||||
@@ -81,8 +86,10 @@ export function OverviewLiveHistogram({
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<Bar
|
||||
dataKey="sessionCount"
|
||||
className="fill-chart-0"
|
||||
fill="rgba(59, 121, 255, 0.2)"
|
||||
isAnimationActive={false}
|
||||
shape={BarShapeBlue}
|
||||
activeBar={BarShapeBlue}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart, Bar, BarChart, Tooltip } from 'recharts';
|
||||
import { Area, AreaChart, Tooltip } from 'recharts';
|
||||
|
||||
import { formatDate, timeAgo } from '@/utils/date';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
@@ -144,33 +144,51 @@ export function OverviewMetricCard({
|
||||
<div className={cn('group relative p-4')}>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-4 right-4 bottom-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
||||
'absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<AutoSizer style={{ height: 20 }}>
|
||||
{({ width }) => (
|
||||
<BarChart
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={20}
|
||||
height={height / 4}
|
||||
data={data}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
}}
|
||||
style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
|
||||
onMouseMove={(event) => {
|
||||
setCurrentIndex(event.activeTooltipIndex ?? null);
|
||||
}}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
>
|
||||
<Tooltip content={() => null} cursor={false} />
|
||||
<Bar
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`colorUv${id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={graphColors}
|
||||
stopOpacity={0.2}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={graphColors}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Tooltip content={() => null} />
|
||||
<Area
|
||||
dataKey={'current'}
|
||||
type="step"
|
||||
fill={graphColors}
|
||||
fill={`url(#colorUv${id})`}
|
||||
fillOpacity={1}
|
||||
strokeWidth={0}
|
||||
stroke={graphColors}
|
||||
strokeWidth={1}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</AreaChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
@@ -207,11 +225,13 @@ export function OverviewMetricCardNumber({
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0 col gap-2 items-start', className)}>
|
||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
|
||||
{label}
|
||||
</span>
|
||||
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
@@ -219,13 +239,13 @@ export function OverviewMetricCardNumber({
|
||||
<Skeleton className="h-6 w-12" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
|
||||
{value}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
|
||||
{value}
|
||||
</div>
|
||||
{enhancer}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute right-0 top-0 bottom-0 center justify-center col pr-4">
|
||||
{enhancer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -11,12 +13,7 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import {
|
||||
OverviewLineChart,
|
||||
OverviewLineChartLoading,
|
||||
} from './overview-line-chart';
|
||||
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
|
||||
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import {
|
||||
OverviewWidgetTableGeneric,
|
||||
OverviewWidgetTableLoading,
|
||||
@@ -34,7 +31,6 @@ export default function OverviewTopDevices({
|
||||
useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const [chartType] = useState<IChartType>('bar');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
|
||||
device: {
|
||||
@@ -320,7 +316,6 @@ export default function OverviewTopDevices({
|
||||
});
|
||||
|
||||
const trpc = useTRPC();
|
||||
const [view] = useOverviewView();
|
||||
|
||||
const query = useQuery(
|
||||
trpc.overview.topGeneric.queryOptions({
|
||||
@@ -333,67 +328,31 @@ export default function OverviewTopDevices({
|
||||
}),
|
||||
);
|
||||
|
||||
const seriesQuery = useQuery(
|
||||
trpc.overview.topGenericSeries.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
enabled: view === 'chart',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const data = (query.data ?? []).slice(0, 15);
|
||||
if (!searchQuery.trim()) {
|
||||
return data;
|
||||
}
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
|
||||
}, [query.data, searchQuery]);
|
||||
|
||||
const tabs = widgets.map((w) => ({
|
||||
key: w.key,
|
||||
label: w.btn,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
/>
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
type="button"
|
||||
key={w.key}
|
||||
onClick={() => setWidget(w.key)}
|
||||
className={cn(w.key === widget.key && 'active')}
|
||||
>
|
||||
{w.btn}
|
||||
</button>
|
||||
))}
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
{view === 'chart' ? (
|
||||
seriesQuery.isLoading ? (
|
||||
<OverviewLineChartLoading />
|
||||
) : seriesQuery.data ? (
|
||||
<OverviewLineChart
|
||||
data={seriesQuery.data}
|
||||
interval={interval}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
) : (
|
||||
<OverviewLineChartLoading />
|
||||
)
|
||||
) : query.isLoading ? (
|
||||
{query.isLoading ? (
|
||||
<OverviewWidgetTableLoading />
|
||||
) : (
|
||||
<OverviewWidgetTableGeneric
|
||||
data={filteredData}
|
||||
data={query.data ?? []}
|
||||
column={{
|
||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||
render(item) {
|
||||
@@ -425,8 +384,7 @@ export default function OverviewTopDevices({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<OverviewViewToggle />
|
||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -1,172 +1,225 @@
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { IReportInput } from '@openpanel/validation';
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
|
||||
import {
|
||||
type EventTableItem,
|
||||
OverviewWidgetTableEvents,
|
||||
OverviewWidgetTableLoading,
|
||||
} from './overview-widget-table';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export interface OverviewTopEventsProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default function OverviewTopEvents({
|
||||
projectId,
|
||||
}: OverviewTopEventsProps) {
|
||||
const { interval, range, previous, startDate, endDate } =
|
||||
useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const [filters] = useEventQueryFilters();
|
||||
const trpc = useTRPC();
|
||||
const { data: conversions } = useQuery(
|
||||
trpc.event.conversionNames.queryOptions({ projectId }),
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('ev', {
|
||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
|
||||
your: {
|
||||
title: 'Events',
|
||||
btn: 'Events',
|
||||
meta: {
|
||||
filters: [
|
||||
{
|
||||
id: 'ex_session',
|
||||
name: 'name',
|
||||
operator: 'isNot',
|
||||
value: ['session_start', 'session_end', 'screen_view'],
|
||||
},
|
||||
],
|
||||
eventName: '*',
|
||||
title: 'Top events',
|
||||
btn: 'Your',
|
||||
chart: {
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
...filters,
|
||||
{
|
||||
id: 'ex_session',
|
||||
name: 'name',
|
||||
operator: 'isNot',
|
||||
value: ['session_start', 'session_end', 'screen_view'],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'name',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Your top events',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
all: {
|
||||
title: 'Top events',
|
||||
btn: 'All',
|
||||
chart: {
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [...filters],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'name',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'All top events',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
conversions: {
|
||||
title: 'Conversions',
|
||||
btn: 'Conversions',
|
||||
hide: !conversions || conversions.length === 0,
|
||||
meta: {
|
||||
filters: [
|
||||
{
|
||||
id: 'conversion',
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: conversions?.map((c) => c.name) ?? [],
|
||||
},
|
||||
],
|
||||
eventName: '*',
|
||||
chart: {
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
...filters,
|
||||
{
|
||||
id: 'conversion',
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: conversions?.map((c) => c.name) ?? [],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'name',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Conversions',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
link_out: {
|
||||
title: 'Link out',
|
||||
btn: 'Link out',
|
||||
meta: {
|
||||
filters: [],
|
||||
eventName: 'link_out',
|
||||
breakdownProperty: 'properties.href',
|
||||
chart: {
|
||||
report: {
|
||||
limit: 10,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
id: 'A',
|
||||
name: 'link_out',
|
||||
filters: [],
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'properties.href',
|
||||
},
|
||||
],
|
||||
chartType,
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Link out',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report: IReportInput = useMemo(
|
||||
() => ({
|
||||
limit: 1000,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
series: [
|
||||
{
|
||||
type: 'event' as const,
|
||||
segment: 'event' as const,
|
||||
filters: [...filters, ...(widget.meta?.filters ?? [])],
|
||||
id: 'A',
|
||||
name: widget.meta?.eventName ?? '*',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: widget.meta?.breakdownProperty ?? 'name',
|
||||
},
|
||||
],
|
||||
chartType: 'bar' as const,
|
||||
interval,
|
||||
range,
|
||||
previous,
|
||||
metric: 'sum' as const,
|
||||
}),
|
||||
[projectId, startDate, endDate, filters, widget, interval, range, previous],
|
||||
);
|
||||
|
||||
const query = useQuery(trpc.chart.aggregate.queryOptions(report));
|
||||
|
||||
const tableData: EventTableItem[] = useMemo(() => {
|
||||
if (!query.data?.series) return [];
|
||||
|
||||
return query.data.series.map((serie) => ({
|
||||
id: serie.id,
|
||||
name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '',
|
||||
count: serie.metrics.sum,
|
||||
}));
|
||||
}, [query.data]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return tableData.slice(0, 15);
|
||||
}
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return tableData
|
||||
.filter((item) => item.name?.toLowerCase().includes(queryLower))
|
||||
.slice(0, 15);
|
||||
}, [tableData, searchQuery]);
|
||||
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
widgets
|
||||
.filter((item) => item.hide !== true)
|
||||
.map((w) => ({
|
||||
key: w.key,
|
||||
label: w.btn,
|
||||
})),
|
||||
[widgets],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
/>
|
||||
<WidgetBody className="p-0">
|
||||
{query.isLoading ? (
|
||||
<OverviewWidgetTableLoading />
|
||||
) : (
|
||||
<OverviewWidgetTableEvents
|
||||
data={filteredData}
|
||||
onItemClick={(name) => {
|
||||
if (widget.meta?.breakdownProperty) {
|
||||
setFilter(widget.meta.breakdownProperty, name);
|
||||
} else {
|
||||
setFilter('name', name);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets
|
||||
.filter((item) => item.hide !== true)
|
||||
.map((w) => (
|
||||
<button
|
||||
type="button"
|
||||
key={w.key}
|
||||
onClick={() => setWidget(w.key)}
|
||||
className={cn(w.key === widget.key && 'active')}
|
||||
>
|
||||
{w.btn}
|
||||
</button>
|
||||
))}
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-3">
|
||||
<ReportChart
|
||||
options={{
|
||||
hideID: true,
|
||||
columns: ['Event'],
|
||||
renderSerieName(names) {
|
||||
return names[1];
|
||||
},
|
||||
}}
|
||||
report={{
|
||||
...widget.chart.report,
|
||||
previous: false,
|
||||
}}
|
||||
/>
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<div className="flex-1" />
|
||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
|
||||
import type { IGetTopGenericInput } from '@openpanel/db';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import {
|
||||
OVERVIEW_COLUMNS_NAME,
|
||||
OVERVIEW_COLUMNS_NAME_PLURAL,
|
||||
} from './overview-constants';
|
||||
import { OverviewListModal } from './overview-list-modal';
|
||||
import { OverviewWidgetTableGeneric } from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
interface OverviewTopGenericModalProps {
|
||||
@@ -21,55 +24,83 @@ export default function OverviewTopGenericModal({
|
||||
projectId,
|
||||
column,
|
||||
}: OverviewTopGenericModalProps) {
|
||||
const [_filters, setFilter] = useEventQueryFilters();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const { startDate, endDate, range } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
const query = useQuery(
|
||||
trpc.overview.topGeneric.queryOptions({
|
||||
projectId,
|
||||
filters: _filters,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
column,
|
||||
}),
|
||||
const query = useInfiniteQuery(
|
||||
trpc.overview.topGeneric.infiniteQueryOptions(
|
||||
{
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
limit: 50,
|
||||
column,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage, pages) => {
|
||||
if (lastPage.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pages.length + 1;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const data = query.data?.pages.flat() || [];
|
||||
const isEmpty = !query.hasNextPage && !query.isFetching;
|
||||
|
||||
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
|
||||
const columnName = OVERVIEW_COLUMNS_NAME[column];
|
||||
|
||||
return (
|
||||
<OverviewListModal
|
||||
title={`Top ${columnNamePlural}`}
|
||||
searchPlaceholder={`Search ${columnNamePlural.toLowerCase()}...`}
|
||||
data={query.data ?? []}
|
||||
keyExtractor={(item) => (item.prefix ?? '') + item.name}
|
||||
searchFilter={(item, query) =>
|
||||
item.name?.toLowerCase().includes(query) ||
|
||||
item.prefix?.toLowerCase().includes(query) ||
|
||||
false
|
||||
}
|
||||
columnName={columnName}
|
||||
renderItem={(item) => (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<SerieIcon name={item.prefix || item.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate hover:underline"
|
||||
onClick={() => {
|
||||
setFilter(column, item.name);
|
||||
}}
|
||||
<ModalContent>
|
||||
<ModalHeader title={`Top ${columnNamePlural}`} />
|
||||
<ScrollArea className="-mx-6 px-2">
|
||||
<OverviewWidgetTableGeneric
|
||||
data={data}
|
||||
column={{
|
||||
name: columnName,
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<SerieIcon name={item.prefix || item.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter(column, item.name);
|
||||
}}
|
||||
>
|
||||
{item.prefix && (
|
||||
<span className="mr-1 row inline-flex items-center gap-1">
|
||||
<span>{item.prefix}</span>
|
||||
<span>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{item.name || 'Not set'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<div className="row center-center p-4 pb-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={isEmpty}
|
||||
>
|
||||
{item.prefix && (
|
||||
<span className="mr-1 inline-flex items-center gap-1">
|
||||
<span>{item.prefix}</span>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
{item.name || 'Not set'}
|
||||
</button>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { countries } from '@/translations/countries';
|
||||
@@ -11,20 +13,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { ReportChartShortcut } from '../report-chart/shortcut';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import {
|
||||
OverviewLineChart,
|
||||
OverviewLineChartLoading,
|
||||
} from './overview-line-chart';
|
||||
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
|
||||
import {
|
||||
WidgetFooter,
|
||||
WidgetHead,
|
||||
WidgetHeadSearchable,
|
||||
} from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import {
|
||||
OverviewWidgetTableGeneric,
|
||||
OverviewWidgetTableLoading,
|
||||
@@ -40,7 +32,6 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
useOverviewOptions();
|
||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
|
||||
country: {
|
||||
@@ -57,8 +48,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const number = useNumber();
|
||||
const trpc = useTRPC();
|
||||
const [view] = useOverviewView();
|
||||
|
||||
const query = useQuery(
|
||||
trpc.overview.topGeneric.queryOptions({
|
||||
@@ -71,74 +62,31 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
}),
|
||||
);
|
||||
|
||||
const seriesQuery = useQuery(
|
||||
trpc.overview.topGenericSeries.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
enabled: view === 'chart',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const data = (query.data ?? []).slice(0, 15);
|
||||
if (!searchQuery.trim()) {
|
||||
return data;
|
||||
}
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return data.filter(
|
||||
(item) =>
|
||||
item.name?.toLowerCase().includes(queryLower) ||
|
||||
item.prefix?.toLowerCase().includes(queryLower) ||
|
||||
countries[item.name as keyof typeof countries]
|
||||
?.toLowerCase()
|
||||
.includes(queryLower),
|
||||
);
|
||||
}, [query.data, searchQuery]);
|
||||
|
||||
const tabs = widgets.map((w) => ({
|
||||
key: w.key,
|
||||
label: w.btn,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
/>
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
type="button"
|
||||
key={w.key}
|
||||
onClick={() => setWidget(w.key)}
|
||||
className={cn(w.key === widget.key && 'active')}
|
||||
>
|
||||
{w.btn}
|
||||
</button>
|
||||
))}
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
{view === 'chart' ? (
|
||||
seriesQuery.isLoading ? (
|
||||
<OverviewLineChartLoading />
|
||||
) : seriesQuery.data ? (
|
||||
<OverviewLineChart
|
||||
data={seriesQuery.data}
|
||||
interval={interval}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
) : (
|
||||
<OverviewLineChartLoading />
|
||||
)
|
||||
) : query.isLoading ? (
|
||||
{query.isLoading ? (
|
||||
<OverviewWidgetTableLoading />
|
||||
) : (
|
||||
<OverviewWidgetTableGeneric
|
||||
data={filteredData}
|
||||
data={query.data ?? []}
|
||||
column={{
|
||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||
render(item) {
|
||||
@@ -182,7 +130,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
/>
|
||||
)}
|
||||
</WidgetBody>
|
||||
<WidgetFooter className="row items-center justify-between">
|
||||
<WidgetFooter>
|
||||
<OverviewDetailsButton
|
||||
onClick={() =>
|
||||
pushModal('OverviewTopGenericModal', {
|
||||
@@ -191,19 +139,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<OverviewViewToggle />
|
||||
<span className="text-sm text-muted-foreground pr-2 ml-2">
|
||||
Geo data provided by{' '}
|
||||
<a
|
||||
href="https://ipdata.co"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
className="hover:underline"
|
||||
>
|
||||
MaxMind
|
||||
</a>
|
||||
</span>
|
||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
@@ -211,8 +147,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
<div className="title">Map</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<ReportChartShortcut
|
||||
{...{
|
||||
<ReportChart
|
||||
options={{ hideID: true }}
|
||||
report={{
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -232,9 +169,12 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
},
|
||||
],
|
||||
chartType: 'map',
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top sources',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
}}
|
||||
/>
|
||||
</WidgetBody>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ExternalLinkIcon } from 'lucide-react';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
import { OverviewListModal } from './overview-list-modal';
|
||||
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { OverviewWidgetTablePages } from './overview-widget-table';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
interface OverviewTopPagesProps {
|
||||
@@ -18,54 +18,44 @@ export default function OverviewTopPagesModal({
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const { startDate, endDate, range } = useOverviewOptions();
|
||||
const trpc = useTRPC();
|
||||
const query = useQuery(
|
||||
trpc.overview.topPages.queryOptions({
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
mode: 'page',
|
||||
range,
|
||||
}),
|
||||
const query = useInfiniteQuery(
|
||||
trpc.overview.topPages.infiniteQueryOptions(
|
||||
{
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
mode: 'page',
|
||||
range,
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (_, pages) => pages.length + 1,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const data = query.data?.pages.flat();
|
||||
|
||||
return (
|
||||
<OverviewListModal
|
||||
title="Top Pages"
|
||||
searchPlaceholder="Search pages..."
|
||||
data={query.data ?? []}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
searchFilter={(item, query) =>
|
||||
item.path.toLowerCase().includes(query) ||
|
||||
item.origin.toLowerCase().includes(query)
|
||||
}
|
||||
columnName="Path"
|
||||
renderItem={(item) => (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<SerieIcon name={item.origin} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate hover:underline"
|
||||
onClick={() => {
|
||||
setFilter('path', item.path);
|
||||
setFilter('origin', item.origin);
|
||||
}}
|
||||
>
|
||||
{item.path || <span className="opacity-40">Not set</span>}
|
||||
</button>
|
||||
<a
|
||||
href={item.origin + item.path}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 opacity-0 group-hover/row:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
</Tooltiper>
|
||||
)}
|
||||
/>
|
||||
<ModalContent>
|
||||
<ModalHeader title="Top Pages" />
|
||||
<ScrollArea className="-mx-6 px-2">
|
||||
<OverviewWidgetTablePages
|
||||
data={data ?? []}
|
||||
lastColumnName={'Sessions'}
|
||||
/>
|
||||
<div className="row center-center p-4 pb-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
loading={query.isFetching}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Globe2Icon } from 'lucide-react';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
@@ -9,9 +9,8 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '../ui/button';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import {
|
||||
OverviewWidgetTableEntries,
|
||||
OverviewWidgetTableLoading,
|
||||
OverviewWidgetTablePages,
|
||||
} from './overview-widget-table';
|
||||
@@ -26,11 +25,15 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
|
||||
page: {
|
||||
title: 'Top pages',
|
||||
btn: 'Pages',
|
||||
btn: 'Top pages',
|
||||
meta: {
|
||||
columns: {
|
||||
sessions: 'Sessions',
|
||||
},
|
||||
},
|
||||
},
|
||||
entry: {
|
||||
title: 'Entry Pages',
|
||||
@@ -50,6 +53,10 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
},
|
||||
},
|
||||
},
|
||||
// bot: {
|
||||
// title: 'Bots',
|
||||
// btn: 'Bots',
|
||||
// },
|
||||
});
|
||||
const trpc = useTRPC();
|
||||
|
||||
@@ -64,53 +71,37 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
}),
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const data = query.data?.slice(0, 15) ?? [];
|
||||
if (!searchQuery.trim()) {
|
||||
return data;
|
||||
}
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return data.filter(
|
||||
(item) =>
|
||||
item.path.toLowerCase().includes(queryLower) ||
|
||||
item.origin.toLowerCase().includes(queryLower),
|
||||
);
|
||||
}, [query.data, searchQuery]);
|
||||
|
||||
const tabs = widgets.map((w) => ({
|
||||
key: w.key,
|
||||
label: w.btn,
|
||||
}));
|
||||
const data = query.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
/>
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
type="button"
|
||||
key={w.key}
|
||||
onClick={() => setWidget(w.key)}
|
||||
className={cn(w.key === widget.key && 'active')}
|
||||
>
|
||||
{w.btn}
|
||||
</button>
|
||||
))}
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
{query.isLoading ? (
|
||||
<OverviewWidgetTableLoading />
|
||||
) : (
|
||||
<>
|
||||
{widget.meta?.columns.sessions ? (
|
||||
<OverviewWidgetTableEntries
|
||||
data={filteredData}
|
||||
lastColumnName={widget.meta.columns.sessions}
|
||||
showDomain={!!domain}
|
||||
/>
|
||||
) : (
|
||||
<OverviewWidgetTablePages
|
||||
data={filteredData}
|
||||
showDomain={!!domain}
|
||||
/>
|
||||
)}
|
||||
{/*<OverviewWidgetTableBots data={data ?? []} />*/}
|
||||
<OverviewWidgetTablePages
|
||||
data={data ?? []}
|
||||
lastColumnName={widget.meta.columns.sessions}
|
||||
showDomain={!!domain}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</WidgetBody>
|
||||
@@ -118,6 +109,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
<OverviewDetailsButton
|
||||
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
|
||||
/>
|
||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
@@ -9,12 +9,7 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||
import OverviewDetailsButton from './overview-details-button';
|
||||
import {
|
||||
OverviewLineChart,
|
||||
OverviewLineChartLoading,
|
||||
} from './overview-line-chart';
|
||||
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
|
||||
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import {
|
||||
OverviewWidgetTableGeneric,
|
||||
OverviewWidgetTableLoading,
|
||||
@@ -28,18 +23,16 @@ interface OverviewTopSourcesProps {
|
||||
export default function OverviewTopSources({
|
||||
projectId,
|
||||
}: OverviewTopSourcesProps) {
|
||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [view] = useOverviewView();
|
||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
|
||||
referrer_name: {
|
||||
title: 'Top sources',
|
||||
btn: 'Refs',
|
||||
btn: 'All',
|
||||
},
|
||||
referrer: {
|
||||
title: 'Top urls',
|
||||
btn: 'Urls',
|
||||
btn: 'URLs',
|
||||
},
|
||||
referrer_type: {
|
||||
title: 'Top types',
|
||||
@@ -79,67 +72,31 @@ export default function OverviewTopSources({
|
||||
}),
|
||||
);
|
||||
|
||||
const seriesQuery = useQuery(
|
||||
trpc.overview.topGenericSeries.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
range,
|
||||
filters,
|
||||
column: widget.key,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
enabled: view === 'chart',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const data = (query.data ?? []).slice(0, 15);
|
||||
if (!searchQuery.trim()) {
|
||||
return data;
|
||||
}
|
||||
const queryLower = searchQuery.toLowerCase();
|
||||
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
|
||||
}, [query.data, searchQuery]);
|
||||
|
||||
const tabs = widgets.map((w) => ({
|
||||
key: w.key,
|
||||
label: w.btn,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Widget className="col-span-6 md:col-span-3">
|
||||
<WidgetHeadSearchable
|
||||
tabs={tabs}
|
||||
activeTab={widget.key}
|
||||
onTabChange={setWidget}
|
||||
searchValue={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||
className="border-b-0 pb-2"
|
||||
/>
|
||||
<WidgetHead>
|
||||
<div className="title">{widget.title}</div>
|
||||
|
||||
<WidgetButtons>
|
||||
{widgets.map((w) => (
|
||||
<button
|
||||
type="button"
|
||||
key={w.key}
|
||||
onClick={() => setWidget(w.key)}
|
||||
className={cn(w.key === widget.key && 'active')}
|
||||
>
|
||||
{w.btn}
|
||||
</button>
|
||||
))}
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-0">
|
||||
{view === 'chart' ? (
|
||||
seriesQuery.isLoading ? (
|
||||
<OverviewLineChartLoading />
|
||||
) : seriesQuery.data ? (
|
||||
<OverviewLineChart
|
||||
data={seriesQuery.data}
|
||||
interval={interval}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
) : (
|
||||
<OverviewLineChartLoading />
|
||||
)
|
||||
) : query.isLoading ? (
|
||||
{query.isLoading ? (
|
||||
<OverviewWidgetTableLoading />
|
||||
) : (
|
||||
<OverviewWidgetTableGeneric
|
||||
data={filteredData}
|
||||
data={query.data ?? []}
|
||||
column={{
|
||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||
render(item) {
|
||||
@@ -180,8 +137,7 @@ export default function OverviewTopSources({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<OverviewViewToggle />
|
||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { ResponsiveSankey } from '@nivo/sankey';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
import {
|
||||
type ReactNode,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { truncate } from '@/utils/truncate';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import { useTheme } from '../theme-provider';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
interface OverviewUserJourneyProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
|
||||
|
||||
const showPath = (string: string) => {
|
||||
try {
|
||||
const url = new URL(string);
|
||||
return url.pathname;
|
||||
} catch {
|
||||
return string;
|
||||
}
|
||||
};
|
||||
|
||||
const showDomain = (string: string) => {
|
||||
try {
|
||||
const url = new URL(string);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return string;
|
||||
}
|
||||
};
|
||||
|
||||
function SankeyPortalTooltip({
|
||||
children,
|
||||
offset = 12,
|
||||
padding = 8,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
offset?: number;
|
||||
padding?: number;
|
||||
}) {
|
||||
const anchorRef = useRef<HTMLSpanElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||
const [pos, setPos] = useState<PortalTooltipPosition>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
ready: false,
|
||||
});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Nivo renders the tooltip content inside an absolutely-positioned wrapper <div>.
|
||||
// The wrapper is the immediate parent of our rendered content.
|
||||
const wrapper = el.parentElement;
|
||||
if (!wrapper) return;
|
||||
|
||||
const update = () => {
|
||||
setAnchorRect(wrapper.getBoundingClientRect());
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(wrapper);
|
||||
|
||||
window.addEventListener('scroll', update, true);
|
||||
window.addEventListener('resize', update);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('scroll', update, true);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!anchorRect) return;
|
||||
const tooltipEl = tooltipRef.current;
|
||||
if (!tooltipEl) return;
|
||||
|
||||
const rect = tooltipEl.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
// Start by following Nivo's tooltip anchor position.
|
||||
let left = anchorRect.left + offset;
|
||||
let top = anchorRect.top + offset;
|
||||
|
||||
// Clamp inside viewport with a little padding.
|
||||
left = Math.min(
|
||||
Math.max(padding, left),
|
||||
Math.max(padding, vw - rect.width - padding),
|
||||
);
|
||||
top = Math.min(
|
||||
Math.max(padding, top),
|
||||
Math.max(padding, vh - rect.height - padding),
|
||||
);
|
||||
|
||||
setPos({ left, top, ready: true });
|
||||
}, [mounted, anchorRect, children, offset, padding]);
|
||||
|
||||
// SSR safety: on the server, just render the tooltip normally.
|
||||
if (typeof document === 'undefined') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Render a tiny (screen-reader-only) anchor inside Nivo's tooltip wrapper. */}
|
||||
<span ref={anchorRef} className="sr-only" />
|
||||
{mounted &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="pointer-events-none fixed z-[9999]"
|
||||
style={{
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
visibility: pos.ready ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewUserJourney({
|
||||
projectId,
|
||||
}: OverviewUserJourneyProps) {
|
||||
const { range, startDate, endDate } = useOverviewOptions();
|
||||
const [filters] = useEventQueryFilters();
|
||||
const [steps, setSteps] = useQueryState(
|
||||
'journeySteps',
|
||||
parseAsInteger.withDefault(5).withOptions({ history: 'push' }),
|
||||
);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const trpc = useTRPC();
|
||||
|
||||
const query = useQuery(
|
||||
trpc.overview.userJourney.queryOptions({
|
||||
projectId,
|
||||
filters,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
steps: steps ?? 5,
|
||||
}),
|
||||
);
|
||||
|
||||
const data = query.data;
|
||||
const number = useNumber();
|
||||
|
||||
// Process data for Sankey - nodes are already sorted by step then value from backend
|
||||
const sankeyData = useMemo(() => {
|
||||
if (!data) return { nodes: [], links: [] };
|
||||
|
||||
return {
|
||||
nodes: data.nodes.map((node: any) => ({
|
||||
...node,
|
||||
// Store label for display in tooltips
|
||||
label: node.label || node.id,
|
||||
data: {
|
||||
percentage: node.percentage,
|
||||
value: node.value,
|
||||
step: node.step,
|
||||
label: node.label || node.id,
|
||||
},
|
||||
})),
|
||||
links: data.links,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const totalSessions = useMemo(() => {
|
||||
if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0;
|
||||
// Total sessions used by backend for percentages is the sum of entry nodes (step 1).
|
||||
// Fall back to summing all nodes if step is missing for some reason.
|
||||
const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1);
|
||||
const base = step1.length > 0 ? step1 : sankeyData.nodes;
|
||||
return base.reduce((sum: number, n: any) => sum + (n.data?.value ?? 0), 0);
|
||||
}, [sankeyData.nodes]);
|
||||
|
||||
const stepOptions = [3, 5];
|
||||
|
||||
const { appTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Widget className="col-span-6">
|
||||
<WidgetHead>
|
||||
<div className="title">User Journey</div>
|
||||
<WidgetButtons>
|
||||
{stepOptions.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option}
|
||||
onClick={() => setSteps(option)}
|
||||
className={cn((steps ?? 5) === option && 'active')}
|
||||
>
|
||||
{option} Steps
|
||||
</button>
|
||||
))}
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
{query.isLoading ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
) : sankeyData.nodes.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No journey data available
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full relative aspect-square md:aspect-[2]"
|
||||
>
|
||||
<ResponsiveSankey
|
||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||
data={sankeyData}
|
||||
colors={(node: any) => node.nodeColor}
|
||||
nodeBorderRadius={2}
|
||||
animate={false}
|
||||
nodeBorderWidth={0}
|
||||
nodeOpacity={0.8}
|
||||
linkContract={1}
|
||||
linkOpacity={0.3}
|
||||
linkBlendMode={'normal'}
|
||||
nodeTooltip={({ node }: any) => {
|
||||
const label = node?.data?.label ?? node?.label ?? node?.id;
|
||||
const value = node?.data?.value ?? node?.value ?? 0;
|
||||
const step = node?.data?.step;
|
||||
const pct =
|
||||
typeof node?.data?.percentage === 'number'
|
||||
? node.data.percentage
|
||||
: totalSessions > 0
|
||||
? (value / totalSessions) * 100
|
||||
: 0;
|
||||
const color =
|
||||
node?.color ??
|
||||
node?.data?.nodeColor ??
|
||||
node?.data?.color ??
|
||||
node?.nodeColor ??
|
||||
'#64748b';
|
||||
|
||||
return (
|
||||
<SankeyPortalTooltip>
|
||||
<ChartTooltipContainer className="min-w-[250px]">
|
||||
<ChartTooltipHeader>
|
||||
<div className="min-w-0 flex-1 font-medium break-words">
|
||||
<span className="opacity-40 mr-1">
|
||||
{showDomain(label)}
|
||||
</span>
|
||||
{showPath(label)}
|
||||
</div>
|
||||
{typeof step === 'number' && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
Step {step}
|
||||
</div>
|
||||
)}
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Sessions</div>
|
||||
<div>{number.format(value)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Share</div>
|
||||
<div>{number.format(round(pct, 1))} %</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</SankeyPortalTooltip>
|
||||
);
|
||||
}}
|
||||
linkTooltip={({ link }: any) => {
|
||||
const sourceLabel =
|
||||
link?.source?.data?.label ??
|
||||
link?.source?.label ??
|
||||
link?.source?.id;
|
||||
const targetLabel =
|
||||
link?.target?.data?.label ??
|
||||
link?.target?.label ??
|
||||
link?.target?.id;
|
||||
|
||||
const value = link?.value ?? 0;
|
||||
const sourceValue =
|
||||
link?.source?.data?.value ?? link?.source?.value ?? 0;
|
||||
|
||||
const pctOfTotal =
|
||||
totalSessions > 0 ? (value / totalSessions) * 100 : 0;
|
||||
const pctOfSource =
|
||||
sourceValue > 0 ? (value / sourceValue) * 100 : 0;
|
||||
|
||||
const sourceStep = link?.source?.data?.step;
|
||||
const targetStep = link?.target?.data?.step;
|
||||
|
||||
const color =
|
||||
link?.color ??
|
||||
link?.source?.color ??
|
||||
link?.source?.data?.nodeColor ??
|
||||
'#64748b';
|
||||
|
||||
const sourceDomain = showDomain(sourceLabel);
|
||||
const targetDomain = showDomain(targetLabel);
|
||||
const isSameDomain = sourceDomain === targetDomain;
|
||||
|
||||
return (
|
||||
<SankeyPortalTooltip>
|
||||
<ChartTooltipContainer>
|
||||
<ChartTooltipHeader>
|
||||
<div className="min-w-0 flex-1 font-medium break-words">
|
||||
<span className="opacity-40 mr-1">
|
||||
{showDomain(sourceLabel)}
|
||||
</span>
|
||||
{showPath(sourceLabel)}
|
||||
<ArrowRightIcon className="size-2 inline-block mx-3" />
|
||||
{!isSameDomain && (
|
||||
<span className="opacity-40 mr-1">
|
||||
{showDomain(targetLabel)}
|
||||
</span>
|
||||
)}
|
||||
{showPath(targetLabel)}
|
||||
</div>
|
||||
{typeof sourceStep === 'number' &&
|
||||
typeof targetStep === 'number' && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
{sourceStep} → {targetStep}
|
||||
</div>
|
||||
)}
|
||||
</ChartTooltipHeader>
|
||||
|
||||
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Sessions</div>
|
||||
<div>{number.format(value)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
% of total
|
||||
</div>
|
||||
<div>{number.format(round(pctOfTotal, 1))} %</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
% of source
|
||||
</div>
|
||||
<div>{number.format(round(pctOfSource, 1))} %</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</SankeyPortalTooltip>
|
||||
);
|
||||
}}
|
||||
label={(node: any) => {
|
||||
const label = showPath(
|
||||
node.data?.label || node.label || node.id,
|
||||
);
|
||||
return truncate(label, 30, 'middle');
|
||||
}}
|
||||
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
|
||||
nodeSpacing={10}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</WidgetBody>
|
||||
<WidgetFooter>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Shows the most common paths users take through your application
|
||||
</div>
|
||||
</WidgetFooter>
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { LineChartIcon, TableIcon } from 'lucide-react';
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
type ViewType = 'table' | 'chart';
|
||||
|
||||
interface OverviewViewToggleProps {
|
||||
defaultView?: ViewType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OverviewViewToggle({
|
||||
defaultView = 'table',
|
||||
className,
|
||||
}: OverviewViewToggleProps) {
|
||||
const [view, setView] = useQueryState<ViewType>(
|
||||
'view',
|
||||
parseAsStringEnum(['table', 'chart'])
|
||||
.withDefault(defaultView)
|
||||
.withOptions({ history: 'push' }),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setView(view === 'table' ? 'chart' : 'table');
|
||||
}}
|
||||
title={view === 'table' ? 'Switch to chart view' : 'Switch to table view'}
|
||||
>
|
||||
{view === 'table' ? (
|
||||
<LineChartIcon size={16} />
|
||||
) : (
|
||||
<TableIcon size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOverviewView() {
|
||||
const [view, setView] = useQueryState<ViewType>(
|
||||
'view',
|
||||
parseAsStringEnum(['table', 'chart'])
|
||||
.withDefault('table')
|
||||
.withOptions({ history: 'push' }),
|
||||
);
|
||||
|
||||
return [view, setView] as const;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const OverviewWidgetTable = <T,>({
|
||||
<WidgetTable
|
||||
data={data ?? []}
|
||||
keyExtractor={keyExtractor}
|
||||
className={'text-sm min-h-[358px] @container'}
|
||||
className={'text-sm min-h-[358px] @container [&_.head]:pt-3'}
|
||||
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
||||
eachRow={(item) => {
|
||||
return (
|
||||
@@ -109,6 +109,15 @@ export function OverviewWidgetTableLoading({
|
||||
render: () => <Skeleton className="h-4 w-1/3" />,
|
||||
width: 'w-full',
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
render: () => <Skeleton className="h-4 w-[30px]" />,
|
||||
width: '60px',
|
||||
},
|
||||
// {
|
||||
// name: 'Duration',
|
||||
// render: () => <Skeleton className="h-4 w-[30px]" />,
|
||||
// },
|
||||
{
|
||||
name: 'Sessions',
|
||||
render: () => <Skeleton className="h-4 w-[30px]" />,
|
||||
@@ -132,135 +141,6 @@ function getPath(path: string, showDomain = false) {
|
||||
}
|
||||
|
||||
export function OverviewWidgetTablePages({
|
||||
data,
|
||||
className,
|
||||
showDomain = false,
|
||||
}: {
|
||||
className?: string;
|
||||
data: {
|
||||
origin: string;
|
||||
path: string;
|
||||
sessions: number;
|
||||
pageviews: number;
|
||||
revenue?: number;
|
||||
}[];
|
||||
showDomain?: boolean;
|
||||
}) {
|
||||
const [_filters, setFilter] = useEventQueryFilters();
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
|
||||
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.path + item.origin}
|
||||
getColumnPercentage={(item) => item.sessions / maxSessions}
|
||||
columns={[
|
||||
{
|
||||
name: 'Path',
|
||||
width: 'w-full',
|
||||
responsive: { priority: 1 }, // Always visible
|
||||
render(item) {
|
||||
return (
|
||||
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<SerieIcon name={item.origin} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => {
|
||||
setFilter('path', item.path);
|
||||
setFilter('origin', item.origin);
|
||||
}}
|
||||
>
|
||||
{item.path ? (
|
||||
<>
|
||||
{showDomain ? (
|
||||
<>
|
||||
<span className="opacity-40">{item.origin}</span>
|
||||
<span>{item.path}</span>
|
||||
</>
|
||||
) : (
|
||||
item.path
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="opacity-40">Not set</span>
|
||||
)}
|
||||
</button>
|
||||
<a
|
||||
href={item.origin + item.path}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
</Tooltiper>
|
||||
);
|
||||
},
|
||||
},
|
||||
...(hasRevenue
|
||||
? [
|
||||
{
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
render(item: (typeof data)[number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{revenue > 0 ? number.currency(revenue / 100) : '-'}
|
||||
</span>
|
||||
<RevenuePieChart percentage={revenuePercentage} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Views',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Sess.',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.sessions)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewWidgetTableEntries({
|
||||
data,
|
||||
lastColumnName,
|
||||
className,
|
||||
@@ -271,17 +151,18 @@ export function OverviewWidgetTableEntries({
|
||||
data: {
|
||||
origin: string;
|
||||
path: string;
|
||||
avg_duration: number;
|
||||
bounce_rate: number;
|
||||
sessions: number;
|
||||
pageviews: number;
|
||||
revenue?: number;
|
||||
revenue: number;
|
||||
}[];
|
||||
showDomain?: boolean;
|
||||
}) {
|
||||
const [_filters, setFilter] = useEventQueryFilters();
|
||||
const number = useNumber();
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
|
||||
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
|
||||
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
|
||||
const hasRevenue = data.some((item) => item.revenue > 0);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
@@ -333,6 +214,22 @@ export function OverviewWidgetTableEntries({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
width: '60px',
|
||||
responsive: { priority: 6 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Duration',
|
||||
width: '75px',
|
||||
responsive: { priority: 7 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.avg_duration, 'min');
|
||||
},
|
||||
},
|
||||
...(hasRevenue
|
||||
? [
|
||||
{
|
||||
@@ -340,16 +237,17 @@ export function OverviewWidgetTableEntries({
|
||||
width: '100px',
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
render(item: (typeof data)[number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
totalRevenue > 0 ? revenue / totalRevenue : 0;
|
||||
totalRevenue > 0 ? item.revenue / totalRevenue : 0;
|
||||
return (
|
||||
<div className="row gap-2 items-center justify-end">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ color: '#3ba974' }}
|
||||
>
|
||||
{revenue > 0 ? number.currency(revenue / 100) : '-'}
|
||||
{item.revenue > 0
|
||||
? number.currency(item.revenue / 100)
|
||||
: '-'}
|
||||
</span>
|
||||
<RevenuePieChart percentage={revenuePercentage} />
|
||||
</div>
|
||||
@@ -475,7 +373,6 @@ export function OverviewWidgetTableGeneric({
|
||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
|
||||
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
|
||||
const hasPageviews = data.some((item) => item.pageviews > 0);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
@@ -488,12 +385,27 @@ export function OverviewWidgetTableGeneric({
|
||||
width: 'w-full',
|
||||
responsive: { priority: 1 }, // Always visible
|
||||
},
|
||||
{
|
||||
name: 'BR',
|
||||
width: '60px',
|
||||
responsive: { priority: 6 }, // Hidden when space is tight
|
||||
render(item) {
|
||||
return number.shortWithUnit(item.bounce_rate, '%');
|
||||
},
|
||||
},
|
||||
// {
|
||||
// name: 'Duration',
|
||||
// render(item) {
|
||||
// return number.shortWithUnit(item.avg_session_duration, 'min');
|
||||
// },
|
||||
// },
|
||||
|
||||
...(hasRevenue
|
||||
? [
|
||||
{
|
||||
name: 'Revenue',
|
||||
width: '100px',
|
||||
responsive: { priority: 3 },
|
||||
responsive: { priority: 3 }, // Always show if possible
|
||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||
const revenue = item.revenue ?? 0;
|
||||
const revenuePercentage =
|
||||
@@ -515,28 +427,10 @@ export function OverviewWidgetTableGeneric({
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
...(hasPageviews
|
||||
? [
|
||||
{
|
||||
name: 'Views',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 },
|
||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.pageviews)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Sess.',
|
||||
name: 'Sessions',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 },
|
||||
responsive: { priority: 2 }, // Always show if possible
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
@@ -551,65 +445,3 @@ export function OverviewWidgetTableGeneric({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type EventTableItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export function OverviewWidgetTableEvents({
|
||||
data,
|
||||
className,
|
||||
onItemClick,
|
||||
}: {
|
||||
className?: string;
|
||||
data: EventTableItem[];
|
||||
onItemClick?: (name: string) => void;
|
||||
}) {
|
||||
const number = useNumber();
|
||||
const maxCount = Math.max(...data.map((item) => item.count), 1);
|
||||
return (
|
||||
<OverviewWidgetTable
|
||||
className={className}
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
getColumnPercentage={(item) => item.count / maxCount}
|
||||
columns={[
|
||||
{
|
||||
name: 'Event',
|
||||
width: 'w-full',
|
||||
responsive: { priority: 1 },
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row items-center gap-2 min-w-0 relative">
|
||||
<SerieIcon name={item.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="truncate"
|
||||
onClick={() => onItemClick?.(item.name)}
|
||||
>
|
||||
{item.name || 'Not set'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Count',
|
||||
width: '84px',
|
||||
responsive: { priority: 2 },
|
||||
render(item) {
|
||||
return (
|
||||
<div className="row gap-2 justify-end">
|
||||
<span className="font-semibold">
|
||||
{number.short(item.count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useThrottle } from '@/hooks/use-throttle';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronsUpDownIcon, type LucideIcon, SearchIcon } from 'lucide-react';
|
||||
import { ChevronsUpDownIcon, Icon, type LucideIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { Children, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Children, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { Input } from '../ui/input';
|
||||
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
|
||||
import { WidgetHead as WidgetHeadBase } from '../widget';
|
||||
|
||||
@@ -170,128 +169,6 @@ export function WidgetButtons({
|
||||
);
|
||||
}
|
||||
|
||||
interface WidgetTab<T extends string = string> {
|
||||
key: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface WidgetHeadSearchableProps<T extends string = string> {
|
||||
tabs: WidgetTab<T>[];
|
||||
activeTab: T;
|
||||
className?: string;
|
||||
onTabChange: (key: T) => void;
|
||||
searchValue?: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
export function WidgetHeadSearchable<T extends string>({
|
||||
tabs,
|
||||
className,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Search',
|
||||
}: WidgetHeadSearchableProps<T>) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||
|
||||
const updateGradients = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = el;
|
||||
const hasOverflow = scrollWidth > clientWidth;
|
||||
|
||||
setShowLeftGradient(hasOverflow && scrollLeft > 0);
|
||||
setShowRightGradient(
|
||||
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
updateGradients();
|
||||
|
||||
el.addEventListener('scroll', updateGradients);
|
||||
window.addEventListener('resize', updateGradients);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('scroll', updateGradients);
|
||||
window.removeEventListener('resize', updateGradients);
|
||||
};
|
||||
}, [updateGradients]);
|
||||
|
||||
// Update gradients when tabs change
|
||||
useEffect(() => {
|
||||
// Use RAF to ensure DOM has updated
|
||||
requestAnimationFrame(updateGradients);
|
||||
}, [tabs, updateGradients]);
|
||||
|
||||
return (
|
||||
<div className={cn('border-b border-border', className)}>
|
||||
{/* Scrollable tabs container */}
|
||||
<div className="relative">
|
||||
{/* Left gradient */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
|
||||
showLeftGradient ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Scrollable tabs */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-1 overflow-x-auto px-2 py-3 hide-scrollbar"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={cn(
|
||||
'shrink-0 rounded-md py-1.5 text-sm font-medium transition-colors px-2',
|
||||
activeTab === tab.key
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:bg-def-100 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right gradient */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-0 top-0 z-10 bottom-px w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
|
||||
showRightGradient ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
{onSearchChange && (
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue ?? ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 bg-transparent border-0 text-sm rounded-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0 border-y"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WidgetFooter({
|
||||
className,
|
||||
children,
|
||||
|
||||
@@ -33,10 +33,7 @@ export function useOverviewWidget<T extends string>(
|
||||
|
||||
export function useOverviewWidgetV2<T extends string>(
|
||||
key: string,
|
||||
widgets: Record<
|
||||
T,
|
||||
{ title: string; btn: string; meta?: any; hide?: boolean }
|
||||
>,
|
||||
widgets: Record<T, { title: string; btn: string; meta?: any }>,
|
||||
) {
|
||||
const keys = Object.keys(widgets) as T[];
|
||||
const [widget, setWidget] = useQueryState<T>(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ReportChart } from '@/components/report-chart';
|
||||
import { Widget, WidgetBody } from '@/components/widget';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { IReport } from '@openpanel/validation';
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
import { WidgetHead } from '../overview/overview-widget';
|
||||
|
||||
type Props = {
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
|
||||
export const ProfileCharts = memo(
|
||||
({ profileId, projectId }: Props) => {
|
||||
const pageViewsChart: IReport = {
|
||||
const pageViewsChart: IChartProps = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
series: [
|
||||
@@ -46,7 +46,7 @@ export const ProfileCharts = memo(
|
||||
metric: 'sum',
|
||||
};
|
||||
|
||||
const eventsChart: IReport = {
|
||||
const eventsChart: IChartProps = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
series: [
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { LoginNavbar } from './login-navbar';
|
||||
import { LogoSquare } from './logo';
|
||||
|
||||
interface PublicPageCardProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
showFooter?: boolean;
|
||||
}
|
||||
|
||||
export function PublicPageCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
showFooter = true,
|
||||
}: PublicPageCardProps) {
|
||||
return (
|
||||
<div>
|
||||
<LoginNavbar />
|
||||
<div className="center-center h-screen w-screen p-4 col">
|
||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||
<div className="col mt-1 flex-1 gap-2">
|
||||
<LogoSquare className="size-12 mb-4" />
|
||||
<div className="text-xl font-semibold">{title}</div>
|
||||
{description && (
|
||||
<div className="text-lg text-muted-foreground leading-normal">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!children && <div className="mt-6">{children}</div>}
|
||||
</div>
|
||||
{showFooter && (
|
||||
<div className="p-6 text-sm max-w-sm col gap-1 text-muted-foreground">
|
||||
<p>
|
||||
Powered by{' '}
|
||||
<a href="https://openpanel.dev" className="font-medium">
|
||||
OpenPanel.dev
|
||||
</a>
|
||||
{' · '}
|
||||
<a href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free today!
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { AnimatedNumber } from '../animated-number';
|
||||
import { BarShapeBlue } from '../charts/common-bar';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
|
||||
interface RealtimeLiveHistogramProps {
|
||||
@@ -86,8 +87,10 @@ export function RealtimeLiveHistogram({
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<Bar
|
||||
dataKey="visitorCount"
|
||||
className="fill-chart-0"
|
||||
fill="rgba(59, 121, 255, 0.2)"
|
||||
isAnimationActive={false}
|
||||
shape={BarShapeBlue}
|
||||
activeBar={BarShapeBlue}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -10,27 +9,15 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportAreaChart() {
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -4,322 +4,160 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { round } from '@openpanel/common';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
|
||||
import { DeltaChip } from '@/components/delta-chip';
|
||||
import { OverviewWidgetTable } from '../../overview/overview-widget-table';
|
||||
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
||||
import { SerieIcon } from '../common/serie-icon';
|
||||
import { SerieName } from '../common/serie-name';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
type SortOption =
|
||||
| 'count-desc'
|
||||
| 'count-asc'
|
||||
| 'name-asc'
|
||||
| 'name-desc'
|
||||
| 'percent-desc'
|
||||
| 'percent-asc';
|
||||
|
||||
interface Props {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const [isOpen, setOpen] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('count-desc');
|
||||
const {
|
||||
isEditMode,
|
||||
report: { metric, limit, previous },
|
||||
options: { onClick, dropdownMenuContent },
|
||||
options: { onClick, dropdownMenuContent, columns },
|
||||
} = useReportChartContext();
|
||||
const number = useNumber();
|
||||
const series = useMemo(
|
||||
() => (isEditMode ? data.series : data.series.slice(0, limit || 10)),
|
||||
[data, isEditMode, limit],
|
||||
);
|
||||
const maxCount = Math.max(
|
||||
...series.map((serie) => serie.metrics[metric] ?? 0),
|
||||
);
|
||||
|
||||
// Use useVisibleSeries to add index property for colors
|
||||
const { series: allSeriesWithIndex } = useVisibleSeries(data, 500);
|
||||
const tableColumns = [
|
||||
{
|
||||
name: columns?.[0] || 'Name',
|
||||
width: 'w-full',
|
||||
render: (serie: (typeof series)[0]) => {
|
||||
const isClickable = !serie.names.includes(NOT_SET_VALUE) && onClick;
|
||||
const isDropDownEnabled =
|
||||
!serie.names.includes(NOT_SET_VALUE) &&
|
||||
(dropdownMenuContent?.(serie) || []).length > 0;
|
||||
|
||||
const totalSum = data.metrics.sum || 1;
|
||||
return (
|
||||
<DropdownMenu
|
||||
onOpenChange={() =>
|
||||
setOpen((p) => (p === serie.id ? null : serie.id))
|
||||
}
|
||||
open={isOpen === serie.id}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={!isDropDownEnabled}
|
||||
{...(isDropDownEnabled
|
||||
? {
|
||||
onPointerDown: (e) => e.preventDefault(),
|
||||
onClick: () => setOpen(serie.id),
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 break-all font-medium',
|
||||
(isClickable || isDropDownEnabled) && 'cursor-pointer',
|
||||
)}
|
||||
{...(isClickable && !isDropDownEnabled
|
||||
? {
|
||||
onClick: () => onClick(serie),
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<SerieIcon name={serie.names[0]} />
|
||||
<SerieName name={serie.names} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{dropdownMenuContent?.(serie).map((item) => (
|
||||
<DropdownMenuItem key={item.title} onClick={item.onClick}>
|
||||
{item.icon && <item.icon size={16} className="mr-2" />}
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Percentage column
|
||||
{
|
||||
name: '%',
|
||||
width: '70px',
|
||||
render: (serie: (typeof series)[0]) => (
|
||||
<div className="text-muted-foreground font-mono">
|
||||
{number.format(
|
||||
round((serie.metrics.sum / data.metrics.sum) * 100, 2),
|
||||
)}
|
||||
%
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
// Calculate original ranks (based on count descending - default sort)
|
||||
const seriesWithOriginalRank = useMemo(() => {
|
||||
const sortedByCount = [...allSeriesWithIndex].sort(
|
||||
(a, b) => b.metrics.sum - a.metrics.sum,
|
||||
);
|
||||
const rankMap = new Map<string, number>();
|
||||
sortedByCount.forEach((serie, idx) => {
|
||||
rankMap.set(serie.id, idx + 1);
|
||||
});
|
||||
return allSeriesWithIndex.map((serie) => ({
|
||||
...serie,
|
||||
originalRank: rankMap.get(serie.id) ?? 0,
|
||||
}));
|
||||
}, [allSeriesWithIndex]);
|
||||
// Previous value column
|
||||
{
|
||||
name: 'Previous',
|
||||
width: '130px',
|
||||
render: (serie: (typeof series)[0]) => (
|
||||
<div className="flex items-center gap-2 font-mono justify-end">
|
||||
<div className="font-bold">
|
||||
{number.format(serie.metrics.previous?.[metric]?.value)}
|
||||
</div>
|
||||
<PreviousDiffIndicator
|
||||
{...serie.metrics.previous?.[metric]}
|
||||
size="xs"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
// Filter and sort series
|
||||
const series = useMemo(() => {
|
||||
let filtered = seriesWithOriginalRank;
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((serie) =>
|
||||
serie.names.some((name) => name.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'count-desc':
|
||||
return b.metrics.sum - a.metrics.sum;
|
||||
case 'count-asc':
|
||||
return a.metrics.sum - b.metrics.sum;
|
||||
case 'name-asc':
|
||||
return a.names.join(' > ').localeCompare(b.names.join(' > '));
|
||||
case 'name-desc':
|
||||
return b.names.join(' > ').localeCompare(a.names.join(' > '));
|
||||
case 'percent-desc':
|
||||
return b.metrics.sum / totalSum - a.metrics.sum / totalSum;
|
||||
case 'percent-asc':
|
||||
return a.metrics.sum / totalSum - b.metrics.sum / totalSum;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply limit if not in edit mode
|
||||
return isEditMode ? sorted : sorted.slice(0, limit || 10);
|
||||
}, [
|
||||
seriesWithOriginalRank,
|
||||
searchQuery,
|
||||
sortBy,
|
||||
totalSum,
|
||||
isEditMode,
|
||||
limit,
|
||||
]);
|
||||
// Main count column (always last)
|
||||
{
|
||||
name: 'Count',
|
||||
width: '80px',
|
||||
render: (serie: (typeof series)[0]) => (
|
||||
<div className="font-bold font-mono">
|
||||
{number.format(serie.metrics.sum)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', isEditMode && 'card')}>
|
||||
{isEditMode && (
|
||||
<div className="flex items-center gap-3 p-4 border-b border-def-200 dark:border-def-800">
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Filter by name"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(value) => setSortBy(value as SortOption)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="count-desc">Count (High → Low)</SelectItem>
|
||||
<SelectItem value="count-asc">Count (Low → High)</SelectItem>
|
||||
<SelectItem value="name-asc">Name (A → Z)</SelectItem>
|
||||
<SelectItem value="name-desc">Name (Z → A)</SelectItem>
|
||||
<SelectItem value="percent-desc">
|
||||
Percentage (High → Low)
|
||||
</SelectItem>
|
||||
<SelectItem value="percent-asc">
|
||||
Percentage (Low → High)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isEditMode ? 'card gap-2 p-4 text-base' : '-m-3',
|
||||
)}
|
||||
<div className="overflow-hidden">
|
||||
<div className="divide-y divide-def-200 dark:divide-def-800">
|
||||
{series.map((serie, idx) => {
|
||||
const isClickable =
|
||||
!serie.names.includes(NOT_SET_VALUE) && !!onClick;
|
||||
const isDropDownEnabled =
|
||||
!serie.names.includes(NOT_SET_VALUE) &&
|
||||
(dropdownMenuContent?.(serie) || []).length > 0;
|
||||
|
||||
const color = getChartColor(serie.index);
|
||||
const percentOfTotal = round(
|
||||
(serie.metrics.sum / totalSum) * 100,
|
||||
1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={serie.id}
|
||||
className={cn(
|
||||
'group relative px-4 py-3 transition-colors overflow-hidden',
|
||||
isClickable && 'cursor-pointer',
|
||||
)}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onClick={() => {
|
||||
if (isClickable && !isDropDownEnabled) {
|
||||
onClick?.(serie);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!isClickable || isDropDownEnabled) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick?.(serie);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Subtle accent glow */}
|
||||
<div
|
||||
className="pointer-events-none absolute -left-10 -top-10 h-40 w-96 rounded-full opacity-0 blur-3xl transition-opacity duration-500 group-hover:opacity-10"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border bg-def-50 dark:border-def-800 dark:bg-def-900"
|
||||
style={{ borderColor: `${color}22` }}
|
||||
>
|
||||
<SerieIcon name={serie.names[0]} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
Rank {serie.originalRank}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu
|
||||
onOpenChange={() =>
|
||||
setOpen((p) => (p === serie.id ? null : serie.id))
|
||||
}
|
||||
open={isOpen === serie.id}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={!isDropDownEnabled}
|
||||
{...(isDropDownEnabled
|
||||
? {
|
||||
onPointerDown: (e) => e.preventDefault(),
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(serie.id);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0',
|
||||
isDropDownEnabled && 'cursor-pointer',
|
||||
)}
|
||||
{...(isClickable && !isDropDownEnabled
|
||||
? {
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(serie);
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<SerieName
|
||||
name={serie.names}
|
||||
className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold tracking-tight"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{dropdownMenuContent?.(serie).map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.title}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onClick();
|
||||
}}
|
||||
>
|
||||
{item.icon && (
|
||||
<item.icon size={16} className="mr-2" />
|
||||
)}
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-base font-semibold font-mono tracking-tight">
|
||||
{number.format(serie.metrics.sum)}
|
||||
</div>
|
||||
{previous && serie.metrics.previous?.[metric] && (
|
||||
<DeltaChip
|
||||
variant={
|
||||
serie.metrics.previous[metric].state ===
|
||||
'positive'
|
||||
? 'inc'
|
||||
: 'dec'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{serie.metrics.previous[metric].diff?.toFixed(1)}%
|
||||
</DeltaChip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="flex items-center">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-def-100 dark:bg-def-900">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-700 ease-out"
|
||||
style={{
|
||||
width: `${percentOfTotal}%`,
|
||||
background: `linear-gradient(90deg, ${color}aa, ${color})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
>
|
||||
<OverviewWidgetTable
|
||||
data={series}
|
||||
keyExtractor={(serie) => serie.id}
|
||||
columns={tableColumns.filter((column) => {
|
||||
if (!previous && column.name === 'Previous') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})}
|
||||
getColumnPercentage={(serie) => serie.metrics.sum / maxCount}
|
||||
className={cn(isEditMode ? 'min-h-[358px]' : 'min-h-0')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -10,27 +8,15 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportBarChart() {
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.aggregate.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -40,6 +26,7 @@ export function ReportBarChart() {
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
@@ -52,62 +39,22 @@ export function ReportBarChart() {
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
const { isEditMode } = useReportChartContext();
|
||||
return (
|
||||
<div className={cn('w-full', isEditMode && 'card')}>
|
||||
<div className="overflow-hidden">
|
||||
<div className="divide-y divide-def-200 dark:divide-def-800">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<div
|
||||
key={index as number}
|
||||
className="relative px-4 py-3 animate-pulse"
|
||||
>
|
||||
<div className="relative z-10 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{/* Icon skeleton */}
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border bg-def-100 dark:border-def-800 dark:bg-def-900" />
|
||||
|
||||
<div className="min-w-0">
|
||||
{/* Rank badge skeleton */}
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-def-200 dark:bg-def-700" />
|
||||
<div className="h-2 w-12 rounded bg-def-200 dark:bg-def-700" />
|
||||
</div>
|
||||
|
||||
{/* Name skeleton */}
|
||||
<div
|
||||
className="h-4 rounded bg-def-200 dark:bg-def-700"
|
||||
style={{
|
||||
width: `${Math.random() * 100 + 100}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Count skeleton */}
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<div className="h-5 w-16 rounded bg-def-200 dark:bg-def-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar skeleton */}
|
||||
<div className="flex items-center">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-def-100 dark:bg-def-900">
|
||||
<div
|
||||
className="h-full rounded-full bg-def-200 dark:bg-def-700"
|
||||
style={{
|
||||
width: `${Math.random() * 60 + 20}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<AspectContainer className="col gap-4 overflow-hidden">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<div
|
||||
key={index as number}
|
||||
className="row animate-pulse justify-between"
|
||||
>
|
||||
<div className="h-4 w-2/5 rounded bg-def-200" />
|
||||
<div className="row w-1/5 gap-2">
|
||||
<div className="h-4 w-full rounded bg-def-200" />
|
||||
<div className="h-4 w-full rounded bg-def-200" />
|
||||
<div className="h-4 w-full rounded bg-def-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ReportChartEmpty({
|
||||
</div>
|
||||
<ForkliftIcon
|
||||
strokeWidth={1.2}
|
||||
className="mb-4 size-1/3 max-w-40 animate-pulse text-muted-foreground"
|
||||
className="mb-4 size-1/3 animate-pulse text-muted-foreground"
|
||||
/>
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Ready when you're
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
||||
|
||||
import { DeltaChip } from '@/components/delta-chip';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
export function getDiffIndicator<A, B, C>(
|
||||
@@ -30,7 +29,7 @@ interface PreviousDiffIndicatorProps {
|
||||
children?: React.ReactNode;
|
||||
inverted?: boolean;
|
||||
className?: string;
|
||||
size?: 'sm' | 'lg' | 'md';
|
||||
size?: 'sm' | 'lg' | 'md' | 'xs';
|
||||
}
|
||||
|
||||
export function PreviousDiffIndicator({
|
||||
@@ -42,10 +41,10 @@ export function PreviousDiffIndicator({
|
||||
className,
|
||||
}: PreviousDiffIndicatorProps) {
|
||||
const {
|
||||
report: { previous },
|
||||
report: { previousIndicatorInverted, previous },
|
||||
} = useReportChartContext();
|
||||
const variant = getDiffIndicator(
|
||||
inverted,
|
||||
inverted ?? previousIndicatorInverted,
|
||||
state,
|
||||
'bg-emerald-300',
|
||||
'bg-rose-300',
|
||||
@@ -82,6 +81,7 @@ export function PreviousDiffIndicator({
|
||||
variant,
|
||||
size === 'lg' && 'size-8',
|
||||
size === 'md' && 'size-6',
|
||||
size === 'xs' && 'size-3',
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
@@ -97,7 +97,7 @@ interface PreviousDiffIndicatorPureProps {
|
||||
diff?: number | null | undefined;
|
||||
state?: string | null | undefined;
|
||||
inverted?: boolean;
|
||||
size?: 'sm' | 'lg' | 'md';
|
||||
size?: 'sm' | 'lg' | 'md' | 'xs';
|
||||
className?: string;
|
||||
showPrevious?: boolean;
|
||||
}
|
||||
@@ -133,35 +133,25 @@ export function PreviousDiffIndicatorPure({
|
||||
};
|
||||
|
||||
return (
|
||||
<DeltaChip
|
||||
variant={state === 'positive' ? 'inc' : 'dec'}
|
||||
size={size}
|
||||
inverted={inverted}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 font-mono font-medium',
|
||||
size === 'lg' && 'gap-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-2.5 items-center justify-center rounded-full',
|
||||
variant,
|
||||
size === 'lg' && 'size-8',
|
||||
size === 'md' && 'size-6',
|
||||
size === 'xs' && 'size-3',
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
{diff.toFixed(1)}%
|
||||
</DeltaChip>
|
||||
</div>
|
||||
);
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// className={cn(
|
||||
// 'flex items-center gap-1 font-mono font-medium',
|
||||
// size === 'lg' && 'gap-2',
|
||||
// className,
|
||||
// )}
|
||||
// >
|
||||
// <div
|
||||
// className={cn(
|
||||
// 'flex size-2.5 items-center justify-center rounded-full',
|
||||
// variant,
|
||||
// size === 'lg' && 'size-8',
|
||||
// size === 'md' && 'size-6',
|
||||
// size === 'xs' && 'size-3',
|
||||
// )}
|
||||
// >
|
||||
// {renderIcon()}
|
||||
// </div>
|
||||
// {diff.toFixed(1)}%
|
||||
// </div>
|
||||
// );
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ const data = {
|
||||
whale: 'https://whale.naver.com',
|
||||
wechat: 'https://wechat.com',
|
||||
chrome: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
|
||||
'mobile chrome': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
|
||||
'chrome webview': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
|
||||
'chrome headless': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
|
||||
chromecast: 'https://upload.wikimedia.org/wikipedia/commons/archive/2/26/20161130022732%21Chromecast_cast_button_icon.svg',
|
||||
@@ -40,7 +39,6 @@ const data = {
|
||||
edge: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png',
|
||||
facebook: 'https://facebook.com',
|
||||
firefox: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png',
|
||||
'mobile firefox': 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png',
|
||||
github: 'https://github.com',
|
||||
gmail: 'https://mail.google.com',
|
||||
google: 'https://google.com',
|
||||
|
||||
@@ -2,11 +2,16 @@ import isEqual from 'lodash.isequal';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import type { IChartSerie, IReportInput } from '@openpanel/validation';
|
||||
import type {
|
||||
IChartInput,
|
||||
IChartProps,
|
||||
IChartSerie,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
export type ReportChartContextType = {
|
||||
options: Partial<{
|
||||
columns: React.ReactNode[];
|
||||
hideID: boolean;
|
||||
hideLegend: boolean;
|
||||
hideXAxis: boolean;
|
||||
hideYAxis: boolean;
|
||||
@@ -23,11 +28,9 @@ export type ReportChartContextType = {
|
||||
onClick: () => void;
|
||||
}[];
|
||||
}>;
|
||||
report: IReportInput & { id?: string };
|
||||
report: IChartProps;
|
||||
isLazyLoading: boolean;
|
||||
isEditMode: boolean;
|
||||
shareId?: string;
|
||||
reportId?: string;
|
||||
};
|
||||
|
||||
type ReportChartContextProviderProps = ReportChartContextType & {
|
||||
@@ -35,7 +38,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
|
||||
};
|
||||
|
||||
export type ReportChartProps = Partial<ReportChartContextType> & {
|
||||
report: IReportInput & { id?: string };
|
||||
report: IChartInput;
|
||||
lazy?: boolean;
|
||||
};
|
||||
|
||||
@@ -51,6 +54,20 @@ export const useReportChartContext = () => {
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const useSelectReportChartContext = <T,>(
|
||||
selector: (ctx: ReportChartContextType) => T,
|
||||
) => {
|
||||
const ctx = useReportChartContext();
|
||||
const [state, setState] = useState(selector(ctx));
|
||||
useEffect(() => {
|
||||
const newState = selector(ctx);
|
||||
if (!isEqual(newState, state)) {
|
||||
setState(newState);
|
||||
}
|
||||
}, [ctx]);
|
||||
return state;
|
||||
};
|
||||
|
||||
export const ReportChartProvider = ({
|
||||
children,
|
||||
...propsToContext
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
@@ -12,27 +11,15 @@ import { Chart } from './chart';
|
||||
import { Summary } from './summary';
|
||||
|
||||
export function ReportConversionChart() {
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
console.log(report.limit);
|
||||
const res = useQuery(
|
||||
trpc.chart.conversion.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
trpc.chart.conversion.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -131,36 +131,34 @@ export function Tables({
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns,
|
||||
previous,
|
||||
options,
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
},
|
||||
} = useReportChartContext();
|
||||
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
const funnelWindow = funnelOptions?.funnelWindow;
|
||||
const funnelGroup = funnelOptions?.funnelGroup;
|
||||
|
||||
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
||||
if (!projectId || !step.event.id) return;
|
||||
|
||||
// For funnels, we need to pass the step index so the modal can query
|
||||
// users who completed at least that step in the funnel sequence
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'funnel',
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns || [],
|
||||
interval: interval || 'day',
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'funnel',
|
||||
metric: 'sum',
|
||||
options: funnelOptions,
|
||||
},
|
||||
stepIndex, // Pass the step index for funnel queries
|
||||
});
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'funnel',
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns || [],
|
||||
interval: interval || 'day',
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'funnel',
|
||||
metric: 'sum',
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
},
|
||||
stepIndex, // Pass the step index for funnel queries
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className={cn('col @container divide-y divide-border card')}>
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import type { IReportInput } from '@openpanel/validation';
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
@@ -15,39 +14,35 @@ import { Chart, Summary, Tables } from './chart';
|
||||
export function ReportFunnelChart() {
|
||||
const {
|
||||
report: {
|
||||
id,
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
options,
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
startDate,
|
||||
endDate,
|
||||
previous,
|
||||
breakdowns,
|
||||
interval,
|
||||
},
|
||||
isLazyLoading,
|
||||
shareId,
|
||||
} = useReportChartContext();
|
||||
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
|
||||
|
||||
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||
|
||||
const trpc = useTRPC();
|
||||
const input: IReportInput = {
|
||||
const input: IChartInput = {
|
||||
series,
|
||||
range: overviewRange ?? range,
|
||||
range,
|
||||
projectId,
|
||||
interval: overviewInterval ?? interval ?? 'day',
|
||||
interval: 'day',
|
||||
chartType: 'funnel',
|
||||
breakdowns,
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
startDate: overviewStartDate ?? startDate,
|
||||
endDate: overviewEndDate ?? endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 20,
|
||||
options: funnelOptions,
|
||||
};
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(
|
||||
trpc.chart.funnel.queryOptions(input, {
|
||||
enabled: !isLazyLoading && input.series.length > 0,
|
||||
|
||||
@@ -61,14 +61,9 @@ export function Chart({ data }: Props) {
|
||||
range,
|
||||
series: reportSeries,
|
||||
breakdowns,
|
||||
options: reportOptions,
|
||||
},
|
||||
options: { hideXAxis, hideYAxis },
|
||||
} = useReportChartContext();
|
||||
|
||||
const histogramOptions =
|
||||
reportOptions?.type === 'histogram' ? reportOptions : undefined;
|
||||
const isStacked = histogramOptions?.stacked ?? false;
|
||||
const trpc = useTRPC();
|
||||
const references = useQuery(
|
||||
trpc.reference.getChartReferences.queryOptions(
|
||||
@@ -160,70 +155,68 @@ export function Chart({ data }: Props) {
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-def-200"
|
||||
/>
|
||||
<Tooltip
|
||||
content={<ReportChartTooltip.Tooltip />}
|
||||
cursor={<BarHover />}
|
||||
/>
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} scale={'auto'} type="category" />
|
||||
{previous
|
||||
? series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={`${serie.id}:prev`}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
fillOpacity={0.3}
|
||||
radius={5}
|
||||
stackId={isStacked ? 'prev' : undefined}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={serie.id}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
radius={isStacked ? 0 : 4}
|
||||
fillOpacity={1}
|
||||
stackId={isStacked ? 'current' : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-def-200"
|
||||
/>
|
||||
<Tooltip
|
||||
content={<ReportChartTooltip.Tooltip />}
|
||||
cursor={<BarHover />}
|
||||
/>
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} scale={'auto'} type="category" />
|
||||
{previous
|
||||
? series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={`${serie.id}:prev`}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
fillOpacity={0.3}
|
||||
radius={5}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={serie.id}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
radius={5}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</ChartClickMenu>
|
||||
</ReportChartTooltip.TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -10,27 +9,15 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportHistogramChart() {
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -15,7 +15,6 @@ import { ReportMapChart } from './map';
|
||||
import { ReportMetricChart } from './metric';
|
||||
import { ReportPieChart } from './pie';
|
||||
import { ReportRetentionChart } from './retention';
|
||||
import { ReportSankeyChart } from './sankey';
|
||||
|
||||
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -58,8 +57,6 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||
return <ReportRetentionChart />;
|
||||
case 'conversion':
|
||||
return <ReportConversionChart />;
|
||||
case 'sankey':
|
||||
return <ReportSankeyChart />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -11,27 +10,15 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportLineChart() {
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -10,27 +9,15 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportMapChart() {
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportMetricChart() {
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -63,18 +47,28 @@ export function Loading() {
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
export function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
<div className="relative h-[70px]">
|
||||
<div className="opacity-50">
|
||||
<Loading />
|
||||
</div>
|
||||
<div className="center-center absolute inset-0 text-muted-foreground">
|
||||
<div className="text-sm font-medium">Error fetching data</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
export function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
<div className="relative h-[70px]">
|
||||
<div className="opacity-50">
|
||||
<Loading />
|
||||
</div>
|
||||
<div className="center-center absolute inset-0 text-muted-foreground">
|
||||
<div className="text-sm font-medium">No data</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,10 @@ export function MetricCard({
|
||||
metric,
|
||||
unit,
|
||||
}: MetricCardProps) {
|
||||
const { isEditMode } = useReportChartContext();
|
||||
const {
|
||||
report: { previousIndicatorInverted },
|
||||
isEditMode,
|
||||
} = useReportChartContext();
|
||||
const number = useNumber();
|
||||
|
||||
const renderValue = (value: number | undefined, unitClassName?: string) => {
|
||||
@@ -77,7 +80,7 @@ export function MetricCard({
|
||||
const previous = serie.metrics.previous?.[metric];
|
||||
|
||||
const graphColors = getDiffIndicator(
|
||||
false,
|
||||
previousIndicatorInverted,
|
||||
previous?.state,
|
||||
'#6ee7b7', // green
|
||||
'#fda4af', // red
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
@@ -10,27 +9,15 @@ import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportPieChart() {
|
||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.aggregate.queryOptions(
|
||||
{
|
||||
...report,
|
||||
shareId,
|
||||
reportId: 'id' in report ? report.id : undefined,
|
||||
range: range ?? report.range,
|
||||
startDate: startDate ?? report.startDate,
|
||||
endDate: endDate ?? report.endDate,
|
||||
interval: interval ?? report.interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
},
|
||||
),
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||