38 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
bcddc6f284 wip 2026-01-25 14:36:44 +01:00
Carl-Gerhard Lindesvärd
286f8e160b feat: improve webhook integration (customized body and headers) 2026-01-23 15:01:34 +01:00
Carl-Gerhard Lindesvärd
f8f470adf9 fix: notifications on session_start 2026-01-23 10:20:09 +01:00
Carl-Gerhard Lindesvärd
e7c2834ea0 fix: profile metric overflow issues 2026-01-22 22:18:01 +01:00
Carl-Gerhard Lindesvärd
753d6dce4c fix: encode profile ids #280 2026-01-22 22:08:41 +01:00
Carl-Gerhard Lindesvärd
9e5b482447 fix: report intervals 2026-01-22 21:49:13 +01:00
Carl-Gerhard Lindesvärd
32ea28b2f6 feat: improve activity chart on profile 2026-01-22 20:54:08 +01:00
Carl-Gerhard Lindesvärd
b39d076b32 feat: add sortable overview widgets 2026-01-22 20:53:05 +01:00
Carl-Gerhard Lindesvärd
ec5937e55c docs: add widget 2026-01-22 14:43:57 +01:00
Carl-Gerhard Lindesvärd
f83fe7a0fc fix: react email 2026-01-22 12:32:14 +01:00
Carl-Gerhard Lindesvärd
6c56efdf37 fix: dockerfile worker 2026-01-22 11:21:49 +01:00
Carl-Gerhard Lindesvärd
e5be28a49d fix: link 2026-01-22 10:48:11 +01:00
Carl-Gerhard Lindesvärd
e645c094b2 feature: onboarding emails
* wip

* wip

* wip

* fix coderabbit comments

* remove template
2026-01-22 10:38:05 +01:00
Carl-Gerhard Lindesvärd
67301d928c feat: add product hunt badge widget 2026-01-22 10:11:44 +01:00
Carl-Gerhard Lindesvärd
deb3c3d20c chore: add assign product to org script 2026-01-21 21:56:20 +01:00
Carl-Gerhard Lindesvärd
6e997e62f1 docs: add free open source analytics 2026-01-21 11:43:47 +01:00
Carl-Gerhard Lindesvärd
2c5ca8adec fix: validate revenue payload (must be int) 2026-01-21 11:33:15 +01:00
Carl-Gerhard Lindesvärd
3e573ae27f fix: hide billing on self-hosted 2026-01-20 17:41:58 +01:00
Carl-Gerhard Lindesvärd
5b29f7502c wip 2026-01-20 14:39:27 +01:00
Carl-Gerhard Lindesvärd
d32a279949 fix: profileId 2026-01-20 14:08:44 +01:00
Carl-Gerhard Lindesvärd
ed6e5cd334 Revert "fix: test fix broken dashboard #1"
This reverts commit 24ee6b0b6c.

# Conflicts:
#	apps/start/src/integrations/tanstack-query/root-provider.tsx
2026-01-20 14:06:10 +01:00
Carl-Gerhard Lindesvärd
cf1bf95388 fix: allow int for profileId 2026-01-20 13:06:35 +01:00
Carl-Gerhard Lindesvärd
5830277ba9 fix: trigger build on start changes 2026-01-20 12:45:27 +01:00
Carl-Gerhard Lindesvärd
aa13c87e87 feat: add CUSTOM_COOKIE_DOMAIN 2026-01-20 12:43:30 +01:00
Carl-Gerhard Lindesvärd
83c3647f66 fix: test fix broken dashboard #2 2026-01-20 12:34:39 +01:00
Carl-Gerhard Lindesvärd
927613c09d fix: demo mode 2026-01-20 11:41:32 +01:00
Carl-Gerhard Lindesvärd
24ee6b0b6c fix: test fix broken dashboard #1 2026-01-20 10:55:03 +01:00
Carl-Gerhard Lindesvärd
13d8b92cf3 fix: read issues 2026-01-20 06:44:13 +01:00
Carl-Gerhard Lindesvärd
4b2db351c4 fix: issues with sdk and bot detection 2026-01-20 06:44:13 +01:00
Carl-Gerhard Lindesvärd
334adec9f2 chore: remove dragonfly from local docker-compose 2026-01-20 06:44:13 +01:00
Carl-Gerhard Lindesvärd
9a54daae55 fix: disable bullboard without unsetting the env #273 2026-01-20 06:44:12 +01:00
Carl-Gerhard Lindesvärd
7cd5f84c58 fix: allow custom cookie tld via env (COOKIE_TLDS) 2026-01-20 06:14:02 +01:00
Carl-Gerhard Lindesvärd
470ddbe8e7 feat: add manage api for projects, clients and references 2026-01-20 05:53:57 +01:00
Carl-Gerhard Lindesvärd
c63578b35b fix: widgets 2026-01-20 05:53:54 +01:00
Carl-Gerhard Lindesvärd
b5792df69f fix: show project widgets in settings 2026-01-19 21:50:12 +01:00
Carl-Gerhard Lindesvärd
00f2e2937d feat: add stacked option for histogram 2026-01-19 21:41:36 +01:00
Carl-Gerhard Lindesvärd
0d1773eb74 fix: apply new docker image versions to self-hosting 2026-01-15 23:20:07 +01:00
Carl-Gerhard Lindesvärd
ed1c57dbb8 feat: share dashboard & reports, sankey report, new widgets
* fix: prompt card shadows on light mode

* fix: handle past_due and unpaid from polar

* wip

* wip

* wip 1

* fix: improve types for chart/reports

* wip share
2026-01-14 09:21:18 +01:00
205 changed files with 14030 additions and 1654 deletions

View File

@@ -8,6 +8,7 @@ on:
- "apps/api/**"
- "apps/worker/**"
- "apps/public/**"
- "apps/start/**"
- "packages/**"
- "!packages/sdks/**"
- "**Dockerfile"

View File

@@ -41,6 +41,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/js-runtime/package.json packages/js-runtime/
COPY patches ./patches
# BUILD
@@ -108,6 +109,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/js-runtime ./packages/js-runtime
COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen

View File

