7 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
f880b9a697 wip share 2026-01-14 09:19:55 +01:00
Carl-Gerhard Lindesvärd
13bd16b207 fix: improve types for chart/reports 2026-01-13 15:01:31 +01:00
Carl-Gerhard Lindesvärd
347a01a941 wip 1 2026-01-13 15:01:30 +01:00
Carl-Gerhard Lindesvärd
ba79ac570c wip 2026-01-13 15:01:30 +01:00
Carl-Gerhard Lindesvärd
ca15717885 wip 2026-01-13 14:59:12 +01:00
Carl-Gerhard Lindesvärd
ba93924d20 fix: handle past_due and unpaid from polar 2026-01-12 16:35:32 +01:00
Carl-Gerhard Lindesvärd
7b87a57cbe fix: prompt card shadows on light mode 2026-01-09 22:13:20 +01:00
128 changed files with 513 additions and 7529 deletions

View File

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

View File

@@ -41,7 +41,6 @@ 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
@@ -109,7 +108,6 @@ 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,7 +8,6 @@
"start": "dotenv -e ../../.env node dist/index.js",
"build": "rm -rf dist && tsdown",
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
"test:manage": "jiti scripts/test-manage-api.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -1,340 +0,0 @@
/**
* One-off script to test all /manage/ API endpoints
*
* Usage:
* pnpm test:manage
* or
* pnpm jiti scripts/test-manage-api.ts
*
* Set API_URL environment variable to test against a different server:
* API_URL=http://localhost:3000 pnpm test:manage
*/
const CLIENT_ID = process.env.CLIENT_ID!;
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
if (!CLIENT_ID || !CLIENT_SECRET) {
console.error('CLIENT_ID and CLIENT_SECRET must be set');
process.exit(1);
}
interface TestResult {
name: string;
method: string;
url: string;
status: number;
success: boolean;
error?: string;
data?: any;
}
const results: TestResult[] = [];
async function makeRequest(
method: string,
path: string,
body?: any,
): Promise<TestResult> {
const url = `${API_BASE_URL}${path}`;
const headers: Record<string, string> = {
'openpanel-client-id': CLIENT_ID,
'openpanel-client-secret': CLIENT_SECRET,
};
// Only set Content-Type if there's a body
if (body) {
headers['Content-Type'] = 'application/json';
}
try {
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => ({}));
return {
name: `${method} ${path}`,
method,
url,
status: response.status,
success: response.ok,
error: response.ok ? undefined : data.message || 'Request failed',
data: response.ok ? data : undefined,
};
} catch (error) {
return {
name: `${method} ${path}`,
method,
url,
status: 0,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
async function testProjects() {
console.log('\n📁 Testing Projects endpoints...\n');
// Create project
const createResult = await makeRequest('POST', '/manage/projects', {
name: `Test Project ${Date.now()}`,
domain: 'https://example.com',
cors: ['https://example.com', 'https://www.example.com'],
crossDomain: false,
types: ['website'],
});
results.push(createResult);
console.log(
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
);
if (createResult.error) console.log(` Error: ${createResult.error}`);
const projectId = createResult.data?.data?.id;
const clientId = createResult.data?.data?.client?.id;
const clientSecret = createResult.data?.data?.client?.secret;
if (projectId) {
console.log(` Created project: ${projectId}`);
if (clientId) console.log(` Created client: ${clientId}`);
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
}
// List projects
const listResult = await makeRequest('GET', '/manage/projects');
results.push(listResult);
console.log(
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} projects`);
}
if (projectId) {
// Get project
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
results.push(getResult);
console.log(
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
);
// Update project
const updateResult = await makeRequest(
'PATCH',
`/manage/projects/${projectId}`,
{
name: 'Updated Test Project',
crossDomain: true,
},
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
);
// Delete project (soft delete)
const deleteResult = await makeRequest(
'DELETE',
`/manage/projects/${projectId}`,
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
);
}
return { projectId, clientId };
}
async function testClients(projectId?: string) {
console.log('\n🔑 Testing Clients endpoints...\n');
// Create client
const createResult = await makeRequest('POST', '/manage/clients', {
name: `Test Client ${Date.now()}`,
projectId: projectId || undefined,
type: 'read',
});
results.push(createResult);
console.log(
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
);
if (createResult.error) console.log(` Error: ${createResult.error}`);
const clientId = createResult.data?.data?.id;
const clientSecret = createResult.data?.data?.secret;
if (clientId) {
console.log(` Created client: ${clientId}`);
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
}
// List clients
const listResult = await makeRequest(
'GET',
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients',
);
results.push(listResult);
console.log(
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} clients`);
}
if (clientId) {
// Get client
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
results.push(getResult);
console.log(
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
);
// Update client
const updateResult = await makeRequest(
'PATCH',
`/manage/clients/${clientId}`,
{
name: 'Updated Test Client',
},
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
);
// Delete client
const deleteResult = await makeRequest(
'DELETE',
`/manage/clients/${clientId}`,
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
);
}
}
async function testReferences(projectId?: string) {
console.log('\n📚 Testing References endpoints...\n');
if (!projectId) {
console.log(' ⚠️ Skipping references tests - no project ID available');
return;
}
// Create reference
const createResult = await makeRequest('POST', '/manage/references', {
projectId,
title: `Test Reference ${Date.now()}`,
description: 'This is a test reference',
datetime: new Date().toISOString(),
});
results.push(createResult);
console.log(
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
);
if (createResult.error) console.log(` Error: ${createResult.error}`);
const referenceId = createResult.data?.data?.id;
if (referenceId) {
console.log(` Created reference: ${referenceId}`);
}
// List references
const listResult = await makeRequest(
'GET',
`/manage/references?projectId=${projectId}`,
);
results.push(listResult);
console.log(
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
);
if (listResult.data?.data?.length) {
console.log(` Found ${listResult.data.data.length} references`);
}
if (referenceId) {
// Get reference
const getResult = await makeRequest(
'GET',
`/manage/references/${referenceId}`,
);
results.push(getResult);
console.log(
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
);
// Update reference
const updateResult = await makeRequest(
'PATCH',
`/manage/references/${referenceId}`,
{
title: 'Updated Test Reference',
description: 'Updated description',
datetime: new Date().toISOString(),
},
);
results.push(updateResult);
console.log(
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
);
// Delete reference
const deleteResult = await makeRequest(
'DELETE',
`/manage/references/${referenceId}`,
);
results.push(deleteResult);
console.log(
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
);
}
}
async function main() {
console.log('🚀 Testing Manage API Endpoints\n');
console.log(`API Base URL: ${API_BASE_URL}`);
console.log(`Client ID: ${CLIENT_ID}\n`);
try {
// Test projects first (creates a project we can use for other tests)
const { projectId } = await testProjects();
// Test clients
await testClients(projectId);
// Test references (requires a project)
await testReferences(projectId);
// Summary
console.log(`\n${'='.repeat(60)}`);
console.log('📊 Test Summary\n');
const successful = results.filter((r) => r.success).length;
const failed = results.filter((r) => !r.success).length;
console.log(`Total tests: ${results.length}`);
console.log(`✅ Successful: ${successful}`);
console.log(`❌ Failed: ${failed}\n`);
if (failed > 0) {
console.log('Failed tests:');
results
.filter((r) => !r.success)
.forEach((r) => {
console.log(`${r.name} (${r.status})`);
if (r.error) console.log(` Error: ${r.error}`);
});
}
} catch (error) {
console.error('Fatal error:', error);
process.exit(1);
}
}
main().catch(console.error);

View File