@@ -8,6 +8,7 @@
"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": {

View File

@@ -0,0 +1,340 @@
/**
* 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);

View File

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

View File

@@ -0,0 +1,649 @@
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 });
}

View File

@@ -7,7 +7,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import { getRedisCache, getRedisQueue } from '@openpanel/redis';
import {
type IDecrementPayload,
@@ -49,7 +49,7 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
identity ||
(body.payload.profileId
? {
profileId: body.payload.profileId,
profileId: String(body.payload.profileId),
}
: undefined)
);
@@ -419,7 +419,7 @@ export async function fetchDeviceId(
});
try {
const multi = getRedisCache().multi();
const multi = getRedisQueue().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
const res = await multi.exec();

View File

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

View File

@@ -44,6 +44,6 @@ export async function isBotHook(
}
}
return reply.status(202).send('OK');
return reply.status(202).send();
}
}

View File

@@ -38,6 +38,7 @@ 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';
@@ -143,7 +144,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),
);
@@ -151,6 +152,15 @@ 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;
}
@@ -194,6 +204,7 @@ 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

View File

@@ -0,0 +1,132 @@
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;

View File

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

View File

@@ -236,3 +236,40 @@ 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,505 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
<meta name="author" content="OpenPanel">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta property="og:image" content="/ogimage.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="twitter:image" content="/ogimage.png">
<!-- Additional Meta Tags -->
<meta name="theme-color" content="#0a0a0a">
<meta name="color-scheme" content="dark">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
color: #e5e5e5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 18px;
line-height: 1.75;
padding: 2rem 1.5rem;
}
.container {
max-width: 700px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.5rem;
color: #ffffff;
}
h2 {
font-size: 1.875rem;
font-weight: 800;
line-height: 1.3;
margin-top: 3rem;
margin-bottom: 1.5rem;
color: #ffffff;
}
h3 {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.4;
margin-top: 2rem;
margin-bottom: 1rem;
color: #ffffff;
}
p {
margin-bottom: 1.25em;
}
strong {
font-weight: 700;
color: #ffffff;
}
a {
color: #3b82f6;
text-decoration: underline;
text-underline-offset: 2px;
}
a:hover {
color: #60a5fa;
}
ul, ol {
margin-left: 1.5rem;
margin-bottom: 1.25em;
}
li {
margin-bottom: 0.5em;
}
blockquote {
border-left: 4px solid #3b82f6;
padding-left: 1.5rem;
margin: 1.5rem 0;
font-style: italic;
color: #d1d5db;
}
table {
width: 100%;
border-collapse: collapse;
margin: 2rem 0;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #374151;
}
th {
font-weight: 700;
color: #ffffff;
background: #131313;
}
tr:hover {
background: #131313;
}
.screenshot {
margin: 0 -4rem 4rem;
position: relative;
z-index: 10;
}
@media (max-width: 840px) {
.screenshot {
margin: 0;
}
}
.screenshot-inner {
border-radius: 8px;
overflow: hidden;
padding: 0.5rem;
background: #1a1a1a;
border: 1px solid #2a2a2a;
}
.window-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0 0.25rem;
}
.window-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.window-dot.red {
background: #ef4444;
}
.window-dot.yellow {
background: #eab308;
}
.window-dot.green {
background: #22c55e;
}
.screenshot-image-wrapper {
width: 100%;
border: 1px solid #2a2a2a;
border-radius: 6px;
overflow: hidden;
background: #0a0a0a;
}
.screenshot img {
width: 100%;
height: auto;
display: block;
}
.cta {
background: #131313;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 2rem;
margin: 3rem 0;
text-align: center;
margin: 0 -4rem 4rem;
}
@media (max-width: 840px) {
.cta {
margin: 0;
}
}
.cta h2 {
margin-top: 0;
}
.cta a {
display: inline-block;
background: #fff;
color: #000;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
margin: 0.5rem;
transition: background 0.2s;
}
.cta a:hover {
background: #fff;
color: #000;
}
footer {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid #374151;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
}
.hero {
text-align: left;
margin-top: 4rem;
line-height: 1.5;
}
.hero p {
font-size: 1.25rem;
color: #8f8f8f;
margin-top: 1rem;
}
figcaption {
margin-top: 1rem;
font-size: 0.875rem;
text-align: center;
color: #9ca3af;
max-width: 100%;
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h1>Just Fucking Use OpenPanel</h1>
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
</div>
</div>
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
</figure>
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
<ul>
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
</ul>
<p>"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.</p>
<p>Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.</p>
<h2>The Web-Only Analytics Trap</h2>
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
<blockquote>
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
</blockquote>
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
<p>Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.</p>
<p>Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.</p>
<p>And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a <strong>SECOND tool</strong> on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.</p>
<h2>Counter One Dollar Stats</h2>
<p>"$1/month for page views. Adorable."</p>
<p>Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.</p>
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
<p>You get:</p>
<ul>
<li>Funnels to see where users drop off</li>
<li>Retention analysis to see who comes back</li>
<li>Cohorts to segment your users</li>
<li>User profiles to understand individual behavior</li>
<li>Custom dashboards to see what matters to YOU</li>
<li>Revenue tracking to see what actually makes money</li>
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
</ul>
<p>One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.</p>
<h2>Why OpenPanel is the Answer</h2>
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.</p>
<ul>
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
</ul>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
</div>
</div>
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
</figure>
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
<p>Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?</p>
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
<p>That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.</p>
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
<p>Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.</p>
<h2>The Comparison Table (The Brutal Truth)</h2>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Price at 20M events</th>
<th>What You Get</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mixpanel</strong></td>
<td>$2,300+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>PostHog</strong></td>
<td>$1,982+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>Plausible</strong></td>
<td>Various pricing</td>
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
</tr>
<tr>
<td><strong>One Dollar Stats</strong></td>
<td>$1/month</td>
<td>Page views (but cheaper!)</td>
</tr>
<tr style="background: #131313; border: 2px solid #3b82f6;">
<td><strong>OpenPanel</strong></td>
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
</tr>
</tbody>
</table>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
</div>
</div>
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
</figure>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
</div>
</div>
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
</figure>
<h2>The Bottom Fucking Line</h2>
<p>If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.</p>
<p>You have three choices:</p>
<ol>
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
<li>Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from</li>
<li>Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do</li>
</ol>
<p>If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.</p>
<p>But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.</p>
<div class="cta">
<h2>Ready to understand what your users actually do?</h2>
<p>Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.</p>
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
</div>
</div>
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
</figure>
<footer>
<p><strong>Just Fucking Use OpenPanel</strong></p>
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
</footer>
</div>
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

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

View File

@@ -7,11 +7,12 @@ 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 `track` mode
- **Track API**: Default client works with `write` mode
- **Export API**: Requires `read` or `root` mode
- **Insights API**: Requires `read` or `root` mode
- **Manage API**: Requires `root` mode only
The default client does not have access to the Export or Insights APIs.
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.
## Headers
@@ -48,15 +49,29 @@ If authentication fails, you'll receive a `401 Unauthorized` response:
Common authentication errors:
- Invalid client ID or secret
- Client doesn't have required permissions
- Malformed client ID
- 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.
## 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**: Lower limits for data retrieval
- **Export/Insights APIs**: 100 requests per 10 seconds
- **Manage API**: 20 requests per 10 seconds
If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. Implement exponential backoff for retries.

View File

@@ -0,0 +1,332 @@
---
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)

View File

@@ -0,0 +1,140 @@
---
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

View File

@@ -0,0 +1,5 @@
{
"title": "Manage",
"pages": ["projects", "clients", "references"],
"defaultOpen": false
}

View File

@@ -0,0 +1,327 @@
---
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

View File

@@ -0,0 +1,344 @@
---
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

View File

@@ -1,4 +1,4 @@
{
"title": "API",
"pages": ["track", "export", "insights"]
"pages": ["track", "export", "insights", "manage"]
}

View File

@@ -175,6 +175,48 @@ 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`

View File

@@ -0,0 +1,408 @@
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}
>
<a
href="https://openpanel.dev"
style={{
display: 'inline-block',
overflow: 'hidden',
borderRadius: '8px',
width: '250px',
height: '48px',
}}
>
<iframe
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%231F1F1F"
height="48"
width="100%"
style={{
border: 'none',
overflow: 'hidden',
pointerEvents: 'none',
}}
title="OpenPanel Analytics Badge"
/>
</a>
</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>
<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>
</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>
);
}

View File

@@ -26,6 +26,13 @@ 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',

View File

@@ -71,6 +71,7 @@ export function FeatureCard({
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</FeatureCardContainer>
);
}

View File

@@ -25,6 +25,10 @@ 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>
@@ -75,10 +79,28 @@ export async function Footer() {
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
<div className="container col md:row justify-between gap-8">
<div>
<Link href="/" className="row items-center font-medium -ml-3">
<Logo className="h-6" />
{baseOptions().nav?.title}
</Link>
<a
href="https://openpanel.dev"
style={{
display: 'inline-block',
overflow: 'hidden',
borderRadius: '8px',
width: '100%',
height: '48px',
}}
>
<iframe
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%230B0B0B"
height="48"
width="100%"
style={{
border: 'none',
overflow: 'hidden',
pointerEvents: 'none',
}}
title="OpenPanel Analytics Badge"
/>
</a>
</div>
<Social />
</div>

View File

@@ -10,7 +10,6 @@
"cf-typegen": "wrangler types",
"build": "pnpm with-env vite build",
"serve": "vite preview",
"test": "vitest run",
"format": "biome format",
"lint": "biome lint",
"check": "biome check",
@@ -26,7 +25,7 @@
"@hookform/resolvers": "^3.3.4",
"@hyperdx/node-opentelemetry": "^0.8.1",
"@nivo/sankey": "^0.99.0",
"@number-flow/react": "0.3.5",
"@number-flow/react": "0.5.10",
"@openpanel/common": "workspace:^",
"@openpanel/constants": "workspace:^",
"@openpanel/integrations": "workspace:^",
@@ -85,9 +84,16 @@
"@types/d3": "^7.4.3",
"ai": "^4.2.10",
"bind-event-listener": "^3.0.0",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-javascript": "^6.2.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/state": "^6.4.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.35.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"codemirror": "^6.0.1",
"d3": "^7.8.5",
"date-fns": "^3.3.1",
"debounce": "^2.2.0",
@@ -150,7 +156,7 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@cloudflare/vite-plugin": "^1.13.12",
"@cloudflare/vite-plugin": "1.20.3",
"@openpanel/db": "workspace:*",
"@openpanel/trpc": "workspace:*",
"@tanstack/devtools-event-client": "^0.3.3",
@@ -171,6 +177,6 @@
"vite": "^6.3.5",
"vitest": "^3.0.5",
"web-vitals": "^4.2.4",
"wrangler": "^4.42.2"
"wrangler": "4.59.1"
}
}

View File

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

View File

@@ -4,11 +4,17 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { LogoSquare } from '../logo';
import { PublicPageCard } from '../public-page-card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
export function ShareEnterPassword({ shareId }: { shareId: string }) {
export function ShareEnterPassword({
shareId,
shareType = 'overview',
}: {
shareId: string;
shareType?: 'overview' | 'dashboard' | 'report';
}) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInShare.mutationOptions({
@@ -25,6 +31,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
defaultValues: {
password: '',
shareId,
shareType,
},
});
@@ -32,46 +39,31 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
mutation.mutate({
password: data.password,
shareId,
shareType,
});
});
const typeLabel =
shareType === 'dashboard'
? 'Dashboard'
: shareType === 'report'
? 'Report'
: 'Overview';
return (
<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>
<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>
);
}

View File

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

View File

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

View File

@@ -106,7 +106,7 @@ export function useColumns() {
if (profile) {
return (
<ProjectLink
href={`/profiles/${profile.id}`}
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="whitespace-nowrap font-medium hover:underline"
>
{getProfileName(profile)}
@@ -117,7 +117,7 @@ export function useColumns() {
if (profileId && profileId !== deviceId) {
return (
<ProjectLink
href={`/profiles/${profileId}`}
href={`/profiles/${encodeURIComponent(profileId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
Unknown
@@ -128,7 +128,7 @@ export function useColumns() {
if (deviceId) {
return (
<ProjectLink
href={`/profiles/${deviceId}`}
href={`/profiles/${encodeURIComponent(deviceId)}`}
className="whitespace-nowrap font-medium hover:underline"
>
Anonymous

View File

@@ -0,0 +1,95 @@
import type { IServiceReport } from '@openpanel/db';
import { useMemo } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
const ResponsiveGridLayout = WidthProvider(Responsive);
export type Layout = ReactGridLayout.Layout;
export const useReportLayouts = (
reports: NonNullable<IServiceReport>[],
): ReactGridLayout.Layouts => {
return useMemo(() => {
const baseLayout = reports.map((report, index) => ({
i: report.id,
x: report.layout?.x ?? (index % 2) * 6,
y: report.layout?.y ?? Math.floor(index / 2) * 4,
w: report.layout?.w ?? 6,
h: report.layout?.h ?? 4,
minW: 3,
minH: 3,
}));
return {
lg: baseLayout,
md: baseLayout,
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
};
}, [reports]);
};
export function GrafanaGrid({
layouts,
children,
transitions,
onLayoutChange,
onDragStop,
onResizeStop,
isDraggable,
isResizable,
}: {
children: React.ReactNode;
transitions?: boolean;
} & Pick<
ReactGridLayout.ResponsiveProps,
| 'layouts'
| 'onLayoutChange'
| 'onDragStop'
| 'onResizeStop'
| 'isDraggable'
| 'isResizable'
>) {
return (
<>
<style>{`
.react-grid-item {
transition: ${transitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
}
.react-grid-item.react-grid-placeholder {
background: none !important;
opacity: 0.5;
transition-duration: 100ms;
border-radius: 0.5rem;
border: 1px dashed var(--primary);
}
.react-grid-item.resizing {
transition: none !important;
}
`}</style>
<div className="-m-4">
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
margin={[16, 16]}
transformScale={1}
useCSSTransforms={true}
onLayoutChange={onLayoutChange}
onDragStop={onDragStop}
onResizeStop={onResizeStop}
isDraggable={isDraggable}
isResizable={isResizable}
>
{children}
</ResponsiveGridLayout>
</div>
</>
);
}

View File

@@ -1,18 +1,53 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { JsonEditor } from '@/components/json-editor';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { zodResolver } from '@hookform/resolvers/zod';
import { zCreateWebhookIntegration } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { PlusIcon, TrashIcon } from 'lucide-react';
import { path, mergeDeepRight } from 'ramda';
import { useEffect } from 'react';
import { Controller, useFieldArray, useWatch } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zCreateWebhookIntegration>;
const DEFAULT_TRANSFORMER = `(payload) => {
return payload;
}`;
// Convert Record<string, string> to array format for form
function headersToArray(
headers: Record<string, string> | undefined,
): { key: string; value: string }[] {
if (!headers || Object.keys(headers).length === 0) {
return [];
}
return Object.entries(headers).map(([key, value]) => ({ key, value }));
}
// Convert array format back to Record<string, string> for API
function headersToRecord(
headers: { key: string; value: string }[],
): Record<string, string> {
return headers.reduce(
(acc, { key, value }) => {
if (key.trim()) {
acc[key.trim()] = value;
}
return acc;
},
{} as Record<string, string>,
);
}
export function WebhookIntegrationForm({
defaultValues,
onSuccess,
@@ -21,6 +56,13 @@ export function WebhookIntegrationForm({
onSuccess: () => void;
}) {
const { organizationId } = useAppParams();
// Convert headers from Record to array format for form UI
const defaultHeaders =
defaultValues?.config && 'headers' in defaultValues.config
? headersToArray(defaultValues.config.headers)
: [];
const form = useForm<IForm>({
defaultValues: mergeDeepRight(
{
@@ -30,18 +72,68 @@ export function WebhookIntegrationForm({
type: 'webhook' as const,
url: '',
headers: {},
mode: 'message' as const,
javascriptTemplate: undefined,
},
},
defaultValues ?? {},
),
resolver: zodResolver(zCreateWebhookIntegration),
});
// Use a separate form for headers array to work with useFieldArray
const headersForm = useForm<{ headers: { key: string; value: string }[] }>({
defaultValues: {
headers: defaultHeaders,
},
});
const headersArray = useFieldArray({
control: headersForm.control,
name: 'headers',
});
// Watch headers array and sync to main form
const watchedHeaders = useWatch({
control: headersForm.control,
name: 'headers',
});
// Sync headers array changes back to main form
useEffect(() => {
if (watchedHeaders) {
const validHeaders = watchedHeaders.filter(
(h): h is { key: string; value: string } =>
h !== undefined &&
typeof h.key === 'string' &&
typeof h.value === 'string',
);
form.setValue('config.headers', headersToRecord(validHeaders), {
shouldValidate: false,
});
}
}, [watchedHeaders, form]);
const mode = form.watch('config.mode');
const trpc = useTRPC();
const mutation = useMutation(
trpc.integration.createOrUpdate.mutationOptions({
onSuccess,
onError() {
toast.error('Failed to create integration');
onError(error) {
// Handle validation errors from tRPC
if (error.data?.code === 'BAD_REQUEST') {
const errorMessage = error.message || 'Invalid JavaScript template';
toast.error(errorMessage);
// Set form error if it's a JavaScript template error
if (errorMessage.includes('JavaScript template')) {
form.setError('config.javascriptTemplate', {
type: 'manual',
message: errorMessage,
});
}
} else {
toast.error('Failed to create integration');
}
},
}),
);
@@ -70,7 +162,176 @@ export function WebhookIntegrationForm({
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<Button type="submit">Create</Button>
<WithLabel
label="Headers"
info="Add custom HTTP headers to include with webhook requests"
>
<div className="col gap-2">
{headersArray.fields.map((field, index) => (
<div key={field.id} className="row gap-2">
<Input
placeholder="Header Name"
{...headersForm.register(`headers.${index}.key`)}
className="flex-1"
/>
<Input
placeholder="Header Value"
{...headersForm.register(`headers.${index}.value`)}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => headersArray.remove(index)}
className="text-destructive"
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => headersArray.append({ key: '', value: '' })}
className="self-start"
icon={PlusIcon}
>
Add Header
</Button>
</div>
</WithLabel>
<Controller
control={form.control}
name="config.mode"
render={({ field }) => (
<WithLabel
label="Payload Format"
info="Choose how to format the webhook payload"
>
<Combobox
{...field}
className="w-full"
placeholder="Select format"
items={[
{
label: 'Message',
value: 'message' as const,
},
{
label: 'JavaScript',
value: 'javascript' as const,
},
]}
value={field.value ?? 'message'}
onChange={field.onChange}
/>
</WithLabel>
)}
/>
{mode === 'javascript' && (
<Controller
control={form.control}
name="config.javascriptTemplate"
render={({ field }) => (
<WithLabel
label="JavaScript Transform"
info={
<div className="prose dark:prose-invert max-w-none">
<p>
Write a JavaScript function that transforms the event
payload. The function receives <code>payload</code> as a
parameter and should return an object.
</p>
<p className="text-sm font-semibold mt-2">
Available in payload:
</p>
<ul className="text-sm">
<li>
<code>payload.name</code> - Event name
</li>
<li>
<code>payload.profileId</code> - User profile ID
</li>
<li>
<code>payload.properties</code> - Full properties object
</li>
<li>
<code>payload.properties.your.property</code> - Nested
property value
</li>
<li>
<code>payload.profile.firstName</code> - Profile property
</li>
<li>
<div className="flex gap-x-2 flex-wrap mt-1">
<code>country</code>
<code>city</code>
<code>device</code>
<code>os</code>
<code>browser</code>
<code>path</code>
<code>createdAt</code>
and more...
</div>
</li>
</ul>
<p className="text-sm font-semibold mt-2">
Available helpers:
</p>
<ul className="text-sm">
<li>
<code>Math</code>, <code>Date</code>, <code>JSON</code>,{' '}
<code>Array</code>, <code>String</code>,{' '}
<code>Object</code>
</li>
</ul>
<p className="text-sm mt-2">
<strong>Example:</strong>
</p>
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">
{`(payload) => ({
event: payload.name,
user: payload.profileId,
data: payload.properties,
timestamp: new Date(payload.createdAt).toISOString(),
location: \`\${payload.city}, \${payload.country}\`
})`}
</pre>
<p className="text-sm mt-2 text-yellow-600 dark:text-yellow-400">
<strong>Security:</strong> Network calls, file system
access, and other dangerous operations are blocked.
</p>
</div>
}
>
<JsonEditor
value={field.value ?? DEFAULT_TRANSFORMER}
onChange={(value) => {
field.onChange(value);
// Clear error when user starts typing
if (form.formState.errors.config?.javascriptTemplate) {
form.clearErrors('config.javascriptTemplate');
}
}}
placeholder={DEFAULT_TRANSFORMER}
minHeight="300px"
language="javascript"
/>
{form.formState.errors.config?.javascriptTemplate && (
<p className="mt-1 text-sm text-destructive">
{form.formState.errors.config.javascriptTemplate.message}
</p>
)}
</WithLabel>
)}
/>
)}
<Button type="submit">{defaultValues?.id ? 'Update' : 'Create'}</Button>
</form>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import {
Compartment,
EditorState,
type Extension,
} from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from './theme-provider';
interface JsonEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
language?: 'json' | 'javascript';
onValidate?: (isValid: boolean, error?: string) => void;
}
export function JsonEditor({
value,
onChange,
placeholder = '{}',
className = '',
minHeight = '200px',
language = 'json',
onValidate,
}: JsonEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const themeCompartmentRef = useRef<Compartment | null>(null);
const languageCompartmentRef = useRef<Compartment | null>(null);
const { appTheme } = useTheme();
const [isValid, setIsValid] = useState(true);
const [error, setError] = useState<string | undefined>();
const isUpdatingRef = useRef(false);
const validateContent = (content: string) => {
if (!content.trim()) {
setIsValid(true);
setError(undefined);
onValidate?.(true);
return;
}
if (language === 'json') {
try {
JSON.parse(content);
setIsValid(true);
setError(undefined);
onValidate?.(true);
} catch (e) {
setIsValid(false);
const errorMsg =
e instanceof Error ? e.message : 'Invalid JSON syntax';
setError(errorMsg);
onValidate?.(false, errorMsg);
}
} else if (language === 'javascript') {
// No frontend validation for JavaScript - validation happens in tRPC
setIsValid(true);
setError(undefined);
onValidate?.(true);
}
};
// Create editor once on mount
useEffect(() => {
if (!editorRef.current || viewRef.current) return;
const themeCompartment = new Compartment();
themeCompartmentRef.current = themeCompartment;
const languageCompartment = new Compartment();
languageCompartmentRef.current = languageCompartment;
const extensions: Extension[] = [
basicSetup,
languageCompartment.of(language === 'javascript' ? [javascript()] : [json()]),
EditorState.tabSize.of(2),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
isUpdatingRef.current = true;
const newValue = update.state.doc.toString();
onChange(newValue);
validateContent(newValue);
// Reset flag after a short delay
setTimeout(() => {
isUpdatingRef.current = false;
}, 0);
}
}),
EditorView.theme({
'&': {
fontSize: '14px',
minHeight,
maxHeight: '400px',
},
'&.cm-editor': {
borderRadius: '6px',
border: `1px solid ${
isValid ? 'hsl(var(--border))' : 'hsl(var(--destructive))'
}`,
overflow: 'hidden',
},
'.cm-scroller': {
minHeight,
maxHeight: '400px',
overflow: 'auto',
},
'.cm-content': {
padding: '12px 12px 12px 0',
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
minHeight,
},
'.cm-focused': {
outline: 'none',
},
'.cm-gutters': {
backgroundColor: 'hsl(var(--muted))',
borderRight: '1px solid hsl(var(--border))',
paddingLeft: '8px',
},
'.cm-lineNumbers .cm-gutterElement': {
color: 'hsl(var(--muted-foreground))',
paddingRight: '12px',
paddingLeft: '4px',
},
}),
themeCompartment.of(appTheme === 'dark' ? [oneDark] : []),
];
const state = EditorState.create({
doc: value,
extensions,
});
const view = new EditorView({
state,
parent: editorRef.current,
});
viewRef.current = view;
// Initial validation
validateContent(value);
return () => {
view.destroy();
viewRef.current = null;
themeCompartmentRef.current = null;
};
}, []); // Only create once
// Update theme using compartment
useEffect(() => {
if (!viewRef.current || !themeCompartmentRef.current) return;
viewRef.current.dispatch({
effects: themeCompartmentRef.current.reconfigure(
appTheme === 'dark' ? [oneDark] : [],
),
});
}, [appTheme]);
// Update language using compartment
useEffect(() => {
if (!viewRef.current || !languageCompartmentRef.current) return;
viewRef.current.dispatch({
effects: languageCompartmentRef.current.reconfigure(
language === 'javascript' ? [javascript()] : [json()],
),
});
validateContent(value);
}, [language, value]);
// Update editor content when value changes externally
useEffect(() => {
if (!viewRef.current || isUpdatingRef.current) return;
const currentContent = viewRef.current.state.doc.toString();
if (currentContent !== value) {
viewRef.current.dispatch({
changes: {
from: 0,
to: viewRef.current.state.doc.length,
insert: value,
},
});
// Validate after external update
validateContent(value);
}
}, [value]);
return (
<div className={className}>
<div
ref={editorRef}
className={`rounded-md ${!isValid ? 'ring-1 ring-destructive' : ''}`}
/>
{!isValid && (
<p className="mt-1 text-sm text-destructive">
{error || `Invalid ${language === 'javascript' ? 'JavaScript' : 'JSON'}. Please check your syntax.`}
</p>
)}
</div>
);
}

View File

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

View File

@@ -149,7 +149,7 @@ export function useColumns() {
}
return (
<ProjectLink
href={`/profiles/${event.profileId}`}
href={`/profiles/${encodeURIComponent(event.profileId)}`}
className="inline-flex min-w-full flex-none items-center gap-2"
>
{event.profileId}

View File

@@ -33,7 +33,7 @@ export function PromptCard({
}}
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_rgba(20,20,20,1)] col gap-6 py-6 overflow-hidden">
<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"

View File

@@ -4,7 +4,8 @@ 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 type React from 'react';
import { useMemo, useRef, useState } from 'react';
import { Input } from '../ui/input';
const ROW_HEIGHT = 36;
@@ -106,7 +107,9 @@ export function OverviewListModal<T extends OverviewListItem>({
// Calculate totals and check for revenue
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
useMemo(() => {
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
const maxSessions = Math.max(
...filteredData.map((item) => item.sessions),
);
const totalRevenue = filteredData.reduce(
(sum, item) => sum + (item.revenue ?? 0),
0,
@@ -152,7 +155,8 @@ export function OverviewListModal<T extends OverviewListItem>({
<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(),
gridTemplateColumns:
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
<div className="text-left truncate">{columnName}</div>
@@ -204,11 +208,14 @@ export function OverviewListModal<T extends OverviewListItem>({
<div
className="relative grid h-full items-center px-4 border-b border-border"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
gridTemplateColumns:
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
{/* Main content cell */}
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
<div className="min-w-0 truncate pr-2">
{renderItem(item)}
</div>
{/* Revenue cell */}
{hasRevenue && (
@@ -261,4 +268,3 @@ export function OverviewListModal<T extends OverviewListItem>({
</ModalContent>
);
}

View File

@@ -207,7 +207,7 @@ export function OverviewMetricCardNumber({
isLoading?: boolean;
}) {
return (
<div className={cn('min-w-0 col gap-2 items-start', className)}>
<div className={cn('min-w-0 col gap-2', 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}
@@ -219,7 +219,7 @@ export function OverviewMetricCardNumber({
<Skeleton className="h-6 w-12" />
</div>
) : (
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
<div className="truncate font-mono text-3xl leading-[1.1] font-bold w-full text-left">
{value}
</div>
)}

View File

@@ -351,7 +351,7 @@ export default function OverviewTopDevices({
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}

View File

@@ -1,7 +1,7 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import type { IChartInput } from '@openpanel/validation';
import type { IReportInput } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
@@ -74,7 +74,7 @@ export default function OverviewTopEvents({
},
});
const report: IChartInput = useMemo(
const report: IReportInput = useMemo(
() => ({
limit: 1000,
projectId,
@@ -96,9 +96,7 @@ export default function OverviewTopEvents({
},
],
chartType: 'bar' as const,
lineType: 'monotone' as const,
interval,
name: widget.title,
range,
previous,
metric: 'sum' as const,
@@ -120,12 +118,12 @@ export default function OverviewTopEvents({
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return tableData.slice(0, 15);
return tableData;
}
const queryLower = searchQuery.toLowerCase();
return tableData
.filter((item) => item.name?.toLowerCase().includes(queryLower))
.slice(0, 15);
return tableData.filter((item) =>
item.name?.toLowerCase().includes(queryLower),
);
}, [tableData, searchQuery]);
const tabs = useMemo(

View File

@@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { ReportChart } from '../report-chart';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { ReportChartShortcut } from '../report-chart/shortcut';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
@@ -88,7 +89,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}
@@ -210,9 +211,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ReportChart
options={{ hideID: true }}
report={{
<ReportChartShortcut
{...{
projectId,
startDate,
endDate,
@@ -232,12 +232,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>

View File

@@ -65,7 +65,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
);
const filteredData = useMemo(() => {
const data = query.data?.slice(0, 15) ?? [];
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}

View File

@@ -97,7 +97,7 @@ export default function OverviewTopSources({
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
const data = query.data ?? [];
if (!searchQuery.trim()) {
return data;
}

View File

@@ -2,7 +2,8 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useNumber } from '@/hooks/use-numer-formatter';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { ExternalLinkIcon } from 'lucide-react';
import { ChevronDown, ChevronUp, ExternalLinkIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip';
@@ -45,6 +46,42 @@ function RevenuePieChart({ percentage }: { percentage: number }) {
);
}
function SortableHeader({
name,
isSorted,
sortDirection,
onClick,
isRightAligned,
}: {
name: string;
isSorted: boolean;
sortDirection: 'asc' | 'desc' | null;
onClick: () => void;
isRightAligned?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'row items-center gap-1 hover:opacity-80 transition-opacity',
isRightAligned && 'justify-end ml-auto',
)}
>
<span>{name}</span>
{isSorted ? (
sortDirection === 'desc' ? (
<ChevronDown className="size-3" />
) : (
<ChevronUp className="size-3" />
)
) : (
<ChevronDown className="size-3 opacity-30" />
)}
</button>
);
}
type Props<T> = WidgetTableProps<T> & {
getColumnPercentage: (item: T) => number;
};
@@ -56,10 +93,113 @@ export const OverviewWidgetTable = <T,>({
getColumnPercentage,
className,
}: Props<T>) => {
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(
null,
);
// Handle column header click for sorting
const handleSort = (columnName: string) => {
if (sortColumn === columnName) {
// Cycle through: desc -> asc -> null
if (sortDirection === 'desc') {
setSortDirection('asc');
} else if (sortDirection === 'asc') {
setSortColumn(null);
setSortDirection(null);
}
} else {
// First click on a column = descending (highest to lowest)
setSortColumn(columnName);
setSortDirection('desc');
}
};
// Sort data based on current sort state
// Sort all available items, then limit display to top 15
const sortedData = useMemo(() => {
const allData = data ?? [];
if (!sortColumn || !sortDirection) {
// When not sorting, return top 15 (maintain original behavior)
return allData;
}
const column = columns.find((col) => {
if (typeof col.name === 'string') {
return col.name === sortColumn;
}
return false;
});
if (!column?.getSortValue) {
return allData;
}
// Sort all available items
const sorted = [...allData].sort((a, b) => {
const aValue = column.getSortValue!(a);
const bValue = column.getSortValue!(b);
// Handle null values
if (aValue === null && bValue === null) return 0;
if (aValue === null) return 1;
if (bValue === null) return -1;
// Compare values
let comparison = 0;
if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else {
comparison = String(aValue).localeCompare(String(bValue));
}
return sortDirection === 'desc' ? -comparison : comparison;
});
return sorted;
}, [data, sortColumn, sortDirection, columns]).slice(0, 15);
// Create columns with sortable headers
const columnsWithSortableHeaders = useMemo(() => {
return columns.map((column, index) => {
const columnName =
typeof column.name === 'string' ? column.name : String(column.name);
const isSortable = !!column.getSortValue;
const isSorted = sortColumn === columnName;
const currentSortDirection = isSorted ? sortDirection : null;
const isRightAligned = index !== 0;
return {
...column,
// Add a key property for React keys (using the original column name string)
key: columnName,
name: isSortable ? (
<SortableHeader
name={columnName}
isSorted={isSorted}
sortDirection={currentSortDirection}
onClick={() => handleSort(columnName)}
isRightAligned={isRightAligned}
/>
) : (
column.name
),
className: cn(
index === 0
? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono',
// Remove old responsive logic - now handled by responsive prop
column.className,
),
};
});
}, [columns, sortColumn, sortDirection]);
return (
<div className={cn(className)}>
<WidgetTable
data={data ?? []}
data={sortedData}
keyExtractor={keyExtractor}
className={'text-sm min-h-[358px] @container'}
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
@@ -75,18 +215,7 @@ export const OverviewWidgetTable = <T,>({
</div>
);
}}
columns={columns.map((column, index) => {
return {
...column,
className: cn(
index === 0
? 'text-left w-full font-medium min-w-0'
: 'text-right font-mono',
// Remove old responsive logic - now handled by responsive prop
column.className,
),
};
})}
columns={columnsWithSortableHeaders}
/>
</div>
);
@@ -208,6 +337,8 @@ export function OverviewWidgetTablePages({
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) =>
item.revenue ?? 0,
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
@@ -231,6 +362,7 @@ export function OverviewWidgetTablePages({
name: 'Views',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) => item.pageviews,
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -245,6 +377,7 @@ export function OverviewWidgetTablePages({
name: 'Sess.',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) => item.sessions,
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -339,6 +472,8 @@ export function OverviewWidgetTableEntries({
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) =>
item.revenue ?? 0,
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
@@ -362,6 +497,7 @@ export function OverviewWidgetTableEntries({
name: lastColumnName,
width: '84px',
responsive: { priority: 2 }, // Always show if possible
getSortValue: (item: (typeof data)[number]) => item.sessions,
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -494,6 +630,9 @@ export function OverviewWidgetTableGeneric({
name: 'Revenue',
width: '100px',
responsive: { priority: 3 },
getSortValue: (
item: RouterOutputs['overview']['topGeneric'][number],
) => item.revenue ?? 0,
render(item: RouterOutputs['overview']['topGeneric'][number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
@@ -521,6 +660,9 @@ export function OverviewWidgetTableGeneric({
name: 'Views',
width: '84px',
responsive: { priority: 2 },
getSortValue: (
item: RouterOutputs['overview']['topGeneric'][number],
) => item.pageviews,
render(item: RouterOutputs['overview']['topGeneric'][number]) {
return (
<div className="row gap-2 justify-end">
@@ -537,6 +679,9 @@ export function OverviewWidgetTableGeneric({
name: 'Sess.',
width: '84px',
responsive: { priority: 2 },
getSortValue: (
item: RouterOutputs['overview']['topGeneric'][number],
) => item.sessions,
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -599,6 +744,7 @@ export function OverviewWidgetTableEvents({
name: 'Count',
width: '84px',
responsive: { priority: 2 },
getSortValue: (item: EventTableItem) => item.count,
render(item) {
return (
<div className="row gap-2 justify-end">

View File

@@ -7,6 +7,7 @@ import {
format,
formatISO,
isSameMonth,
isToday,
startOfMonth,
subMonths,
} from 'date-fns';
@@ -18,15 +19,26 @@ import {
WidgetTitle,
} from '../overview/overview-widget';
import { Button } from '../ui/button';
import { Tooltiper } from '../ui/tooltip';
type Props = {
data: { count: number; date: string }[];
};
function getOpacityLevel(count: number, maxCount: number): number {
if (count === 0 || maxCount === 0) return 0;
const ratio = count / maxCount;
if (ratio <= 0.25) return 0.25;
if (ratio <= 0.5) return 0.5;
if (ratio <= 0.75) return 0.75;
return 1;
}
const MonthCalendar = ({
month,
data,
}: { month: Date; data: Props['data'] }) => (
maxCount,
}: { month: Date; data: Props['data']; maxCount: number }) => (
<div>
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
@@ -37,14 +49,42 @@ const MonthCalendar = ({
const hit = data.find((item) =>
item.date.includes(formatISO(date, { representation: 'date' })),
);
const opacity = hit ? getOpacityLevel(hit.count, maxCount) : 0;
return (
<div
<Tooltiper
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
asChild
content={
<div className="text-sm col gap-1">
<div className="font-medium">{format(date, 'EEEE, MMM d')}</div>
{hit ? (
<div className="text-muted-foreground">
{hit.count} {hit.count === 1 ? 'event' : 'events'}
</div>
) : (
<div className="text-muted-foreground">No activity</div>
)}
</div>
}
>
<div
className={cn(
'aspect-square w-full rounded cursor-default group hover:ring-1 hover:ring-foreground overflow-hidden',
)}
>
<div
className={cn(
'size-full group-hover:shadow-[inset_0_0_0_2px_var(--background)] rounded',
isToday(date)
? 'bg-highlight'
: hit
? 'bg-foreground'
: 'bg-def-200',
)}
style={hit && !isToday(date) ? { opacity } : undefined}
/>
</div>
</Tooltiper>
);
})}
</div>
@@ -53,6 +93,7 @@ const MonthCalendar = ({
export const ProfileActivity = ({ data }: Props) => {
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
const maxCount = Math.max(...data.map((item) => item.count), 0);
return (
<Widget className="w-full">
@@ -83,6 +124,7 @@ export const ProfileActivity = ({ data }: Props) => {
key={offset}
month={subMonths(startDate, offset)}
data={data}
maxCount={maxCount}
/>
))}
</div>

View File

@@ -2,7 +2,7 @@ import { ReportChart } from '@/components/report-chart';
import { Widget, WidgetBody } from '@/components/widget';
import { memo } from 'react';
import type { IChartProps } from '@openpanel/validation';
import type { IReport } from '@openpanel/validation';
import { WidgetHead } from '../overview/overview-widget';
type Props = {
@@ -12,7 +12,7 @@ type Props = {
export const ProfileCharts = memo(
({ profileId, projectId }: Props) => {
const pageViewsChart: IChartProps = {
const pageViewsChart: IReport = {
projectId,
chartType: 'linear',
series: [
@@ -46,7 +46,7 @@ export const ProfileCharts = memo(
metric: 'sum',
};
const eventsChart: IChartProps = {
const eventsChart: IReport = {
projectId,
chartType: 'linear',
series: [

View File

@@ -20,7 +20,7 @@ export function useColumns(type: 'profiles' | 'power-users') {
const profile = row.original;
return (
<ProjectLink
href={`/profiles/${profile.id}`}
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="flex items-center gap-2 font-medium"
title={getProfileName(profile, false)}
>

View File

@@ -0,0 +1,51 @@
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>
);
}

View File

@@ -9,15 +9,21 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportAreaChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -9,15 +9,21 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportBarChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const res = useQuery(
trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -42,10 +42,10 @@ export function PreviousDiffIndicator({
className,
}: PreviousDiffIndicatorProps) {
const {
report: { previousIndicatorInverted, previous },
report: { previous },
} = useReportChartContext();
const variant = getDiffIndicator(
inverted ?? previousIndicatorInverted,
inverted,
state,
'bg-emerald-300',
'bg-rose-300',

View File

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

View File

@@ -11,15 +11,20 @@ import { Chart } from './chart';
import { Summary } from './summary';
export function ReportConversionChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
console.log(report.limit);
const res = useQuery(
trpc.chart.conversion.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.conversion.queryOptions(
{
...report,
shareId,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -131,34 +131,36 @@ export function Tables({
series: reportSeries,
breakdowns: reportBreakdowns,
previous,
funnelWindow,
funnelGroup,
options,
},
} = useReportChartContext();
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const funnelWindow = funnelOptions?.funnelWindow;
const funnelGroup = funnelOptions?.funnelGroup;
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
if (!projectId || !step.event.id) return;
// For funnels, we need to pass the step index so the modal can query
// users who completed at least that step in the funnel sequence
pushModal('ViewChartUsers', {
type: 'funnel',
report: {
projectId,
series: reportSeries,
breakdowns: reportBreakdowns || [],
interval: interval || 'day',
startDate,
endDate,
range,
previous,
chartType: 'funnel',
metric: 'sum',
funnelWindow,
funnelGroup,
},
stepIndex, // Pass the step index for funnel queries
});
pushModal('ViewChartUsers', {
type: 'funnel',
report: {
projectId,
series: reportSeries,
breakdowns: reportBreakdowns || [],
interval: interval || 'day',
startDate,
endDate,
range,
previous,
chartType: 'funnel',
metric: 'sum',
options: funnelOptions,
},
stepIndex, // Pass the step index for funnel queries
});
};
return (
<div className={cn('col @container divide-y divide-border card')}>

View File

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

View File

@@ -61,9 +61,14 @@ 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(
@@ -155,68 +160,70 @@ 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}
/>
);
})
: null}
{series.map((serie) => {
return (
<Bar
key={serie.id}
name={serie.id}
dataKey={`${serie.id}:count`}
fill={getChartColor(serie.index)}
radius={5}
fillOpacity={1}
/>
);
})}
{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"
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
<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}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider>
);

View File

@@ -9,15 +9,21 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportHistogramChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -15,6 +15,7 @@ import { ReportMapChart } from './map';
import { ReportMetricChart } from './metric';
import { ReportPieChart } from './pie';
import { ReportRetentionChart } from './retention';
import { ReportSankeyChart } from './sankey';
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
const ref = useRef<HTMLDivElement>(null);
@@ -57,6 +58,8 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
return <ReportRetentionChart />;
case 'conversion':
return <ReportConversionChart />;
case 'sankey':
return <ReportSankeyChart />;
default:
return null;
}

View File

@@ -1,7 +1,6 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -10,15 +9,20 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportLineChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -9,15 +9,21 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportMapChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -8,15 +8,21 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportMetricChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -54,10 +54,7 @@ export function MetricCard({
metric,
unit,
}: MetricCardProps) {
const {
report: { previousIndicatorInverted },
isEditMode,
} = useReportChartContext();
const { isEditMode } = useReportChartContext();
const number = useNumber();
const renderValue = (value: number | undefined, unitClassName?: string) => {
@@ -80,7 +77,7 @@ export function MetricCard({
const previous = serie.metrics.previous?.[metric];
const graphColors = getDiffIndicator(
previousIndicatorInverted,
false,
previous?.state,
'#6ee7b7', // green
'#fda4af', // red

View File

@@ -9,15 +9,21 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportPieChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const res = useQuery(
trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -11,7 +11,6 @@ import {
changeStartDate,
ready,
reset,
setName,
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
@@ -19,9 +18,10 @@ import { TimeWindowPicker } from '@/components/time-window-picker';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { bind } from 'bind-event-listener';
import { GanttChartSquareIcon } from 'lucide-react';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db';
@@ -54,8 +54,19 @@ export default function ReportEditor({
return (
<Sheet>
<div>
<div className="p-4">
<div className="p-4 flex items-center justify-between">
<EditReportName />
{initialReport?.id && (
<Button
variant="outline"
icon={ShareIcon}
onClick={() =>
pushModal('ShareReportModal', { reportId: initialReport.id })
}
>
Share
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
<SheetTrigger asChild>

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -12,21 +13,33 @@ import CohortTable from './table';
export function ReportRetentionChart() {
const {
report: {
id,
series,
range,
projectId,
options,
startDate,
endDate,
criteria,
interval,
},
isLazyLoading,
shareId,
} = useReportChartContext();
const {
range: overviewRange,
startDate: overviewStartDate,
endDate: overviewEndDate,
interval: overviewInterval,
} = useOverviewOptions();
const eventSeries = series.filter((item) => item.type === 'event');
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
const isEnabled =
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
const retentionOptions = options?.type === 'retention' ? options : undefined;
const criteria = retentionOptions?.criteria ?? 'on_or_after';
const trpc = useTRPC();
const res = useQuery(
trpc.chart.cohort.queryOptions(
@@ -34,11 +47,13 @@ export function ReportRetentionChart() {
firstEvent,
secondEvent,
projectId,
range,
startDate,
endDate,
range: overviewRange ?? range,
startDate: overviewStartDate ?? startDate,
endDate: overviewEndDate ?? endDate,
criteria,
interval,
interval: overviewInterval ?? interval,
shareId,
id,
},
{
placeholderData: keepPreviousData,

View File

@@ -0,0 +1,302 @@
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '@/components/charts/chart-tooltip';
import { useNumber } from '@/hooks/use-numer-formatter';
import { round } from '@/utils/math';
import { ResponsiveSankey } from '@nivo/sankey';
import {
type ReactNode,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useTheme } from '@/components/theme-provider';
import { truncate } from '@/utils/truncate';
import { ArrowRightIcon } from 'lucide-react';
import { AspectContainer } from '../aspect-container';
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
function SankeyPortalTooltip({
children,
offset = 12,
padding = 8,
}: {
children: ReactNode;
offset?: number;
padding?: number;
}) {
const anchorRef = useRef<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;
const wrapper = el.parentElement;
if (!wrapper) return;
const update = () => {
setAnchorRect(wrapper.getBoundingClientRect());
};
update();
const ro = new ResizeObserver(update);
ro.observe(wrapper);
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
ro.disconnect();
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, []);
useLayoutEffect(() => {
if (!mounted) return;
if (!anchorRect) return;
const tooltipEl = tooltipRef.current;
if (!tooltipEl) return;
const rect = tooltipEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = anchorRect.left + offset;
let top = anchorRect.top + offset;
left = Math.min(
Math.max(padding, left),
Math.max(padding, vw - rect.width - padding),
);
top = Math.min(
Math.max(padding, top),
Math.max(padding, vh - rect.height - padding),
);
setPos({ left, top, ready: true });
}, [mounted, anchorRect, children, offset, padding]);
if (typeof document === 'undefined') {
return <>{children}</>;
}
return (
<>
<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,
)}
</>
);
}
type SankeyData = {
nodes: Array<{
id: string;
label: string;
nodeColor: string;
percentage?: number;
value?: number;
step?: number;
}>;
links: Array<{ source: string; target: string; value: number }>;
};
export function Chart({ data }: { data: SankeyData }) {
const number = useNumber();
const containerRef = useRef<HTMLDivElement>(null);
const { appTheme } = useTheme();
// Process data for Sankey
const sankeyData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
return {
nodes: data.nodes.map((node) => ({
...node,
label: node.label || node.id,
data: {
percentage: node.percentage,
value: node.value,
step: node.step,
label: node.label || node.id,
},
})),
links: data.links,
};
}, [data]);
const totalSessions = useMemo(() => {
if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0;
const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1);
const base = step1.length > 0 ? step1 : sankeyData.nodes;
return base.reduce((sum: number, n: any) => sum + (n.data?.value ?? 0), 0);
}, [sankeyData.nodes]);
return (
<AspectContainer>
<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">
{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';
return (
<SankeyPortalTooltip>
<ChartTooltipContainer>
<ChartTooltipHeader>
<div className="min-w-0 flex-1 font-medium break-words">
{sourceLabel}
<ArrowRightIcon className="size-2 inline-block mx-3" />
{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 = node.data?.label || node.label || node.id;
return truncate(label, 30, 'middle');
}}
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
nodeSpacing={10}
/>
</div>
</AspectContainer>
);
}

View File

@@ -0,0 +1,93 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import type { IReportInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportSankeyChart() {
const {
report: {
series,
range,
projectId,
options,
startDate,
endDate,
breakdowns,
},
isLazyLoading,
} = useReportChartContext();
if (!options) {
return <Empty />;
}
const input: IReportInput = {
series,
range,
projectId,
interval: 'day',
chartType: 'sankey',
breakdowns,
options,
metric: 'sum',
startDate,
endDate,
limit: 20,
previous: false,
};
const trpc = useTRPC();
const res = useQuery(
trpc.chart.sankey.queryOptions(input, {
enabled: !isLazyLoading && input.series.length > 0,
}),
);
if (isLazyLoading || res.isLoading) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (!res.data || res.data.nodes.length === 0) {
return <Empty />;
}
return (
<div className="col gap-4">
<Chart data={res.data} />
</div>
);
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -26,7 +26,6 @@ export const ReportChartShortcut = ({
return (
<ReportChart
report={{
name: 'Shortcut',
projectId,
range,
breakdowns: breakdowns ?? [],

View File

@@ -5,6 +5,7 @@ import {
ChartColumnIncreasingIcon,
ConeIcon,
GaugeIcon,
GitBranchIcon,
Globe2Icon,
LineChartIcon,
type LucideIcon,
@@ -58,6 +59,7 @@ export function ReportChartType({
retention: UsersIcon,
map: Globe2Icon,
conversion: TrendingUpIcon,
sankey: GitBranchIcon,
};
return (

View File

@@ -0,0 +1,255 @@
import { ReportChart } from '@/components/report-chart';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { CopyIcon, MoreHorizontal, Trash } from 'lucide-react';
import { timeWindows } from '@openpanel/constants';
import { useRouter } from '@tanstack/react-router';
export function ReportItemSkeleton() {
return (
<div className="card h-full flex flex-col animate-pulse">
<div className="flex items-center justify-between border-b border-border p-4">
<div className="flex-1">
<div className="h-5 w-32 bg-muted rounded mb-2" />
<div className="h-4 w-24 bg-muted/50 rounded" />
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-muted rounded" />
<div className="w-8 h-8 bg-muted rounded" />
</div>
</div>
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
</div>
);
}
export function ReportItem({
report,
organizationId,
projectId,
range,
startDate,
endDate,
interval,
onDelete,
onDuplicate,
}: {
report: any;
organizationId: string;
projectId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
onDelete: (reportId: string) => void;
onDuplicate: (reportId: string) => void;
}) {
const router = useRouter();
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
<div
className="flex-1 cursor-pointer -m-4 p-4"
onClick={(event) => {
if (event.metaKey) {
window.open(
`/${organizationId}/${projectId}/reports/${report.id}`,
'_blank',
);
return;
}
router.navigate({
to: '/$organizationId/$projectId/reports/$reportId',
params: {
organizationId,
projectId,
reportId: report.id,
},
});
}}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
router.navigate({
to: '/$organizationId/$projectId/reports/$reportId',
params: {
organizationId,
projectId,
reportId: report.id,
},
});
}
}}
role="button"
tabIndex={0}
>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
className="opacity-30 hover:opacity-100"
>
<circle cx="4" cy="4" r="1.5" />
<circle cx="4" cy="8" r="1.5" />
<circle cx="4" cy="12" r="1.5" />
<circle cx="12" cy="4" r="1.5" />
<circle cx="12" cy="8" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
</svg>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDuplicate(report.id);
}}
>
<CopyIcon size={16} className="mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
event.stopPropagation();
onDelete(report.id);
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
report={{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
}}
/>
</div>
</div>
);
}
export function ReportItemReadOnly({
report,
shareId,
range,
startDate,
endDate,
interval,
}: {
report: any;
shareId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
}) {
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center justify-between border-b border-border p-4 leading-none">
<div className="flex-1">
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
report={{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
}}
shareId={shareId}
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { endOfDay, format, isSameDay, isSameMonth, startOfDay } from 'date-fns';
import { shortId } from '@openpanel/common';
import {
@@ -12,18 +11,19 @@ import {
import type {
IChartBreakdown,
IChartEventItem,
IChartFormula,
IChartLineType,
IChartProps,
IChartRange,
IChartType,
IInterval,
IReport,
IReportOptions,
UnionOmit,
zCriteria,
} from '@openpanel/validation';
import type { z } from 'zod';
type InitialState = IChartProps & {
type InitialState = IReport & {
id?: string;
dirty: boolean;
ready: boolean;
startDate: string | null;
@@ -34,7 +34,6 @@ type InitialState = IChartProps & {
const initialState: InitialState = {
ready: false,
dirty: false,
// TODO: remove this
projectId: '',
name: '',
chartType: 'linear',
@@ -50,9 +49,7 @@ const initialState: InitialState = {
unit: undefined,
metric: 'sum',
limit: 500,
criteria: 'on_or_after',
funnelGroup: undefined,
funnelWindow: undefined,
options: undefined,
};
export const reportSlice = createSlice({
@@ -74,7 +71,7 @@ export const reportSlice = createSlice({
ready: true,
};
},
setReport(state, action: PayloadAction<IChartProps>) {
setReport(state, action: PayloadAction<IReport>) {
return {
...state,
...action.payload,
@@ -187,6 +184,16 @@ export const reportSlice = createSlice({
state.dirty = true;
state.chartType = action.payload;
// Initialize sankey options if switching to sankey
if (action.payload === 'sankey' && !state.options) {
state.options = {
type: 'sankey',
mode: 'after',
steps: 5,
exclude: [],
};
}
if (
!isMinuteIntervalEnabledByRange(state.range) &&
state.interval === 'minute'
@@ -254,7 +261,14 @@ export const reportSlice = createSlice({
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
state.dirty = true;
state.criteria = action.payload;
if (!state.options || state.options.type !== 'retention') {
state.options = {
type: 'retention',
criteria: action.payload,
};
} else {
state.options.criteria = action.payload;
}
},
changeUnit(state, action: PayloadAction<string | undefined>) {
@@ -264,12 +278,99 @@ export const reportSlice = createSlice({
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
state.dirty = true;
state.funnelGroup = action.payload || undefined;
if (!state.options || state.options.type !== 'funnel') {
state.options = {
type: 'funnel',
funnelGroup: action.payload,
funnelWindow: undefined,
};
} else {
state.options.funnelGroup = action.payload;
}
},
changeFunnelWindow(state, action: PayloadAction<number | undefined>) {
state.dirty = true;
state.funnelWindow = action.payload || undefined;
if (!state.options || state.options.type !== 'funnel') {
state.options = {
type: 'funnel',
funnelGroup: undefined,
funnelWindow: action.payload,
};
} else {
state.options.funnelWindow = action.payload;
}
},
changeOptions(state, action: PayloadAction<IReportOptions | undefined>) {
state.dirty = true;
state.options = action.payload || undefined;
},
changeSankeyMode(
state,
action: PayloadAction<'between' | 'after' | 'before'>,
) {
state.dirty = true;
if (!state.options) {
state.options = {
type: 'sankey',
mode: action.payload,
steps: 5,
exclude: [],
};
} else if (state.options.type === 'sankey') {
state.options.mode = action.payload;
}
},
changeSankeySteps(state, action: PayloadAction<number>) {
state.dirty = true;
if (!state.options) {
state.options = {
type: 'sankey',
mode: 'after',
steps: action.payload,
exclude: [],
};
} else if (state.options.type === 'sankey') {
state.options.steps = action.payload;
}
},
changeSankeyExclude(state, action: PayloadAction<string[]>) {
state.dirty = true;
if (!state.options) {
state.options = {
type: 'sankey',
mode: 'after',
steps: 5,
exclude: action.payload,
};
} else if (state.options.type === 'sankey') {
state.options.exclude = action.payload;
}
},
changeSankeyInclude(state, action: PayloadAction<string[] | undefined>) {
state.dirty = true;
if (!state.options) {
state.options = {
type: 'sankey',
mode: 'after',
steps: 5,
exclude: [],
include: action.payload,
};
} else if (state.options.type === 'sankey') {
state.options.include = action.payload;
}
},
changeStacked(state, action: PayloadAction<boolean>) {
state.dirty = true;
if (!state.options || state.options.type !== 'histogram') {
state.options = {
type: 'histogram',
stacked: action.payload,
};
} else {
state.options.stacked = action.payload;
}
},
reorderEvents(
state,
@@ -311,6 +412,12 @@ export const {
changeUnit,
changeFunnelGroup,
changeFunnelWindow,
changeOptions,
changeSankeyMode,
changeSankeySteps,
changeSankeyExclude,
changeSankeyInclude,
changeStacked,
reorderEvents,
} = reportSlice.actions;

View File

@@ -23,15 +23,13 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventItem,
IChartFormula,
} from '@openpanel/validation';
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import { HandIcon, PiIcon, PlusIcon } from 'lucide-react';
import {
addSerie,
changeEvent,
@@ -39,27 +37,21 @@ import {
removeEvent,
reorderEvents,
} from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import type { ReportEventMoreProps } from './ReportEventMore';
import { ReportEventMore } from './ReportEventMore';
import { FiltersList } from './filters/FiltersList';
import {
ReportSeriesItem,
type ReportSeriesItemProps,
} from './ReportSeriesItem';
function SortableSeries({
function SortableReportSeriesItem({
event,
index,
showSegment,
showAddFilter,
isSelectManyEvents,
...props
}: {
event: IChartEventItem | IChartEvent;
index: number;
showSegment: boolean;
showAddFilter: boolean;
isSelectManyEvents: boolean;
} & React.HTMLAttributes<HTMLDivElement>) {
const dispatch = useDispatch();
}: Omit<ReportSeriesItemProps, 'renderDragHandle'>) {
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: eventId ?? '' });
@@ -69,85 +61,26 @@ function SortableSeries({
transition,
};
// Normalize event to have type field
const normalizedEvent: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalizedEvent.type === 'formula';
const chartEvent = isFormula
? null
: (normalizedEvent as IChartEventItem & { type: 'event' });
return (
<div ref={setNodeRef} style={style} {...attributes} {...props}>
<div className="flex items-center gap-2 p-2 group">
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
<ColorSquare className="relative">
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
{alphabetIds[index]}
</span>
</ColorSquare>
</button>
{props.children}
</div>
{/* Segment and Filter buttons - only for events */}
{chartEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={chartEvent.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...chartEvent,
segment,
}),
);
}}
/>
)}
{showAddFilter && (
<PropertiesCombobox
event={chartEvent}
onSelect={(action) => {
dispatch(
changeEvent({
...chartEvent,
filters: [
...chartEvent.filters,
{
id: shortId(),
name: action.value,
operator: 'is',
value: [],
},
],
}),
);
}}
>
{(setOpen) => (
<button
onClick={() => setOpen((p) => !p)}
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
<FilterIcon size={12} /> Add filter
</button>
)}
</PropertiesCombobox>
)}
{showSegment && chartEvent.segment.startsWith('property_') && (
<EventPropertiesCombobox event={chartEvent} />
)}
</div>
)}
{/* Filters - only for events */}
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
<div ref={setNodeRef} style={style} {...attributes}>
<ReportSeriesItem
event={event}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
isSelectManyEvents={isSelectManyEvents}
renderDragHandle={(index) => (
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
<ColorSquare className="relative">
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
{alphabetIds[index]}
</span>
</ColorSquare>
</button>
)}
{...props}
/>
</div>
);
}
@@ -161,12 +94,23 @@ export function ReportSeries() {
projectId,
});
const showSegment = !['retention', 'funnel'].includes(chartType);
const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention'].includes(chartType);
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
const showAddFilter = !['retention', 'sankey'].includes(chartType);
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
const options = useSelector((state) => state.report.options);
const isSankey = chartType === 'sankey';
const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') &&
selectedSeries.length >= 2;
const isSankeyEventLimitReached =
isSankey &&
options &&
((options.type === 'sankey' &&
options.mode === 'between' &&
selectedSeries.length >= 2) ||
(options.type === 'sankey' &&
options.mode !== 'between' &&
selectedSeries.length >= 1));
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
dispatch(changeEvent(event));
});
@@ -218,7 +162,8 @@ export function ReportSeries() {
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
chartType !== 'retention' &&
chartType !== 'sankey';
return (
<div>
@@ -239,7 +184,7 @@ export function ReportSeries() {
const isFormula = event.type === 'formula';
return (
<SortableSeries
<SortableReportSeriesItem
key={event.id}
event={event}
index={index}
@@ -348,13 +293,14 @@ export function ReportSeries() {
<ReportEventMore onClick={handleMore(event)} />
</>
)}
</SortableSeries>
</SortableReportSeriesItem>
);
})}
<div className="flex gap-2">
<ComboboxEvents
disabled={isAddEventDisabled}
className="flex-1"
disabled={isAddEventDisabled || isSankeyEventLimitReached}
value={''}
searchable
onChange={(value) => {
@@ -386,13 +332,13 @@ export function ReportSeries() {
}}
placeholder="Select event"
items={eventNames}
className="flex-1"
/>
{showFormula && (
<Button
type="button"
variant="outline"
icon={PiIcon}
className="flex-1 justify-start text-left px-4"
onClick={() => {
dispatch(
addSerie({
@@ -402,9 +348,9 @@ export function ReportSeries() {
}),
);
}}
className="px-4"
>
Add Formula
<PlusIcon className="size-4 ml-auto text-muted-foreground" />
</Button>
)}
</div>

View File

@@ -0,0 +1,114 @@
import { ColorSquare } from '@/components/color-square';
import { useDispatch } from '@/redux';
import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent, IChartEventItem } from '@openpanel/validation';
import { FilterIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import { changeEvent } from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import { FiltersList } from './filters/FiltersList';
export interface ReportSeriesItemProps
extends React.HTMLAttributes<HTMLDivElement> {
event: IChartEventItem | IChartEvent;
index: number;
showSegment: boolean;
showAddFilter: boolean;
isSelectManyEvents: boolean;
renderDragHandle?: (index: number) => React.ReactNode;
}
export function ReportSeriesItem({
event,
index,
showSegment,
showAddFilter,
isSelectManyEvents,
renderDragHandle,
...props
}: ReportSeriesItemProps) {
const dispatch = useDispatch();
// Normalize event to have type field
const normalizedEvent: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalizedEvent.type === 'formula';
const chartEvent = isFormula
? null
: (normalizedEvent as IChartEventItem & { type: 'event' });
return (
<div {...props}>
<div className="flex items-center gap-2 p-2 group">
{renderDragHandle ? (
renderDragHandle(index)
) : (
<ColorSquare>
<span className="block">{alphabetIds[index]}</span>
</ColorSquare>
)}
{props.children}
</div>
{/* Segment and Filter buttons - only for events */}
{chartEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={chartEvent.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...chartEvent,
segment,
}),
);
}}
/>
)}
{showAddFilter && (
<PropertiesCombobox
event={chartEvent}
onSelect={(action) => {
dispatch(
changeEvent({
...chartEvent,
filters: [
...chartEvent.filters,
{
id: shortId(),
name: action.value,
operator: 'is',
value: [],
},
],
}),
);
}}
>
{(setOpen) => (
<button
onClick={() => setOpen((p) => !p)}
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
<FilterIcon size={12} /> Add filter
</button>
)}
</PropertiesCombobox>
)}
{showSegment && chartEvent.segment.startsWith('property_') && (
<EventPropertiesCombobox event={chartEvent} />
)}
</div>
)}
{/* Filters - only for events */}
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
</div>
);
}

View File

@@ -1,32 +1,50 @@
import { Combobox } from '@/components/ui/combobox';
import { useDispatch, useSelector } from '@/redux';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { InputEnter } from '@/components/ui/input-enter';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventNames } from '@/hooks/use-event-names';
import { useMemo } from 'react';
import {
changeCriteria,
changeFunnelGroup,
changeFunnelWindow,
changePrevious,
changeSankeyExclude,
changeSankeyInclude,
changeSankeyMode,
changeSankeySteps,
changeStacked,
changeUnit,
} from '../reportSlice';
export function ReportSettings() {
const chartType = useSelector((state) => state.report.chartType);
const previous = useSelector((state) => state.report.previous);
const criteria = useSelector((state) => state.report.criteria);
const unit = useSelector((state) => state.report.unit);
const funnelGroup = useSelector((state) => state.report.funnelGroup);
const funnelWindow = useSelector((state) => state.report.funnelWindow);
const options = useSelector((state) => state.report.options);
const retentionOptions = options?.type === 'retention' ? options : undefined;
const criteria = retentionOptions?.criteria ?? 'on_or_after';
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const funnelGroup = funnelOptions?.funnelGroup;
const funnelWindow = funnelOptions?.funnelWindow;
const histogramOptions = options?.type === 'histogram' ? options : undefined;
const stacked = histogramOptions?.stacked ?? false;
const dispatch = useDispatch();
const { projectId } = useAppParams();
const eventNames = useEventNames({ projectId });
const fields = useMemo(() => {
const fields = [];
if (chartType !== 'retention') {
if (chartType !== 'retention' && chartType !== 'sankey') {
fields.push('previous');
}
@@ -40,6 +58,17 @@ export function ReportSettings() {
fields.push('funnelWindow');
}
if (chartType === 'sankey') {
fields.push('sankeyMode');
fields.push('sankeySteps');
fields.push('sankeyExclude');
fields.push('sankeyInclude');
}
if (chartType === 'histogram') {
fields.push('stacked');
}
return fields;
}, [chartType]);
@@ -50,7 +79,7 @@ export function ReportSettings() {
return (
<div>
<h3 className="mb-2 font-medium">Settings</h3>
<div className="col rounded-lg border bg-card p-4 gap-2">
<div className="col rounded-lg border bg-card p-4 gap-4">
{fields.includes('previous') && (
<Label className="flex items-center justify-between mb-0">
<span className="whitespace-nowrap">
@@ -64,7 +93,9 @@ export function ReportSettings() {
)}
{fields.includes('criteria') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Criteria</span>
<Label className="whitespace-nowrap font-medium mb-0">
Criteria
</Label>
<Combobox
align="end"
placeholder="Select criteria"
@@ -85,7 +116,7 @@ export function ReportSettings() {
)}
{fields.includes('unit') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Unit</span>
<Label className="whitespace-nowrap font-medium mb-0">Unit</Label>
<Combobox
align="end"
placeholder="Unit"
@@ -108,7 +139,9 @@ export function ReportSettings() {
)}
{fields.includes('funnelGroup') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Group</span>
<Label className="whitespace-nowrap font-medium mb-0">
Funnel Group
</Label>
<Combobox
align="end"
placeholder="Default: Session"
@@ -133,7 +166,9 @@ export function ReportSettings() {
)}
{fields.includes('funnelWindow') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Window</span>
<Label className="whitespace-nowrap font-medium mb-0">
Funnel Window
</Label>
<InputEnter
type="number"
value={funnelWindow ? String(funnelWindow) : ''}
@@ -149,6 +184,98 @@ export function ReportSettings() {
/>
</div>
)}
{fields.includes('sankeyMode') && options?.type === 'sankey' && (
<div className="flex items-center justify-between gap-4">
<Label className="whitespace-nowrap font-medium mb-0">Mode</Label>
<Combobox
align="end"
placeholder="Select mode"
value={options?.mode || 'after'}
onChange={(val) => {
dispatch(
changeSankeyMode(val as 'between' | 'after' | 'before'),
);
}}
items={[
{
label: 'After',
value: 'after',
},
{
label: 'Before',
value: 'before',
},
{
label: 'Between',
value: 'between',
},
]}
/>
</div>
)}
{fields.includes('sankeySteps') && options?.type === 'sankey' && (
<div className="flex items-center justify-between gap-4">
<Label className="whitespace-nowrap font-medium mb-0">Steps</Label>
<InputEnter
type="number"
value={options?.steps ? String(options.steps) : '5'}
placeholder="Default: 5"
onChangeValue={(value) => {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 2 || parsed > 10) {
dispatch(changeSankeySteps(5));
} else {
dispatch(changeSankeySteps(parsed));
}
}}
/>
</div>
)}
{fields.includes('sankeyExclude') && options?.type === 'sankey' && (
<div className="flex flex-col">
<Label className="whitespace-nowrap font-medium">
Exclude Events
</Label>
<ComboboxEvents
multiple
searchable
value={options?.exclude || []}
onChange={(value) => {
dispatch(changeSankeyExclude(value));
}}
items={eventNames.filter((item) => item.name !== '*')}
placeholder="Select events to exclude"
/>
</div>
)}
{fields.includes('sankeyInclude') && options?.type === 'sankey' && (
<div className="flex flex-col">
<Label className="whitespace-nowrap font-medium">
Include events
</Label>
<ComboboxEvents
multiple
searchable
value={options?.include || []}
onChange={(value) => {
dispatch(
changeSankeyInclude(value.length > 0 ? value : undefined),
);
}}
items={eventNames.filter((item) => item.name !== '*')}
placeholder="Leave empty to include all"
/>
</div>
)}
{fields.includes('stacked') && (
<Label className="flex items-center justify-between mb-0">
<span className="whitespace-nowrap">Stack series</span>
<Switch
checked={stacked}
onCheckedChange={(val) => dispatch(changeStacked(!!val))}
/>
</Label>
)}
</div>
</div>
);

View File

@@ -5,14 +5,24 @@ import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportSeries } from './ReportSeries';
import { ReportSettings } from './ReportSettings';
import { ReportFixedEvents } from './report-fixed-events';
export function ReportSidebar() {
const { chartType } = useSelector((state) => state.report);
const showBreakdown = chartType !== 'retention';
const { chartType, options } = useSelector((state) => state.report);
const showBreakdown = chartType !== 'retention' && chartType !== 'sankey';
const showFixedEvents = chartType === 'sankey';
return (
<>
<div className="flex flex-col gap-8">
<ReportSeries />
{showFixedEvents ? (
<ReportFixedEvents
numberOfEvents={
options?.type === 'sankey' && options.mode === 'between' ? 2 : 1
}
/>
) : (
<ReportSeries />
)}
{showBreakdown && <ReportBreakdowns />}
<ReportSettings />
</div>

View File

@@ -0,0 +1,223 @@
import { ColorSquare } from '@/components/color-square';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Input } from '@/components/ui/input';
import { InputEnter } from '@/components/ui/input-enter';
import { useAppParams } from '@/hooks/use-app-params';
import { useDebounceFn } from '@/hooks/use-debounce-fn';
import { useEventNames } from '@/hooks/use-event-names';
import { useDispatch, useSelector } from '@/redux';
import { alphabetIds } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventItem,
IChartFormula,
} from '@openpanel/validation';
import {
addSerie,
changeEvent,
duplicateEvent,
removeEvent,
} from '../reportSlice';
import type { ReportEventMoreProps } from './ReportEventMore';
import { ReportEventMore } from './ReportEventMore';
import { ReportSeriesItem } from './ReportSeriesItem';
export function ReportFixedEvents({
numberOfEvents,
}: {
numberOfEvents: number;
}) {
const selectedSeries = useSelector((state) => state.report.series);
const chartType = useSelector((state) => state.report.chartType);
const dispatch = useDispatch();
const { projectId } = useAppParams();
const eventNames = useEventNames({
projectId,
});
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
dispatch(changeEvent(event));
});
const isSelectManyEvents = chartType === 'retention';
const handleMore = (event: IChartEventItem | IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(
removeEvent({
id: 'type' in event ? event.id : (event as IChartEvent).id,
}),
);
}
case 'duplicate': {
const normalized =
'type' in event ? event : { ...event, type: 'event' as const };
return dispatch(duplicateEvent(normalized));
}
}
};
return callback;
};
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
dispatch(changeEvent(formula));
});
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention' &&
chartType !== 'sankey';
return (
<div>
<h3 className="mb-2 font-medium">Metrics</h3>
<div className="flex flex-col gap-4">
{Array.from({ length: numberOfEvents }, (_, index) => ({
slotId: `fixed-event-slot-${index}`,
index,
})).map(({ slotId, index }) => {
const event = selectedSeries[index];
// If no event exists at this index, render an empty slot
if (!event) {
return (
<div key={slotId} className="rounded-lg border bg-def-100">
<div className="flex items-center gap-2 p-2">
<ColorSquare>
<span className="block">{alphabetIds[index]}</span>
</ColorSquare>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={''}
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
addSerie({
type: 'event',
segment: 'user',
name: value,
filters: [
{
name: 'name',
operator: 'is',
value: [value],
},
],
}),
);
} else {
dispatch(
addSerie({
type: 'event',
name: value,
segment: 'event',
filters: [],
}),
);
}
}}
items={eventNames}
placeholder="Select event"
/>
</div>
</div>
);
}
const isFormula = event.type === 'formula';
if (isFormula) {
return null;
}
return (
<ReportSeriesItem
key={event.id}
event={event}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
isSelectManyEvents={isSelectManyEvents}
className="rounded-lg border bg-def-100"
>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
(isSelectManyEvents
? ((
event as IChartEventItem & {
type: 'event';
}
).filters[0]?.value ?? [])
: (
event as IChartEventItem & {
type: 'event';
}
).name) as any
}
onChange={(value) => {
dispatch(
changeEvent(
Array.isArray(value)
? {
id: event.id,
type: 'event',
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...event,
type: 'event',
name: value,
filters: [],
},
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
(event as IChartEventItem & { type: 'event' }).name
? `${(event as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={
(event as IChartEventItem & { type: 'event' }).displayName
}
onChange={(e) => {
dispatchChangeEvent({
...(event as IChartEventItem & {
type: 'event';
}),
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(event)} />
</ReportSeriesItem>
);
})}
</div>
</div>
);
}

View File

@@ -62,7 +62,7 @@ export function useColumns() {
if (session.profile) {
return (
<ProjectLink
href={`/profiles/${session.profile.id}`}
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
className="font-medium"
>
{getProfileName(session.profile)}
@@ -71,7 +71,7 @@ export function useColumns() {
}
return (
<ProjectLink
href={`/profiles/${session.profileId}`}
href={`/profiles/${encodeURIComponent(session.profileId)}`}
className="font-mono font-medium"
>
{session.profileId}

View File

@@ -7,11 +7,12 @@ import {
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { Link, useNavigate } from '@tanstack/react-router';
import { Link, useNavigate, useRouteContext } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronDownIcon, PlusIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -28,6 +29,8 @@ export default function SidebarOrganizationMenu({
}: {
organization: RouterOutputs['organization']['list'][number];
}) {
const { isSelfHosted } = useAppContext();
return (
<>
<Link
@@ -52,21 +55,23 @@ export default function SidebarOrganizationMenu({
<CogIcon size={20} />
<div className="flex-1">Settings</div>
</Link>
<Link
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',
)}
activeOptions={{ exact: true }}
to="/$organizationId/billing"
from="/$organizationId"
>
<CreditCardIcon size={20} />
<div className="flex-1">Billing</div>
{organization?.isTrial && <Badge>Trial</Badge>}
{organization?.isExpired && <Badge>Expired</Badge>}
{organization?.isWillBeCanceled && <Badge>Canceled</Badge>}
{organization?.isCanceled && <Badge>Canceled</Badge>}
</Link>
{!isSelfHosted && (
<Link
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',
)}
activeOptions={{ exact: true }}
to="/$organizationId/billing"
from="/$organizationId"
>
<CreditCardIcon size={20} />
<div className="flex-1">Billing</div>
{organization?.isTrial && <Badge>Trial</Badge>}
{organization?.isExpired && <Badge>Expired</Badge>}
{organization?.isWillBeCanceled && <Badge>Canceled</Badge>}
{organization?.isCanceled && <Badge>Canceled</Badge>}
</Link>
)}
<Link
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',

View File

@@ -178,7 +178,7 @@ export function ComboboxEvents<
<CommandEmpty>Nothing selected</CommandEmpty>
<VirtualList
height={400}
height={300}
data={items.filter((item) => {
if (search === '') return true;
return item.name.toLowerCase().includes(search.toLowerCase());

View File

@@ -5,7 +5,7 @@ import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
const labelVariants = cva(
'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground/70',
);
const Label = React.forwardRef<

View File

@@ -29,6 +29,14 @@ export interface Props<T> {
* If not provided, column is always visible.
*/
responsive?: ColumnResponsive;
/**
* Function to extract sortable value. If provided, header becomes clickable.
*/
getSortValue?: (item: T) => number | string | null;
/**
* Optional key for React keys. If not provided, will try to extract from name or use index.
*/
key?: string;
}[];
keyExtractor: (item: T) => string;
data: T[];
@@ -177,9 +185,16 @@ export function WidgetTable<T>({
dataAttrs['data-min-width'] = String(column.responsive.minWidth);
}
// Use column.key if available, otherwise try to extract string from name, fallback to index
const columnKey =
column.key ??
(typeof column.name === 'string'
? column.name
: `col-${colIndex}`);
return (
<div
key={column.name?.toString()}
key={columnKey}
className={cn(
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
columns.length > 1 && column !== columns[0]
@@ -231,9 +246,16 @@ export function WidgetTable<T>({
);
}
// Use column.key if available, otherwise try to extract string from name, fallback to index
const columnKey =
column.key ??
(typeof column.name === 'string'
? column.name
: `col-${colIndex}`);
return (
<div
key={column.name?.toString()}
key={columnKey}
className={cn(
'px-2 relative cell',
columns.length > 1 && column !== columns[0]

View File

@@ -35,7 +35,7 @@ const setCookieFn = createServerFn({ method: 'POST' })
});
// Called in __root.tsx beforeLoad hook to get cookies from the server
// And recieved with useRouteContext in the client
// And received with useRouteContext in the client
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
pick(VALID_COOKIES, getCookies()),
);

View File

@@ -25,12 +25,40 @@ export function createTRPCClientWithHeaders(apiUrl: string) {
transformer: superjson,
url: `${apiUrl}/trpc`,
headers: () => getIsomorphicHeaders(),
fetch: (url, options) => {
return fetch(url, {
...options,
mode: 'cors',
credentials: 'include',
});
fetch: async (url, options) => {
try {
console.log('fetching', url, options);
const response = await fetch(url, {
...options,
mode: 'cors',
credentials: 'include',
});
// Log HTTP errors on server
if (!response.ok && typeof window === 'undefined') {
const text = await response.clone().text();
console.error('[tRPC SSR Error]', {
url: url.toString(),
status: response.status,
statusText: response.statusText,
body: text,
options,
});
}
return response;
} catch (error) {
// Log fetch errors on server
if (typeof window === 'undefined') {
console.error('[tRPC SSR Error]', {
url: url.toString(),
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
options,
});
}
throw error;
}
},
}),
],

View File

@@ -102,7 +102,7 @@ export default function CreateInvite() {
<div>
<SheetTitle>Invite a user</SheetTitle>
<SheetDescription>
Invite users to your organization. They will recieve an email
Invite users to your organization. They will receive an email
will instructions.
</SheetDescription>
</div>

View File

@@ -232,7 +232,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
{profile && (
<ProjectLink
onClick={() => popModal()}
href={`/profiles/${profile.id}`}
href={`/profiles/${encodeURIComponent(profile.id)}`}
className="card p-4 py-2 col gap-2 hover:bg-def-100"
>
<div className="row items-center gap-2 justify-between">

View File

@@ -30,7 +30,9 @@ import OverviewFilters from './overview-filters';
import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report';
import SelectBillingPlan from './select-billing-plan';
import ShareDashboardModal from './share-dashboard-modal';
import ShareOverviewModal from './share-overview-modal';
import ShareReportModal from './share-report-modal';
import ViewChartUsers from './view-chart-users';
const modals = {
@@ -51,6 +53,8 @@ const modals = {
EditReport: EditReport,
EditReference: EditReference,
ShareOverviewModal: ShareOverviewModal,
ShareDashboardModal: ShareDashboardModal,
ShareReportModal: ShareReportModal,
AddReference: AddReference,
ViewChartUsers: ViewChartUsers,
Instructions: Instructions,

View File

@@ -1,12 +1,12 @@
import { ReportChart } from '@/components/report-chart';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { IChartProps } from '@openpanel/validation';
import type { IReport } from '@openpanel/validation';
import { ModalContent, ModalHeader } from './Modal/Container';
type Props = {
chart: IChartProps;
chart: IReport;
};
const OverviewChartDetails = (props: Props) => {

View File

@@ -10,7 +10,7 @@ import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IChartProps } from '@openpanel/validation';
import type { IReport } from '@openpanel/validation';
import { Input } from '@/components/ui/input';
import { useTRPC } from '@/integrations/trpc/react';
@@ -21,7 +21,7 @@ import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type SaveReportProps = {
report: IChartProps;
report: IReport;
disableRedirect?: boolean;
};

View File

@@ -0,0 +1,192 @@
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { handleError } from '@/integrations/trpc/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from '@tanstack/react-router';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { zShareDashboard } from '@openpanel/validation';
import { Input } from '@/components/ui/input';
import { Tooltiper } from '@/components/ui/tooltip';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
import { useState } from 'react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
const validator = zShareDashboard;
type IForm = z.infer<typeof validator>;
export default function ShareDashboardModal({
dashboardId,
}: {
dashboardId: string;
}) {
const { projectId, organizationId } = useAppParams();
const navigate = useNavigate();
const [copied, setCopied] = useState(false);
const trpc = useTRPC();
const queryClient = useQueryClient();
// Fetch current share status
const shareQuery = useQuery(
trpc.share.dashboard.queryOptions({
dashboardId,
}),
);
const existingShare = shareQuery.data;
const isShared = existingShare?.public ?? false;
const shareUrl = existingShare?.id
? `${window.location.origin}/share/dashboard/${existingShare.id}`
: '';
const { register, handleSubmit, watch } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
public: true,
password: existingShare?.password ? '••••••••' : '',
projectId,
organizationId,
dashboardId,
},
});
const password = watch('password');
const mutation = useMutation(
trpc.share.createDashboard.mutationOptions({
onError: handleError,
onSuccess(res) {
queryClient.invalidateQueries(trpc.share.dashboard.pathFilter());
toast('Success', {
description: `Your dashboard is now ${
res.public ? 'public' : 'private'
}`,
action: res.public
? {
label: 'View',
onClick: () =>
navigate({
to: '/share/dashboard/$shareId',
params: {
shareId: res.id,
},
}),
}
: undefined,
});
popModal();
},
}),
);
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast('Link copied to clipboard');
};
const handleMakePrivate = () => {
mutation.mutate({
public: false,
password: null,
projectId,
organizationId,
dashboardId,
});
};
return (
<ModalContent className="max-w-md">
<ModalHeader
title="Dashboard public availability"
text={
isShared
? 'Your dashboard is currently public and can be accessed by anyone with the link.'
: 'You can choose if you want to add a password to make it a bit more private.'
}
/>
{isShared && (
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="size-4" />
<span className="font-medium">Currently shared</span>
</div>
<div className="flex items-center gap-1">
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
<Tooltiper content="Copy link">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyLink}
>
{copied ? (
<CheckCircle2 className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltiper>
<Tooltiper content="Open in new tab">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => window.open(shareUrl, '_blank')}
>
<ExternalLink className="size-4" />
</Button>
</Tooltiper>
<Tooltiper content="Make private">
<Button
type="button"
variant="destructive"
onClick={handleMakePrivate}
>
<TrashIcon className="size-4" />
</Button>
</Tooltiper>
</div>
</div>
)}
<form
onSubmit={handleSubmit((values) => {
mutation.mutate({
...values,
// Only send password if it's not the placeholder
password:
values.password === '••••••••' ? null : values.password || null,
});
})}
>
<Input
{...register('password')}
placeholder="Enter your password (optional)"
size="large"
type={password === '••••••••' ? 'text' : 'password'}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit">
{isShared ? 'Update' : 'Make it public'}
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}

View File

@@ -11,8 +11,11 @@ import type { z } from 'zod';
import { zShareOverview } from '@openpanel/validation';
import { Input } from '@/components/ui/input';
import { Tooltiper } from '@/components/ui/tooltip';
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
import { useState } from 'react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
@@ -23,19 +26,36 @@ type IForm = z.infer<typeof validator>;
export default function ShareOverviewModal() {
const { projectId, organizationId } = useAppParams();
const navigate = useNavigate();
const [copied, setCopied] = useState(false);
const { register, handleSubmit } = useForm<IForm>({
const trpc = useTRPC();
const queryClient = useQueryClient();
// Fetch current share status
const shareQuery = useQuery(
trpc.share.overview.queryOptions({
projectId,
}),
);
const existingShare = shareQuery.data;
const isShared = existingShare?.public ?? false;
const shareUrl = existingShare?.id
? `${window.location.origin}/share/overview/${existingShare.id}`
: '';
const { register, handleSubmit, watch } = useForm<IForm>({
resolver: zodResolver(validator),
defaultValues: {
public: true,
password: '',
password: existingShare?.password ? '••••••••' : '',
projectId,
organizationId,
},
});
const trpc = useTRPC();
const queryClient = useQueryClient();
const password = watch('password');
const mutation = useMutation(
trpc.share.createOverview.mutationOptions({
onError: handleError,
@@ -45,47 +65,122 @@ export default function ShareOverviewModal() {
description: `Your overview is now ${
res.public ? 'public' : 'private'
}`,
action: {
label: 'View',
onClick: () =>
navigate({
to: '/share/overview/$shareId',
params: {
shareId: res.id,
},
}),
},
action: res.public
? {
label: 'View',
onClick: () =>
navigate({
to: '/share/overview/$shareId',
params: {
shareId: res.id,
},
}),
}
: undefined,
});
popModal();
},
}),
);
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast('Link copied to clipboard');
};
const handleMakePrivate = () => {
mutation.mutate({
public: false,
password: null,
projectId,
organizationId,
});
};
return (
<ModalContent className="max-w-md">
<ModalHeader
title="Dashboard public availability"
text="You can choose if you want to add a password to make it a bit more private."
title="Overview public availability"
text={
isShared
? 'Your overview is currently public and can be accessed by anyone with the link.'
: 'You can choose if you want to add a password to make it a bit more private.'
}
/>
{isShared && (
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="size-4" />
<span className="font-medium">Currently shared</span>
</div>
<div className="flex items-center gap-1">
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
<Tooltiper content="Copy link">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyLink}
>
{copied ? (
<CheckCircle2 className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</Tooltiper>
<Tooltiper content="Open in new tab">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => window.open(shareUrl, '_blank')}
>
<ExternalLink className="size-4" />
</Button>
</Tooltiper>
<Tooltiper content="Make private">
<Button
type="button"
variant="destructive"
onClick={handleMakePrivate}
>
<TrashIcon className="size-4" />
</Button>
</Tooltiper>
</div>
</div>
)}
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
mutation.mutate({
...values,
// Only send password if it's not the placeholder
password:
values.password === '••••••••' ? null : values.password || null,
});
})}
>
<Input
{...register('password')}
placeholder="Enter your password"
placeholder="Enter your password (optional)"
size="large"
type={password === '••••••••' ? 'text' : 'password'}
/>
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Cancel
</Button>
<Button type="submit" loading={mutation.isPending}>
Make it public
<Button type="submit">
{isShared ? 'Update' : 'Make it public'}
</Button>
</ButtonContainer>
</form>
</ModalContent>
);
}
}

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