@@ -1,649 +0,0 @@
import crypto from 'node:crypto';
import { HttpError } from '@/utils/errors';
import { stripTrailingSlash } from '@openpanel/common';
import { hashPassword } from '@openpanel/common/server';
import {
db,
getClientByIdCached,
getId,
getProjectByIdCached,
} from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
// Validation schemas
const zCreateProject = z.object({
name: z.string().min(1),
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
cors: z.array(z.string()).default([]),
crossDomain: z.boolean().optional().default(false),
types: z
.array(z.enum(['website', 'app', 'backend']))
.optional()
.default([]),
});
const zUpdateProject = z.object({
name: z.string().min(1).optional(),
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
cors: z.array(z.string()).optional(),
crossDomain: z.boolean().optional(),
allowUnsafeRevenueTracking: z.boolean().optional(),
});
const zCreateClient = z.object({
name: z.string().min(1),
projectId: z.string().optional(),
type: z.enum(['read', 'write', 'root']).optional().default('write'),
});
const zUpdateClient = z.object({
name: z.string().min(1).optional(),
});
const zCreateReference = z.object({
projectId: z.string(),
title: z.string().min(1),
description: z.string().optional(),
datetime: z.string(),
});
const zUpdateReference = z.object({
title: z.string().min(1).optional(),
description: z.string().optional(),
datetime: z.string().optional(),
});
// Projects CRUD
export async function listProjects(
request: FastifyRequest,
reply: FastifyReply,
) {
const projects = await db.project.findMany({
where: {
organizationId: request.client!.organizationId,
deleteAt: null,
},
orderBy: {
createdAt: 'desc',
},
});
reply.send({ data: projects });
}
export async function getProject(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const project = await db.project.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
reply.send({ data: project });
}
export async function createProject(
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
reply: FastifyReply,
) {
const parsed = zCreateProject.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
const { name, domain, cors, crossDomain, types } = parsed.data;
// Generate a default client secret
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
const clientData = {
organizationId: request.client!.organizationId,
name: 'First client',
type: 'write' as const,
secret: await hashPassword(secret),
};
const project = await db.project.create({
data: {
id: await getId('project', name),
organizationId: request.client!.organizationId,
name,
domain: domain ? stripTrailingSlash(domain) : null,
cors: cors.map((c) => stripTrailingSlash(c)),
crossDomain: crossDomain ?? false,
allowUnsafeRevenueTracking: false,
filters: [],
types,
clients: {
create: clientData,
},
},
include: {
clients: {
select: {
id: true,
},
},
},
});
// Clear cache
await Promise.all([
getProjectByIdCached.clear(project.id),
project.clients.map((client) => {
getClientByIdCached.clear(client.id);
}),
]);
reply.send({
data: {
...project,
client: project.clients[0]
? {
id: project.clients[0].id,
secret,
}
: null,
},
});
}
export async function updateProject(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateProject>;
}>,
reply: FastifyReply,
) {
const parsed = zUpdateProject.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
// Verify project exists and belongs to organization
const existing = await db.project.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
include: {
clients: {
select: {
id: true,
},
},
},
});
if (!existing) {
throw new HttpError('Project not found', { status: 404 });
}
const updateData: any = {};
if (parsed.data.name !== undefined) {
updateData.name = parsed.data.name;
}
if (parsed.data.domain !== undefined) {
updateData.domain = parsed.data.domain
? stripTrailingSlash(parsed.data.domain)
: null;
}
if (parsed.data.cors !== undefined) {
updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c));
}
if (parsed.data.crossDomain !== undefined) {
updateData.crossDomain = parsed.data.crossDomain;
}
if (parsed.data.allowUnsafeRevenueTracking !== undefined) {
updateData.allowUnsafeRevenueTracking =
parsed.data.allowUnsafeRevenueTracking;
}
const project = await db.project.update({
where: {
id: request.params.id,
},
data: updateData,
});
// Clear cache
await Promise.all([
getProjectByIdCached.clear(project.id),
existing.clients.map((client) => {
getClientByIdCached.clear(client.id);
}),
]);
reply.send({ data: project });
}
export async function deleteProject(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const project = await db.project.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
await db.project.update({
where: {
id: request.params.id,
},
data: {
deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
await getProjectByIdCached.clear(request.params.id);
reply.send({ success: true });
}
// Clients CRUD
export async function listClients(
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply,
) {
const where: any = {
organizationId: request.client!.organizationId,
};
if (request.query.projectId) {
// Verify project belongs to organization
const project = await db.project.findFirst({
where: {
id: request.query.projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
where.projectId = request.query.projectId;
}
const clients = await db.client.findMany({
where,
orderBy: {
createdAt: 'desc',
},
});
reply.send({ data: clients });
}
export async function getClient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const client = await db.client.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!client) {
throw new HttpError('Client not found', { status: 404 });
}
reply.send({ data: client });
}
export async function createClient(
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
reply: FastifyReply,
) {
const parsed = zCreateClient.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
const { name, projectId, type } = parsed.data;
// If projectId is provided, verify it belongs to organization
if (projectId) {
const project = await db.project.findFirst({
where: {
id: projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
}
// Generate secret
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
const client = await db.client.create({
data: {
organizationId: request.client!.organizationId,
projectId: projectId || null,
name,
type: type || 'write',
secret: await hashPassword(secret),
},
});
await getClientByIdCached.clear(client.id);
reply.send({
data: {
...client,
secret, // Return plain secret only once
},
});
}
export async function updateClient(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateClient>;
}>,
reply: FastifyReply,
) {
const parsed = zUpdateClient.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
// Verify client exists and belongs to organization
const existing = await db.client.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!existing) {
throw new HttpError('Client not found', { status: 404 });
}
const updateData: any = {};
if (parsed.data.name !== undefined) {
updateData.name = parsed.data.name;
}
const client = await db.client.update({
where: {
id: request.params.id,
},
data: updateData,
});
await getClientByIdCached.clear(client.id);
reply.send({ data: client });
}
export async function deleteClient(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const client = await db.client.findFirst({
where: {
id: request.params.id,
organizationId: request.client!.organizationId,
},
});
if (!client) {
throw new HttpError('Client not found', { status: 404 });
}
await db.client.delete({
where: {
id: request.params.id,
},
});
await getClientByIdCached.clear(request.params.id);
reply.send({ success: true });
}
// References CRUD
export async function listReferences(
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
reply: FastifyReply,
) {
const where: any = {};
if (request.query.projectId) {
// Verify project belongs to organization
const project = await db.project.findFirst({
where: {
id: request.query.projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
where.projectId = request.query.projectId;
} else {
// If no projectId, get all projects in org and filter references
const projects = await db.project.findMany({
where: {
organizationId: request.client!.organizationId,
},
select: { id: true },
});
where.projectId = {
in: projects.map((p) => p.id),
};
}
const references = await db.reference.findMany({
where,
orderBy: {
createdAt: 'desc',
},
});
reply.send({ data: references });
}
export async function getReference(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const reference = await db.reference.findUnique({
where: {
id: request.params.id,
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
if (!reference) {
throw new HttpError('Reference not found', { status: 404 });
}
if (reference.project.organizationId !== request.client!.organizationId) {
throw new HttpError('Reference not found', { status: 404 });
}
reply.send({ data: reference });
}
export async function createReference(
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
reply: FastifyReply,
) {
const parsed = zCreateReference.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
const { projectId, title, description, datetime } = parsed.data;
// Verify project belongs to organization
const project = await db.project.findFirst({
where: {
id: projectId,
organizationId: request.client!.organizationId,
},
});
if (!project) {
throw new HttpError('Project not found', { status: 404 });
}
const reference = await db.reference.create({
data: {
projectId,
title,
description: description || null,
date: new Date(datetime),
},
});
reply.send({ data: reference });
}
export async function updateReference(
request: FastifyRequest<{
Params: { id: string };
Body: z.infer<typeof zUpdateReference>;
}>,
reply: FastifyReply,
) {
const parsed = zUpdateReference.safeParse(request.body);
if (parsed.success === false) {
return reply.status(400).send({
error: 'Bad Request',
message: 'Invalid request body',
details: parsed.error.errors,
});
}
// Verify reference exists and belongs to organization
const existing = await db.reference.findUnique({
where: {
id: request.params.id,
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
if (!existing) {
throw new HttpError('Reference not found', { status: 404 });
}
if (existing.project.organizationId !== request.client!.organizationId) {
throw new HttpError('Reference not found', { status: 404 });
}
const updateData: any = {};
if (parsed.data.title !== undefined) {
updateData.title = parsed.data.title;
}
if (parsed.data.description !== undefined) {
updateData.description = parsed.data.description ?? null;
}
if (parsed.data.datetime !== undefined) {
updateData.date = new Date(parsed.data.datetime);
}
const reference = await db.reference.update({
where: {
id: request.params.id,
},
data: updateData,
});
reply.send({ data: reference });
}
export async function deleteReference(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
) {
const reference = await db.reference.findUnique({
where: {
id: request.params.id,
},
include: {
project: {
select: {
organizationId: true,
},
},
},
});
if (!reference) {
throw new HttpError('Reference not found', { status: 404 });
}
if (reference.project.organizationId !== request.client!.organizationId) {
throw new HttpError('Reference not found', { status: 404 });
}
await db.reference.delete({
where: {
id: request.params.id,
},
});
reply.send({ success: true });
}

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, getRedisQueue } from '@openpanel/redis';
import { getRedisCache } from '@openpanel/redis';
import {
type IDecrementPayload,
@@ -49,7 +49,7 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
identity ||
(body.payload.profileId
? {
profileId: String(body.payload.profileId),
profileId: body.payload.profileId,
}
: undefined)
);
@@ -419,7 +419,7 @@ export async function fetchDeviceId(
});
try {
const multi = getRedisQueue().multi();
const multi = getRedisCache().multi();
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
const res = await multi.exec();

View File

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

View File

@@ -38,7 +38,6 @@ import exportRouter from './routes/export.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
import manageRouter from './routes/manage.router';
import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router';
import profileRouter from './routes/profile.router';
@@ -144,7 +143,7 @@ const startServer = async () => {
instance.addHook('onRequest', async (req) => {
if (req.cookies?.session) {
try {
const sessionId = decodeSessionToken(req.cookies?.session);
const sessionId = decodeSessionToken(req.cookies.session);
const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session),
);
@@ -152,15 +151,6 @@ const startServer = async () => {
} catch (e) {
req.session = EMPTY_SESSION;
}
} else if (process.env.DEMO_USER_ID) {
try {
const session = await runWithAlsSession('1', () =>
validateSessionToken(null),
);
req.session = session;
} catch (e) {
req.session = EMPTY_SESSION;
}
} else {
req.session = EMPTY_SESSION;
}
@@ -204,7 +194,6 @@ const startServer = async () => {
instance.register(importRouter, { prefix: '/import' });
instance.register(insightsRouter, { prefix: '/insights' });
instance.register(trackRouter, { prefix: '/track' });
instance.register(manageRouter, { prefix: '/manage' });
// Keep existing endpoints for backward compatibility
instance.get('/healthcheck', healthcheck);
// New Kubernetes-style health endpoints

View File

@@ -1,132 +0,0 @@
import * as controller from '@/controllers/manage.controller';
import { validateManageRequest } from '@/utils/auth';
import { activateRateLimiter } from '@/utils/rate-limiter';
import { Prisma } from '@openpanel/db';
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
const manageRouter: FastifyPluginCallback = async (fastify) => {
await activateRateLimiter({
fastify,
max: 20,
timeWindow: '10 seconds',
});
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
try {
const client = await validateManageRequest(req.headers);
req.client = client;
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
return reply.status(401).send({
error: 'Unauthorized',
message: 'Client ID seems to be malformed',
});
}
if (e instanceof Error) {
return reply
.status(401)
.send({ error: 'Unauthorized', message: e.message });
}
return reply
.status(401)
.send({ error: 'Unauthorized', message: 'Unexpected error' });
}
});
// Projects routes
fastify.route({
method: 'GET',
url: '/projects',
handler: controller.listProjects,
});
fastify.route({
method: 'GET',
url: '/projects/:id',
handler: controller.getProject,
});
fastify.route({
method: 'POST',
url: '/projects',
handler: controller.createProject,
});
fastify.route({
method: 'PATCH',
url: '/projects/:id',
handler: controller.updateProject,
});
fastify.route({
method: 'DELETE',
url: '/projects/:id',
handler: controller.deleteProject,
});
// Clients routes
fastify.route({
method: 'GET',
url: '/clients',
handler: controller.listClients,
});
fastify.route({
method: 'GET',
url: '/clients/:id',
handler: controller.getClient,
});
fastify.route({
method: 'POST',
url: '/clients',
handler: controller.createClient,
});
fastify.route({
method: 'PATCH',
url: '/clients/:id',
handler: controller.updateClient,
});
fastify.route({
method: 'DELETE',
url: '/clients/:id',
handler: controller.deleteClient,
});
// References routes
fastify.route({
method: 'GET',
url: '/references',
handler: controller.listReferences,
});
fastify.route({
method: 'GET',
url: '/references/:id',
handler: controller.getReference,
});
fastify.route({
method: 'POST',
url: '/references',
handler: controller.createReference,
});
fastify.route({
method: 'PATCH',
url: '/references/:id',
handler: controller.updateReference,
});
fastify.route({
method: 'DELETE',
url: '/references/:id',
handler: controller.deleteReference,
});
};
export default manageRouter;

View File

@@ -236,40 +236,3 @@ export async function validateImportRequest(
return client;
}
export async function validateManageRequest(
headers: RawRequestDefaultExpression['headers'],
): Promise<IServiceClientWithProject> {
const clientId = headers['openpanel-client-id'] as string;
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
clientId,
)
) {
throw new Error('Manage: Client ID must be a valid UUIDv4');
}
const client = await getClientByIdCached(clientId);
if (!client) {
throw new Error('Manage: Invalid client id');
}
if (!client.secret) {
throw new Error('Manage: Client has no secret');
}
if (client.type !== ClientType.root) {
throw new Error(
'Manage: Only root clients are allowed to manage resources',
);
}
if (!(await verifyPassword(clientSecret, client.secret))) {
throw new Error('Manage: Invalid client secret');
}
return client;
}

View File

@@ -7,12 +7,11 @@ description: Learn how to authenticate with the OpenPanel API using client crede
To authenticate with the OpenPanel API, you need to use your `clientId` and `clientSecret`. Different API endpoints may require different access levels:
- **Track API**: Default client works with `write` mode
- **Track API**: Default client works with `track` mode
- **Export API**: Requires `read` or `root` mode
- **Insights API**: Requires `read` or `root` mode
- **Manage API**: Requires `root` mode only
The default client (created with a project) has `write` mode and does not have access to the Export, Insights, or Manage APIs. You'll need to create additional clients with appropriate access levels.
The default client does not have access to the Export or Insights APIs.
## Headers
@@ -49,29 +48,15 @@ If authentication fails, you'll receive a `401 Unauthorized` response:
Common authentication errors:
- Invalid client ID or secret
- Client doesn't have required permissions (e.g., trying to access Manage API with a non-root client)
- Malformed client ID (must be a valid UUIDv4)
- Client type mismatch (e.g., `write` client trying to access Export API)
## Client Types
OpenPanel supports three client types with different access levels:
| Type | Description | Access |
|------|-------------|--------|
| `write` | Write access | Track API only |
| `read` | Read-only access | Export API, Insights API |
| `root` | Full access | All APIs including Manage API |
**Note**: Root clients have organization-wide access and can manage all resources. Use root clients carefully and store their credentials securely.
- Client doesn't have required permissions
- Malformed client ID
## Rate Limiting
The API implements rate limiting to prevent abuse. Rate limits vary by endpoint:
- **Track API**: Higher limits for event tracking
- **Export/Insights APIs**: 100 requests per 10 seconds
- **Manage API**: 20 requests per 10 seconds
- **Export/Insights APIs**: Lower limits for data retrieval
If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. Implement exponential backoff for retries.

View File

@@ -1,332 +0,0 @@
---
title: Clients
description: Manage API clients for your OpenPanel projects. Create, read, update, and delete clients with different access levels.
---
## Authentication
To authenticate with the Clients API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All Clients API requests should be made to:
```
https://api.openpanel.dev/manage/clients
```
## Client Types
OpenPanel supports three client types with different access levels:
| Type | Description | Use Case |
|------|-------------|----------|
| `read` | Read-only access | Export data, view insights, read-only operations |
| `write` | Write access | Track events, send data to OpenPanel |
| `root` | Full access | Manage resources, access Manage API |
**Note**: Only `root` clients can access the Manage API.
## Endpoints
### List Clients
Retrieve all clients in your organization, optionally filtered by project.
```
GET /manage/clients
```
#### Query Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `projectId` | string | Optional. Filter clients by project ID |
#### Example Request
```bash
# List all clients
curl 'https://api.openpanel.dev/manage/clients' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
# List clients for a specific project
curl 'https://api.openpanel.dev/manage/clients?projectId=my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": [
{
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
"name": "First client",
"type": "write",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
},
{
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
"name": "Read-only Client",
"type": "read",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T11:00:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z"
}
]
}
```
**Note**: Client secrets are never returned in list or get responses for security reasons.
### Get Client
Retrieve a specific client by ID.
```
GET /manage/clients/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the client (UUID) |
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/clients/fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": {
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
"name": "First client",
"type": "write",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
}
```
### Create Client
Create a new API client. A secure secret is automatically generated and returned once.
```
POST /manage/clients
```
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Client name (minimum 1 character) |
| `projectId` | string | No | Associate client with a specific project |
| `type` | string | No | Client type: `read`, `write`, or `root` (default: `write`) |
#### Example Request
```bash
curl -X POST 'https://api.openpanel.dev/manage/clients' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "My API Client",
"projectId": "my-project",
"type": "read"
}'
```
#### Response
```json
{
"data": {
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
"name": "My API Client",
"type": "read",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T11:00:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z",
"secret": "sec_b2521ca283bf903b46b3"
}
}
```
**Important**: The `secret` field is only returned once when the client is created. Store it securely immediately. You cannot retrieve the secret later - if lost, you'll need to delete and recreate the client.
### Update Client
Update an existing client's name.
```
PATCH /manage/clients/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the client (UUID) |
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | No | New client name (minimum 1 character) |
**Note**: Currently, only the `name` field can be updated. To change the client type or project association, delete and recreate the client.
#### Example Request
```bash
curl -X PATCH 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "Updated Client Name"
}'
```
#### Response
```json
{
"data": {
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
"name": "Updated Client Name",
"type": "read",
"projectId": "my-project",
"organizationId": "org_123",
"ignoreCorsAndSecret": false,
"createdAt": "2024-01-15T11:00:00.000Z",
"updatedAt": "2024-01-15T11:30:00.000Z"
}
}
```
### Delete Client
Permanently delete a client. This action cannot be undone.
```
DELETE /manage/clients/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the client (UUID) |
#### Example Request
```bash
curl -X DELETE 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"success": true
}
```
**Warning**: Deleting a client is permanent. Any applications using this client will immediately lose access. Make sure to update your applications before deleting a client.
## Error Handling
The API uses standard HTTP response codes. Common error responses:
### 400 Bad Request
```json
{
"error": "Bad Request",
"message": "Invalid request body",
"details": [
{
"path": ["name"],
"message": "String must contain at least 1 character(s)"
}
]
}
```
### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Manage: Only root clients are allowed to manage resources"
}
```
### 404 Not Found
```json
{
"error": "Not Found",
"message": "Client not found"
}
```
### 429 Too Many Requests
Rate limiting response includes headers indicating your rate limit status.
## Rate Limiting
The Clients API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Security Best Practices
1. **Store Secrets Securely**: Client secrets are only shown once on creation. Store them in secure credential management systems
2. **Use Appropriate Client Types**: Use the minimum required access level for each use case
3. **Rotate Secrets Regularly**: Delete old clients and create new ones to rotate secrets
4. **Never Expose Secrets**: Never commit client secrets to version control or expose them in client-side code
5. **Monitor Client Usage**: Regularly review and remove unused clients
## Notes
- Client IDs are UUIDs (Universally Unique Identifiers)
- Client secrets are automatically generated with the format `sec_` followed by random hex characters
- Secrets are hashed using argon2 before storage
- Clients can be associated with a project or exist at the organization level
- Clients are scoped to your organization - you can only manage clients in your organization
- The `ignoreCorsAndSecret` field is an advanced setting that bypasses CORS and secret validation (use with caution)

View File

@@ -1,140 +0,0 @@
---
title: Manage API Overview
description: Programmatically manage projects, clients, and references in your OpenPanel organization using the Manage API.
---
## Overview
The Manage API provides programmatic access to manage your OpenPanel resources including projects, clients, and references. This API is designed for automation, infrastructure-as-code, and administrative tasks.
## Authentication
The Manage API requires a **root client** for authentication. Root clients have organization-wide access and can manage all resources within their organization.
To authenticate with the Manage API, you need:
- A client with `type: 'root'`
- Your `clientId` and `clientSecret`
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All Manage API requests should be made to:
```
https://api.openpanel.dev/manage
```
## Available Resources
The Manage API provides CRUD operations for three resource types:
### Projects
Manage your analytics projects programmatically:
- **[Projects Documentation](/docs/api/manage/projects)** - Create, read, update, and delete projects
- Automatically creates a default write client when creating a project
- Supports project configuration including domains, CORS settings, and project types
### Clients
Manage API clients for your projects:
- **[Clients Documentation](/docs/api/manage/clients)** - Create, read, update, and delete clients
- Supports different client types: `read`, `write`, and `root`
- Auto-generates secure secrets on creation (returned once)
### References
Manage reference points for your analytics:
- **[References Documentation](/docs/api/manage/references)** - Create, read, update, and delete references
- Useful for marking important dates or events in your analytics timeline
- Can be filtered by project
## Common Features
All endpoints share these common characteristics:
### Organization Scope
All operations are scoped to your organization. You can only manage resources that belong to your organization.
### Response Format
Successful responses follow this structure:
```json
{
"data": {
// Resource data
}
}
```
For list endpoints:
```json
{
"data": [
// Array of resources
]
}
```
### Error Handling
The API uses standard HTTP response codes:
- `200 OK` - Request successful
- `400 Bad Request` - Invalid request parameters
- `401 Unauthorized` - Authentication failed
- `404 Not Found` - Resource not found
- `429 Too Many Requests` - Rate limit exceeded
## Rate Limiting
The Manage API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Use Cases
The Manage API is ideal for:
- **Infrastructure as Code**: Manage OpenPanel resources alongside your application infrastructure
- **Automation**: Automatically create projects and clients for new deployments
- **Bulk Operations**: Programmatically manage multiple resources
- **CI/CD Integration**: Set up projects and clients as part of your deployment pipeline
- **Administrative Tools**: Build custom admin interfaces
## Security Best Practices
1. **Root Clients Only**: Only root clients can access the Manage API
2. **Store Credentials Securely**: Never expose root client credentials in client-side code
3. **Use HTTPS**: Always use HTTPS for API requests
4. **Rotate Credentials**: Regularly rotate your root client credentials
5. **Limit Access**: Restrict root client creation to trusted administrators
## Getting Started
1. **Create a Root Client**: Use the dashboard to create a root client in your organization
2. **Store Credentials**: Securely store your root client ID and secret
3. **Make Your First Request**: Start with listing projects to verify authentication
Example:
```bash
curl 'https://api.openpanel.dev/manage/projects' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
## Next Steps
- Read the [Projects documentation](/docs/api/manage/projects) to manage projects
- Read the [Clients documentation](/docs/api/manage/clients) to manage API clients
- Read the [References documentation](/docs/api/manage/references) to manage reference points

View File

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

View File

@@ -1,327 +0,0 @@
---
title: Projects
description: Manage your OpenPanel projects programmatically. Create, read, update, and delete projects using the Manage API.
---
## Authentication
To authenticate with the Projects API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All Projects API requests should be made to:
```
https://api.openpanel.dev/manage/projects
```
## Endpoints
### List Projects
Retrieve all projects in your organization.
```
GET /manage/projects
```
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/projects' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": [
{
"id": "my-project",
"name": "My Project",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com", "https://www.example.com"],
"crossDomain": false,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"deleteAt": null
}
]
}
```
### Get Project
Retrieve a specific project by ID.
```
GET /manage/projects/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the project |
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/projects/my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": {
"id": "my-project",
"name": "My Project",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com"],
"crossDomain": false,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"deleteAt": null
}
}
```
### Create Project
Create a new project in your organization. A default write client is automatically created with the project.
```
POST /manage/projects
```
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | Yes | Project name (minimum 1 character) |
| `domain` | string \| null | No | Primary domain for the project (URL format or empty string) |
| `cors` | string[] | No | Array of allowed CORS origins (default: `[]`) |
| `crossDomain` | boolean | No | Enable cross-domain tracking (default: `false`) |
| `types` | string[] | No | Project types: `website`, `app`, `backend` (default: `[]`) |
#### Project Types
- `website`: Web-based project
- `app`: Mobile application
- `backend`: Backend/server-side project
#### Example Request
```bash
curl -X POST 'https://api.openpanel.dev/manage/projects' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "My New Project",
"domain": "https://example.com",
"cors": ["https://example.com", "https://www.example.com"],
"crossDomain": false,
"types": ["website"]
}'
```
#### Response
```json
{
"data": {
"id": "my-new-project",
"name": "My New Project",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com", "https://www.example.com"],
"crossDomain": false,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z",
"deleteAt": null,
"client": {
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
"secret": "sec_6c8ae85a092d6c66b242"
}
}
}
```
**Important**: The `client.secret` is only returned once when the project is created. Store it securely immediately.
### Update Project
Update an existing project's configuration.
```
PATCH /manage/projects/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the project |
#### Request Body
All fields are optional. Only include fields you want to update.
| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | string | Project name (minimum 1 character) |
| `domain` | string \| null | Primary domain (URL format, empty string, or null) |
| `cors` | string[] | Array of allowed CORS origins |
| `crossDomain` | boolean | Enable cross-domain tracking |
| `allowUnsafeRevenueTracking` | boolean | Allow revenue tracking without client secret |
#### Example Request
```bash
curl -X PATCH 'https://api.openpanel.dev/manage/projects/my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"name": "Updated Project Name",
"crossDomain": true,
"allowUnsafeRevenueTracking": false
}'
```
#### Response
```json
{
"data": {
"id": "my-project",
"name": "Updated Project Name",
"organizationId": "org_123",
"domain": "https://example.com",
"cors": ["https://example.com"],
"crossDomain": true,
"allowUnsafeRevenueTracking": false,
"filters": [],
"types": ["website"],
"eventsCount": 0,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T11:00:00.000Z",
"deleteAt": null
}
}
```
### Delete Project
Soft delete a project. The project will be scheduled for deletion after 24 hours.
```
DELETE /manage/projects/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the project |
#### Example Request
```bash
curl -X DELETE 'https://api.openpanel.dev/manage/projects/my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"success": true
}
```
**Note**: Projects are soft-deleted. The `deleteAt` field is set to 24 hours in the future. You can cancel deletion by updating the project before the deletion time.
## Error Handling
The API uses standard HTTP response codes. Common error responses:
### 400 Bad Request
```json
{
"error": "Bad Request",
"message": "Invalid request body",
"details": [
{
"path": ["name"],
"message": "String must contain at least 1 character(s)"
}
]
}
```
### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Manage: Only root clients are allowed to manage resources"
}
```
### 404 Not Found
```json
{
"error": "Not Found",
"message": "Project not found"
}
```
### 429 Too Many Requests
Rate limiting response includes headers indicating your rate limit status.
## Rate Limiting
The Projects API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Notes
- Project IDs are automatically generated from the project name using a slug format
- If a project ID already exists, a numeric suffix is added
- CORS domains are automatically normalized (trailing slashes removed)
- The default client created with a project has `type: 'write'`
- Projects are scoped to your organization - you can only manage projects in your organization
- Soft-deleted projects are excluded from list endpoints

View File

@@ -1,344 +0,0 @@
---
title: References
description: Manage reference points for your OpenPanel projects. References are useful for marking important dates or events in your analytics timeline.
---
## Authentication
To authenticate with the References API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
Include the following headers with your requests:
- `openpanel-client-id`: Your OpenPanel root client ID
- `openpanel-client-secret`: Your OpenPanel root client secret
## Base URL
All References API requests should be made to:
```
https://api.openpanel.dev/manage/references
```
## What are References?
References are markers you can add to your analytics timeline to track important events such as:
- Product launches
- Marketing campaign start dates
- Feature releases
- Website redesigns
- Major announcements
References appear in your analytics charts and help you correlate changes in metrics with specific events.
## Endpoints
### List References
Retrieve all references in your organization, optionally filtered by project.
```
GET /manage/references
```
#### Query Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `projectId` | string | Optional. Filter references by project ID |
#### Example Request
```bash
# List all references
curl 'https://api.openpanel.dev/manage/references' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
# List references for a specific project
curl 'https://api.openpanel.dev/manage/references?projectId=my-project' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": [
{
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch",
"description": "Version 2.0 released",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T08:00:00.000Z"
},
{
"id": "2bf19738-3ee8-4c48-af6d-7ggb8f561f96",
"title": "Marketing Campaign Start",
"description": "Q1 2024 campaign launched",
"date": "2024-01-20T09:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-18T10:00:00.000Z",
"updatedAt": "2024-01-18T10:00:00.000Z"
}
]
}
```
### Get Reference
Retrieve a specific reference by ID.
```
GET /manage/references/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the reference (UUID) |
#### Example Request
```bash
curl 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"data": {
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch",
"description": "Version 2.0 released",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T08:00:00.000Z"
}
}
```
### Create Reference
Create a new reference point for a project.
```
POST /manage/references
```
#### Request Body
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `projectId` | string | Yes | The ID of the project this reference belongs to |
| `title` | string | Yes | Reference title (minimum 1 character) |
| `description` | string | No | Optional description or notes |
| `datetime` | string | Yes | Date and time for the reference (ISO 8601 format) |
#### Example Request
```bash
curl -X POST 'https://api.openpanel.dev/manage/references' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"projectId": "my-project",
"title": "Product Launch",
"description": "Version 2.0 released with new features",
"datetime": "2024-01-15T10:00:00.000Z"
}'
```
#### Response
```json
{
"data": {
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch",
"description": "Version 2.0 released with new features",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T08:00:00.000Z"
}
}
```
**Note**: The `date` field in the response is parsed from the `datetime` string you provided.
### Update Reference
Update an existing reference.
```
PATCH /manage/references/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the reference (UUID) |
#### Request Body
All fields are optional. Only include fields you want to update.
| Parameter | Type | Description |
|-----------|------|-------------|
| `title` | string | Reference title (minimum 1 character) |
| `description` | string \| null | Description or notes (set to `null` to clear) |
| `datetime` | string | Date and time for the reference (ISO 8601 format) |
#### Example Request
```bash
curl -X PATCH 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"title": "Product Launch v2.1",
"description": "Updated: Version 2.1 released with bug fixes",
"datetime": "2024-01-15T10:00:00.000Z"
}'
```
#### Response
```json
{
"data": {
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
"title": "Product Launch v2.1",
"description": "Updated: Version 2.1 released with bug fixes",
"date": "2024-01-15T10:00:00.000Z",
"projectId": "my-project",
"createdAt": "2024-01-10T08:00:00.000Z",
"updatedAt": "2024-01-10T09:30:00.000Z"
}
}
```
### Delete Reference
Permanently delete a reference. This action cannot be undone.
```
DELETE /manage/references/{id}
```
#### Path Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | The ID of the reference (UUID) |
#### Example Request
```bash
curl -X DELETE 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
```
#### Response
```json
{
"success": true
}
```
## Error Handling
The API uses standard HTTP response codes. Common error responses:
### 400 Bad Request
```json
{
"error": "Bad Request",
"message": "Invalid request body",
"details": [
{
"path": ["title"],
"message": "String must contain at least 1 character(s)"
}
]
}
```
### 401 Unauthorized
```json
{
"error": "Unauthorized",
"message": "Manage: Only root clients are allowed to manage resources"
}
```
### 404 Not Found
```json
{
"error": "Not Found",
"message": "Reference not found"
}
```
This error can occur if:
- The reference ID doesn't exist
- The reference belongs to a different organization
### 429 Too Many Requests
Rate limiting response includes headers indicating your rate limit status.
## Rate Limiting
The References API implements rate limiting:
- **20 requests per 10 seconds** per client
- Rate limit headers included in responses
- Implement exponential backoff for retries
## Date Format
References use ISO 8601 date format. Examples:
- `2024-01-15T10:00:00.000Z` - UTC timezone
- `2024-01-15T10:00:00-05:00` - Eastern Time (UTC-5)
- `2024-01-15` - Date only (time defaults to 00:00:00)
The `datetime` field in requests is converted to a `date` field in responses, stored as a timestamp.
## Use Cases
References are useful for:
- **Product Launches**: Mark when new versions or features are released
- **Marketing Campaigns**: Track campaign start and end dates
- **Website Changes**: Note when major redesigns or updates occur
- **Business Events**: Record important business milestones
- **A/B Testing**: Mark when experiments start or end
- **Seasonal Events**: Track holidays, sales periods, or seasonal changes
## Notes
- Reference IDs are UUIDs (Universally Unique Identifiers)
- References are scoped to projects - each reference belongs to a specific project
- References are scoped to your organization - you can only manage references for projects in your organization
- The `description` field is optional and can be set to `null` to clear it
- References appear in analytics charts to help correlate metrics with events
- When filtering by `projectId`, the project must exist and belong to your organization

View File

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

View File

@@ -175,48 +175,6 @@ COOKIE_SECRET=your-random-secret-here
Never use the default value in production! Always generate a unique secret.
</Callout>
### COOKIE_TLDS
**Type**: `string` (comma-separated)
**Required**: No
**Default**: None
Custom multi-part TLDs for cookie domain handling. Use this when deploying on domains with public suffixes that aren't recognized by default (e.g., `.my.id`, `.web.id`, `.co.id`).
**Example**:
```bash
# For domains like abc.my.id
COOKIE_TLDS=my.id
# Multiple TLDs
COOKIE_TLDS=my.id,web.id,co.id
```
<Callout>
This is required when using domain suffixes that are public suffixes (like `.co.uk`). Without this, the browser will reject authentication cookies. Common examples include Indonesian domains (`.my.id`, `.web.id`, `.co.id`).
</Callout>
### CUSTOM_COOKIE_DOMAIN
**Type**: `string`
**Required**: No
**Default**: None
Override the automatic cookie domain detection and set a specific domain for authentication cookies. Useful when proxying the API through your main domain or when you need precise control over cookie scope.
**Example**:
```bash
# Set cookies only on the main domain
CUSTOM_COOKIE_DOMAIN=.example.com
# Set cookies on a specific subdomain
CUSTOM_COOKIE_DOMAIN=.app.example.com
```
<Callout>
When set, this completely bypasses the automatic domain parsing logic. The cookie will always be set as secure. Include a leading dot (`.`) to allow the cookie to be shared across subdomains.
</Callout>
### DEMO_USER_ID
**Type**: `string`

View File

@@ -1,408 +0,0 @@
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { FaqItem, Faqs } from '@/components/faq';
import { FeatureCard } from '@/components/feature-card';
import { Section, SectionHeader } from '@/components/section';
import { Button } from '@/components/ui/button';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import {
BarChartIcon,
CheckIcon,
CodeIcon,
GlobeIcon,
HeartHandshakeIcon,
LinkIcon,
MailIcon,
MessageSquareIcon,
SparklesIcon,
UsersIcon,
ZapIcon,
} from 'lucide-react';
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
export const metadata: Metadata = getPageMetadata({
title: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
description:
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
url: url('/open-source'),
image: getOgImageUrl('/open-source'),
});
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: 'Free Analytics for Open Source Projects | OpenPanel OSS Program',
description:
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
url: url('/open-source'),
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
mainEntity: {
'@type': 'Offer',
name: 'Free Analytics for Open Source Projects',
description:
'Free analytics service for open source projects up to 2.5M events per month',
price: '0',
priceCurrency: 'USD',
},
};
export default function OpenSourcePage() {
return (
<div>
<Script
id="open-source-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer>
<div className="col center-center flex-1">
<SectionHeader
as="h1"
align="center"
className="flex-1"
title={
<>
Free Analytics for
<br />
Open Source Projects
</>
}
description="Track your users, understand adoption, and grow your project - all without cost. Get free analytics for your open source project with up to 2.5M events per month."
/>
<div className="col gap-4 justify-center items-center mt-8">
<Button size="lg" asChild>
<Link href="mailto:oss@openpanel.dev">
Apply for Free Access
<MailIcon className="size-4" />
</Link>
</Button>
<p className="text-sm text-muted-foreground">
Up to 2.5M events/month No credit card required
</p>
</div>
</div>
</HeroContainer>
<div className="container">
<div className="col gap-16">
{/* What You Get Section */}
<Section className="my-0">
<SectionHeader
title="What you get"
description="Everything you need to understand your users and grow your open source project."
/>
<div className="grid md:grid-cols-2 gap-6 mt-8">
<FeatureCard
title="2.5 Million Events/Month"
description="More than enough for most open source projects. Track page views, user actions, and custom events without worrying about limits."
icon={BarChartIcon}
/>
<FeatureCard
title="Full Feature Access"
description="Same powerful capabilities as paid plans. Funnels, retention analysis, custom dashboards, and real-time analytics."
icon={ZapIcon}
/>
<FeatureCard
title="Unlimited Team Members"
description="Invite your entire contributor team. Collaborate with maintainers and core contributors on understanding your project's growth."
icon={UsersIcon}
/>
<FeatureCard
title="Priority Support"
description="Dedicated help for open source maintainers. Get faster responses and priority assistance when you need it."
icon={MessageSquareIcon}
/>
</div>
</Section>
{/* Why We Do This Section */}
<Section className="my-0">
<SectionHeader
title="Why we do this"
description="OpenPanel is built by and for the open source community. We believe in giving back."
/>
<div className="col gap-6 mt-8">
<p className="text-muted-foreground">
We started OpenPanel because we believed analytics tools
shouldn't be complicated or locked behind expensive enterprise
subscriptions. As an open source project ourselves, we
understand the challenges of building and growing a project
without the resources of big corporations.
</p>
<div className="grid md:grid-cols-3 gap-4">
<FeatureCard
title="Built for OSS"
description="OpenPanel is open source. We know what it's like to build in the open."
icon={CodeIcon}
/>
<FeatureCard
title="No Barriers"
description="Analytics shouldn't be a barrier to understanding your users. We're removing that barrier."
icon={HeartHandshakeIcon}
/>
<FeatureCard
title="Giving Back"
description="We're giving back to the projects that inspire us and the community that supports us."
icon={SparklesIcon}
/>
</div>
</div>
</Section>
{/* What We Ask In Return Section */}
<Section className="my-0">
<SectionHeader
title="What we ask in return"
description="We keep it simple. Just a small way to help us grow and support more projects."
/>
<div className="row gap-6 mt-8">
<div className="col gap-6">
<FeatureCard
title="Backlink to OpenPanel"
description="A simple link on your website or README helps others discover OpenPanel. It's a win-win for the community."
icon={LinkIcon}
>
<p className="text-sm text-muted-foreground mt-2">
Example: "Analytics powered by{' '}
<Link
href="https://openpanel.dev"
className="text-primary hover:underline"
>
OpenPanel
</Link>
"
</p>
</FeatureCard>
<FeatureCard
title="Display a Widget"
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
icon={GlobeIcon}
>
<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,13 +26,6 @@ async function getOgData(
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
};
}
case 'open-source': {
return {
title: 'Free analytics for open source projects',
description:
"Get free web and product analytics for your open source project. Track up to 2.5M events/month. Apply to OpenPanel's open source program today.",
};
}
case 'pricing': {
return {
title: 'Pricing',

View File

@@ -71,7 +71,6 @@ 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,10 +25,6 @@ export async function Footer() {
{ title: 'About', url: '/about' },
{ title: 'Contact', url: '/contact' },
{ title: 'Become a supporter', url: '/supporter' },
{
title: 'Free analytics for open source projects',
url: '/open-source',
},
]}
/>
</div>
@@ -79,28 +75,10 @@ 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>
<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>
<Link href="/" className="row items-center font-medium -ml-3">
<Logo className="h-6" />
{baseOptions().nav?.title}
</Link>
</div>
<Social />
</div>

View File

@@ -84,16 +84,9 @@
"@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",

View File

@@ -4,7 +4,7 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { PublicPageCard } from '../public-page-card';
import { LogoSquare } from '../logo';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
@@ -43,27 +43,54 @@ export function ShareEnterPassword({
});
});
const typeLabel =
shareType === 'dashboard'
? 'Dashboard'
: shareType === 'report'
? 'Report'
: 'Overview';
return (
<PublicPageCard
title={`${typeLabel} is locked`}
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
>
<form onSubmit={onSubmit} className="col gap-4">
<Input
{...form.register('password')}
type="password"
placeholder="Enter your password"
size="large"
/>
<Button type="submit">Get access</Button>
</form>
</PublicPageCard>
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">
{shareType === 'dashboard'
? 'Dashboard is locked'
: shareType === 'report'
? 'Report is locked'
: 'Overview is locked'}
</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this{' '}
{shareType === 'dashboard'
? 'dashboard'
: shareType === 'report'
? 'report'
: 'overview'}
</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>
);
}

View File

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

View File

@@ -1,53 +1,18 @@
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
import { JsonEditor } from '@/components/json-editor';
import { InputWithLabel } from '@/components/forms/input-with-label';
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,
@@ -56,13 +21,6 @@ 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(
{
@@ -72,68 +30,18 @@ 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(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');
}
onError() {
toast.error('Failed to create integration');
},
}),
);
@@ -162,176 +70,7 @@ export function WebhookIntegrationForm({
{...form.register('config.url')}
error={path(['config', 'url', 'message'], form.formState.errors)}
/>
<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>
<Button type="submit">Create</Button>
</form>
);
}

View File

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

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

View File

@@ -4,8 +4,7 @@ import { cn } from '@/utils/cn';
import { DialogTitle } from '@radix-ui/react-dialog';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SearchIcon } from 'lucide-react';
import type React from 'react';
import { useMemo, useRef, useState } from 'react';
import React, { useMemo, useRef, useState } from 'react';
import { Input } from '../ui/input';
const ROW_HEIGHT = 36;
@@ -107,9 +106,7 @@ 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,
@@ -155,8 +152,7 @@ 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>
@@ -208,14 +204,11 @@ 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 && (
@@ -268,3 +261,4 @@ 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', className)}>
<div className={cn('min-w-0 col gap-2 items-start', className)}>
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label}
@@ -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 w-full text-left">
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
{value}
</div>
)}

View File

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

View File

@@ -118,12 +118,12 @@ export default function OverviewTopEvents({
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return tableData;
return tableData.slice(0, 15);
}
const queryLower = searchQuery.toLowerCase();
return tableData.filter((item) =>
item.name?.toLowerCase().includes(queryLower),
);
return tableData
.filter((item) => item.name?.toLowerCase().includes(queryLower))
.slice(0, 15);
}, [tableData, searchQuery]);
const tabs = useMemo(

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,7 @@ 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 { ChevronDown, ChevronUp, ExternalLinkIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { ExternalLinkIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Skeleton } from '../skeleton';
import { Tooltiper } from '../ui/tooltip';
@@ -46,42 +45,6 @@ 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;
};
@@ -93,113 +56,10 @@ 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={sortedData}
data={data ?? []}
keyExtractor={keyExtractor}
className={'text-sm min-h-[358px] @container'}
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
@@ -215,7 +75,18 @@ export const OverviewWidgetTable = <T,>({
</div>
);
}}
columns={columnsWithSortableHeaders}
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,
),
};
})}
/>
</div>
);
@@ -337,8 +208,6 @@ 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 =
@@ -362,7 +231,6 @@ 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">
@@ -377,7 +245,6 @@ 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">
@@ -472,8 +339,6 @@ 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 =
@@ -497,7 +362,6 @@ 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">
@@ -630,9 +494,6 @@ 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 =
@@ -660,9 +521,6 @@ 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">
@@ -679,9 +537,6 @@ 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">
@@ -744,7 +599,6 @@ 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,7 +7,6 @@ import {
format,
formatISO,
isSameMonth,
isToday,
startOfMonth,
subMonths,
} from 'date-fns';
@@ -19,26 +18,15 @@ 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,
maxCount,
}: { month: Date; data: Props['data']; maxCount: number }) => (
}: { month: Date; data: Props['data'] }) => (
<div>
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
@@ -49,42 +37,14 @@ const MonthCalendar = ({
const hit = data.find((item) =>
item.date.includes(formatISO(date, { representation: 'date' })),
);
const opacity = hit ? getOpacityLevel(hit.count, maxCount) : 0;
return (
<Tooltiper
<div
key={date.toISOString()}
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>
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
@@ -93,7 +53,6 @@ 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">
@@ -124,7 +83,6 @@ export const ProfileActivity = ({ data }: Props) => {
key={offset}
month={subMonths(startDate, offset)}
data={data}
maxCount={maxCount}
/>
))}
</div>

View File

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

View File

@@ -1,51 +0,0 @@
import type { ReactNode } from 'react';
import { LoginNavbar } from './login-navbar';
import { LogoSquare } from './logo';
interface PublicPageCardProps {
title: string;
description?: ReactNode;
children?: ReactNode;
showFooter?: boolean;
}
export function PublicPageCard({
title,
description,
children,
showFooter = true,
}: PublicPageCardProps) {
return (
<div>
<LoginNavbar />
<div className="center-center h-screen w-screen p-4 col">
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">{title}</div>
{description && (
<div className="text-lg text-muted-foreground leading-normal">
{description}
</div>
)}
</div>
{!!children && <div className="mt-6">{children}</div>}
</div>
{showFooter && (
<div className="p-6 text-sm max-w-sm col gap-1 text-muted-foreground">
<p>
Powered by{' '}
<a href="https://openpanel.dev" className="font-medium">
OpenPanel.dev
</a>
{' · '}
<a href="https://dashboard.openpanel.dev/onboarding">
Try it for free today!
</a>
</p>
</div>
)}
</div>
</div>
);
}

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';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportAreaChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

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 { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportBarChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

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 { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -13,11 +14,18 @@ import { Summary } from './summary';
export function ReportConversionChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
console.log(report.limit);
const res = useQuery(
trpc.chart.conversion.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -61,14 +61,9 @@ export function Chart({ data }: Props) {
range,
series: reportSeries,
breakdowns,
options: reportOptions,
},
options: { hideXAxis, hideYAxis },
} = useReportChartContext();
const histogramOptions =
reportOptions?.type === 'histogram' ? reportOptions : undefined;
const isStacked = histogramOptions?.stacked ?? false;
const trpc = useTRPC();
const references = useQuery(
trpc.reference.getChartReferences.queryOptions(
@@ -160,70 +155,68 @@ export function Chart({ data }: Props) {
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<BarChart data={rechartData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
className="stroke-def-200"
/>
<Tooltip
content={<ReportChartTooltip.Tooltip />}
cursor={<BarHover />}
/>
<YAxis {...yAxisProps} />
<XAxis {...xAxisProps} scale={'auto'} type="category" />
{previous
? series.map((serie) => {
return (
<Bar
key={`${serie.id}:prev`}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.3}
radius={5}
stackId={isStacked ? 'prev' : undefined}
/>
);
})
: null}
{series.map((serie) => {
return (
<Bar
key={serie.id}
name={serie.id}
dataKey={`${serie.id}:count`}
fill={getChartColor(serie.index)}
radius={isStacked ? 0 : 4}
fillOpacity={1}
stackId={isStacked ? 'current' : undefined}
/>
);
})}
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
className="stroke-def-200"
/>
<Tooltip
content={<ReportChartTooltip.Tooltip />}
cursor={<BarHover />}
/>
<YAxis {...yAxisProps} />
<XAxis {...xAxisProps} scale={'auto'} type="category" />
{previous
? series.map((serie) => {
return (
<Bar
key={`${serie.id}:prev`}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.3}
radius={5}
/>
);
})
: null}
{series.map((serie) => {
return (
<Bar
key={serie.id}
name={serie.id}
dataKey={`${serie.id}:count`}
fill={getChartColor(serie.index)}
radius={5}
fillOpacity={1}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
);
})}
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider>
);

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';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportHistogramChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -1,6 +1,8 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { cn } from '@/utils/cn';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -11,11 +13,18 @@ import { Chart } from './chart';
export function ReportLineChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

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';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportMapChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

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';
@@ -10,12 +11,18 @@ import { Chart } from './chart';
export function ReportMetricChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

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';
@@ -11,12 +12,18 @@ import { Chart } from './chart';
export function ReportPieChart() {
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,

View File

@@ -53,7 +53,7 @@ export function ReportRetentionChart() {
criteria,
interval: overviewInterval ?? interval,
shareId,
id,
reportId: id,
},
{
placeholderData: keepPreviousData,

View File

@@ -361,17 +361,6 @@ export const reportSlice = createSlice({
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,
action: PayloadAction<{ fromIndex: number; toIndex: number }>,
@@ -417,7 +406,6 @@ export const {
changeSankeySteps,
changeSankeyExclude,
changeSankeyInclude,
changeStacked,
reorderEvents,
} = reportSlice.actions;

View File

@@ -17,7 +17,6 @@ import {
changeSankeyInclude,
changeSankeyMode,
changeSankeySteps,
changeStacked,
changeUnit,
} from '../reportSlice';
@@ -26,17 +25,14 @@ export function ReportSettings() {
const previous = useSelector((state) => state.report.previous);
const unit = useSelector((state) => state.report.unit);
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 });
@@ -65,10 +61,6 @@ export function ReportSettings() {
fields.push('sankeyInclude');
}
if (chartType === 'histogram') {
fields.push('stacked');
}
return fields;
}, [chartType]);
@@ -267,15 +259,6 @@ export function ReportSettings() {
/>
</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

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

View File

@@ -7,12 +7,11 @@ 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, useRouteContext } from '@tanstack/react-router';
import { Link, useNavigate } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronDownIcon, PlusIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -29,8 +28,6 @@ export default function SidebarOrganizationMenu({
}: {
organization: RouterOutputs['organization']['list'][number];
}) {
const { isSelfHosted } = useAppContext();
return (
<>
<Link
@@ -55,23 +52,21 @@ export default function SidebarOrganizationMenu({
<CogIcon size={20} />
<div className="flex-1">Settings</div>
</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]',
)}
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

@@ -29,14 +29,6 @@ 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[];
@@ -185,16 +177,9 @@ 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={columnKey}
key={column.name?.toString()}
className={cn(
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
columns.length > 1 && column !== columns[0]
@@ -246,16 +231,9 @@ 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={columnKey}
key={column.name?.toString()}
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 received with useRouteContext in the client
// And recieved with useRouteContext in the client
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
pick(VALID_COOKIES, getCookies()),
);

View File

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

View File

@@ -232,7 +232,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
{profile && (
<ProjectLink
onClick={() => popModal()}
href={`/profiles/${encodeURIComponent(profile.id)}`}
href={`/profiles/${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

@@ -25,7 +25,7 @@ const ProfileItem = ({ profile }: { profile: any }) => {
return (
<ProjectLink
preload={false}
href={`/profiles/${encodeURIComponent(profile.id)}`}
href={`/profiles/${profile.id}`}
title={getProfileName(profile, false)}
className="col gap-2 rounded-lg border p-2 bg-card"
onClick={(e) => {

View File

@@ -11,7 +11,6 @@
import { createFileRoute } from '@tanstack/react-router'
import { Route as rootRouteImport } from './routes/__root'
import { Route as UnsubscribeRouteImport } from './routes/unsubscribe'
import { Route as StepsRouteImport } from './routes/_steps'
import { Route as PublicRouteImport } from './routes/_public'
import { Route as LoginRouteImport } from './routes/_login'
@@ -20,7 +19,6 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as WidgetTestRouteImport } from './routes/widget/test'
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
import { Route as WidgetBadgeRouteImport } from './routes/widget/badge'
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
import { Route as ApiConfigRouteImport } from './routes/api/config'
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
@@ -38,7 +36,6 @@ import { Route as AppOrganizationIdProjectIdRouteImport } from './routes/_app.$o
import { Route as AppOrganizationIdProjectIdIndexRouteImport } from './routes/_app.$organizationId.$projectId.index'
import { Route as StepsOnboardingProjectIdVerifyRouteImport } from './routes/_steps.onboarding.$projectId.verify'
import { Route as StepsOnboardingProjectIdConnectRouteImport } from './routes/_steps.onboarding.$projectId.connect'
import { Route as AppOrganizationIdProfileTabsRouteImport } from './routes/_app.$organizationId.profile._tabs'
import { Route as AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
@@ -49,10 +46,8 @@ import { Route as AppOrganizationIdProjectIdPagesRouteImport } from './routes/_a
import { Route as AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
import { Route as AppOrganizationIdProfileTabsIndexRouteImport } from './routes/_app.$organizationId.profile._tabs.index'
import { Route as AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index'
import { Route as AppOrganizationIdIntegrationsTabsIndexRouteImport } from './routes/_app.$organizationId.integrations._tabs.index'
import { Route as AppOrganizationIdProfileTabsEmailPreferencesRouteImport } from './routes/_app.$organizationId.profile._tabs.email-preferences'
import { Route as AppOrganizationIdMembersTabsMembersRouteImport } from './routes/_app.$organizationId.members._tabs.members'
import { Route as AppOrganizationIdMembersTabsInvitationsRouteImport } from './routes/_app.$organizationId.members._tabs.invitations'
import { Route as AppOrganizationIdIntegrationsTabsInstalledRouteImport } from './routes/_app.$organizationId.integrations._tabs.installed'
@@ -85,9 +80,6 @@ import { Route as AppOrganizationIdProjectIdEventsTabsConversionsRouteImport } f
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
const AppOrganizationIdProfileRouteImport = createFileRoute(
'/_app/$organizationId/profile',
)()
const AppOrganizationIdMembersRouteImport = createFileRoute(
'/_app/$organizationId/members',
)()
@@ -110,11 +102,6 @@ const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
'/_app/$organizationId/$projectId/profiles/$profileId',
)()
const UnsubscribeRoute = UnsubscribeRouteImport.update({
id: '/unsubscribe',
path: '/unsubscribe',
getParentRoute: () => rootRouteImport,
} as any)
const StepsRoute = StepsRouteImport.update({
id: '/_steps',
getParentRoute: () => rootRouteImport,
@@ -151,11 +138,6 @@ const WidgetCounterRoute = WidgetCounterRouteImport.update({
path: '/widget/counter',
getParentRoute: () => rootRouteImport,
} as any)
const WidgetBadgeRoute = WidgetBadgeRouteImport.update({
id: '/widget/badge',
path: '/widget/badge',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
id: '/api/healthcheck',
path: '/api/healthcheck',
@@ -186,12 +168,6 @@ const AppOrganizationIdRoute = AppOrganizationIdRouteImport.update({
path: '/$organizationId',
getParentRoute: () => AppRoute,
} as any)
const AppOrganizationIdProfileRoute =
AppOrganizationIdProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => AppOrganizationIdRoute,
} as any)
const AppOrganizationIdMembersRoute =
AppOrganizationIdMembersRouteImport.update({
id: '/members',
@@ -289,11 +265,6 @@ const StepsOnboardingProjectIdConnectRoute =
path: '/onboarding/$projectId/connect',
getParentRoute: () => StepsRoute,
} as any)
const AppOrganizationIdProfileTabsRoute =
AppOrganizationIdProfileTabsRouteImport.update({
id: '/_tabs',
getParentRoute: () => AppOrganizationIdProfileRoute,
} as any)
const AppOrganizationIdMembersTabsRoute =
AppOrganizationIdMembersTabsRouteImport.update({
id: '/_tabs',
@@ -358,12 +329,6 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute =
path: '/$profileId',
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
} as any)
const AppOrganizationIdProfileTabsIndexRoute =
AppOrganizationIdProfileTabsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AppOrganizationIdProfileTabsRoute,
} as any)
const AppOrganizationIdMembersTabsIndexRoute =
AppOrganizationIdMembersTabsIndexRouteImport.update({
id: '/',
@@ -376,12 +341,6 @@ const AppOrganizationIdIntegrationsTabsIndexRoute =
path: '/',
getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute,
} as any)
const AppOrganizationIdProfileTabsEmailPreferencesRoute =
AppOrganizationIdProfileTabsEmailPreferencesRouteImport.update({
id: '/email-preferences',
path: '/email-preferences',
getParentRoute: () => AppOrganizationIdProfileTabsRoute,
} as any)
const AppOrganizationIdMembersTabsMembersRoute =
AppOrganizationIdMembersTabsMembersRouteImport.update({
id: '/members',
@@ -566,14 +525,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/unsubscribe': typeof UnsubscribeRoute
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
'/login': typeof LoginLoginRoute
'/reset-password': typeof LoginResetPasswordRoute
'/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
@@ -595,7 +552,6 @@ export interface FileRoutesByFullPath {
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
'/$organizationId/profile': typeof AppOrganizationIdProfileTabsRouteWithChildren
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
@@ -610,10 +566,8 @@ export interface FileRoutesByFullPath {
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
'/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
'/$organizationId/integrations/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members/': typeof AppOrganizationIdMembersTabsIndexRoute
'/$organizationId/profile/': typeof AppOrganizationIdProfileTabsIndexRoute
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
@@ -637,13 +591,11 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/unsubscribe': typeof UnsubscribeRoute
'/login': typeof LoginLoginRoute
'/reset-password': typeof LoginResetPasswordRoute
'/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
@@ -664,7 +616,6 @@ export interface FileRoutesByTo {
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
'/$organizationId/profile': typeof AppOrganizationIdProfileTabsIndexRoute
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
@@ -679,7 +630,6 @@ export interface FileRoutesByTo {
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
'/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
@@ -703,14 +653,12 @@ export interface FileRoutesById {
'/_login': typeof LoginRouteWithChildren
'/_public': typeof PublicRouteWithChildren
'/_steps': typeof StepsRouteWithChildren
'/unsubscribe': typeof UnsubscribeRoute
'/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren
'/_login/login': typeof LoginLoginRoute
'/_login/reset-password': typeof LoginResetPasswordRoute
'/_public/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
@@ -734,8 +682,6 @@ export interface FileRoutesById {
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
'/_app/$organizationId/members': typeof AppOrganizationIdMembersRouteWithChildren
'/_app/$organizationId/members/_tabs': typeof AppOrganizationIdMembersTabsRouteWithChildren
'/_app/$organizationId/profile': typeof AppOrganizationIdProfileRouteWithChildren
'/_app/$organizationId/profile/_tabs': typeof AppOrganizationIdProfileTabsRouteWithChildren
'/_steps/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
'/_steps/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
'/_app/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
@@ -754,10 +700,8 @@ export interface FileRoutesById {
'/_app/$organizationId/integrations/_tabs/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
'/_app/$organizationId/members/_tabs/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
'/_app/$organizationId/members/_tabs/members': typeof AppOrganizationIdMembersTabsMembersRoute
'/_app/$organizationId/profile/_tabs/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
'/_app/$organizationId/integrations/_tabs/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
'/_app/$organizationId/members/_tabs/': typeof AppOrganizationIdMembersTabsIndexRoute
'/_app/$organizationId/profile/_tabs/': typeof AppOrganizationIdProfileTabsIndexRoute
'/_app/$organizationId/$projectId/events/_tabs/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
@@ -784,14 +728,12 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/unsubscribe'
| '/$organizationId'
| '/login'
| '/reset-password'
| '/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
@@ -813,7 +755,6 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
| '/$organizationId/profile'
| '/onboarding/$projectId/connect'
| '/onboarding/$projectId/verify'
| '/$organizationId/$projectId/'
@@ -828,10 +769,8 @@ export interface FileRouteTypes {
| '/$organizationId/integrations/installed'
| '/$organizationId/members/invitations'
| '/$organizationId/members/members'
| '/$organizationId/profile/email-preferences'
| '/$organizationId/integrations/'
| '/$organizationId/members/'
| '/$organizationId/profile/'
| '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
@@ -855,13 +794,11 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/unsubscribe'
| '/login'
| '/reset-password'
| '/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
@@ -882,7 +819,6 @@ export interface FileRouteTypes {
| '/$organizationId/$projectId/sessions'
| '/$organizationId/integrations'
| '/$organizationId/members'
| '/$organizationId/profile'
| '/onboarding/$projectId/connect'
| '/onboarding/$projectId/verify'
| '/$organizationId/$projectId'
@@ -897,7 +833,6 @@ export interface FileRouteTypes {
| '/$organizationId/integrations/installed'
| '/$organizationId/members/invitations'
| '/$organizationId/members/members'
| '/$organizationId/profile/email-preferences'
| '/$organizationId/$projectId/events/conversions'
| '/$organizationId/$projectId/events/events'
| '/$organizationId/$projectId/events/stats'
@@ -920,14 +855,12 @@ export interface FileRouteTypes {
| '/_login'
| '/_public'
| '/_steps'
| '/unsubscribe'
| '/_app/$organizationId'
| '/_login/login'
| '/_login/reset-password'
| '/_public/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
@@ -951,8 +884,6 @@ export interface FileRouteTypes {
| '/_app/$organizationId/integrations/_tabs'
| '/_app/$organizationId/members'
| '/_app/$organizationId/members/_tabs'
| '/_app/$organizationId/profile'
| '/_app/$organizationId/profile/_tabs'
| '/_steps/onboarding/$projectId/connect'
| '/_steps/onboarding/$projectId/verify'
| '/_app/$organizationId/$projectId/'
@@ -971,10 +902,8 @@ export interface FileRouteTypes {
| '/_app/$organizationId/integrations/_tabs/installed'
| '/_app/$organizationId/members/_tabs/invitations'
| '/_app/$organizationId/members/_tabs/members'
| '/_app/$organizationId/profile/_tabs/email-preferences'
| '/_app/$organizationId/integrations/_tabs/'
| '/_app/$organizationId/members/_tabs/'
| '/_app/$organizationId/profile/_tabs/'
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
| '/_app/$organizationId/$projectId/events/_tabs/events'
| '/_app/$organizationId/$projectId/events/_tabs/stats'
@@ -1004,10 +933,8 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRouteWithChildren
PublicRoute: typeof PublicRouteWithChildren
StepsRoute: typeof StepsRouteWithChildren
UnsubscribeRoute: typeof UnsubscribeRoute
ApiConfigRoute: typeof ApiConfigRoute
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
WidgetBadgeRoute: typeof WidgetBadgeRoute
WidgetCounterRoute: typeof WidgetCounterRoute
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
WidgetTestRoute: typeof WidgetTestRoute
@@ -1018,13 +945,6 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/unsubscribe': {
id: '/unsubscribe'
path: '/unsubscribe'
fullPath: '/unsubscribe'
preLoaderRoute: typeof UnsubscribeRouteImport
parentRoute: typeof rootRouteImport
}
'/_steps': {
id: '/_steps'
path: ''
@@ -1081,13 +1001,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WidgetCounterRouteImport
parentRoute: typeof rootRouteImport
}
'/widget/badge': {
id: '/widget/badge'
path: '/widget/badge'
fullPath: '/widget/badge'
preLoaderRoute: typeof WidgetBadgeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/healthcheck': {
id: '/api/healthcheck'
path: '/api/healthcheck'
@@ -1130,13 +1043,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdRouteImport
parentRoute: typeof AppRoute
}
'/_app/$organizationId/profile': {
id: '/_app/$organizationId/profile'
path: '/profile'
fullPath: '/$organizationId/profile'
preLoaderRoute: typeof AppOrganizationIdProfileRouteImport
parentRoute: typeof AppOrganizationIdRoute
}
'/_app/$organizationId/members': {
id: '/_app/$organizationId/members'
path: '/members'
@@ -1256,13 +1162,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StepsOnboardingProjectIdConnectRouteImport
parentRoute: typeof StepsRoute
}
'/_app/$organizationId/profile/_tabs': {
id: '/_app/$organizationId/profile/_tabs'
path: '/profile'
fullPath: '/$organizationId/profile'
preLoaderRoute: typeof AppOrganizationIdProfileTabsRouteImport
parentRoute: typeof AppOrganizationIdProfileRoute
}
'/_app/$organizationId/members/_tabs': {
id: '/_app/$organizationId/members/_tabs'
path: '/members'
@@ -1340,13 +1239,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
}
'/_app/$organizationId/profile/_tabs/': {
id: '/_app/$organizationId/profile/_tabs/'
path: '/'
fullPath: '/$organizationId/profile/'
preLoaderRoute: typeof AppOrganizationIdProfileTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProfileTabsRoute
}
'/_app/$organizationId/members/_tabs/': {
id: '/_app/$organizationId/members/_tabs/'
path: '/'
@@ -1361,13 +1253,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute
}
'/_app/$organizationId/profile/_tabs/email-preferences': {
id: '/_app/$organizationId/profile/_tabs/email-preferences'
path: '/email-preferences'
fullPath: '/$organizationId/profile/email-preferences'
preLoaderRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRouteImport
parentRoute: typeof AppOrganizationIdProfileTabsRoute
}
'/_app/$organizationId/members/_tabs/members': {
id: '/_app/$organizationId/members/_tabs/members'
path: '/members'
@@ -1912,39 +1797,6 @@ const AppOrganizationIdMembersRouteWithChildren =
AppOrganizationIdMembersRouteChildren,
)
interface AppOrganizationIdProfileTabsRouteChildren {
AppOrganizationIdProfileTabsEmailPreferencesRoute: typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
AppOrganizationIdProfileTabsIndexRoute: typeof AppOrganizationIdProfileTabsIndexRoute
}
const AppOrganizationIdProfileTabsRouteChildren: AppOrganizationIdProfileTabsRouteChildren =
{
AppOrganizationIdProfileTabsEmailPreferencesRoute:
AppOrganizationIdProfileTabsEmailPreferencesRoute,
AppOrganizationIdProfileTabsIndexRoute:
AppOrganizationIdProfileTabsIndexRoute,
}
const AppOrganizationIdProfileTabsRouteWithChildren =
AppOrganizationIdProfileTabsRoute._addFileChildren(
AppOrganizationIdProfileTabsRouteChildren,
)
interface AppOrganizationIdProfileRouteChildren {
AppOrganizationIdProfileTabsRoute: typeof AppOrganizationIdProfileTabsRouteWithChildren
}
const AppOrganizationIdProfileRouteChildren: AppOrganizationIdProfileRouteChildren =
{
AppOrganizationIdProfileTabsRoute:
AppOrganizationIdProfileTabsRouteWithChildren,
}
const AppOrganizationIdProfileRouteWithChildren =
AppOrganizationIdProfileRoute._addFileChildren(
AppOrganizationIdProfileRouteChildren,
)
interface AppOrganizationIdRouteChildren {
AppOrganizationIdProjectIdRoute: typeof AppOrganizationIdProjectIdRouteWithChildren
AppOrganizationIdBillingRoute: typeof AppOrganizationIdBillingRoute
@@ -1952,7 +1804,6 @@ interface AppOrganizationIdRouteChildren {
AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute
AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren
AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren
AppOrganizationIdProfileRoute: typeof AppOrganizationIdProfileRouteWithChildren
}
const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
@@ -1963,7 +1814,6 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
AppOrganizationIdIntegrationsRoute:
AppOrganizationIdIntegrationsRouteWithChildren,
AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren,
AppOrganizationIdProfileRoute: AppOrganizationIdProfileRouteWithChildren,
}
const AppOrganizationIdRouteWithChildren =
@@ -2022,10 +1872,8 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRouteWithChildren,
PublicRoute: PublicRouteWithChildren,
StepsRoute: StepsRouteWithChildren,
UnsubscribeRoute: UnsubscribeRoute,
ApiConfigRoute: ApiConfigRoute,
ApiHealthcheckRoute: ApiHealthcheckRoute,
WidgetBadgeRoute: WidgetBadgeRoute,
WidgetCounterRoute: WidgetCounterRoute,
WidgetRealtimeRoute: WidgetRealtimeRoute,
WidgetTestRoute: WidgetTestRoute,

View File

@@ -86,32 +86,31 @@ function Component() {
return (
<div className="space-y-6">
<RealtimeWidgetSection
widget={realtimeWidget as any}
dashboardUrl={dashboardUrl}
isToggling={toggleMutation.isPending}
isUpdatingOptions={updateOptionsMutation.isPending}
onToggle={(enabled) => handleToggle('realtime', enabled)}
onUpdateOptions={(options) =>
updateOptionsMutation.mutate({
projectId,
organizationId,
options,
})
}
/>
{realtimeWidget && (
<RealtimeWidgetSection
widget={realtimeWidget as any}
dashboardUrl={dashboardUrl}
isToggling={toggleMutation.isPending}
isUpdatingOptions={updateOptionsMutation.isPending}
onToggle={(enabled) => handleToggle('realtime', enabled)}
onUpdateOptions={(options) =>
updateOptionsMutation.mutate({
projectId,
organizationId,
options,
})
}
/>
)}
<CounterWidgetSection
widget={counterWidget as any}
dashboardUrl={dashboardUrl}
isToggling={toggleMutation.isPending}
onToggle={(enabled) => handleToggle('counter', enabled)}
/>
<BadgeWidgetSection
widget={counterWidget as any}
dashboardUrl={dashboardUrl}
/>
{counterWidget && (
<CounterWidgetSection
widget={counterWidget}
dashboardUrl={dashboardUrl}
isToggling={toggleMutation.isPending}
onToggle={(enabled) => handleToggle('counter', enabled)}
/>
)}
</div>
);
}
@@ -153,15 +152,11 @@ function RealtimeWidgetSection({
countries: true,
paths: false,
};
const [options, setOptions] = useState<IRealtimeWidgetOptions>(
(widget?.options as IRealtimeWidgetOptions) || defaultOptions,
);
// Create a checksum based on URL and current options to force iframe reload
const widgetChecksum = widgetUrl
? btoa(JSON.stringify(Object.values(options)))
: null;
// Update local options when widget data changes
useEffect(() => {
if (widget?.options) {
@@ -259,8 +254,7 @@ function RealtimeWidgetSection({
<h3 className="text-sm font-medium">Preview</h3>
<div className="border rounded-lg overflow-hidden">
<iframe
key={widgetChecksum}
src={`${widgetUrl}&checksum=${widgetChecksum}`}
src={widgetUrl!}
width="100%"
height="600"
className="border-0"
@@ -374,96 +368,3 @@ function CounterWidgetSection({
</Widget>
);
}
interface BadgeWidgetSectionProps {
widget: {
id: string;
public: boolean;
} | null;
dashboardUrl: string;
}
function BadgeWidgetSection({ widget, dashboardUrl }: BadgeWidgetSectionProps) {
const isEnabled = widget?.public ?? false;
const badgeUrl =
isEnabled && widget?.id
? `${dashboardUrl}/widget/badge?shareId=${widget.id}`
: null;
const badgeEmbedCode = badgeUrl
? `<a href="https://openpanel.dev" style="display: inline-block; overflow: hidden; border-radius: 8px;">
<iframe src="${badgeUrl}" height="48" width="250" style="border: none; overflow: hidden; pointer-events: none;" title="OpenPanel Analytics Badge"></iframe>
</a>`
: null;
if (!isEnabled || !badgeUrl) {
return null;
}
return (
<Widget className="max-w-screen-md w-full">
<WidgetHead className="row items-center justify-between gap-6">
<div className="space-y-2">
<span className="title">Analytics Badge</span>
<p className="text-muted-foreground">
A Product Hunt-style badge showing your 30-day unique visitor count.
Perfect for showcasing your analytics powered by OpenPanel.
</p>
</div>
</WidgetHead>
<WidgetBody className="space-y-6">
<div className="space-y-2">
<h3 className="text-sm font-medium">Widget URL</h3>
<CopyInput label="" value={badgeUrl} className="w-full" />
<p className="text-xs text-muted-foreground">
Direct link to the analytics badge widget.
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Embed Code</h3>
<Syntax code={badgeEmbedCode!} language="bash" />
<p className="text-xs text-muted-foreground">
Copy this code and paste it into your website HTML where you want
the badge to appear.
</p>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Preview</h3>
<div className="border rounded-lg p-4 bg-muted/30">
<a
href="https://openpanel.dev"
target="_blank"
rel="noopener noreferrer"
style={{
overflow: 'hidden',
borderRadius: '8px',
display: 'inline-block',
}}
>
<iframe
src={badgeUrl}
height="48"
width="250"
className="border-0 pointer-events-none"
title="Analytics Badge Preview"
/>
</a>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
icon={ExternalLinkIcon}
onClick={() =>
window.open(badgeUrl, '_blank', 'noopener,noreferrer')
}
>
Open in new tab
</Button>
</div>
</div>
</WidgetBody>
</Widget>
);
}

View File

@@ -1,139 +0,0 @@
import { WithLabel } from '@/components/forms/input-with-label';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/integrations/trpc/react';
import { emailCategories } from '@openpanel/constants';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { SaveIcon } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const validator = z.object({
categories: z.record(z.string(), z.boolean()),
});
type IForm = z.infer<typeof validator>;
/**
* Build explicit boolean values for every key in emailCategories.
* Uses saved preferences when available, falling back to true (opted-in).
*/
function buildCategoryDefaults(
savedPreferences?: Record<string, boolean>,
): Record<string, boolean> {
return Object.keys(emailCategories).reduce(
(acc, category) => {
acc[category] = savedPreferences?.[category] ?? true;
return acc;
},
{} as Record<string, boolean>,
);
}
export const Route = createFileRoute(
'/_app/$organizationId/profile/_tabs/email-preferences',
)({
component: Component,
pendingComponent: FullPageLoadingState,
});
function Component() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const preferencesQuery = useSuspenseQuery(
trpc.email.getPreferences.queryOptions(),
);
const { control, handleSubmit, formState, reset } = useForm<IForm>({
defaultValues: {
categories: buildCategoryDefaults(preferencesQuery.data),
},
});
const mutation = useMutation(
trpc.email.updatePreferences.mutationOptions({
onSuccess: async () => {
toast('Email preferences updated', {
description: 'Your email preferences have been saved.',
});
await queryClient.invalidateQueries(
trpc.email.getPreferences.pathFilter(),
);
// Reset form with fresh data after refetch
const freshData = await queryClient.fetchQuery(
trpc.email.getPreferences.queryOptions(),
);
reset({
categories: buildCategoryDefaults(freshData),
});
},
onError: handleError,
}),
);
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Email Preferences</span>
</WidgetHead>
<WidgetBody className="gap-4 col">
<p className="text-sm text-muted-foreground mb-4">
Choose which types of emails you want to receive. Uncheck a category
to stop receiving those emails.
</p>
<div className="space-y-4">
{Object.entries(emailCategories).map(([category, label]) => (
<Controller
key={category}
name={`categories.${category}`}
control={control}
render={({ field }) => (
<div className="flex items-center justify-between gap-4 px-4 py-4 rounded-md border border-border hover:bg-def-200 transition-colors">
<div className="flex-1">
<div className="font-medium">{label}</div>
<div className="text-sm text-muted-foreground">
{category === 'onboarding' &&
'Get started tips and guidance emails'}
{category === 'billing' &&
'Subscription updates and payment reminders'}
</div>
</div>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={mutation.isPending}
/>
</div>
)}
/>
))}
</div>
<Button
size="sm"
type="submit"
disabled={!formState.isDirty || mutation.isPending}
className="self-end mt-4"
icon={SaveIcon}
loading={mutation.isPending}
>
Save
</Button>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -1,96 +0,0 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { SaveIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const validator = z.object({
firstName: z.string(),
lastName: z.string(),
});
type IForm = z.infer<typeof validator>;
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({
component: Component,
pendingComponent: FullPageLoadingState,
});
function Component() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const session = useSuspenseQuery(trpc.auth.session.queryOptions());
const user = session.data?.user;
const { register, handleSubmit, formState, reset } = useForm<IForm>({
defaultValues: {
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
},
});
const mutation = useMutation(
trpc.user.update.mutationOptions({
onSuccess: (data) => {
toast('Profile updated', {
description: 'Your profile has been updated.',
});
queryClient.invalidateQueries(trpc.auth.session.pathFilter());
reset({
firstName: data.firstName ?? '',
lastName: data.lastName ?? '',
});
},
onError: handleError,
}),
);
if (!user) {
return null;
}
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Profile</span>
</WidgetHead>
<WidgetBody className="gap-4 col">
<InputWithLabel
label="First name"
{...register('firstName')}
defaultValue={user.firstName ?? ''}
/>
<InputWithLabel
label="Last name"
{...register('lastName')}
defaultValue={user.lastName ?? ''}
/>
<Button
size="sm"
type="submit"
disabled={!formState.isDirty || mutation.isPending}
className="self-end"
icon={SaveIcon}
loading={mutation.isPending}
>
Save
</Button>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -1,55 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { useTRPC } from '@/integrations/trpc/react';
import { getProfileName } from '@/utils/getters';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs')({
component: Component,
pendingComponent: FullPageLoadingState,
});
function Component() {
const router = useRouter();
const { activeTab, tabs } = usePageTabs([
{
id: '/$organizationId/profile',
label: 'Profile',
},
{ id: 'email-preferences', label: 'Email preferences' },
]);
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
return (
<PageContainer>
<PageHeader title={'Your profile'} />
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="mt-2 mb-8"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</PageContainer>
);
}

View File

@@ -4,7 +4,6 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import { LoginNavbar } from '@/components/login-navbar';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ReportChart } from '@/components/report-chart';
import { useTRPC } from '@/integrations/trpc/react';
import { useSuspenseQuery } from '@tanstack/react-query';
@@ -64,7 +63,6 @@ function RouteComponent() {
const { shareId } = Route.useParams();
const { header } = useSearch({ from: '/share/report/$shareId' });
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const shareQuery = useSuspenseQuery(
trpc.share.report.queryOptions({
shareId,
@@ -83,6 +81,8 @@ function RouteComponent() {
const share = shareQuery.data;
console.log('share', share);
// Handle password protection
if (share.password && !hasAccess) {
return <ShareEnterPassword shareId={share.id} shareType="report" />;
@@ -114,16 +114,7 @@ function RouteComponent() {
<div className="font-medium text-xl">{share.report.name}</div>
</div>
<div className="p-4">
<ReportChart
report={{
...share.report,
range: range ?? share.report.range,
startDate: startDate ?? share.report.startDate,
endDate: endDate ?? share.report.endDate,
interval: interval ?? share.report.interval,
}}
shareId={shareId}
/>
<ReportChart report={share.report} shareId={shareId} />
</div>
</div>
</div>

View File

@@ -1,89 +0,0 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PublicPageCard } from '@/components/public-page-card';
import { Button, LinkButton } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { emailCategories } from '@openpanel/constants';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { useState } from 'react';
import { z } from 'zod';
const unsubscribeSearchSchema = z.object({
email: z.string().email(),
category: z.string(),
token: z.string(),
});
export const Route = createFileRoute('/unsubscribe')({
component: RouteComponent,
validateSearch: unsubscribeSearchSchema,
pendingComponent: FullPageLoadingState,
});
function RouteComponent() {
const search = useSearch({ from: '/unsubscribe' });
const { email, category, token } = search;
const trpc = useTRPC();
const [isUnsubscribing, setIsUnsubscribing] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const unsubscribeMutation = useMutation(
trpc.email.unsubscribe.mutationOptions({
onSuccess: () => {
setIsSuccess(true);
setIsUnsubscribing(false);
},
onError: (err) => {
setError(err.message || 'Failed to unsubscribe');
setIsUnsubscribing(false);
},
}),
);
const handleUnsubscribe = () => {
setIsUnsubscribing(true);
setError(null);
unsubscribeMutation.mutate({ email, category, token });
};
const categoryName =
emailCategories[category as keyof typeof emailCategories] || category;
if (isSuccess) {
return (
<PublicPageCard
title="Unsubscribed"
description={`You've been unsubscribed from ${categoryName} emails. You won't receive any more ${categoryName.toLowerCase()} emails from
us.`}
/>
);
}
return (
<PublicPageCard
title="Unsubscribe"
description={
<>
Unsubscribe from {categoryName} emails? You'll stop receiving{' '}
{categoryName.toLowerCase()} emails sent to&nbsp;
<span className="">{email}</span>
</>
}
>
<div className="col gap-3">
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm">
{error}
</div>
)}
<Button onClick={handleUnsubscribe} disabled={isUnsubscribing}>
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
</Button>
<LinkButton href="/" variant="ghost">
Cancel
</LinkButton>
</div>
</PublicPageCard>
);
}

View File

@@ -1,76 +0,0 @@
import { LogoSquare } from '@/components/logo';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { UsersIcon } from 'lucide-react';
import { z } from 'zod';
const widgetSearchSchema = z.object({
shareId: z.string(),
color: z.string().optional(),
});
export const Route = createFileRoute('/widget/badge')({
component: RouteComponent,
validateSearch: widgetSearchSchema,
});
function RouteComponent() {
const { shareId, color } = Route.useSearch();
const trpc = useTRPC();
// Fetch widget data
const { data, isLoading } = useQuery(
trpc.widget.badge.queryOptions({ shareId }),
);
if (isLoading) {
return <BadgeWidget visitors={0} isLoading color={color} />;
}
if (!data) {
return <BadgeWidget visitors={0} color={color} />;
}
return <BadgeWidget visitors={data.visitors} color={color} />;
}
interface BadgeWidgetProps {
visitors: number;
isLoading?: boolean;
color?: string;
}
function BadgeWidget({ visitors, isLoading, color }: BadgeWidgetProps) {
const number = useNumber();
return (
<div
className="absolute inset-0 group inline-flex items-center gap-3 rounded-lg center-center px-2"
style={{
backgroundColor: color,
}}
>
{/* Logo on the left */}
<div className="flex-shrink-0">
<LogoSquare className="h-8 w-8" />
</div>
{/* Center text */}
<div className="flex flex-col gap-0.5 flex-1 min-w-0 items-start -mt-px">
<div className="text-[10px] font-medium uppercase tracking-wide text-white/80">
ANALYTICS FROM
</div>
<div className="font-semibold text-white leading-tight">OpenPanel</div>
</div>
{/* Visitor count on the right */}
<div className="col center-center flex-shrink-0 gap-1">
<UsersIcon className="size-4 text-white" />
<div className="text-sm font-medium text-white tabular-nums">
{isLoading ? <span>...</span> : number.short(visitors)}
</div>
</div>
</div>
);
}

View File

@@ -119,7 +119,7 @@ function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
return (
<div className="flex h-screen w-full flex-col bg-background text-foreground">
{/* Header with live counter */}
<div className="p-6 pb-3">
<div className="border-b p-6 pb-3">
<div className="flex items-center justify-between w-full h-4">
<div className="flex items-center gap-3 w-full">
<Ping />
@@ -171,10 +171,10 @@ function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
</div>
</div>
{(data.countries.length > 0 ||
data.referrers.length > 0 ||
data.paths.length > 0) && (
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar border-t">
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar">
{/* Histogram */}
{/* Countries and Referrers */}
{(data.countries.length > 0 || data.referrers.length > 0) && (
<div className={cn('grid grid-cols-1 gap-6', grids)}>
{/* Countries */}
{data.countries.length > 0 && (
@@ -296,8 +296,8 @@ function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
</div>
)}
</div>
</div>
)}
)}
</div>
</div>
);
}

View File

@@ -36,11 +36,9 @@ COPY packages/queue/package.json ./packages/queue/
COPY packages/logger/package.json ./packages/logger/
COPY packages/common/package.json ./packages/common/
COPY packages/importer/package.json ./packages/importer/
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 packages/integrations/package.json packages/integrations/
COPY patches ./patches
# BUILD
@@ -87,11 +85,8 @@ COPY --from=build /app/packages/queue ./packages/queue
COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/common ./packages/common
COPY --from=build /app/packages/importer ./packages/importer
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

@@ -17,15 +17,12 @@
"@openpanel/db": "workspace:*",
"@openpanel/email": "workspace:*",
"@openpanel/integrations": "workspace:^",
"@openpanel/js-runtime": "workspace:*",
"@openpanel/json": "workspace:*",
"@openpanel/logger": "workspace:*",
"@openpanel/importer": "workspace:*",
"@openpanel/payments": "workspace:*",
"@openpanel/queue": "workspace:*",
"@openpanel/redis": "workspace:*",
"bullmq": "^5.63.0",
"date-fns": "^3.3.1",
"express": "^4.18.2",
"groupmq": "catalog:",
"prom-client": "^15.1.3",

View File

@@ -39,11 +39,6 @@ export async function bootCron() {
type: 'insightsDaily',
pattern: '0 2 * * *',
},
{
name: 'onboarding',
type: 'onboarding',
pattern: '0 * * * *',
},
];
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {

View File

@@ -281,20 +281,10 @@ export async function bootWorkers() {
eventName: string,
evtOrExitCodeOrError: number | string | Error,
) {
// Log the actual error details for unhandled rejections/exceptions
if (evtOrExitCodeOrError instanceof Error) {
logger.error('Unhandled error triggered shutdown', {
eventName,
message: evtOrExitCodeOrError.message,
stack: evtOrExitCodeOrError.stack,
name: evtOrExitCodeOrError.name,
});
} else {
logger.info('Starting graceful shutdown', {
code: evtOrExitCodeOrError,
eventName,
});
}
logger.info('Starting graceful shutdown', {
code: evtOrExitCodeOrError,
eventName,
});
try {
const time = performance.now();

View File

@@ -30,10 +30,7 @@ async function start() {
const PORT = Number.parseInt(process.env.WORKER_PORT || '3000', 10);
const app = express();
if (
process.env.DISABLE_BULLBOARD !== '1' &&
process.env.DISABLE_BULLBOARD !== 'true'
) {
if (process.env.DISABLE_BULLBOARD === undefined) {
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/');
createBullBoard({

View File

@@ -1,276 +0,0 @@
import type { Job } from 'bullmq';
import { differenceInDays } from 'date-fns';
import { db } from '@openpanel/db';
import {
type EmailData,
type EmailTemplate,
sendEmail,
} from '@openpanel/email';
import type { CronQueuePayload } from '@openpanel/queue';
import { getRecommendedPlan } from '@openpanel/payments';
import { logger } from '../utils/logger';
// Types for the onboarding email system
const orgQuery = {
include: {
createdBy: {
select: {
id: true,
email: true,
firstName: true,
deletedAt: true,
},
},
},
} as const;
type OrgWithCreator = Awaited<
ReturnType<typeof db.organization.findMany<typeof orgQuery>>
>[number];
type OnboardingContext = {
org: OrgWithCreator;
user: NonNullable<OrgWithCreator['createdBy']>;
};
type OnboardingEmail<T extends EmailTemplate = EmailTemplate> = {
day: number;
template: T;
shouldSend?: (ctx: OnboardingContext) => Promise<boolean | 'complete'>;
data: (ctx: OnboardingContext) => EmailData<T>;
};
// Helper to create type-safe email entries with correlated template/data types
function email<T extends EmailTemplate>(config: OnboardingEmail<T>) {
return config;
}
const getters = {
firstName: (ctx: OnboardingContext) => ctx.user.firstName || undefined,
organizationName: (ctx: OnboardingContext) => ctx.org.name,
dashboardUrl: (ctx: OnboardingContext) => {
return `${process.env.DASHBOARD_URL}/${ctx.org.id}`;
},
billingUrl: (ctx: OnboardingContext) => {
return `${process.env.DASHBOARD_URL}/${ctx.org.id}/billing`;
},
recommendedPlan: (ctx: OnboardingContext) => {
return getRecommendedPlan(
ctx.org.subscriptionPeriodEventsCount,
(plan) =>
`${plan.formattedEvents} events per month for ${plan.formattedPrice}`,
);
},
} as const;
// Declarative email schedule - easy to add, remove, or reorder
const ONBOARDING_EMAILS = [
email({
day: 0,
template: 'onboarding-welcome',
data: (ctx) => ({
firstName: getters.firstName(ctx),
dashboardUrl: getters.dashboardUrl(ctx),
}),
}),
email({
day: 2,
template: 'onboarding-what-to-track',
data: (ctx) => ({
firstName: getters.firstName(ctx),
}),
}),
email({
day: 6,
template: 'onboarding-dashboards',
data: (ctx) => ({
firstName: getters.firstName(ctx),
dashboardUrl: getters.dashboardUrl(ctx),
}),
}),
email({
day: 14,
template: 'onboarding-feature-request',
data: (ctx) => ({
firstName: getters.firstName(ctx),
}),
}),
email({
day: 26,
template: 'onboarding-trial-ending',
shouldSend: async ({ org }) => {
if (org.subscriptionStatus === 'active') {
return 'complete';
}
return true;
},
data: (ctx) => {
return {
firstName: getters.firstName(ctx),
organizationName: getters.organizationName(ctx),
billingUrl: getters.billingUrl(ctx),
recommendedPlan: getters.recommendedPlan(ctx),
};
},
}),
email({
day: 30,
template: 'onboarding-trial-ended',
shouldSend: async ({ org }) => {
if (org.subscriptionStatus === 'active') {
return 'complete';
}
return true;
},
data: (ctx) => {
return {
firstName: getters.firstName(ctx),
billingUrl: getters.billingUrl(ctx),
recommendedPlan: getters.recommendedPlan(ctx),
};
},
}),
];
export async function onboardingJob(job: Job<CronQueuePayload>) {
if (process.env.SELF_HOSTED === 'true') {
return null;
}
logger.info('Starting onboarding email job');
// Fetch organizations that are in onboarding (not completed)
const orgs = await db.organization.findMany({
where: {
OR: [{ onboarding: null }, { onboarding: { notIn: ['completed'] } }],
deleteAt: null,
createdBy: {
deletedAt: null,
},
},
...orgQuery,
});
logger.info(`Found ${orgs.length} organizations in onboarding`);
let emailsSent = 0;
let orgsCompleted = 0;
let orgsSkipped = 0;
for (const org of orgs) {
// Skip if no creator or creator is deleted
if (!org.createdBy || org.createdBy.deletedAt) {
orgsSkipped++;
continue;
}
const user = org.createdBy;
const daysSinceOrgCreation = differenceInDays(new Date(), org.createdAt);
// Find the next email to send
// If org.onboarding is null or empty string, they haven't received any email yet
const lastSentIndex = org.onboarding
? ONBOARDING_EMAILS.findIndex((e) => e.template === org.onboarding)
: -1;
const nextEmailIndex = lastSentIndex + 1;
// No more emails to send
if (nextEmailIndex >= ONBOARDING_EMAILS.length) {
await db.organization.update({
where: { id: org.id },
data: { onboarding: 'completed' },
});
orgsCompleted++;
logger.info(
`Completed onboarding for organization ${org.id} (all emails sent)`,
);
continue;
}
const nextEmail = ONBOARDING_EMAILS[nextEmailIndex];
if (!nextEmail) {
continue;
}
logger.info(
`Checking if enough days have passed for organization ${org.id}`,
{
daysSinceOrgCreation,
nextEmailDay: nextEmail.day,
orgCreatedAt: org.createdAt,
today: new Date(),
},
);
// Check if enough days have passed
if (daysSinceOrgCreation < nextEmail.day) {
orgsSkipped++;
continue;
}
// Check shouldSend callback if defined
if (nextEmail.shouldSend) {
const result = await nextEmail.shouldSend({ org, user });
if (result === 'complete') {
await db.organization.update({
where: { id: org.id },
data: { onboarding: 'completed' },
});
orgsCompleted++;
logger.info(
`Completed onboarding for organization ${org.id} (shouldSend returned complete)`,
);
continue;
}
if (result === false) {
orgsSkipped++;
continue;
}
}
try {
const emailData = nextEmail.data({ org, user });
await sendEmail(nextEmail.template, {
to: user.email,
data: emailData as never,
});
// Update onboarding to the template name we just sent
await db.organization.update({
where: { id: org.id },
data: { onboarding: nextEmail.template },
});
emailsSent++;
logger.info(
`Sent onboarding email "${nextEmail.template}" to organization ${org.id} (user ${user.id})`,
);
} catch (error) {
logger.error(
`Failed to send onboarding email to organization ${org.id}`,
{
error,
template: nextEmail.template,
},
);
}
}
logger.info('Completed onboarding email job', {
totalOrgs: orgs.length,
emailsSent,
orgsCompleted,
orgsSkipped,
});
return {
totalOrgs: orgs.length,
emailsSent,
orgsCompleted,
orgsSkipped,
};
}

View File

@@ -4,7 +4,6 @@ import { eventBuffer, profileBuffer, sessionBuffer } from '@openpanel/db';
import type { CronQueuePayload } from '@openpanel/queue';
import { jobdeleteProjects } from './cron.delete-projects';
import { onboardingJob } from './cron.onboarding';
import { ping } from './cron.ping';
import { salt } from './cron.salt';
import { insightsDailyJob } from './insights';
@@ -32,8 +31,5 @@ export async function cronJob(job: Job<CronQueuePayload>) {
case 'insightsDaily': {
return await insightsDailyJob(job);
}
case 'onboarding': {
return await onboardingJob(job);
}
}
}

View File

@@ -1,6 +1,10 @@
import { logger as baseLogger } from '@/utils/logger';
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
import { getTime, isSameDomain, parsePath } from '@openpanel/common';
import {
createSessionEndJob,
createSessionStart,
getSessionEnd,
} from '@/utils/session-handler';
import { isSameDomain, parsePath } from '@openpanel/common';
import {
getReferrerWithQuery,
parseReferrer,
@@ -189,14 +193,7 @@ export async function incomingEvent(
if (!sessionEnd) {
logger.info('Creating session start event', { event: payload });
await createEventAndNotify(
{
...payload,
name: 'session_start',
createdAt: new Date(getTime(payload.createdAt) - 100),
},
logger,
).catch((error) => {
await createSessionStart({ payload }).catch((error) => {
logger.error('Error creating session start event', { event: payload });
throw error;
});

View File

@@ -1,22 +1,11 @@
import type { Job } from 'bullmq';
import { Prisma, db } from '@openpanel/db';
import { db } from '@openpanel/db';
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
import { execute as executeJavaScriptTemplate } from '@openpanel/js-runtime';
import { setSuperJson } from '@openpanel/json';
import type { NotificationQueuePayload } from '@openpanel/queue';
import { publishEvent } from '@openpanel/redis';
function isValidJson<T>(
value: T | Prisma.NullableJsonNullValueInput | null | undefined,
): value is T {
return (
value !== null &&
value !== undefined &&
value !== Prisma.JsonNull &&
value !== Prisma.DbNull
);
}
import { getRedisPub, publishEvent } from '@openpanel/redis';
export async function notificationJob(job: Job<NotificationQueuePayload>) {
switch (job.data.type) {
@@ -25,10 +14,12 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
if (notification.sendToApp) {
publishEvent('notification', 'created', notification);
// empty for now
return;
}
if (notification.sendToEmail) {
// empty for now
return;
}
@@ -42,44 +33,18 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
},
});
const payload = notification.payload;
if (!isValidJson(payload)) {
return new Error('Invalid payload');
}
switch (integration.config.type) {
case 'webhook': {
let body: unknown;
if (integration.config.mode === 'javascript') {
// We only transform event payloads for now (not funnel)
if (
integration.config.javascriptTemplate &&
payload.type === 'event'
) {
const result = executeJavaScriptTemplate(
integration.config.javascriptTemplate,
payload.event,
);
body = result;
} else {
body = payload;
}
} else {
body = {
title: notification.title,
message: notification.message,
};
}
return fetch(integration.config.url, {
method: 'POST',
headers: {
...(integration.config.headers ?? {}),
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
body: JSON.stringify({
title: notification.title,
message: notification.message,
}),
});
}
case 'discord': {

View File

@@ -12,6 +12,18 @@ export const SESSION_TIMEOUT = 1000 * 60 * 30;
const getSessionEndJobId = (projectId: string, deviceId: string) =>
`sessionEnd:${projectId}:${deviceId}`;
export async function createSessionStart({
payload,
}: {
payload: IServiceCreateEventPayload;
}) {
return createEvent({
...payload,
name: 'session_start',
createdAt: new Date(getTime(payload.createdAt) - 100),
});
}
export async function createSessionEndJob({
payload,
}: {

View File

@@ -12,6 +12,19 @@ services:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
op-df:
image: docker.dragonflydb.io/dragonflydb/dragonfly:latest
container_name: op-df
restart: always
ports:
- "6380:6379"
ulimits:
memlock: -1
nofile: 65535
command:
- "--cluster_mode=emulated"
- "--lock_on_hashtags"
op-kv:
image: redis:7.2.5-alpine
restart: always

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { parseCookieDomain } from './parse-cookie-domain';
describe('parseCookieDomain', () => {
@@ -399,100 +399,4 @@ describe('parseCookieDomain', () => {
});
});
});
describe('custom multi-part TLDs via COOKIE_TLDS', () => {
const originalEnv = process.env.COOKIE_TLDS;
beforeEach(() => {
// Reset the environment variable before each test
delete process.env.COOKIE_TLDS;
});
afterEach(() => {
// Restore original value
if (originalEnv !== undefined) {
process.env.COOKIE_TLDS = originalEnv;
} else {
delete process.env.COOKIE_TLDS;
}
});
it('should handle my.id domains when COOKIE_TLDS includes my.id', () => {
process.env.COOKIE_TLDS = 'my.id';
expect(parseCookieDomain('https://abc.my.id')).toEqual({
domain: '.abc.my.id',
secure: true,
});
});
it('should handle subdomains of my.id domains correctly', () => {
process.env.COOKIE_TLDS = 'my.id';
expect(parseCookieDomain('https://api.abc.my.id')).toEqual({
domain: '.abc.my.id',
secure: true,
});
});
it('should handle multiple custom TLDs', () => {
process.env.COOKIE_TLDS = 'my.id,web.id,co.id';
expect(parseCookieDomain('https://abc.my.id')).toEqual({
domain: '.abc.my.id',
secure: true,
});
expect(parseCookieDomain('https://abc.web.id')).toEqual({
domain: '.abc.web.id',
secure: true,
});
expect(parseCookieDomain('https://abc.co.id')).toEqual({
domain: '.abc.co.id',
secure: true,
});
});
it('should handle custom TLDs with extra whitespace', () => {
process.env.COOKIE_TLDS = ' my.id , web.id ';
expect(parseCookieDomain('https://abc.my.id')).toEqual({
domain: '.abc.my.id',
secure: true,
});
});
it('should handle case-insensitive custom TLDs', () => {
process.env.COOKIE_TLDS = 'MY.ID';
expect(parseCookieDomain('https://abc.my.id')).toEqual({
domain: '.abc.my.id',
secure: true,
});
});
it('should not affect domains when env variable is empty', () => {
process.env.COOKIE_TLDS = '';
// Without the custom TLD, my.id is treated as a regular TLD
expect(parseCookieDomain('https://abc.my.id')).toEqual({
domain: '.my.id',
secure: true,
});
});
it('should not affect domains when env variable is not set', () => {
delete process.env.COOKIE_TLDS;
// Without the custom TLD, my.id is treated as a regular TLD
expect(parseCookieDomain('https://abc.my.id')).toEqual({
domain: '.my.id',
secure: true,
});
});
it('should still work with built-in multi-part TLDs when custom TLDs are set', () => {
process.env.COOKIE_TLDS = 'my.id';
// Built-in TLDs should still work
expect(parseCookieDomain('https://example.co.uk')).toEqual({
domain: '.example.co.uk',
secure: true,
});
});
});
});

View File

@@ -12,34 +12,7 @@ const MULTI_PART_TLDS = [
/go\.\w{2}$/,
];
function getCustomMultiPartTLDs(): string[] {
const envValue = process.env.COOKIE_TLDS || '';
if (!envValue.trim()) {
return [];
}
return envValue
.split(',')
.map((tld) => tld.trim().toLowerCase())
.filter((tld) => tld.length > 0);
}
function isMultiPartTLD(potentialTLD: string): boolean {
if (MULTI_PART_TLDS.some((pattern) => pattern.test(potentialTLD))) {
return true;
}
const customTLDs = getCustomMultiPartTLDs();
return customTLDs.includes(potentialTLD.toLowerCase());
}
export const parseCookieDomain = (url: string) => {
if (process.env.CUSTOM_COOKIE_DOMAIN) {
return {
domain: process.env.CUSTOM_COOKIE_DOMAIN,
secure: true,
};
}
if (!url) {
return {
domain: undefined,
@@ -63,7 +36,7 @@ export const parseCookieDomain = (url: string) => {
// Handle multi-part TLDs like co.uk, com.au, etc.
if (parts.length >= 3) {
const potentialTLD = parts.slice(-2).join('.');
if (isMultiPartTLD(potentialTLD)) {
if (MULTI_PART_TLDS.some((tld) => tld.test(potentialTLD))) {
// For domains like example.co.uk or subdomain.example.co.uk
// Use the last 3 parts: .example.co.uk
return {

View File

@@ -3,7 +3,10 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns';
export const DEFAULT_ASPECT_RATIO = 0.5625;
export const NOT_SET_VALUE = '(not set)';
export const RESERVED_EVENT_NAMES = ['session_start', 'session_end'] as const;
export const RESERVED_EVENT_NAMES = [
'session_start',
'session_end',
] as const;
export const timeWindows = {
'30min': {
@@ -505,12 +508,6 @@ export function getCountry(code?: string) {
return countries[code as keyof typeof countries];
}
export const emailCategories = {
onboarding: 'Onboarding',
} as const;
export type EmailCategory = keyof typeof emailCategories;
export const chartColors = [
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
{ main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' },

View File

@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "public"."organizations"
ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed';

View File

@@ -1,12 +0,0 @@
-- CreateTable
CREATE TABLE "public"."email_unsubscribes" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"email" TEXT NOT NULL,
"category" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "email_unsubscribes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "email_unsubscribes_email_category_key" ON "public"."email_unsubscribes"("email", "category");

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;

View File

@@ -62,7 +62,6 @@ model Organization {
integrations Integration[]
invites Invite[]
timezone String?
onboarding String? @default("completed")
// Subscription
subscriptionId String?
@@ -611,13 +610,3 @@ model InsightEvent {
@@index([insightId, createdAt])
@@map("insight_events")
}
model EmailUnsubscribe {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
email String
category String
createdAt DateTime @default(now())
@@unique([email, category])
@@map("email_unsubscribes")
}

View File

@@ -109,9 +109,7 @@ export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({
interval: zTimeInterval,
});
export type IGetTopGenericSeriesInput = z.infer<
typeof zGetTopGenericSeriesInput
> & {
export type IGetTopGenericSeriesInput = z.infer<typeof zGetTopGenericSeriesInput> & {
timezone: string;
};
@@ -736,7 +734,7 @@ export class OverviewService {
}>;
}> {
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
const TOP_LIMIT = 500;
const TOP_LIMIT = 15;
const fillConfig = this.getFillConfig(interval, startDate, endDate);
// Step 1: Get top 15 items

View File

@@ -8,7 +8,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/db": "workspace:*",
"@react-email/components": "^0.5.6",
"react": "catalog:",
"react-dom": "catalog:",

View File

@@ -1,25 +0,0 @@
import { Button as EmailButton } from '@react-email/components';
// biome-ignore lint/style/useImportType: <explanation>
import React from 'react';
export function Button({
href,
children,
style,
}: { href: string; children: React.ReactNode; style?: React.CSSProperties }) {
return (
<EmailButton
href={href}
style={{
backgroundColor: '#000',
borderRadius: '6px',
color: '#fff',
padding: '12px 20px',
textDecoration: 'none',
...style,
}}
>
{children}
</EmailButton>
);
}

View File

@@ -11,7 +11,7 @@ import React from 'react';
const baseUrl = 'https://openpanel.dev';
export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
export function Footer() {
return (
<>
<Hr />
@@ -71,17 +71,15 @@ export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
</Text>
</Row>
{unsubscribeUrl && (
<Row>
<Link
className="text-[#707070] text-[14px]"
href={unsubscribeUrl}
title="Unsubscribe"
>
Notification preferences
</Link>
</Row>
)}
{/* <Row>
<Link
className="text-[#707070] text-[14px]"
href="https://dashboard.openpanel.dev/settings/notifications"
title="Unsubscribe"
>
Notification preferences
</Link>
</Row> */}
</Section>
</>
);

View File

@@ -7,16 +7,15 @@ import {
Section,
Tailwind,
} from '@react-email/components';
// biome-ignore lint/style/useImportType: <explanation>
// biome-ignore lint/style/useImportType: resend needs React
import React from 'react';
import { Footer } from './footer';
type Props = {
children: React.ReactNode;
unsubscribeUrl?: string;
};
export function Layout({ children, unsubscribeUrl }: Props) {
export function Layout({ children }: Props) {
return (
<Html>
<Tailwind>
@@ -58,7 +57,7 @@ export function Layout({ children, unsubscribeUrl }: Props) {
/>
</Section>
<Section className="p-6">{children}</Section>
<Footer unsubscribeUrl={unsubscribeUrl} />
<Footer />
</Container>
</Body>
</Tailwind>

View File

@@ -1,15 +0,0 @@
import { Text } from '@react-email/components';
// biome-ignore lint/style/useImportType: <explanation>
import React from 'react';
export function List({ items }: { items: React.ReactNode[] }) {
return (
<ul style={{ paddingLeft: 20 }}>
{items.map((node, index) => (
<li key={index.toString()}>
<Text style={{ marginBottom: 2, marginTop: 2 }}>{node}</Text>
</li>
))}
</ul>
);
}

View File

@@ -13,10 +13,9 @@ export default EmailInvite;
export function EmailInvite({
organizationName = 'Acme Co',
url = 'https://openpanel.dev',
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
}: Props) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Layout>
<Text>You've been invited to join {organizationName}!</Text>
<Text>
If you don't have an account yet, click the button below to create one

View File

@@ -9,12 +9,9 @@ export const zEmailResetPassword = z.object({
export type Props = z.infer<typeof zEmailResetPassword>;
export default EmailResetPassword;
export function EmailResetPassword({
url = 'https://openpanel.dev',
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Layout>
<Text>
You have requested to reset your password. Follow the link below to
reset your password:

View File

@@ -3,22 +3,6 @@ import { EmailInvite, zEmailInvite } from './email-invite';
import EmailResetPassword, {
zEmailResetPassword,
} from './email-reset-password';
import OnboardingDashboards, {
zOnboardingDashboards,
} from './onboarding-dashboards';
import OnboardingFeatureRequest, {
zOnboardingFeatureRequest,
} from './onboarding-feature-request';
import OnboardingTrialEnded, {
zOnboardingTrialEnded,
} from './onboarding-trial-ended';
import OnboardingTrialEnding, {
zOnboardingTrialEnding,
} from './onboarding-trial-ending';
import OnboardingWelcome, { zOnboardingWelcome } from './onboarding-welcome';
import OnboardingWhatToTrack, {
zOnboardingWhatToTrack,
} from './onboarding-what-to-track';
import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon';
export const templates = {
@@ -40,40 +24,6 @@ export const templates = {
Component: TrailEndingSoon,
schema: zTrailEndingSoon,
},
'onboarding-welcome': {
subject: () => "You're in",
Component: OnboardingWelcome,
schema: zOnboardingWelcome,
category: 'onboarding' as const,
},
'onboarding-what-to-track': {
subject: () => "What's actually worth tracking",
Component: OnboardingWhatToTrack,
schema: zOnboardingWhatToTrack,
category: 'onboarding' as const,
},
'onboarding-dashboards': {
subject: () => 'The part most people skip',
Component: OnboardingDashboards,
schema: zOnboardingDashboards,
category: 'onboarding' as const,
},
'onboarding-feature-request': {
subject: () => 'One provider to rule them all',
Component: OnboardingFeatureRequest,
schema: zOnboardingFeatureRequest,
category: 'onboarding' as const,
},
'onboarding-trial-ending': {
subject: () => 'Your trial ends in a few days',
Component: OnboardingTrialEnding,
schema: zOnboardingTrialEnding,
},
'onboarding-trial-ended': {
subject: () => 'Your trial has ended',
Component: OnboardingTrialEnded,
schema: zOnboardingTrialEnded,
},
} as const;
export type Templates = typeof templates;

View File

@@ -1,65 +0,0 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
export const zOnboardingDashboards = z.object({
firstName: z.string().optional(),
dashboardUrl: z.string(),
});
export type Props = z.infer<typeof zOnboardingDashboards>;
export default OnboardingDashboards;
export function OnboardingDashboards({
firstName,
dashboardUrl = 'https://dashboard.openpanel.dev',
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(dashboardUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-dashboards');
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking events is the easy part. The value comes from actually looking
at them.
</Text>
<Text>
If you haven't yet, try building a simple dashboard. Pick one thing you
care about and visualize it. Could be:
</Text>
<List
items={[
'How many people sign up and then actually do something',
'Where users drop off in a flow (funnel)',
'Which pages lead to conversions (entry page CTA)',
]}
/>
<Text>
This is usually when people go from "I have analytics" to "I understand
what's happening." It's a different feeling.
</Text>
<Text>Takes maybe 10 minutes to set up. Worth it.</Text>
<Text>
Best regards,
<br />
Carl
</Text>
<span style={{ margin: '0 -20px', display: 'block' }}>
<img
src="https://openpanel.dev/_next/image?url=%2Fscreenshots%2Fdashboard-dark.webp&w=3840&q=75"
alt="Dashboard"
style={{
width: '100%',
height: 'auto',
borderRadius: '5px',
}}
/>
</span>
</Layout>
);
}

View File

@@ -1,42 +0,0 @@
import { Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
export const zOnboardingFeatureRequest = z.object({
firstName: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingFeatureRequest>;
export default OnboardingFeatureRequest;
export function OnboardingFeatureRequest({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
OpenPanel aims to be the one stop shop for all your analytics needs.
</Text>
<Text>
We have already in a very short time become one of the most popular
open-source analytics platforms out there and we're working hard to add
more features to make it the best analytics platform.
</Text>
<Text>
Do you feel like you're missing a feature that's important to you? If
that's the case, please reply here or go to our feedback board and add
your request there.
</Text>
<Text>
<Link href={'https://feedback.openpanel.dev'}>Feedback board</Link>
</Text>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

View File

@@ -1,56 +0,0 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Button } from '../components/button';
import { Layout } from '../components/layout';
export const zOnboardingTrialEnded = z.object({
firstName: z.string().optional(),
billingUrl: z.string(),
recommendedPlan: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingTrialEnded>;
export default OnboardingTrialEnded;
export function OnboardingTrialEnded({
firstName,
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ended');
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Your OpenPanel trial has ended.</Text>
<Text>
Your tracking is still running in the background, but you won't be able
to see any new data until you upgrade. All your dashboards, reports, and
event history are still there waiting for you.
</Text>
<Text>
Important: If you don't upgrade within 30 days, your workspace and
projects will be permanently deleted.
</Text>
<Text>
To keep your data and continue using OpenPanel, upgrade to a paid plan.{' '}
{recommendedPlan
? `Based on your usage we recommend upgrading to the ${recommendedPlan}`
: 'Plans start at $2.50/month'}
.
</Text>
<Text>
If you have any questions or something's holding you back, just reply to
this email.
</Text>
<Text>
<Button href={newUrl.toString()}>Upgrade Now</Button>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -1,57 +0,0 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Button } from '../components/button';
import { Layout } from '../components/layout';
export const zOnboardingTrialEnding = z.object({
firstName: z.string().optional(),
organizationName: z.string(),
billingUrl: z.string(),
recommendedPlan: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingTrialEnding>;
export default OnboardingTrialEnding;
export function OnboardingTrialEnding({
firstName,
organizationName = 'your organization',
billingUrl = 'https://dashboard.openpanel.dev',
recommendedPlan,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
const newUrl = new URL(billingUrl);
newUrl.searchParams.set('utm_source', 'email');
newUrl.searchParams.set('utm_medium', 'email');
newUrl.searchParams.set('utm_campaign', 'onboarding-trial-ending');
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Quick heads up: your OpenPanel trial ends soon.</Text>
<Text>
Your tracking will keep working, but you won't be able to see new data
until you upgrade. Everything you've built so far (dashboards, reports,
event history) stays intact.
</Text>
<Text>
To continue using OpenPanel, you'll need to upgrade to a paid plan.{' '}
{recommendedPlan
? `Based on your usage we recommend upgrading to the ${recommendedPlan} plan`
: 'Plans start at $2.50/month'}
.
</Text>
<Text>
If something's holding you back, I'd like to hear about it. Just reply.
</Text>
<Text>
Your project will receive events for the next 30 days, if you haven't
upgraded by then we'll remove your workspace and projects.
</Text>
<Text>
<Button href={newUrl.toString()}>Upgrade Now</Button>
</Text>
<Text>Carl</Text>
</Layout>
);
}

View File

@@ -1,55 +0,0 @@
import { Heading, Link, Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
export const zOnboardingWelcome = z.object({
firstName: z.string().optional(),
dashboardUrl: z.string(),
});
export type Props = z.infer<typeof zOnboardingWelcome>;
export default OnboardingWelcome;
export function OnboardingWelcome({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>Thanks for trying OpenPanel.</Text>
<Text>
We built OpenPanel because most analytics tools are either too
expensive, too complicated, or both. OpenPanel is different.
</Text>
<Text>
We hope you find OpenPanel useful and if you have any questions,
regarding tracking or how to import your existing events, just reach
out. We're here to help.
</Text>
<Text>To get started, you can:</Text>
<List
items={[
<Link
key=""
href={'https://openpanel.dev/docs/get-started/install-openpanel'}
>
Install tracking script
</Link>,
<Link
key=""
href={'https://openpanel.dev/docs/get-started/track-events'}
>
Start tracking your events
</Link>,
]}
/>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

View File

@@ -1,46 +0,0 @@
import { Text } from '@react-email/components';
import React from 'react';
import { z } from 'zod';
import { Layout } from '../components/layout';
import { List } from '../components/list';
export const zOnboardingWhatToTrack = z.object({
firstName: z.string().optional(),
});
export type Props = z.infer<typeof zOnboardingWhatToTrack>;
export default OnboardingWhatToTrack;
export function OnboardingWhatToTrack({
firstName,
unsubscribeUrl,
}: Props & { unsubscribeUrl?: string }) {
return (
<Layout unsubscribeUrl={unsubscribeUrl}>
<Text>Hi{firstName ? ` ${firstName}` : ''},</Text>
<Text>
Tracking can be overwhelming at first, and that's why its important to
focus on what's matters. For most products, that's something like:
</Text>
<List
items={[
'Find good funnels to track (onboarding or checkout)',
'Conversions (how many clicks your hero CTA)',
'What did the user do after clicking the CTA',
]}
/>
<Text>
Start small and incrementally add more events as you go is usually the
best approach.
</Text>
<Text>
If you're not sure whether something's worth tracking, or have any
questions, just reply here.
</Text>
<Text>
Best regards,
<br />
Carl
</Text>
</Layout>
);
}

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