Compare commits
7 Commits
feature/te
...
feature/sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f880b9a697 | ||
|
|
13bd16b207 | ||
|
|
347a01a941 | ||
|
|
ba79ac570c | ||
|
|
ca15717885 | ||
|
|
ba93924d20 | ||
|
|
7b87a57cbe |
1
.github/workflows/docker-build.yml
vendored
1
.github/workflows/docker-build.yml
vendored
@@ -8,7 +8,6 @@ on:
|
|||||||
- "apps/api/**"
|
- "apps/api/**"
|
||||||
- "apps/worker/**"
|
- "apps/worker/**"
|
||||||
- "apps/public/**"
|
- "apps/public/**"
|
||||||
- "apps/start/**"
|
|
||||||
- "packages/**"
|
- "packages/**"
|
||||||
- "!packages/sdks/**"
|
- "!packages/sdks/**"
|
||||||
- "**Dockerfile"
|
- "**Dockerfile"
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ COPY packages/payments/package.json packages/payments/
|
|||||||
COPY packages/constants/package.json packages/constants/
|
COPY packages/constants/package.json packages/constants/
|
||||||
COPY packages/validation/package.json packages/validation/
|
COPY packages/validation/package.json packages/validation/
|
||||||
COPY packages/integrations/package.json packages/integrations/
|
COPY packages/integrations/package.json packages/integrations/
|
||||||
COPY packages/js-runtime/package.json packages/js-runtime/
|
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
|
|
||||||
# BUILD
|
# 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/constants ./packages/constants
|
||||||
COPY --from=build /app/packages/validation ./packages/validation
|
COPY --from=build /app/packages/validation ./packages/validation
|
||||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
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
|
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||||
RUN pnpm db:codegen
|
RUN pnpm db:codegen
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"start": "dotenv -e ../../.env node dist/index.js",
|
"start": "dotenv -e ../../.env node dist/index.js",
|
||||||
"build": "rm -rf dist && tsdown",
|
"build": "rm -rf dist && tsdown",
|
||||||
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
|
"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"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
|||||||
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
||||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||||
import { getRedisCache, getRedisQueue } from '@openpanel/redis';
|
import { getRedisCache } from '@openpanel/redis';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type IDecrementPayload,
|
type IDecrementPayload,
|
||||||
@@ -49,7 +49,7 @@ function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
|||||||
identity ||
|
identity ||
|
||||||
(body.payload.profileId
|
(body.payload.profileId
|
||||||
? {
|
? {
|
||||||
profileId: String(body.payload.profileId),
|
profileId: body.payload.profileId,
|
||||||
}
|
}
|
||||||
: undefined)
|
: undefined)
|
||||||
);
|
);
|
||||||
@@ -419,7 +419,7 @@ export async function fetchDeviceId(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const multi = getRedisQueue().multi();
|
const multi = getRedisCache().multi();
|
||||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||||
const res = await multi.exec();
|
const res = await multi.exec();
|
||||||
|
|||||||
@@ -44,6 +44,6 @@ export async function isBotHook(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.status(202).send();
|
return reply.status(202).send('OK');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import exportRouter from './routes/export.router';
|
|||||||
import importRouter from './routes/import.router';
|
import importRouter from './routes/import.router';
|
||||||
import insightsRouter from './routes/insights.router';
|
import insightsRouter from './routes/insights.router';
|
||||||
import liveRouter from './routes/live.router';
|
import liveRouter from './routes/live.router';
|
||||||
import manageRouter from './routes/manage.router';
|
|
||||||
import miscRouter from './routes/misc.router';
|
import miscRouter from './routes/misc.router';
|
||||||
import oauthRouter from './routes/oauth-callback.router';
|
import oauthRouter from './routes/oauth-callback.router';
|
||||||
import profileRouter from './routes/profile.router';
|
import profileRouter from './routes/profile.router';
|
||||||
@@ -144,7 +143,7 @@ const startServer = async () => {
|
|||||||
instance.addHook('onRequest', async (req) => {
|
instance.addHook('onRequest', async (req) => {
|
||||||
if (req.cookies?.session) {
|
if (req.cookies?.session) {
|
||||||
try {
|
try {
|
||||||
const sessionId = decodeSessionToken(req.cookies?.session);
|
const sessionId = decodeSessionToken(req.cookies.session);
|
||||||
const session = await runWithAlsSession(sessionId, () =>
|
const session = await runWithAlsSession(sessionId, () =>
|
||||||
validateSessionToken(req.cookies.session),
|
validateSessionToken(req.cookies.session),
|
||||||
);
|
);
|
||||||
@@ -152,15 +151,6 @@ const startServer = async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
req.session = EMPTY_SESSION;
|
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 {
|
} else {
|
||||||
req.session = EMPTY_SESSION;
|
req.session = EMPTY_SESSION;
|
||||||
}
|
}
|
||||||
@@ -204,7 +194,6 @@ const startServer = async () => {
|
|||||||
instance.register(importRouter, { prefix: '/import' });
|
instance.register(importRouter, { prefix: '/import' });
|
||||||
instance.register(insightsRouter, { prefix: '/insights' });
|
instance.register(insightsRouter, { prefix: '/insights' });
|
||||||
instance.register(trackRouter, { prefix: '/track' });
|
instance.register(trackRouter, { prefix: '/track' });
|
||||||
instance.register(manageRouter, { prefix: '/manage' });
|
|
||||||
// Keep existing endpoints for backward compatibility
|
// Keep existing endpoints for backward compatibility
|
||||||
instance.get('/healthcheck', healthcheck);
|
instance.get('/healthcheck', healthcheck);
|
||||||
// New Kubernetes-style health endpoints
|
// New Kubernetes-style health endpoints
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import * as controller from '@/controllers/manage.controller';
|
|
||||||
import { validateManageRequest } from '@/utils/auth';
|
|
||||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
|
||||||
import { Prisma } from '@openpanel/db';
|
|
||||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
const manageRouter: FastifyPluginCallback = async (fastify) => {
|
|
||||||
await activateRateLimiter({
|
|
||||||
fastify,
|
|
||||||
max: 20,
|
|
||||||
timeWindow: '10 seconds',
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
|
||||||
try {
|
|
||||||
const client = await validateManageRequest(req.headers);
|
|
||||||
req.client = client;
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
return reply.status(401).send({
|
|
||||||
error: 'Unauthorized',
|
|
||||||
message: 'Client ID seems to be malformed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e instanceof Error) {
|
|
||||||
return reply
|
|
||||||
.status(401)
|
|
||||||
.send({ error: 'Unauthorized', message: e.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply
|
|
||||||
.status(401)
|
|
||||||
.send({ error: 'Unauthorized', message: 'Unexpected error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Projects routes
|
|
||||||
fastify.route({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/projects',
|
|
||||||
handler: controller.listProjects,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/projects/:id',
|
|
||||||
handler: controller.getProject,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/projects',
|
|
||||||
handler: controller.createProject,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/projects/:id',
|
|
||||||
handler: controller.updateProject,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/projects/:id',
|
|
||||||
handler: controller.deleteProject,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clients routes
|
|
||||||
fastify.route({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/clients',
|
|
||||||
handler: controller.listClients,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/clients/:id',
|
|
||||||
handler: controller.getClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/clients',
|
|
||||||
handler: controller.createClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/clients/:id',
|
|
||||||
handler: controller.updateClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/clients/:id',
|
|
||||||
handler: controller.deleteClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
// References routes
|
|
||||||
fastify.route({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/references',
|
|
||||||
handler: controller.listReferences,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/references/:id',
|
|
||||||
handler: controller.getReference,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/references',
|
|
||||||
handler: controller.createReference,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'PATCH',
|
|
||||||
url: '/references/:id',
|
|
||||||
handler: controller.updateReference,
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.route({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: '/references/:id',
|
|
||||||
handler: controller.deleteReference,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default manageRouter;
|
|
||||||
@@ -236,40 +236,3 @@ export async function validateImportRequest(
|
|||||||
|
|
||||||
return client;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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:
|
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
|
- **Export API**: Requires `read` or `root` mode
|
||||||
- **Insights 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
|
## Headers
|
||||||
|
|
||||||
@@ -49,29 +48,15 @@ If authentication fails, you'll receive a `401 Unauthorized` response:
|
|||||||
|
|
||||||
Common authentication errors:
|
Common authentication errors:
|
||||||
- Invalid client ID or secret
|
- Invalid client ID or secret
|
||||||
- Client doesn't have required permissions (e.g., trying to access Manage API with a non-root client)
|
- Client doesn't have required permissions
|
||||||
- Malformed client ID (must be a valid UUIDv4)
|
- Malformed client ID
|
||||||
- Client type mismatch (e.g., `write` client trying to access Export API)
|
|
||||||
|
|
||||||
## Client Types
|
|
||||||
|
|
||||||
OpenPanel supports three client types with different access levels:
|
|
||||||
|
|
||||||
| Type | Description | Access |
|
|
||||||
|------|-------------|--------|
|
|
||||||
| `write` | Write access | Track API only |
|
|
||||||
| `read` | Read-only access | Export API, Insights API |
|
|
||||||
| `root` | Full access | All APIs including Manage API |
|
|
||||||
|
|
||||||
**Note**: Root clients have organization-wide access and can manage all resources. Use root clients carefully and store their credentials securely.
|
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
The API implements rate limiting to prevent abuse. Rate limits vary by endpoint:
|
The API implements rate limiting to prevent abuse. Rate limits vary by endpoint:
|
||||||
|
|
||||||
- **Track API**: Higher limits for event tracking
|
- **Track API**: Higher limits for event tracking
|
||||||
- **Export/Insights APIs**: 100 requests per 10 seconds
|
- **Export/Insights APIs**: Lower limits for data retrieval
|
||||||
- **Manage API**: 20 requests per 10 seconds
|
|
||||||
|
|
||||||
If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. Implement exponential backoff for retries.
|
If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. Implement exponential backoff for retries.
|
||||||
|
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
---
|
|
||||||
title: Clients
|
|
||||||
description: Manage API clients for your OpenPanel projects. Create, read, update, and delete clients with different access levels.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
To authenticate with the Clients API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
|
|
||||||
|
|
||||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
|
||||||
|
|
||||||
Include the following headers with your requests:
|
|
||||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
|
||||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
|
||||||
|
|
||||||
## Base URL
|
|
||||||
|
|
||||||
All Clients API requests should be made to:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://api.openpanel.dev/manage/clients
|
|
||||||
```
|
|
||||||
|
|
||||||
## Client Types
|
|
||||||
|
|
||||||
OpenPanel supports three client types with different access levels:
|
|
||||||
|
|
||||||
| Type | Description | Use Case |
|
|
||||||
|------|-------------|----------|
|
|
||||||
| `read` | Read-only access | Export data, view insights, read-only operations |
|
|
||||||
| `write` | Write access | Track events, send data to OpenPanel |
|
|
||||||
| `root` | Full access | Manage resources, access Manage API |
|
|
||||||
|
|
||||||
**Note**: Only `root` clients can access the Manage API.
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
### List Clients
|
|
||||||
|
|
||||||
Retrieve all clients in your organization, optionally filtered by project.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /manage/clients
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Query Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `projectId` | string | Optional. Filter clients by project ID |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all clients
|
|
||||||
curl 'https://api.openpanel.dev/manage/clients' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
|
|
||||||
# List clients for a specific project
|
|
||||||
curl 'https://api.openpanel.dev/manage/clients?projectId=my-project' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
|
|
||||||
"name": "First client",
|
|
||||||
"type": "write",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"organizationId": "org_123",
|
|
||||||
"ignoreCorsAndSecret": false,
|
|
||||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
|
||||||
"updatedAt": "2024-01-15T10:30:00.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
|
|
||||||
"name": "Read-only Client",
|
|
||||||
"type": "read",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"organizationId": "org_123",
|
|
||||||
"ignoreCorsAndSecret": false,
|
|
||||||
"createdAt": "2024-01-15T11:00:00.000Z",
|
|
||||||
"updatedAt": "2024-01-15T11:00:00.000Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Client secrets are never returned in list or get responses for security reasons.
|
|
||||||
|
|
||||||
### Get Client
|
|
||||||
|
|
||||||
Retrieve a specific client by ID.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /manage/clients/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | string | The ID of the client (UUID) |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.openpanel.dev/manage/clients/fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
|
|
||||||
"name": "First client",
|
|
||||||
"type": "write",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"organizationId": "org_123",
|
|
||||||
"ignoreCorsAndSecret": false,
|
|
||||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
|
||||||
"updatedAt": "2024-01-15T10:30:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create Client
|
|
||||||
|
|
||||||
Create a new API client. A secure secret is automatically generated and returned once.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /manage/clients
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Request Body
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
|-----------|------|----------|-------------|
|
|
||||||
| `name` | string | Yes | Client name (minimum 1 character) |
|
|
||||||
| `projectId` | string | No | Associate client with a specific project |
|
|
||||||
| `type` | string | No | Client type: `read`, `write`, or `root` (default: `write`) |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST 'https://api.openpanel.dev/manage/clients' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"name": "My API Client",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"type": "read"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
|
|
||||||
"name": "My API Client",
|
|
||||||
"type": "read",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"organizationId": "org_123",
|
|
||||||
"ignoreCorsAndSecret": false,
|
|
||||||
"createdAt": "2024-01-15T11:00:00.000Z",
|
|
||||||
"updatedAt": "2024-01-15T11:00:00.000Z",
|
|
||||||
"secret": "sec_b2521ca283bf903b46b3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**: The `secret` field is only returned once when the client is created. Store it securely immediately. You cannot retrieve the secret later - if lost, you'll need to delete and recreate the client.
|
|
||||||
|
|
||||||
### Update Client
|
|
||||||
|
|
||||||
Update an existing client's name.
|
|
||||||
|
|
||||||
```
|
|
||||||
PATCH /manage/clients/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | string | The ID of the client (UUID) |
|
|
||||||
|
|
||||||
#### Request Body
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
|-----------|------|----------|-------------|
|
|
||||||
| `name` | string | No | New client name (minimum 1 character) |
|
|
||||||
|
|
||||||
**Note**: Currently, only the `name` field can be updated. To change the client type or project association, delete and recreate the client.
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X PATCH 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"name": "Updated Client Name"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "b8904453-863d-4e04-8ebc-8abae30ffb1a",
|
|
||||||
"name": "Updated Client Name",
|
|
||||||
"type": "read",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"organizationId": "org_123",
|
|
||||||
"ignoreCorsAndSecret": false,
|
|
||||||
"createdAt": "2024-01-15T11:00:00.000Z",
|
|
||||||
"updatedAt": "2024-01-15T11:30:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete Client
|
|
||||||
|
|
||||||
Permanently delete a client. This action cannot be undone.
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /manage/clients/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | string | The ID of the client (UUID) |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Warning**: Deleting a client is permanent. Any applications using this client will immediately lose access. Make sure to update your applications before deleting a client.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The API uses standard HTTP response codes. Common error responses:
|
|
||||||
|
|
||||||
### 400 Bad Request
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Bad Request",
|
|
||||||
"message": "Invalid request body",
|
|
||||||
"details": [
|
|
||||||
{
|
|
||||||
"path": ["name"],
|
|
||||||
"message": "String must contain at least 1 character(s)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 401 Unauthorized
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Unauthorized",
|
|
||||||
"message": "Manage: Only root clients are allowed to manage resources"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 404 Not Found
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Not Found",
|
|
||||||
"message": "Client not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 429 Too Many Requests
|
|
||||||
|
|
||||||
Rate limiting response includes headers indicating your rate limit status.
|
|
||||||
|
|
||||||
## Rate Limiting
|
|
||||||
|
|
||||||
The Clients API implements rate limiting:
|
|
||||||
- **20 requests per 10 seconds** per client
|
|
||||||
- Rate limit headers included in responses
|
|
||||||
- Implement exponential backoff for retries
|
|
||||||
|
|
||||||
## Security Best Practices
|
|
||||||
|
|
||||||
1. **Store Secrets Securely**: Client secrets are only shown once on creation. Store them in secure credential management systems
|
|
||||||
2. **Use Appropriate Client Types**: Use the minimum required access level for each use case
|
|
||||||
3. **Rotate Secrets Regularly**: Delete old clients and create new ones to rotate secrets
|
|
||||||
4. **Never Expose Secrets**: Never commit client secrets to version control or expose them in client-side code
|
|
||||||
5. **Monitor Client Usage**: Regularly review and remove unused clients
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Client IDs are UUIDs (Universally Unique Identifiers)
|
|
||||||
- Client secrets are automatically generated with the format `sec_` followed by random hex characters
|
|
||||||
- Secrets are hashed using argon2 before storage
|
|
||||||
- Clients can be associated with a project or exist at the organization level
|
|
||||||
- Clients are scoped to your organization - you can only manage clients in your organization
|
|
||||||
- The `ignoreCorsAndSecret` field is an advanced setting that bypasses CORS and secret validation (use with caution)
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
---
|
|
||||||
title: Manage API Overview
|
|
||||||
description: Programmatically manage projects, clients, and references in your OpenPanel organization using the Manage API.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Manage API provides programmatic access to manage your OpenPanel resources including projects, clients, and references. This API is designed for automation, infrastructure-as-code, and administrative tasks.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
The Manage API requires a **root client** for authentication. Root clients have organization-wide access and can manage all resources within their organization.
|
|
||||||
|
|
||||||
To authenticate with the Manage API, you need:
|
|
||||||
- A client with `type: 'root'`
|
|
||||||
- Your `clientId` and `clientSecret`
|
|
||||||
|
|
||||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
|
||||||
|
|
||||||
Include the following headers with your requests:
|
|
||||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
|
||||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
|
||||||
|
|
||||||
## Base URL
|
|
||||||
|
|
||||||
All Manage API requests should be made to:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://api.openpanel.dev/manage
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Resources
|
|
||||||
|
|
||||||
The Manage API provides CRUD operations for three resource types:
|
|
||||||
|
|
||||||
### Projects
|
|
||||||
|
|
||||||
Manage your analytics projects programmatically:
|
|
||||||
- **[Projects Documentation](/docs/api/manage/projects)** - Create, read, update, and delete projects
|
|
||||||
- Automatically creates a default write client when creating a project
|
|
||||||
- Supports project configuration including domains, CORS settings, and project types
|
|
||||||
|
|
||||||
### Clients
|
|
||||||
|
|
||||||
Manage API clients for your projects:
|
|
||||||
- **[Clients Documentation](/docs/api/manage/clients)** - Create, read, update, and delete clients
|
|
||||||
- Supports different client types: `read`, `write`, and `root`
|
|
||||||
- Auto-generates secure secrets on creation (returned once)
|
|
||||||
|
|
||||||
### References
|
|
||||||
|
|
||||||
Manage reference points for your analytics:
|
|
||||||
- **[References Documentation](/docs/api/manage/references)** - Create, read, update, and delete references
|
|
||||||
- Useful for marking important dates or events in your analytics timeline
|
|
||||||
- Can be filtered by project
|
|
||||||
|
|
||||||
## Common Features
|
|
||||||
|
|
||||||
All endpoints share these common characteristics:
|
|
||||||
|
|
||||||
### Organization Scope
|
|
||||||
|
|
||||||
All operations are scoped to your organization. You can only manage resources that belong to your organization.
|
|
||||||
|
|
||||||
### Response Format
|
|
||||||
|
|
||||||
Successful responses follow this structure:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
// Resource data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
For list endpoints:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
// Array of resources
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
The API uses standard HTTP response codes:
|
|
||||||
|
|
||||||
- `200 OK` - Request successful
|
|
||||||
- `400 Bad Request` - Invalid request parameters
|
|
||||||
- `401 Unauthorized` - Authentication failed
|
|
||||||
- `404 Not Found` - Resource not found
|
|
||||||
- `429 Too Many Requests` - Rate limit exceeded
|
|
||||||
|
|
||||||
## Rate Limiting
|
|
||||||
|
|
||||||
The Manage API implements rate limiting:
|
|
||||||
- **20 requests per 10 seconds** per client
|
|
||||||
- Rate limit headers included in responses
|
|
||||||
- Implement exponential backoff for retries
|
|
||||||
|
|
||||||
## Use Cases
|
|
||||||
|
|
||||||
The Manage API is ideal for:
|
|
||||||
|
|
||||||
- **Infrastructure as Code**: Manage OpenPanel resources alongside your application infrastructure
|
|
||||||
- **Automation**: Automatically create projects and clients for new deployments
|
|
||||||
- **Bulk Operations**: Programmatically manage multiple resources
|
|
||||||
- **CI/CD Integration**: Set up projects and clients as part of your deployment pipeline
|
|
||||||
- **Administrative Tools**: Build custom admin interfaces
|
|
||||||
|
|
||||||
## Security Best Practices
|
|
||||||
|
|
||||||
1. **Root Clients Only**: Only root clients can access the Manage API
|
|
||||||
2. **Store Credentials Securely**: Never expose root client credentials in client-side code
|
|
||||||
3. **Use HTTPS**: Always use HTTPS for API requests
|
|
||||||
4. **Rotate Credentials**: Regularly rotate your root client credentials
|
|
||||||
5. **Limit Access**: Restrict root client creation to trusted administrators
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
1. **Create a Root Client**: Use the dashboard to create a root client in your organization
|
|
||||||
2. **Store Credentials**: Securely store your root client ID and secret
|
|
||||||
3. **Make Your First Request**: Start with listing projects to verify authentication
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.openpanel.dev/manage/projects' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- Read the [Projects documentation](/docs/api/manage/projects) to manage projects
|
|
||||||
- Read the [Clients documentation](/docs/api/manage/clients) to manage API clients
|
|
||||||
- Read the [References documentation](/docs/api/manage/references) to manage reference points
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Manage",
|
|
||||||
"pages": ["projects", "clients", "references"],
|
|
||||||
"defaultOpen": false
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
---
|
|
||||||
title: Projects
|
|
||||||
description: Manage your OpenPanel projects programmatically. Create, read, update, and delete projects using the Manage API.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
To authenticate with the Projects API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
|
|
||||||
|
|
||||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
|
||||||
|
|
||||||
Include the following headers with your requests:
|
|
||||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
|
||||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
|
||||||
|
|
||||||
## Base URL
|
|
||||||
|
|
||||||
All Projects API requests should be made to:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://api.openpanel.dev/manage/projects
|
|
||||||
```
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
### List Projects
|
|
||||||
|
|
||||||
Retrieve all projects in your organization.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /manage/projects
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.openpanel.dev/manage/projects' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "my-project",
|
|
||||||
"name": "My Project",
|
|
||||||
"organizationId": "org_123",
|
|
||||||
"domain": "https://example.com",
|
|
||||||
"cors": ["https://example.com", "https://www.example.com"],
|
|
||||||
"crossDomain": false,
|
|
||||||
"allowUnsafeRevenueTracking": false,
|
|
||||||
"filters": [],
|
|
||||||
"types": ["website"],
|
|
||||||
"eventsCount": 0,
|
|
||||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
|
||||||
"updatedAt": "2024-01-15T10:30:00.000Z",
|
|
||||||
"deleteAt": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Project
|
|
||||||
|
|
||||||
Retrieve a specific project by ID.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /manage/projects/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | string | The ID of the project |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.openpanel.dev/manage/projects/my-project' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "my-project",
|
|
||||||
"name": "My Project",
|
|
||||||
"organizationId": "org_123",
|
|
||||||
"domain": "https://example.com",
|
|
||||||
"cors": ["https://example.com"],
|
|
||||||
"crossDomain": false,
|
|
||||||
"allowUnsafeRevenueTracking": false,
|
|
||||||
"filters": [],
|
|
||||||
"types": ["website"],
|
|
||||||
"eventsCount": 0,
|
|
||||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
|
||||||
"updatedAt": "2024-01-15T10:30:00.000Z",
|
|
||||||
"deleteAt": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create Project
|
|
||||||
|
|
||||||
Create a new project in your organization. A default write client is automatically created with the project.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /manage/projects
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Request Body
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
|-----------|------|----------|-------------|
|
|
||||||
| `name` | string | Yes | Project name (minimum 1 character) |
|
|
||||||
| `domain` | string \| null | No | Primary domain for the project (URL format or empty string) |
|
|
||||||
| `cors` | string[] | No | Array of allowed CORS origins (default: `[]`) |
|
|
||||||
| `crossDomain` | boolean | No | Enable cross-domain tracking (default: `false`) |
|
|
||||||
| `types` | string[] | No | Project types: `website`, `app`, `backend` (default: `[]`) |
|
|
||||||
|
|
||||||
#### Project Types
|
|
||||||
|
|
||||||
- `website`: Web-based project
|
|
||||||
- `app`: Mobile application
|
|
||||||
- `backend`: Backend/server-side project
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST 'https://api.openpanel.dev/manage/projects' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"name": "My New Project",
|
|
||||||
"domain": "https://example.com",
|
|
||||||
"cors": ["https://example.com", "https://www.example.com"],
|
|
||||||
"crossDomain": false,
|
|
||||||
"types": ["website"]
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "my-new-project",
|
|
||||||
"name": "My New Project",
|
|
||||||
"organizationId": "org_123",
|
|
||||||
"domain": "https://example.com",
|
|
||||||
"cors": ["https://example.com", "https://www.example.com"],
|
|
||||||
"crossDomain": false,
|
|
||||||
"allowUnsafeRevenueTracking": false,
|
|
||||||
"filters": [],
|
|
||||||
"types": ["website"],
|
|
||||||
"eventsCount": 0,
|
|
||||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
|
||||||
"updatedAt": "2024-01-15T10:30:00.000Z",
|
|
||||||
"deleteAt": null,
|
|
||||||
"client": {
|
|
||||||
"id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9",
|
|
||||||
"secret": "sec_6c8ae85a092d6c66b242"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**: The `client.secret` is only returned once when the project is created. Store it securely immediately.
|
|
||||||
|
|
||||||
### Update Project
|
|
||||||
|
|
||||||
Update an existing project's configuration.
|
|
||||||
|
|
||||||
```
|
|
||||||
PATCH /manage/projects/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | string | The ID of the project |
|
|
||||||
|
|
||||||
#### Request Body
|
|
||||||
|
|
||||||
All fields are optional. Only include fields you want to update.
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `name` | string | Project name (minimum 1 character) |
|
|
||||||
| `domain` | string \| null | Primary domain (URL format, empty string, or null) |
|
|
||||||
| `cors` | string[] | Array of allowed CORS origins |
|
|
||||||
| `crossDomain` | boolean | Enable cross-domain tracking |
|
|
||||||
| `allowUnsafeRevenueTracking` | boolean | Allow revenue tracking without client secret |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X PATCH 'https://api.openpanel.dev/manage/projects/my-project' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"name": "Updated Project Name",
|
|
||||||
"crossDomain": true,
|
|
||||||
"allowUnsafeRevenueTracking": false
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "my-project",
|
|
||||||
"name": "Updated Project Name",
|
|
||||||
"organizationId": "org_123",
|
|
||||||
"domain": "https://example.com",
|
|
||||||
"cors": ["https://example.com"],
|
|
||||||
"crossDomain": true,
|
|
||||||
"allowUnsafeRevenueTracking": false,
|
|
||||||
"filters": [],
|
|
||||||
"types": ["website"],
|
|
||||||
"eventsCount": 0,
|
|
||||||
"createdAt": "2024-01-15T10:30:00.000Z",
|
|
||||||
"updatedAt": "2024-01-15T11:00:00.000Z",
|
|
||||||
"deleteAt": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete Project
|
|
||||||
|
|
||||||
Soft delete a project. The project will be scheduled for deletion after 24 hours.
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /manage/projects/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | string | The ID of the project |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE 'https://api.openpanel.dev/manage/projects/my-project' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Projects are soft-deleted. The `deleteAt` field is set to 24 hours in the future. You can cancel deletion by updating the project before the deletion time.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The API uses standard HTTP response codes. Common error responses:
|
|
||||||
|
|
||||||
### 400 Bad Request
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Bad Request",
|
|
||||||
"message": "Invalid request body",
|
|
||||||
"details": [
|
|
||||||
{
|
|
||||||
"path": ["name"],
|
|
||||||
"message": "String must contain at least 1 character(s)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 401 Unauthorized
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Unauthorized",
|
|
||||||
"message": "Manage: Only root clients are allowed to manage resources"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 404 Not Found
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Not Found",
|
|
||||||
"message": "Project not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 429 Too Many Requests
|
|
||||||
|
|
||||||
Rate limiting response includes headers indicating your rate limit status.
|
|
||||||
|
|
||||||
## Rate Limiting
|
|
||||||
|
|
||||||
The Projects API implements rate limiting:
|
|
||||||
- **20 requests per 10 seconds** per client
|
|
||||||
- Rate limit headers included in responses
|
|
||||||
- Implement exponential backoff for retries
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Project IDs are automatically generated from the project name using a slug format
|
|
||||||
- If a project ID already exists, a numeric suffix is added
|
|
||||||
- CORS domains are automatically normalized (trailing slashes removed)
|
|
||||||
- The default client created with a project has `type: 'write'`
|
|
||||||
- Projects are scoped to your organization - you can only manage projects in your organization
|
|
||||||
- Soft-deleted projects are excluded from list endpoints
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
---
|
|
||||||
title: References
|
|
||||||
description: Manage reference points for your OpenPanel projects. References are useful for marking important dates or events in your analytics timeline.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
To authenticate with the References API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access.
|
|
||||||
|
|
||||||
For detailed authentication information, see the [Authentication](/docs/api/authentication) guide.
|
|
||||||
|
|
||||||
Include the following headers with your requests:
|
|
||||||
- `openpanel-client-id`: Your OpenPanel root client ID
|
|
||||||
- `openpanel-client-secret`: Your OpenPanel root client secret
|
|
||||||
|
|
||||||
## Base URL
|
|
||||||
|
|
||||||
All References API requests should be made to:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://api.openpanel.dev/manage/references
|
|
||||||
```
|
|
||||||
|
|
||||||
## What are References?
|
|
||||||
|
|
||||||
References are markers you can add to your analytics timeline to track important events such as:
|
|
||||||
- Product launches
|
|
||||||
- Marketing campaign start dates
|
|
||||||
- Feature releases
|
|
||||||
- Website redesigns
|
|
||||||
- Major announcements
|
|
||||||
|
|
||||||
References appear in your analytics charts and help you correlate changes in metrics with specific events.
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
### List References
|
|
||||||
|
|
||||||
Retrieve all references in your organization, optionally filtered by project.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /manage/references
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Query Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `projectId` | string | Optional. Filter references by project ID |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all references
|
|
||||||
curl 'https://api.openpanel.dev/manage/references' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
|
|
||||||
# List references for a specific project
|
|
||||||
curl 'https://api.openpanel.dev/manage/references?projectId=my-project' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": [
|
|
||||||
{
|
|
||||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
|
||||||
"title": "Product Launch",
|
|
||||||
"description": "Version 2.0 released",
|
|
||||||
"date": "2024-01-15T10:00:00.000Z",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
|
||||||
"updatedAt": "2024-01-10T08:00:00.000Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "2bf19738-3ee8-4c48-af6d-7ggb8f561f96",
|
|
||||||
"title": "Marketing Campaign Start",
|
|
||||||
"description": "Q1 2024 campaign launched",
|
|
||||||
"date": "2024-01-20T09:00:00.000Z",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"createdAt": "2024-01-18T10:00:00.000Z",
|
|
||||||
"updatedAt": "2024-01-18T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Reference
|
|
||||||
|
|
||||||
Retrieve a specific reference by ID.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /manage/references/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | string | The ID of the reference (UUID) |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
|
||||||
"title": "Product Launch",
|
|
||||||
"description": "Version 2.0 released",
|
|
||||||
"date": "2024-01-15T10:00:00.000Z",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
|
||||||
"updatedAt": "2024-01-10T08:00:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Create Reference
|
|
||||||
|
|
||||||
Create a new reference point for a project.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /manage/references
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Request Body
|
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
|
||||||
|-----------|------|----------|-------------|
|
|
||||||
| `projectId` | string | Yes | The ID of the project this reference belongs to |
|
|
||||||
| `title` | string | Yes | Reference title (minimum 1 character) |
|
|
||||||
| `description` | string | No | Optional description or notes |
|
|
||||||
| `datetime` | string | Yes | Date and time for the reference (ISO 8601 format) |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST 'https://api.openpanel.dev/manage/references' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"projectId": "my-project",
|
|
||||||
"title": "Product Launch",
|
|
||||||
"description": "Version 2.0 released with new features",
|
|
||||||
"datetime": "2024-01-15T10:00:00.000Z"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
|
||||||
"title": "Product Launch",
|
|
||||||
"description": "Version 2.0 released with new features",
|
|
||||||
"date": "2024-01-15T10:00:00.000Z",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
|
||||||
"updatedAt": "2024-01-10T08:00:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: The `date` field in the response is parsed from the `datetime` string you provided.
|
|
||||||
|
|
||||||
### Update Reference
|
|
||||||
|
|
||||||
Update an existing reference.
|
|
||||||
|
|
||||||
```
|
|
||||||
PATCH /manage/references/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | string | The ID of the reference (UUID) |
|
|
||||||
|
|
||||||
#### Request Body
|
|
||||||
|
|
||||||
All fields are optional. Only include fields you want to update.
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `title` | string | Reference title (minimum 1 character) |
|
|
||||||
| `description` | string \| null | Description or notes (set to `null` to clear) |
|
|
||||||
| `datetime` | string | Date and time for the reference (ISO 8601 format) |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X PATCH 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"title": "Product Launch v2.1",
|
|
||||||
"description": "Updated: Version 2.1 released with bug fixes",
|
|
||||||
"datetime": "2024-01-15T10:00:00.000Z"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85",
|
|
||||||
"title": "Product Launch v2.1",
|
|
||||||
"description": "Updated: Version 2.1 released with bug fixes",
|
|
||||||
"date": "2024-01-15T10:00:00.000Z",
|
|
||||||
"projectId": "my-project",
|
|
||||||
"createdAt": "2024-01-10T08:00:00.000Z",
|
|
||||||
"updatedAt": "2024-01-10T09:30:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete Reference
|
|
||||||
|
|
||||||
Permanently delete a reference. This action cannot be undone.
|
|
||||||
|
|
||||||
```
|
|
||||||
DELETE /manage/references/{id}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Path Parameters
|
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
|
||||||
|-----------|------|-------------|
|
|
||||||
| `id` | string | The ID of the reference (UUID) |
|
|
||||||
|
|
||||||
#### Example Request
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \
|
|
||||||
-H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \
|
|
||||||
-H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Response
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The API uses standard HTTP response codes. Common error responses:
|
|
||||||
|
|
||||||
### 400 Bad Request
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Bad Request",
|
|
||||||
"message": "Invalid request body",
|
|
||||||
"details": [
|
|
||||||
{
|
|
||||||
"path": ["title"],
|
|
||||||
"message": "String must contain at least 1 character(s)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 401 Unauthorized
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Unauthorized",
|
|
||||||
"message": "Manage: Only root clients are allowed to manage resources"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 404 Not Found
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Not Found",
|
|
||||||
"message": "Reference not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This error can occur if:
|
|
||||||
- The reference ID doesn't exist
|
|
||||||
- The reference belongs to a different organization
|
|
||||||
|
|
||||||
### 429 Too Many Requests
|
|
||||||
|
|
||||||
Rate limiting response includes headers indicating your rate limit status.
|
|
||||||
|
|
||||||
## Rate Limiting
|
|
||||||
|
|
||||||
The References API implements rate limiting:
|
|
||||||
- **20 requests per 10 seconds** per client
|
|
||||||
- Rate limit headers included in responses
|
|
||||||
- Implement exponential backoff for retries
|
|
||||||
|
|
||||||
## Date Format
|
|
||||||
|
|
||||||
References use ISO 8601 date format. Examples:
|
|
||||||
|
|
||||||
- `2024-01-15T10:00:00.000Z` - UTC timezone
|
|
||||||
- `2024-01-15T10:00:00-05:00` - Eastern Time (UTC-5)
|
|
||||||
- `2024-01-15` - Date only (time defaults to 00:00:00)
|
|
||||||
|
|
||||||
The `datetime` field in requests is converted to a `date` field in responses, stored as a timestamp.
|
|
||||||
|
|
||||||
## Use Cases
|
|
||||||
|
|
||||||
References are useful for:
|
|
||||||
|
|
||||||
- **Product Launches**: Mark when new versions or features are released
|
|
||||||
- **Marketing Campaigns**: Track campaign start and end dates
|
|
||||||
- **Website Changes**: Note when major redesigns or updates occur
|
|
||||||
- **Business Events**: Record important business milestones
|
|
||||||
- **A/B Testing**: Mark when experiments start or end
|
|
||||||
- **Seasonal Events**: Track holidays, sales periods, or seasonal changes
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Reference IDs are UUIDs (Universally Unique Identifiers)
|
|
||||||
- References are scoped to projects - each reference belongs to a specific project
|
|
||||||
- References are scoped to your organization - you can only manage references for projects in your organization
|
|
||||||
- The `description` field is optional and can be set to `null` to clear it
|
|
||||||
- References appear in analytics charts to help correlate metrics with events
|
|
||||||
- When filtering by `projectId`, the project must exist and belong to your organization
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "API",
|
"title": "API",
|
||||||
"pages": ["track", "export", "insights", "manage"]
|
"pages": ["track", "export", "insights"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,48 +175,6 @@ COOKIE_SECRET=your-random-secret-here
|
|||||||
Never use the default value in production! Always generate a unique secret.
|
Never use the default value in production! Always generate a unique secret.
|
||||||
</Callout>
|
</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
|
### DEMO_USER_ID
|
||||||
|
|
||||||
**Type**: `string`
|
**Type**: `string`
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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.',
|
'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': {
|
case 'pricing': {
|
||||||
return {
|
return {
|
||||||
title: 'Pricing',
|
title: 'Pricing',
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export function FeatureCard({
|
|||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
|
||||||
</FeatureCardContainer>
|
</FeatureCardContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ export async function Footer() {
|
|||||||
{ title: 'About', url: '/about' },
|
{ title: 'About', url: '/about' },
|
||||||
{ title: 'Contact', url: '/contact' },
|
{ title: 'Contact', url: '/contact' },
|
||||||
{ title: 'Become a supporter', url: '/supporter' },
|
{ title: 'Become a supporter', url: '/supporter' },
|
||||||
{
|
|
||||||
title: 'Free analytics for open source projects',
|
|
||||||
url: '/open-source',
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="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 className="container col md:row justify-between gap-8">
|
||||||
<div>
|
<div>
|
||||||
<a
|
<Link href="/" className="row items-center font-medium -ml-3">
|
||||||
href="https://openpanel.dev"
|
<Logo className="h-6" />
|
||||||
style={{
|
{baseOptions().nav?.title}
|
||||||
display: 'inline-block',
|
</Link>
|
||||||
overflow: 'hidden',
|
|
||||||
borderRadius: '8px',
|
|
||||||
width: '100%',
|
|
||||||
height: '48px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%230B0B0B"
|
|
||||||
height="48"
|
|
||||||
width="100%"
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
overflow: 'hidden',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
title="OpenPanel Analytics Badge"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<Social />
|
<Social />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,16 +84,9 @@
|
|||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"ai": "^4.2.10",
|
"ai": "^4.2.10",
|
||||||
"bind-event-listener": "^3.0.0",
|
"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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
"codemirror": "^6.0.1",
|
|
||||||
"d3": "^7.8.5",
|
"d3": "^7.8.5",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"debounce": "^2.2.0",
|
"debounce": "^2.2.0",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { type ISignInShare, zSignInShare } from '@openpanel/validation';
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { PublicPageCard } from '../public-page-card';
|
import { LogoSquare } from '../logo';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
|
|
||||||
@@ -43,19 +43,28 @@ export function ShareEnterPassword({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const typeLabel =
|
|
||||||
shareType === 'dashboard'
|
|
||||||
? 'Dashboard'
|
|
||||||
: shareType === 'report'
|
|
||||||
? 'Report'
|
|
||||||
: 'Overview';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PublicPageCard
|
<div className="center-center h-screen w-screen p-4 col">
|
||||||
title={`${typeLabel} is locked`}
|
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||||
description={`Please enter correct password to access this ${typeLabel.toLowerCase()}`}
|
<div className="col mt-1 flex-1 gap-2">
|
||||||
>
|
<LogoSquare className="size-12 mb-4" />
|
||||||
<form onSubmit={onSubmit} className="col gap-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
|
<Input
|
||||||
{...form.register('password')}
|
{...form.register('password')}
|
||||||
type="password"
|
type="password"
|
||||||
@@ -64,6 +73,24 @@ export function ShareEnterPassword({
|
|||||||
/>
|
/>
|
||||||
<Button type="submit">Get access</Button>
|
<Button type="submit">Get access</Button>
|
||||||
</form>
|
</form>
|
||||||
</PublicPageCard>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function useColumns() {
|
|||||||
if (profile) {
|
if (profile) {
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
href={`/profiles/${profile.id}`}
|
||||||
className="whitespace-nowrap font-medium hover:underline"
|
className="whitespace-nowrap font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{getProfileName(profile)}
|
{getProfileName(profile)}
|
||||||
@@ -117,7 +117,7 @@ export function useColumns() {
|
|||||||
if (profileId && profileId !== deviceId) {
|
if (profileId && profileId !== deviceId) {
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(profileId)}`}
|
href={`/profiles/${profileId}`}
|
||||||
className="whitespace-nowrap font-medium hover:underline"
|
className="whitespace-nowrap font-medium hover:underline"
|
||||||
>
|
>
|
||||||
Unknown
|
Unknown
|
||||||
@@ -128,7 +128,7 @@ export function useColumns() {
|
|||||||
if (deviceId) {
|
if (deviceId) {
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(deviceId)}`}
|
href={`/profiles/${deviceId}`}
|
||||||
className="whitespace-nowrap font-medium hover:underline"
|
className="whitespace-nowrap font-medium hover:underline"
|
||||||
>
|
>
|
||||||
Anonymous
|
Anonymous
|
||||||
|
|||||||
@@ -1,53 +1,18 @@
|
|||||||
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
|
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||||
import { JsonEditor } from '@/components/json-editor';
|
|
||||||
import { Button } from '@/components/ui/button';
|
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 { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { zCreateWebhookIntegration } from '@openpanel/validation';
|
import { zCreateWebhookIntegration } from '@openpanel/validation';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { PlusIcon, TrashIcon } from 'lucide-react';
|
|
||||||
import { path, mergeDeepRight } from 'ramda';
|
import { path, mergeDeepRight } from 'ramda';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Controller, useFieldArray, useWatch } from 'react-hook-form';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
type IForm = z.infer<typeof zCreateWebhookIntegration>;
|
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({
|
export function WebhookIntegrationForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
@@ -56,13 +21,6 @@ export function WebhookIntegrationForm({
|
|||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { organizationId } = useAppParams();
|
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>({
|
const form = useForm<IForm>({
|
||||||
defaultValues: mergeDeepRight(
|
defaultValues: mergeDeepRight(
|
||||||
{
|
{
|
||||||
@@ -72,68 +30,18 @@ export function WebhookIntegrationForm({
|
|||||||
type: 'webhook' as const,
|
type: 'webhook' as const,
|
||||||
url: '',
|
url: '',
|
||||||
headers: {},
|
headers: {},
|
||||||
mode: 'message' as const,
|
|
||||||
javascriptTemplate: undefined,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultValues ?? {},
|
defaultValues ?? {},
|
||||||
),
|
),
|
||||||
resolver: zodResolver(zCreateWebhookIntegration),
|
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 trpc = useTRPC();
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
trpc.integration.createOrUpdate.mutationOptions({
|
trpc.integration.createOrUpdate.mutationOptions({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError(error) {
|
onError() {
|
||||||
// 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');
|
toast.error('Failed to create integration');
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -162,176 +70,7 @@ export function WebhookIntegrationForm({
|
|||||||
{...form.register('config.url')}
|
{...form.register('config.url')}
|
||||||
error={path(['config', 'url', 'message'], form.formState.errors)}
|
error={path(['config', 'url', 'message'], form.formState.errors)}
|
||||||
/>
|
/>
|
||||||
|
<Button type="submit">Create</Button>
|
||||||
<WithLabel
|
|
||||||
label="Headers"
|
|
||||||
info="Add custom HTTP headers to include with webhook requests"
|
|
||||||
>
|
|
||||||
<div className="col gap-2">
|
|
||||||
{headersArray.fields.map((field, index) => (
|
|
||||||
<div key={field.id} className="row gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Header Name"
|
|
||||||
{...headersForm.register(`headers.${index}.key`)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="Header Value"
|
|
||||||
{...headersForm.register(`headers.${index}.value`)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => headersArray.remove(index)}
|
|
||||||
className="text-destructive"
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => headersArray.append({ key: '', value: '' })}
|
|
||||||
className="self-start"
|
|
||||||
icon={PlusIcon}
|
|
||||||
>
|
|
||||||
Add Header
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</WithLabel>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="config.mode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<WithLabel
|
|
||||||
label="Payload Format"
|
|
||||||
info="Choose how to format the webhook payload"
|
|
||||||
>
|
|
||||||
<Combobox
|
|
||||||
{...field}
|
|
||||||
className="w-full"
|
|
||||||
placeholder="Select format"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: 'Message',
|
|
||||||
value: 'message' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'JavaScript',
|
|
||||||
value: 'javascript' as const,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={field.value ?? 'message'}
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</WithLabel>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{mode === 'javascript' && (
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="config.javascriptTemplate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<WithLabel
|
|
||||||
label="JavaScript Transform"
|
|
||||||
info={
|
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
|
||||||
<p>
|
|
||||||
Write a JavaScript function that transforms the event
|
|
||||||
payload. The function receives <code>payload</code> as a
|
|
||||||
parameter and should return an object.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-semibold mt-2">
|
|
||||||
Available in payload:
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm">
|
|
||||||
<li>
|
|
||||||
<code>payload.name</code> - Event name
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>payload.profileId</code> - User profile ID
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>payload.properties</code> - Full properties object
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>payload.properties.your.property</code> - Nested
|
|
||||||
property value
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>payload.profile.firstName</code> - Profile property
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div className="flex gap-x-2 flex-wrap mt-1">
|
|
||||||
<code>country</code>
|
|
||||||
<code>city</code>
|
|
||||||
<code>device</code>
|
|
||||||
<code>os</code>
|
|
||||||
<code>browser</code>
|
|
||||||
<code>path</code>
|
|
||||||
<code>createdAt</code>
|
|
||||||
and more...
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-sm font-semibold mt-2">
|
|
||||||
Available helpers:
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm">
|
|
||||||
<li>
|
|
||||||
<code>Math</code>, <code>Date</code>, <code>JSON</code>,{' '}
|
|
||||||
<code>Array</code>, <code>String</code>,{' '}
|
|
||||||
<code>Object</code>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-sm mt-2">
|
|
||||||
<strong>Example:</strong>
|
|
||||||
</p>
|
|
||||||
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">
|
|
||||||
{`(payload) => ({
|
|
||||||
event: payload.name,
|
|
||||||
user: payload.profileId,
|
|
||||||
data: payload.properties,
|
|
||||||
timestamp: new Date(payload.createdAt).toISOString(),
|
|
||||||
location: \`\${payload.city}, \${payload.country}\`
|
|
||||||
})`}
|
|
||||||
</pre>
|
|
||||||
<p className="text-sm mt-2 text-yellow-600 dark:text-yellow-400">
|
|
||||||
<strong>Security:</strong> Network calls, file system
|
|
||||||
access, and other dangerous operations are blocked.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<JsonEditor
|
|
||||||
value={field.value ?? DEFAULT_TRANSFORMER}
|
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
// Clear error when user starts typing
|
|
||||||
if (form.formState.errors.config?.javascriptTemplate) {
|
|
||||||
form.clearErrors('config.javascriptTemplate');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={DEFAULT_TRANSFORMER}
|
|
||||||
minHeight="300px"
|
|
||||||
language="javascript"
|
|
||||||
/>
|
|
||||||
{form.formState.errors.config?.javascriptTemplate && (
|
|
||||||
<p className="mt-1 text-sm text-destructive">
|
|
||||||
{form.formState.errors.config.javascriptTemplate.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</WithLabel>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit">{defaultValues?.id ? 'Update' : 'Create'}</Button>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -149,7 +149,7 @@ export function useColumns() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(event.profileId)}`}
|
href={`/profiles/${event.profileId}`}
|
||||||
className="inline-flex min-w-full flex-none items-center gap-2"
|
className="inline-flex min-w-full flex-none items-center gap-2"
|
||||||
>
|
>
|
||||||
{event.profileId}
|
{event.profileId}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import { DialogTitle } from '@radix-ui/react-dialog';
|
import { DialogTitle } from '@radix-ui/react-dialog';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { SearchIcon } from 'lucide-react';
|
import { SearchIcon } from 'lucide-react';
|
||||||
import type React from 'react';
|
import React, { useMemo, useRef, useState } from 'react';
|
||||||
import { useMemo, useRef, useState } from 'react';
|
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
|
|
||||||
const ROW_HEIGHT = 36;
|
const ROW_HEIGHT = 36;
|
||||||
@@ -107,9 +106,7 @@ export function OverviewListModal<T extends OverviewListItem>({
|
|||||||
// Calculate totals and check for revenue
|
// Calculate totals and check for revenue
|
||||||
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
|
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
const maxSessions = Math.max(
|
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
|
||||||
...filteredData.map((item) => item.sessions),
|
|
||||||
);
|
|
||||||
const totalRevenue = filteredData.reduce(
|
const totalRevenue = filteredData.reduce(
|
||||||
(sum, item) => sum + (item.revenue ?? 0),
|
(sum, item) => sum + (item.revenue ?? 0),
|
||||||
0,
|
0,
|
||||||
@@ -155,8 +152,7 @@ export function OverviewListModal<T extends OverviewListItem>({
|
|||||||
<div
|
<div
|
||||||
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
|
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns:
|
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||||
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-left truncate">{columnName}</div>
|
<div className="text-left truncate">{columnName}</div>
|
||||||
@@ -208,14 +204,11 @@ export function OverviewListModal<T extends OverviewListItem>({
|
|||||||
<div
|
<div
|
||||||
className="relative grid h-full items-center px-4 border-b border-border"
|
className="relative grid h-full items-center px-4 border-b border-border"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns:
|
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||||
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Main content cell */}
|
{/* Main content cell */}
|
||||||
<div className="min-w-0 truncate pr-2">
|
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
|
||||||
{renderItem(item)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Revenue cell */}
|
{/* Revenue cell */}
|
||||||
{hasRevenue && (
|
{hasRevenue && (
|
||||||
@@ -268,3 +261,4 @@ export function OverviewListModal<T extends OverviewListItem>({
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export function OverviewMetricCardNumber({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<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]">
|
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
|
||||||
{label}
|
{label}
|
||||||
@@ -219,7 +219,7 @@ export function OverviewMetricCardNumber({
|
|||||||
<Skeleton className="h-6 w-12" />
|
<Skeleton className="h-6 w-12" />
|
||||||
</div>
|
</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}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export default function OverviewTopDevices({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const data = query.data ?? [];
|
const data = (query.data ?? []).slice(0, 15);
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,12 +118,12 @@ export default function OverviewTopEvents({
|
|||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return tableData;
|
return tableData.slice(0, 15);
|
||||||
}
|
}
|
||||||
const queryLower = searchQuery.toLowerCase();
|
const queryLower = searchQuery.toLowerCase();
|
||||||
return tableData.filter((item) =>
|
return tableData
|
||||||
item.name?.toLowerCase().includes(queryLower),
|
.filter((item) => item.name?.toLowerCase().includes(queryLower))
|
||||||
);
|
.slice(0, 15);
|
||||||
}, [tableData, searchQuery]);
|
}, [tableData, searchQuery]);
|
||||||
|
|
||||||
const tabs = useMemo(
|
const tabs = useMemo(
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const data = query.data ?? [];
|
const data = (query.data ?? []).slice(0, 15);
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const data = query.data ?? [];
|
const data = query.data?.slice(0, 15) ?? [];
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function OverviewTopSources({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const data = query.data ?? [];
|
const data = (query.data ?? []).slice(0, 15);
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
|||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { ChevronDown, ChevronUp, ExternalLinkIcon } from 'lucide-react';
|
import { ExternalLinkIcon } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import { Skeleton } from '../skeleton';
|
import { Skeleton } from '../skeleton';
|
||||||
import { Tooltiper } from '../ui/tooltip';
|
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> & {
|
type Props<T> = WidgetTableProps<T> & {
|
||||||
getColumnPercentage: (item: T) => number;
|
getColumnPercentage: (item: T) => number;
|
||||||
};
|
};
|
||||||
@@ -93,113 +56,10 @@ export const OverviewWidgetTable = <T,>({
|
|||||||
getColumnPercentage,
|
getColumnPercentage,
|
||||||
className,
|
className,
|
||||||
}: Props<T>) => {
|
}: 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 (
|
return (
|
||||||
<div className={cn(className)}>
|
<div className={cn(className)}>
|
||||||
<WidgetTable
|
<WidgetTable
|
||||||
data={sortedData}
|
data={data ?? []}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
className={'text-sm min-h-[358px] @container'}
|
className={'text-sm min-h-[358px] @container'}
|
||||||
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
||||||
@@ -215,7 +75,18 @@ export const OverviewWidgetTable = <T,>({
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -337,8 +208,6 @@ export function OverviewWidgetTablePages({
|
|||||||
name: 'Revenue',
|
name: 'Revenue',
|
||||||
width: '100px',
|
width: '100px',
|
||||||
responsive: { priority: 3 }, // Always show if possible
|
responsive: { priority: 3 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) =>
|
|
||||||
item.revenue ?? 0,
|
|
||||||
render(item: (typeof data)[number]) {
|
render(item: (typeof data)[number]) {
|
||||||
const revenue = item.revenue ?? 0;
|
const revenue = item.revenue ?? 0;
|
||||||
const revenuePercentage =
|
const revenuePercentage =
|
||||||
@@ -362,7 +231,6 @@ export function OverviewWidgetTablePages({
|
|||||||
name: 'Views',
|
name: 'Views',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 }, // Always show if possible
|
responsive: { priority: 2 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) => item.pageviews,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -377,7 +245,6 @@ export function OverviewWidgetTablePages({
|
|||||||
name: 'Sess.',
|
name: 'Sess.',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 }, // Always show if possible
|
responsive: { priority: 2 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) => item.sessions,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -472,8 +339,6 @@ export function OverviewWidgetTableEntries({
|
|||||||
name: 'Revenue',
|
name: 'Revenue',
|
||||||
width: '100px',
|
width: '100px',
|
||||||
responsive: { priority: 3 }, // Always show if possible
|
responsive: { priority: 3 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) =>
|
|
||||||
item.revenue ?? 0,
|
|
||||||
render(item: (typeof data)[number]) {
|
render(item: (typeof data)[number]) {
|
||||||
const revenue = item.revenue ?? 0;
|
const revenue = item.revenue ?? 0;
|
||||||
const revenuePercentage =
|
const revenuePercentage =
|
||||||
@@ -497,7 +362,6 @@ export function OverviewWidgetTableEntries({
|
|||||||
name: lastColumnName,
|
name: lastColumnName,
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 }, // Always show if possible
|
responsive: { priority: 2 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) => item.sessions,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -630,9 +494,6 @@ export function OverviewWidgetTableGeneric({
|
|||||||
name: 'Revenue',
|
name: 'Revenue',
|
||||||
width: '100px',
|
width: '100px',
|
||||||
responsive: { priority: 3 },
|
responsive: { priority: 3 },
|
||||||
getSortValue: (
|
|
||||||
item: RouterOutputs['overview']['topGeneric'][number],
|
|
||||||
) => item.revenue ?? 0,
|
|
||||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||||
const revenue = item.revenue ?? 0;
|
const revenue = item.revenue ?? 0;
|
||||||
const revenuePercentage =
|
const revenuePercentage =
|
||||||
@@ -660,9 +521,6 @@ export function OverviewWidgetTableGeneric({
|
|||||||
name: 'Views',
|
name: 'Views',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 },
|
responsive: { priority: 2 },
|
||||||
getSortValue: (
|
|
||||||
item: RouterOutputs['overview']['topGeneric'][number],
|
|
||||||
) => item.pageviews,
|
|
||||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -679,9 +537,6 @@ export function OverviewWidgetTableGeneric({
|
|||||||
name: 'Sess.',
|
name: 'Sess.',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 },
|
responsive: { priority: 2 },
|
||||||
getSortValue: (
|
|
||||||
item: RouterOutputs['overview']['topGeneric'][number],
|
|
||||||
) => item.sessions,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -744,7 +599,6 @@ export function OverviewWidgetTableEvents({
|
|||||||
name: 'Count',
|
name: 'Count',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 },
|
responsive: { priority: 2 },
|
||||||
getSortValue: (item: EventTableItem) => item.count,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
format,
|
format,
|
||||||
formatISO,
|
formatISO,
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
isToday,
|
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
subMonths,
|
subMonths,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
@@ -19,26 +18,15 @@ import {
|
|||||||
WidgetTitle,
|
WidgetTitle,
|
||||||
} from '../overview/overview-widget';
|
} from '../overview/overview-widget';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Tooltiper } from '../ui/tooltip';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: { count: number; date: string }[];
|
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 = ({
|
const MonthCalendar = ({
|
||||||
month,
|
month,
|
||||||
data,
|
data,
|
||||||
maxCount,
|
}: { month: Date; data: Props['data'] }) => (
|
||||||
}: { month: Date; data: Props['data']; maxCount: number }) => (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
|
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
|
||||||
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
|
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
|
||||||
@@ -49,42 +37,14 @@ const MonthCalendar = ({
|
|||||||
const hit = data.find((item) =>
|
const hit = data.find((item) =>
|
||||||
item.date.includes(formatISO(date, { representation: 'date' })),
|
item.date.includes(formatISO(date, { representation: 'date' })),
|
||||||
);
|
);
|
||||||
const opacity = hit ? getOpacityLevel(hit.count, maxCount) : 0;
|
|
||||||
return (
|
return (
|
||||||
<Tooltiper
|
<div
|
||||||
key={date.toISOString()}
|
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(
|
className={cn(
|
||||||
'aspect-square w-full rounded cursor-default group hover:ring-1 hover:ring-foreground overflow-hidden',
|
'aspect-square w-full rounded',
|
||||||
|
hit ? 'bg-highlight' : 'bg-def-200',
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'size-full group-hover:shadow-[inset_0_0_0_2px_var(--background)] rounded',
|
|
||||||
isToday(date)
|
|
||||||
? 'bg-highlight'
|
|
||||||
: hit
|
|
||||||
? 'bg-foreground'
|
|
||||||
: 'bg-def-200',
|
|
||||||
)}
|
|
||||||
style={hit && !isToday(date) ? { opacity } : undefined}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Tooltiper>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +53,6 @@ const MonthCalendar = ({
|
|||||||
|
|
||||||
export const ProfileActivity = ({ data }: Props) => {
|
export const ProfileActivity = ({ data }: Props) => {
|
||||||
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
||||||
const maxCount = Math.max(...data.map((item) => item.count), 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Widget className="w-full">
|
<Widget className="w-full">
|
||||||
@@ -124,7 +83,6 @@ export const ProfileActivity = ({ data }: Props) => {
|
|||||||
key={offset}
|
key={offset}
|
||||||
month={subMonths(startDate, offset)}
|
month={subMonths(startDate, offset)}
|
||||||
data={data}
|
data={data}
|
||||||
maxCount={maxCount}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useColumns(type: 'profiles' | 'power-users') {
|
|||||||
const profile = row.original;
|
const profile = row.original;
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
href={`/profiles/${profile.id}`}
|
||||||
className="flex items-center gap-2 font-medium"
|
className="flex items-center gap-2 font-medium"
|
||||||
title={getProfileName(profile, false)}
|
title={getProfileName(profile, false)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportAreaChart() {
|
export function ReportAreaChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
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,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportBarChart() {
|
export function ReportBarChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.aggregate.queryOptions(
|
trpc.chart.aggregate.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
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,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
@@ -13,11 +14,18 @@ import { Summary } from './summary';
|
|||||||
export function ReportConversionChart() {
|
export function ReportConversionChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
console.log(report.limit);
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.conversion.queryOptions(
|
trpc.chart.conversion.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
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,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -61,14 +61,9 @@ export function Chart({ data }: Props) {
|
|||||||
range,
|
range,
|
||||||
series: reportSeries,
|
series: reportSeries,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
options: reportOptions,
|
|
||||||
},
|
},
|
||||||
options: { hideXAxis, hideYAxis },
|
options: { hideXAxis, hideYAxis },
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
|
||||||
const histogramOptions =
|
|
||||||
reportOptions?.type === 'histogram' ? reportOptions : undefined;
|
|
||||||
const isStacked = histogramOptions?.stacked ?? false;
|
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const references = useQuery(
|
const references = useQuery(
|
||||||
trpc.reference.getChartReferences.queryOptions(
|
trpc.reference.getChartReferences.queryOptions(
|
||||||
@@ -181,7 +176,6 @@ export function Chart({ data }: Props) {
|
|||||||
fill={getChartColor(serie.index)}
|
fill={getChartColor(serie.index)}
|
||||||
fillOpacity={0.3}
|
fillOpacity={0.3}
|
||||||
radius={5}
|
radius={5}
|
||||||
stackId={isStacked ? 'prev' : undefined}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -193,9 +187,8 @@ export function Chart({ data }: Props) {
|
|||||||
name={serie.id}
|
name={serie.id}
|
||||||
dataKey={`${serie.id}:count`}
|
dataKey={`${serie.id}:count`}
|
||||||
fill={getChartColor(serie.index)}
|
fill={getChartColor(serie.index)}
|
||||||
radius={isStacked ? 0 : 4}
|
radius={5}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
stackId={isStacked ? 'current' : undefined}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportHistogramChart() {
|
export function ReportHistogramChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
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,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,11 +13,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportLineChart() {
|
export function ReportLineChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
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,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportMapChart() {
|
export function ReportMapChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
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,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -10,12 +11,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportMetricChart() {
|
export function ReportMetricChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
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,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportPieChart() {
|
export function ReportPieChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.aggregate.queryOptions(
|
trpc.chart.aggregate.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
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,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function ReportRetentionChart() {
|
|||||||
criteria,
|
criteria,
|
||||||
interval: overviewInterval ?? interval,
|
interval: overviewInterval ?? interval,
|
||||||
shareId,
|
shareId,
|
||||||
id,
|
reportId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -361,17 +361,6 @@ export const reportSlice = createSlice({
|
|||||||
state.options.include = action.payload;
|
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(
|
reorderEvents(
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{ fromIndex: number; toIndex: number }>,
|
action: PayloadAction<{ fromIndex: number; toIndex: number }>,
|
||||||
@@ -417,7 +406,6 @@ export const {
|
|||||||
changeSankeySteps,
|
changeSankeySteps,
|
||||||
changeSankeyExclude,
|
changeSankeyExclude,
|
||||||
changeSankeyInclude,
|
changeSankeyInclude,
|
||||||
changeStacked,
|
|
||||||
reorderEvents,
|
reorderEvents,
|
||||||
} = reportSlice.actions;
|
} = reportSlice.actions;
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
changeSankeyInclude,
|
changeSankeyInclude,
|
||||||
changeSankeyMode,
|
changeSankeyMode,
|
||||||
changeSankeySteps,
|
changeSankeySteps,
|
||||||
changeStacked,
|
|
||||||
changeUnit,
|
changeUnit,
|
||||||
} from '../reportSlice';
|
} from '../reportSlice';
|
||||||
|
|
||||||
@@ -34,9 +33,6 @@ export function ReportSettings() {
|
|||||||
const funnelGroup = funnelOptions?.funnelGroup;
|
const funnelGroup = funnelOptions?.funnelGroup;
|
||||||
const funnelWindow = funnelOptions?.funnelWindow;
|
const funnelWindow = funnelOptions?.funnelWindow;
|
||||||
|
|
||||||
const histogramOptions = options?.type === 'histogram' ? options : undefined;
|
|
||||||
const stacked = histogramOptions?.stacked ?? false;
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const eventNames = useEventNames({ projectId });
|
const eventNames = useEventNames({ projectId });
|
||||||
@@ -65,10 +61,6 @@ export function ReportSettings() {
|
|||||||
fields.push('sankeyInclude');
|
fields.push('sankeyInclude');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chartType === 'histogram') {
|
|
||||||
fields.push('stacked');
|
|
||||||
}
|
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}, [chartType]);
|
}, [chartType]);
|
||||||
|
|
||||||
@@ -267,15 +259,6 @@ export function ReportSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function useColumns() {
|
|||||||
if (session.profile) {
|
if (session.profile) {
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
|
href={`/profiles/${session.profile.id}`}
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
>
|
>
|
||||||
{getProfileName(session.profile)}
|
{getProfileName(session.profile)}
|
||||||
@@ -71,7 +71,7 @@ export function useColumns() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(session.profileId)}`}
|
href={`/profiles/${session.profileId}`}
|
||||||
className="font-mono font-medium"
|
className="font-mono font-medium"
|
||||||
>
|
>
|
||||||
{session.profileId}
|
{session.profileId}
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAppContext } from '@/hooks/use-app-context';
|
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
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 { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { ChevronDownIcon, PlusIcon } from 'lucide-react';
|
import { ChevronDownIcon, PlusIcon } from 'lucide-react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
@@ -29,8 +28,6 @@ export default function SidebarOrganizationMenu({
|
|||||||
}: {
|
}: {
|
||||||
organization: RouterOutputs['organization']['list'][number];
|
organization: RouterOutputs['organization']['list'][number];
|
||||||
}) {
|
}) {
|
||||||
const { isSelfHosted } = useAppContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
@@ -55,7 +52,6 @@ export default function SidebarOrganizationMenu({
|
|||||||
<CogIcon size={20} />
|
<CogIcon size={20} />
|
||||||
<div className="flex-1">Settings</div>
|
<div className="flex-1">Settings</div>
|
||||||
</Link>
|
</Link>
|
||||||
{!isSelfHosted && (
|
|
||||||
<Link
|
<Link
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',
|
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',
|
||||||
@@ -71,7 +67,6 @@ export default function SidebarOrganizationMenu({
|
|||||||
{organization?.isWillBeCanceled && <Badge>Canceled</Badge>}
|
{organization?.isWillBeCanceled && <Badge>Canceled</Badge>}
|
||||||
{organization?.isCanceled && <Badge>Canceled</Badge>}
|
{organization?.isCanceled && <Badge>Canceled</Badge>}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
|
||||||
<Link
|
<Link
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',
|
'flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200 text-[13px]',
|
||||||
|
|||||||
@@ -29,14 +29,6 @@ export interface Props<T> {
|
|||||||
* If not provided, column is always visible.
|
* If not provided, column is always visible.
|
||||||
*/
|
*/
|
||||||
responsive?: ColumnResponsive;
|
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;
|
keyExtractor: (item: T) => string;
|
||||||
data: T[];
|
data: T[];
|
||||||
@@ -185,16 +177,9 @@ export function WidgetTable<T>({
|
|||||||
dataAttrs['data-min-width'] = String(column.responsive.minWidth);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={columnKey}
|
key={column.name?.toString()}
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
||||||
columns.length > 1 && column !== columns[0]
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={columnKey}
|
key={column.name?.toString()}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 relative cell',
|
'px-2 relative cell',
|
||||||
columns.length > 1 && column !== columns[0]
|
columns.length > 1 && column !== columns[0]
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const setCookieFn = createServerFn({ method: 'POST' })
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Called in __root.tsx beforeLoad hook to get cookies from the server
|
// 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(() =>
|
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
|
||||||
pick(VALID_COOKIES, getCookies()),
|
pick(VALID_COOKIES, getCookies()),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function CreateInvite() {
|
|||||||
<div>
|
<div>
|
||||||
<SheetTitle>Invite a user</SheetTitle>
|
<SheetTitle>Invite a user</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Invite users to your organization. They will receive an email
|
Invite users to your organization. They will recieve an email
|
||||||
will instructions.
|
will instructions.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
{profile && (
|
{profile && (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
onClick={() => popModal()}
|
onClick={() => popModal()}
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
href={`/profiles/${profile.id}`}
|
||||||
className="card p-4 py-2 col gap-2 hover:bg-def-100"
|
className="card p-4 py-2 col gap-2 hover:bg-def-100"
|
||||||
>
|
>
|
||||||
<div className="row items-center gap-2 justify-between">
|
<div className="row items-center gap-2 justify-between">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const ProfileItem = ({ profile }: { profile: any }) => {
|
|||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
preload={false}
|
preload={false}
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
href={`/profiles/${profile.id}`}
|
||||||
title={getProfileName(profile, false)}
|
title={getProfileName(profile, false)}
|
||||||
className="col gap-2 rounded-lg border p-2 bg-card"
|
className="col gap-2 rounded-lg border p-2 bg-card"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as UnsubscribeRouteImport } from './routes/unsubscribe'
|
|
||||||
import { Route as StepsRouteImport } from './routes/_steps'
|
import { Route as StepsRouteImport } from './routes/_steps'
|
||||||
import { Route as PublicRouteImport } from './routes/_public'
|
import { Route as PublicRouteImport } from './routes/_public'
|
||||||
import { Route as LoginRouteImport } from './routes/_login'
|
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 WidgetTestRouteImport } from './routes/widget/test'
|
||||||
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
|
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
|
||||||
import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
|
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 ApiHealthcheckRouteImport } from './routes/api/healthcheck'
|
||||||
import { Route as ApiConfigRouteImport } from './routes/api/config'
|
import { Route as ApiConfigRouteImport } from './routes/api/config'
|
||||||
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
|
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 AppOrganizationIdProjectIdIndexRouteImport } from './routes/_app.$organizationId.$projectId.index'
|
||||||
import { Route as StepsOnboardingProjectIdVerifyRouteImport } from './routes/_steps.onboarding.$projectId.verify'
|
import { Route as StepsOnboardingProjectIdVerifyRouteImport } from './routes/_steps.onboarding.$projectId.verify'
|
||||||
import { Route as StepsOnboardingProjectIdConnectRouteImport } from './routes/_steps.onboarding.$projectId.connect'
|
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 AppOrganizationIdMembersTabsRouteImport } from './routes/_app.$organizationId.members._tabs'
|
||||||
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
|
import { Route as AppOrganizationIdIntegrationsTabsRouteImport } from './routes/_app.$organizationId.integrations._tabs'
|
||||||
import { Route as AppOrganizationIdProjectIdSessionsRouteImport } from './routes/_app.$organizationId.$projectId.sessions'
|
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 AppOrganizationIdProjectIdInsightsRouteImport } from './routes/_app.$organizationId.$projectId.insights'
|
||||||
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
|
import { Route as AppOrganizationIdProjectIdDashboardsRouteImport } from './routes/_app.$organizationId.$projectId.dashboards'
|
||||||
import { Route as AppOrganizationIdProjectIdChatRouteImport } from './routes/_app.$organizationId.$projectId.chat'
|
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 AppOrganizationIdMembersTabsIndexRouteImport } from './routes/_app.$organizationId.members._tabs.index'
|
||||||
import { Route as AppOrganizationIdIntegrationsTabsIndexRouteImport } from './routes/_app.$organizationId.integrations._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 AppOrganizationIdMembersTabsMembersRouteImport } from './routes/_app.$organizationId.members._tabs.members'
|
||||||
import { Route as AppOrganizationIdMembersTabsInvitationsRouteImport } from './routes/_app.$organizationId.members._tabs.invitations'
|
import { Route as AppOrganizationIdMembersTabsInvitationsRouteImport } from './routes/_app.$organizationId.members._tabs.invitations'
|
||||||
import { Route as AppOrganizationIdIntegrationsTabsInstalledRouteImport } from './routes/_app.$organizationId.integrations._tabs.installed'
|
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 AppOrganizationIdProjectIdProfilesProfileIdTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.index'
|
||||||
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
import { Route as AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.profiles.$profileId._tabs.events'
|
||||||
|
|
||||||
const AppOrganizationIdProfileRouteImport = createFileRoute(
|
|
||||||
'/_app/$organizationId/profile',
|
|
||||||
)()
|
|
||||||
const AppOrganizationIdMembersRouteImport = createFileRoute(
|
const AppOrganizationIdMembersRouteImport = createFileRoute(
|
||||||
'/_app/$organizationId/members',
|
'/_app/$organizationId/members',
|
||||||
)()
|
)()
|
||||||
@@ -110,11 +102,6 @@ const AppOrganizationIdProjectIdProfilesProfileIdRouteImport = createFileRoute(
|
|||||||
'/_app/$organizationId/$projectId/profiles/$profileId',
|
'/_app/$organizationId/$projectId/profiles/$profileId',
|
||||||
)()
|
)()
|
||||||
|
|
||||||
const UnsubscribeRoute = UnsubscribeRouteImport.update({
|
|
||||||
id: '/unsubscribe',
|
|
||||||
path: '/unsubscribe',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const StepsRoute = StepsRouteImport.update({
|
const StepsRoute = StepsRouteImport.update({
|
||||||
id: '/_steps',
|
id: '/_steps',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
@@ -151,11 +138,6 @@ const WidgetCounterRoute = WidgetCounterRouteImport.update({
|
|||||||
path: '/widget/counter',
|
path: '/widget/counter',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const WidgetBadgeRoute = WidgetBadgeRouteImport.update({
|
|
||||||
id: '/widget/badge',
|
|
||||||
path: '/widget/badge',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
|
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
|
||||||
id: '/api/healthcheck',
|
id: '/api/healthcheck',
|
||||||
path: '/api/healthcheck',
|
path: '/api/healthcheck',
|
||||||
@@ -186,12 +168,6 @@ const AppOrganizationIdRoute = AppOrganizationIdRouteImport.update({
|
|||||||
path: '/$organizationId',
|
path: '/$organizationId',
|
||||||
getParentRoute: () => AppRoute,
|
getParentRoute: () => AppRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AppOrganizationIdProfileRoute =
|
|
||||||
AppOrganizationIdProfileRouteImport.update({
|
|
||||||
id: '/profile',
|
|
||||||
path: '/profile',
|
|
||||||
getParentRoute: () => AppOrganizationIdRoute,
|
|
||||||
} as any)
|
|
||||||
const AppOrganizationIdMembersRoute =
|
const AppOrganizationIdMembersRoute =
|
||||||
AppOrganizationIdMembersRouteImport.update({
|
AppOrganizationIdMembersRouteImport.update({
|
||||||
id: '/members',
|
id: '/members',
|
||||||
@@ -289,11 +265,6 @@ const StepsOnboardingProjectIdConnectRoute =
|
|||||||
path: '/onboarding/$projectId/connect',
|
path: '/onboarding/$projectId/connect',
|
||||||
getParentRoute: () => StepsRoute,
|
getParentRoute: () => StepsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AppOrganizationIdProfileTabsRoute =
|
|
||||||
AppOrganizationIdProfileTabsRouteImport.update({
|
|
||||||
id: '/_tabs',
|
|
||||||
getParentRoute: () => AppOrganizationIdProfileRoute,
|
|
||||||
} as any)
|
|
||||||
const AppOrganizationIdMembersTabsRoute =
|
const AppOrganizationIdMembersTabsRoute =
|
||||||
AppOrganizationIdMembersTabsRouteImport.update({
|
AppOrganizationIdMembersTabsRouteImport.update({
|
||||||
id: '/_tabs',
|
id: '/_tabs',
|
||||||
@@ -358,12 +329,6 @@ const AppOrganizationIdProjectIdProfilesProfileIdRoute =
|
|||||||
path: '/$profileId',
|
path: '/$profileId',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdProfilesRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AppOrganizationIdProfileTabsIndexRoute =
|
|
||||||
AppOrganizationIdProfileTabsIndexRouteImport.update({
|
|
||||||
id: '/',
|
|
||||||
path: '/',
|
|
||||||
getParentRoute: () => AppOrganizationIdProfileTabsRoute,
|
|
||||||
} as any)
|
|
||||||
const AppOrganizationIdMembersTabsIndexRoute =
|
const AppOrganizationIdMembersTabsIndexRoute =
|
||||||
AppOrganizationIdMembersTabsIndexRouteImport.update({
|
AppOrganizationIdMembersTabsIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
@@ -376,12 +341,6 @@ const AppOrganizationIdIntegrationsTabsIndexRoute =
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute,
|
getParentRoute: () => AppOrganizationIdIntegrationsTabsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AppOrganizationIdProfileTabsEmailPreferencesRoute =
|
|
||||||
AppOrganizationIdProfileTabsEmailPreferencesRouteImport.update({
|
|
||||||
id: '/email-preferences',
|
|
||||||
path: '/email-preferences',
|
|
||||||
getParentRoute: () => AppOrganizationIdProfileTabsRoute,
|
|
||||||
} as any)
|
|
||||||
const AppOrganizationIdMembersTabsMembersRoute =
|
const AppOrganizationIdMembersTabsMembersRoute =
|
||||||
AppOrganizationIdMembersTabsMembersRouteImport.update({
|
AppOrganizationIdMembersTabsMembersRouteImport.update({
|
||||||
id: '/members',
|
id: '/members',
|
||||||
@@ -566,14 +525,12 @@ const AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute =
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/unsubscribe': typeof UnsubscribeRoute
|
|
||||||
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
'/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
||||||
'/login': typeof LoginLoginRoute
|
'/login': typeof LoginLoginRoute
|
||||||
'/reset-password': typeof LoginResetPasswordRoute
|
'/reset-password': typeof LoginResetPasswordRoute
|
||||||
'/onboarding': typeof PublicOnboardingRoute
|
'/onboarding': typeof PublicOnboardingRoute
|
||||||
'/api/config': typeof ApiConfigRoute
|
'/api/config': typeof ApiConfigRoute
|
||||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||||
'/widget/badge': typeof WidgetBadgeRoute
|
|
||||||
'/widget/counter': typeof WidgetCounterRoute
|
'/widget/counter': typeof WidgetCounterRoute
|
||||||
'/widget/realtime': typeof WidgetRealtimeRoute
|
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||||
'/widget/test': typeof WidgetTestRoute
|
'/widget/test': typeof WidgetTestRoute
|
||||||
@@ -595,7 +552,6 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
'/$organizationId/members': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
||||||
'/$organizationId/profile': typeof AppOrganizationIdProfileTabsRouteWithChildren
|
|
||||||
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
|
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
|
||||||
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
|
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
|
||||||
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
|
'/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
|
||||||
@@ -610,10 +566,8 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
|
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
|
||||||
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
|
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
|
||||||
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
|
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
|
||||||
'/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
|
|
||||||
'/$organizationId/integrations/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
'/$organizationId/integrations/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||||
'/$organizationId/members/': typeof AppOrganizationIdMembersTabsIndexRoute
|
'/$organizationId/members/': typeof AppOrganizationIdMembersTabsIndexRoute
|
||||||
'/$organizationId/profile/': typeof AppOrganizationIdProfileTabsIndexRoute
|
|
||||||
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||||
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||||
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||||
@@ -637,13 +591,11 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/unsubscribe': typeof UnsubscribeRoute
|
|
||||||
'/login': typeof LoginLoginRoute
|
'/login': typeof LoginLoginRoute
|
||||||
'/reset-password': typeof LoginResetPasswordRoute
|
'/reset-password': typeof LoginResetPasswordRoute
|
||||||
'/onboarding': typeof PublicOnboardingRoute
|
'/onboarding': typeof PublicOnboardingRoute
|
||||||
'/api/config': typeof ApiConfigRoute
|
'/api/config': typeof ApiConfigRoute
|
||||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||||
'/widget/badge': typeof WidgetBadgeRoute
|
|
||||||
'/widget/counter': typeof WidgetCounterRoute
|
'/widget/counter': typeof WidgetCounterRoute
|
||||||
'/widget/realtime': typeof WidgetRealtimeRoute
|
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||||
'/widget/test': typeof WidgetTestRoute
|
'/widget/test': typeof WidgetTestRoute
|
||||||
@@ -664,7 +616,6 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
'/$organizationId/$projectId/sessions': typeof AppOrganizationIdProjectIdSessionsRoute
|
||||||
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
'/$organizationId/integrations': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||||
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
|
'/$organizationId/members': typeof AppOrganizationIdMembersTabsIndexRoute
|
||||||
'/$organizationId/profile': typeof AppOrganizationIdProfileTabsIndexRoute
|
|
||||||
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
|
'/onboarding/$projectId/connect': typeof StepsOnboardingProjectIdConnectRoute
|
||||||
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
|
'/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
|
||||||
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
|
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdIndexRoute
|
||||||
@@ -679,7 +630,6 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
|
'/$organizationId/integrations/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
|
||||||
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
|
'/$organizationId/members/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
|
||||||
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
|
'/$organizationId/members/members': typeof AppOrganizationIdMembersTabsMembersRoute
|
||||||
'/$organizationId/profile/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
|
|
||||||
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
'/$organizationId/$projectId/events/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||||
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
'/$organizationId/$projectId/events/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||||
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
'/$organizationId/$projectId/events/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||||
@@ -703,14 +653,12 @@ export interface FileRoutesById {
|
|||||||
'/_login': typeof LoginRouteWithChildren
|
'/_login': typeof LoginRouteWithChildren
|
||||||
'/_public': typeof PublicRouteWithChildren
|
'/_public': typeof PublicRouteWithChildren
|
||||||
'/_steps': typeof StepsRouteWithChildren
|
'/_steps': typeof StepsRouteWithChildren
|
||||||
'/unsubscribe': typeof UnsubscribeRoute
|
|
||||||
'/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
'/_app/$organizationId': typeof AppOrganizationIdRouteWithChildren
|
||||||
'/_login/login': typeof LoginLoginRoute
|
'/_login/login': typeof LoginLoginRoute
|
||||||
'/_login/reset-password': typeof LoginResetPasswordRoute
|
'/_login/reset-password': typeof LoginResetPasswordRoute
|
||||||
'/_public/onboarding': typeof PublicOnboardingRoute
|
'/_public/onboarding': typeof PublicOnboardingRoute
|
||||||
'/api/config': typeof ApiConfigRoute
|
'/api/config': typeof ApiConfigRoute
|
||||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||||
'/widget/badge': typeof WidgetBadgeRoute
|
|
||||||
'/widget/counter': typeof WidgetCounterRoute
|
'/widget/counter': typeof WidgetCounterRoute
|
||||||
'/widget/realtime': typeof WidgetRealtimeRoute
|
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||||
'/widget/test': typeof WidgetTestRoute
|
'/widget/test': typeof WidgetTestRoute
|
||||||
@@ -734,8 +682,6 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
'/_app/$organizationId/integrations/_tabs': typeof AppOrganizationIdIntegrationsTabsRouteWithChildren
|
||||||
'/_app/$organizationId/members': typeof AppOrganizationIdMembersRouteWithChildren
|
'/_app/$organizationId/members': typeof AppOrganizationIdMembersRouteWithChildren
|
||||||
'/_app/$organizationId/members/_tabs': typeof AppOrganizationIdMembersTabsRouteWithChildren
|
'/_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/connect': typeof StepsOnboardingProjectIdConnectRoute
|
||||||
'/_steps/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
|
'/_steps/onboarding/$projectId/verify': typeof StepsOnboardingProjectIdVerifyRoute
|
||||||
'/_app/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
|
'/_app/$organizationId/$projectId/': typeof AppOrganizationIdProjectIdIndexRoute
|
||||||
@@ -754,10 +700,8 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/integrations/_tabs/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
|
'/_app/$organizationId/integrations/_tabs/installed': typeof AppOrganizationIdIntegrationsTabsInstalledRoute
|
||||||
'/_app/$organizationId/members/_tabs/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
|
'/_app/$organizationId/members/_tabs/invitations': typeof AppOrganizationIdMembersTabsInvitationsRoute
|
||||||
'/_app/$organizationId/members/_tabs/members': typeof AppOrganizationIdMembersTabsMembersRoute
|
'/_app/$organizationId/members/_tabs/members': typeof AppOrganizationIdMembersTabsMembersRoute
|
||||||
'/_app/$organizationId/profile/_tabs/email-preferences': typeof AppOrganizationIdProfileTabsEmailPreferencesRoute
|
|
||||||
'/_app/$organizationId/integrations/_tabs/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
'/_app/$organizationId/integrations/_tabs/': typeof AppOrganizationIdIntegrationsTabsIndexRoute
|
||||||
'/_app/$organizationId/members/_tabs/': typeof AppOrganizationIdMembersTabsIndexRoute
|
'/_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/conversions': typeof AppOrganizationIdProjectIdEventsTabsConversionsRoute
|
||||||
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
'/_app/$organizationId/$projectId/events/_tabs/events': typeof AppOrganizationIdProjectIdEventsTabsEventsRoute
|
||||||
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
'/_app/$organizationId/$projectId/events/_tabs/stats': typeof AppOrganizationIdProjectIdEventsTabsStatsRoute
|
||||||
@@ -784,14 +728,12 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/unsubscribe'
|
|
||||||
| '/$organizationId'
|
| '/$organizationId'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
| '/api/config'
|
| '/api/config'
|
||||||
| '/api/healthcheck'
|
| '/api/healthcheck'
|
||||||
| '/widget/badge'
|
|
||||||
| '/widget/counter'
|
| '/widget/counter'
|
||||||
| '/widget/realtime'
|
| '/widget/realtime'
|
||||||
| '/widget/test'
|
| '/widget/test'
|
||||||
@@ -813,7 +755,6 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/sessions'
|
| '/$organizationId/$projectId/sessions'
|
||||||
| '/$organizationId/integrations'
|
| '/$organizationId/integrations'
|
||||||
| '/$organizationId/members'
|
| '/$organizationId/members'
|
||||||
| '/$organizationId/profile'
|
|
||||||
| '/onboarding/$projectId/connect'
|
| '/onboarding/$projectId/connect'
|
||||||
| '/onboarding/$projectId/verify'
|
| '/onboarding/$projectId/verify'
|
||||||
| '/$organizationId/$projectId/'
|
| '/$organizationId/$projectId/'
|
||||||
@@ -828,10 +769,8 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/integrations/installed'
|
| '/$organizationId/integrations/installed'
|
||||||
| '/$organizationId/members/invitations'
|
| '/$organizationId/members/invitations'
|
||||||
| '/$organizationId/members/members'
|
| '/$organizationId/members/members'
|
||||||
| '/$organizationId/profile/email-preferences'
|
|
||||||
| '/$organizationId/integrations/'
|
| '/$organizationId/integrations/'
|
||||||
| '/$organizationId/members/'
|
| '/$organizationId/members/'
|
||||||
| '/$organizationId/profile/'
|
|
||||||
| '/$organizationId/$projectId/events/conversions'
|
| '/$organizationId/$projectId/events/conversions'
|
||||||
| '/$organizationId/$projectId/events/events'
|
| '/$organizationId/$projectId/events/events'
|
||||||
| '/$organizationId/$projectId/events/stats'
|
| '/$organizationId/$projectId/events/stats'
|
||||||
@@ -855,13 +794,11 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/unsubscribe'
|
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
| '/api/config'
|
| '/api/config'
|
||||||
| '/api/healthcheck'
|
| '/api/healthcheck'
|
||||||
| '/widget/badge'
|
|
||||||
| '/widget/counter'
|
| '/widget/counter'
|
||||||
| '/widget/realtime'
|
| '/widget/realtime'
|
||||||
| '/widget/test'
|
| '/widget/test'
|
||||||
@@ -882,7 +819,6 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/sessions'
|
| '/$organizationId/$projectId/sessions'
|
||||||
| '/$organizationId/integrations'
|
| '/$organizationId/integrations'
|
||||||
| '/$organizationId/members'
|
| '/$organizationId/members'
|
||||||
| '/$organizationId/profile'
|
|
||||||
| '/onboarding/$projectId/connect'
|
| '/onboarding/$projectId/connect'
|
||||||
| '/onboarding/$projectId/verify'
|
| '/onboarding/$projectId/verify'
|
||||||
| '/$organizationId/$projectId'
|
| '/$organizationId/$projectId'
|
||||||
@@ -897,7 +833,6 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/integrations/installed'
|
| '/$organizationId/integrations/installed'
|
||||||
| '/$organizationId/members/invitations'
|
| '/$organizationId/members/invitations'
|
||||||
| '/$organizationId/members/members'
|
| '/$organizationId/members/members'
|
||||||
| '/$organizationId/profile/email-preferences'
|
|
||||||
| '/$organizationId/$projectId/events/conversions'
|
| '/$organizationId/$projectId/events/conversions'
|
||||||
| '/$organizationId/$projectId/events/events'
|
| '/$organizationId/$projectId/events/events'
|
||||||
| '/$organizationId/$projectId/events/stats'
|
| '/$organizationId/$projectId/events/stats'
|
||||||
@@ -920,14 +855,12 @@ export interface FileRouteTypes {
|
|||||||
| '/_login'
|
| '/_login'
|
||||||
| '/_public'
|
| '/_public'
|
||||||
| '/_steps'
|
| '/_steps'
|
||||||
| '/unsubscribe'
|
|
||||||
| '/_app/$organizationId'
|
| '/_app/$organizationId'
|
||||||
| '/_login/login'
|
| '/_login/login'
|
||||||
| '/_login/reset-password'
|
| '/_login/reset-password'
|
||||||
| '/_public/onboarding'
|
| '/_public/onboarding'
|
||||||
| '/api/config'
|
| '/api/config'
|
||||||
| '/api/healthcheck'
|
| '/api/healthcheck'
|
||||||
| '/widget/badge'
|
|
||||||
| '/widget/counter'
|
| '/widget/counter'
|
||||||
| '/widget/realtime'
|
| '/widget/realtime'
|
||||||
| '/widget/test'
|
| '/widget/test'
|
||||||
@@ -951,8 +884,6 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/integrations/_tabs'
|
| '/_app/$organizationId/integrations/_tabs'
|
||||||
| '/_app/$organizationId/members'
|
| '/_app/$organizationId/members'
|
||||||
| '/_app/$organizationId/members/_tabs'
|
| '/_app/$organizationId/members/_tabs'
|
||||||
| '/_app/$organizationId/profile'
|
|
||||||
| '/_app/$organizationId/profile/_tabs'
|
|
||||||
| '/_steps/onboarding/$projectId/connect'
|
| '/_steps/onboarding/$projectId/connect'
|
||||||
| '/_steps/onboarding/$projectId/verify'
|
| '/_steps/onboarding/$projectId/verify'
|
||||||
| '/_app/$organizationId/$projectId/'
|
| '/_app/$organizationId/$projectId/'
|
||||||
@@ -971,10 +902,8 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/integrations/_tabs/installed'
|
| '/_app/$organizationId/integrations/_tabs/installed'
|
||||||
| '/_app/$organizationId/members/_tabs/invitations'
|
| '/_app/$organizationId/members/_tabs/invitations'
|
||||||
| '/_app/$organizationId/members/_tabs/members'
|
| '/_app/$organizationId/members/_tabs/members'
|
||||||
| '/_app/$organizationId/profile/_tabs/email-preferences'
|
|
||||||
| '/_app/$organizationId/integrations/_tabs/'
|
| '/_app/$organizationId/integrations/_tabs/'
|
||||||
| '/_app/$organizationId/members/_tabs/'
|
| '/_app/$organizationId/members/_tabs/'
|
||||||
| '/_app/$organizationId/profile/_tabs/'
|
|
||||||
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
|
| '/_app/$organizationId/$projectId/events/_tabs/conversions'
|
||||||
| '/_app/$organizationId/$projectId/events/_tabs/events'
|
| '/_app/$organizationId/$projectId/events/_tabs/events'
|
||||||
| '/_app/$organizationId/$projectId/events/_tabs/stats'
|
| '/_app/$organizationId/$projectId/events/_tabs/stats'
|
||||||
@@ -1004,10 +933,8 @@ export interface RootRouteChildren {
|
|||||||
LoginRoute: typeof LoginRouteWithChildren
|
LoginRoute: typeof LoginRouteWithChildren
|
||||||
PublicRoute: typeof PublicRouteWithChildren
|
PublicRoute: typeof PublicRouteWithChildren
|
||||||
StepsRoute: typeof StepsRouteWithChildren
|
StepsRoute: typeof StepsRouteWithChildren
|
||||||
UnsubscribeRoute: typeof UnsubscribeRoute
|
|
||||||
ApiConfigRoute: typeof ApiConfigRoute
|
ApiConfigRoute: typeof ApiConfigRoute
|
||||||
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
||||||
WidgetBadgeRoute: typeof WidgetBadgeRoute
|
|
||||||
WidgetCounterRoute: typeof WidgetCounterRoute
|
WidgetCounterRoute: typeof WidgetCounterRoute
|
||||||
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
|
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
|
||||||
WidgetTestRoute: typeof WidgetTestRoute
|
WidgetTestRoute: typeof WidgetTestRoute
|
||||||
@@ -1018,13 +945,6 @@ export interface RootRouteChildren {
|
|||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/unsubscribe': {
|
|
||||||
id: '/unsubscribe'
|
|
||||||
path: '/unsubscribe'
|
|
||||||
fullPath: '/unsubscribe'
|
|
||||||
preLoaderRoute: typeof UnsubscribeRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/_steps': {
|
'/_steps': {
|
||||||
id: '/_steps'
|
id: '/_steps'
|
||||||
path: ''
|
path: ''
|
||||||
@@ -1081,13 +1001,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof WidgetCounterRouteImport
|
preLoaderRoute: typeof WidgetCounterRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/widget/badge': {
|
|
||||||
id: '/widget/badge'
|
|
||||||
path: '/widget/badge'
|
|
||||||
fullPath: '/widget/badge'
|
|
||||||
preLoaderRoute: typeof WidgetBadgeRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/api/healthcheck': {
|
'/api/healthcheck': {
|
||||||
id: '/api/healthcheck'
|
id: '/api/healthcheck'
|
||||||
path: '/api/healthcheck'
|
path: '/api/healthcheck'
|
||||||
@@ -1130,13 +1043,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdRouteImport
|
preLoaderRoute: typeof AppOrganizationIdRouteImport
|
||||||
parentRoute: typeof AppRoute
|
parentRoute: typeof AppRoute
|
||||||
}
|
}
|
||||||
'/_app/$organizationId/profile': {
|
|
||||||
id: '/_app/$organizationId/profile'
|
|
||||||
path: '/profile'
|
|
||||||
fullPath: '/$organizationId/profile'
|
|
||||||
preLoaderRoute: typeof AppOrganizationIdProfileRouteImport
|
|
||||||
parentRoute: typeof AppOrganizationIdRoute
|
|
||||||
}
|
|
||||||
'/_app/$organizationId/members': {
|
'/_app/$organizationId/members': {
|
||||||
id: '/_app/$organizationId/members'
|
id: '/_app/$organizationId/members'
|
||||||
path: '/members'
|
path: '/members'
|
||||||
@@ -1256,13 +1162,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof StepsOnboardingProjectIdConnectRouteImport
|
preLoaderRoute: typeof StepsOnboardingProjectIdConnectRouteImport
|
||||||
parentRoute: typeof StepsRoute
|
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': {
|
'/_app/$organizationId/members/_tabs': {
|
||||||
id: '/_app/$organizationId/members/_tabs'
|
id: '/_app/$organizationId/members/_tabs'
|
||||||
path: '/members'
|
path: '/members'
|
||||||
@@ -1340,13 +1239,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdProfilesProfileIdRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdProfilesRoute
|
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/': {
|
'/_app/$organizationId/members/_tabs/': {
|
||||||
id: '/_app/$organizationId/members/_tabs/'
|
id: '/_app/$organizationId/members/_tabs/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -1361,13 +1253,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsIndexRouteImport
|
preLoaderRoute: typeof AppOrganizationIdIntegrationsTabsIndexRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdIntegrationsTabsRoute
|
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': {
|
'/_app/$organizationId/members/_tabs/members': {
|
||||||
id: '/_app/$organizationId/members/_tabs/members'
|
id: '/_app/$organizationId/members/_tabs/members'
|
||||||
path: '/members'
|
path: '/members'
|
||||||
@@ -1912,39 +1797,6 @@ const AppOrganizationIdMembersRouteWithChildren =
|
|||||||
AppOrganizationIdMembersRouteChildren,
|
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 {
|
interface AppOrganizationIdRouteChildren {
|
||||||
AppOrganizationIdProjectIdRoute: typeof AppOrganizationIdProjectIdRouteWithChildren
|
AppOrganizationIdProjectIdRoute: typeof AppOrganizationIdProjectIdRouteWithChildren
|
||||||
AppOrganizationIdBillingRoute: typeof AppOrganizationIdBillingRoute
|
AppOrganizationIdBillingRoute: typeof AppOrganizationIdBillingRoute
|
||||||
@@ -1952,7 +1804,6 @@ interface AppOrganizationIdRouteChildren {
|
|||||||
AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute
|
AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute
|
||||||
AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren
|
AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren
|
||||||
AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren
|
AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren
|
||||||
AppOrganizationIdProfileRoute: typeof AppOrganizationIdProfileRouteWithChildren
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
|
const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
|
||||||
@@ -1963,7 +1814,6 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
|
|||||||
AppOrganizationIdIntegrationsRoute:
|
AppOrganizationIdIntegrationsRoute:
|
||||||
AppOrganizationIdIntegrationsRouteWithChildren,
|
AppOrganizationIdIntegrationsRouteWithChildren,
|
||||||
AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren,
|
AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren,
|
||||||
AppOrganizationIdProfileRoute: AppOrganizationIdProfileRouteWithChildren,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppOrganizationIdRouteWithChildren =
|
const AppOrganizationIdRouteWithChildren =
|
||||||
@@ -2022,10 +1872,8 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
LoginRoute: LoginRouteWithChildren,
|
LoginRoute: LoginRouteWithChildren,
|
||||||
PublicRoute: PublicRouteWithChildren,
|
PublicRoute: PublicRouteWithChildren,
|
||||||
StepsRoute: StepsRouteWithChildren,
|
StepsRoute: StepsRouteWithChildren,
|
||||||
UnsubscribeRoute: UnsubscribeRoute,
|
|
||||||
ApiConfigRoute: ApiConfigRoute,
|
ApiConfigRoute: ApiConfigRoute,
|
||||||
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
||||||
WidgetBadgeRoute: WidgetBadgeRoute,
|
|
||||||
WidgetCounterRoute: WidgetCounterRoute,
|
WidgetCounterRoute: WidgetCounterRoute,
|
||||||
WidgetRealtimeRoute: WidgetRealtimeRoute,
|
WidgetRealtimeRoute: WidgetRealtimeRoute,
|
||||||
WidgetTestRoute: WidgetTestRoute,
|
WidgetTestRoute: WidgetTestRoute,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ function Component() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{realtimeWidget && (
|
||||||
<RealtimeWidgetSection
|
<RealtimeWidgetSection
|
||||||
widget={realtimeWidget as any}
|
widget={realtimeWidget as any}
|
||||||
dashboardUrl={dashboardUrl}
|
dashboardUrl={dashboardUrl}
|
||||||
@@ -100,18 +101,16 @@ function Component() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{counterWidget && (
|
||||||
<CounterWidgetSection
|
<CounterWidgetSection
|
||||||
widget={counterWidget as any}
|
widget={counterWidget}
|
||||||
dashboardUrl={dashboardUrl}
|
dashboardUrl={dashboardUrl}
|
||||||
isToggling={toggleMutation.isPending}
|
isToggling={toggleMutation.isPending}
|
||||||
onToggle={(enabled) => handleToggle('counter', enabled)}
|
onToggle={(enabled) => handleToggle('counter', enabled)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<BadgeWidgetSection
|
|
||||||
widget={counterWidget as any}
|
|
||||||
dashboardUrl={dashboardUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -153,15 +152,11 @@ function RealtimeWidgetSection({
|
|||||||
countries: true,
|
countries: true,
|
||||||
paths: false,
|
paths: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [options, setOptions] = useState<IRealtimeWidgetOptions>(
|
const [options, setOptions] = useState<IRealtimeWidgetOptions>(
|
||||||
(widget?.options as IRealtimeWidgetOptions) || defaultOptions,
|
(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
|
// Update local options when widget data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (widget?.options) {
|
if (widget?.options) {
|
||||||
@@ -259,8 +254,7 @@ function RealtimeWidgetSection({
|
|||||||
<h3 className="text-sm font-medium">Preview</h3>
|
<h3 className="text-sm font-medium">Preview</h3>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<iframe
|
<iframe
|
||||||
key={widgetChecksum}
|
src={widgetUrl!}
|
||||||
src={`${widgetUrl}&checksum=${widgetChecksum}`}
|
|
||||||
width="100%"
|
width="100%"
|
||||||
height="600"
|
height="600"
|
||||||
className="border-0"
|
className="border-0"
|
||||||
@@ -374,96 +368,3 @@ function CounterWidgetSection({
|
|||||||
</Widget>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
|
|||||||
import { LoginNavbar } from '@/components/login-navbar';
|
import { LoginNavbar } from '@/components/login-navbar';
|
||||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||||
import { OverviewRange } from '@/components/overview/overview-range';
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
|
||||||
import { ReportChart } from '@/components/report-chart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
@@ -64,7 +63,6 @@ function RouteComponent() {
|
|||||||
const { shareId } = Route.useParams();
|
const { shareId } = Route.useParams();
|
||||||
const { header } = useSearch({ from: '/share/report/$shareId' });
|
const { header } = useSearch({ from: '/share/report/$shareId' });
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
|
||||||
const shareQuery = useSuspenseQuery(
|
const shareQuery = useSuspenseQuery(
|
||||||
trpc.share.report.queryOptions({
|
trpc.share.report.queryOptions({
|
||||||
shareId,
|
shareId,
|
||||||
@@ -83,6 +81,8 @@ function RouteComponent() {
|
|||||||
|
|
||||||
const share = shareQuery.data;
|
const share = shareQuery.data;
|
||||||
|
|
||||||
|
console.log('share', share);
|
||||||
|
|
||||||
// Handle password protection
|
// Handle password protection
|
||||||
if (share.password && !hasAccess) {
|
if (share.password && !hasAccess) {
|
||||||
return <ShareEnterPassword shareId={share.id} shareType="report" />;
|
return <ShareEnterPassword shareId={share.id} shareType="report" />;
|
||||||
@@ -114,16 +114,7 @@ function RouteComponent() {
|
|||||||
<div className="font-medium text-xl">{share.report.name}</div>
|
<div className="font-medium text-xl">{share.report.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<ReportChart
|
<ReportChart report={share.report} shareId={shareId} />
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -119,7 +119,7 @@ function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full flex-col bg-background text-foreground">
|
<div className="flex h-screen w-full flex-col bg-background text-foreground">
|
||||||
{/* Header with live counter */}
|
{/* 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 justify-between w-full h-4">
|
||||||
<div className="flex items-center gap-3 w-full">
|
<div className="flex items-center gap-3 w-full">
|
||||||
<Ping />
|
<Ping />
|
||||||
@@ -171,10 +171,10 @@ function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(data.countries.length > 0 ||
|
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar">
|
||||||
data.referrers.length > 0 ||
|
{/* Histogram */}
|
||||||
data.paths.length > 0) && (
|
{/* Countries and Referrers */}
|
||||||
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar border-t">
|
{(data.countries.length > 0 || data.referrers.length > 0) && (
|
||||||
<div className={cn('grid grid-cols-1 gap-6', grids)}>
|
<div className={cn('grid grid-cols-1 gap-6', grids)}>
|
||||||
{/* Countries */}
|
{/* Countries */}
|
||||||
{data.countries.length > 0 && (
|
{data.countries.length > 0 && (
|
||||||
@@ -296,9 +296,9 @@ function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,9 @@ COPY packages/queue/package.json ./packages/queue/
|
|||||||
COPY packages/logger/package.json ./packages/logger/
|
COPY packages/logger/package.json ./packages/logger/
|
||||||
COPY packages/common/package.json ./packages/common/
|
COPY packages/common/package.json ./packages/common/
|
||||||
COPY packages/importer/package.json ./packages/importer/
|
COPY packages/importer/package.json ./packages/importer/
|
||||||
COPY packages/payments/package.json ./packages/payments/
|
|
||||||
COPY packages/constants/package.json ./packages/constants/
|
COPY packages/constants/package.json ./packages/constants/
|
||||||
COPY packages/validation/package.json ./packages/validation/
|
COPY packages/validation/package.json ./packages/validation/
|
||||||
COPY packages/integrations/package.json ./packages/integrations/
|
COPY packages/integrations/package.json packages/integrations/
|
||||||
COPY packages/js-runtime/package.json ./packages/js-runtime/
|
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
|
|
||||||
# BUILD
|
# 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/logger ./packages/logger
|
||||||
COPY --from=build /app/packages/common ./packages/common
|
COPY --from=build /app/packages/common ./packages/common
|
||||||
COPY --from=build /app/packages/importer ./packages/importer
|
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/validation ./packages/validation
|
||||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
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
|
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||||
RUN pnpm db:codegen
|
RUN pnpm db:codegen
|
||||||
|
|
||||||
|
|||||||
@@ -17,15 +17,12 @@
|
|||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/email": "workspace:*",
|
"@openpanel/email": "workspace:*",
|
||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
"@openpanel/js-runtime": "workspace:*",
|
|
||||||
"@openpanel/json": "workspace:*",
|
"@openpanel/json": "workspace:*",
|
||||||
"@openpanel/logger": "workspace:*",
|
"@openpanel/logger": "workspace:*",
|
||||||
"@openpanel/importer": "workspace:*",
|
"@openpanel/importer": "workspace:*",
|
||||||
"@openpanel/payments": "workspace:*",
|
|
||||||
"@openpanel/queue": "workspace:*",
|
"@openpanel/queue": "workspace:*",
|
||||||
"@openpanel/redis": "workspace:*",
|
"@openpanel/redis": "workspace:*",
|
||||||
"bullmq": "^5.63.0",
|
"bullmq": "^5.63.0",
|
||||||
"date-fns": "^3.3.1",
|
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"groupmq": "catalog:",
|
"groupmq": "catalog:",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
|
|||||||
@@ -39,11 +39,6 @@ export async function bootCron() {
|
|||||||
type: 'insightsDaily',
|
type: 'insightsDaily',
|
||||||
pattern: '0 2 * * *',
|
pattern: '0 2 * * *',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'onboarding',
|
|
||||||
type: 'onboarding',
|
|
||||||
pattern: '0 * * * *',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
|
if (process.env.SELF_HOSTED && process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
@@ -281,20 +281,10 @@ export async function bootWorkers() {
|
|||||||
eventName: string,
|
eventName: string,
|
||||||
evtOrExitCodeOrError: number | string | Error,
|
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', {
|
logger.info('Starting graceful shutdown', {
|
||||||
code: evtOrExitCodeOrError,
|
code: evtOrExitCodeOrError,
|
||||||
eventName,
|
eventName,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const time = performance.now();
|
const time = performance.now();
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ async function start() {
|
|||||||
const PORT = Number.parseInt(process.env.WORKER_PORT || '3000', 10);
|
const PORT = Number.parseInt(process.env.WORKER_PORT || '3000', 10);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
if (
|
if (process.env.DISABLE_BULLBOARD === undefined) {
|
||||||
process.env.DISABLE_BULLBOARD !== '1' &&
|
|
||||||
process.env.DISABLE_BULLBOARD !== 'true'
|
|
||||||
) {
|
|
||||||
const serverAdapter = new ExpressAdapter();
|
const serverAdapter = new ExpressAdapter();
|
||||||
serverAdapter.setBasePath('/');
|
serverAdapter.setBasePath('/');
|
||||||
createBullBoard({
|
createBullBoard({
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import { eventBuffer, profileBuffer, sessionBuffer } from '@openpanel/db';
|
|||||||
import type { CronQueuePayload } from '@openpanel/queue';
|
import type { CronQueuePayload } from '@openpanel/queue';
|
||||||
|
|
||||||
import { jobdeleteProjects } from './cron.delete-projects';
|
import { jobdeleteProjects } from './cron.delete-projects';
|
||||||
import { onboardingJob } from './cron.onboarding';
|
|
||||||
import { ping } from './cron.ping';
|
import { ping } from './cron.ping';
|
||||||
import { salt } from './cron.salt';
|
import { salt } from './cron.salt';
|
||||||
import { insightsDailyJob } from './insights';
|
import { insightsDailyJob } from './insights';
|
||||||
@@ -32,8 +31,5 @@ export async function cronJob(job: Job<CronQueuePayload>) {
|
|||||||
case 'insightsDaily': {
|
case 'insightsDaily': {
|
||||||
return await insightsDailyJob(job);
|
return await insightsDailyJob(job);
|
||||||
}
|
}
|
||||||
case 'onboarding': {
|
|
||||||
return await onboardingJob(job);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { logger as baseLogger } from '@/utils/logger';
|
import { logger as baseLogger } from '@/utils/logger';
|
||||||
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
|
import {
|
||||||
import { getTime, isSameDomain, parsePath } from '@openpanel/common';
|
createSessionEndJob,
|
||||||
|
createSessionStart,
|
||||||
|
getSessionEnd,
|
||||||
|
} from '@/utils/session-handler';
|
||||||
|
import { isSameDomain, parsePath } from '@openpanel/common';
|
||||||
import {
|
import {
|
||||||
getReferrerWithQuery,
|
getReferrerWithQuery,
|
||||||
parseReferrer,
|
parseReferrer,
|
||||||
@@ -189,14 +193,7 @@ export async function incomingEvent(
|
|||||||
|
|
||||||
if (!sessionEnd) {
|
if (!sessionEnd) {
|
||||||
logger.info('Creating session start event', { event: payload });
|
logger.info('Creating session start event', { event: payload });
|
||||||
await createEventAndNotify(
|
await createSessionStart({ payload }).catch((error) => {
|
||||||
{
|
|
||||||
...payload,
|
|
||||||
name: 'session_start',
|
|
||||||
createdAt: new Date(getTime(payload.createdAt) - 100),
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
).catch((error) => {
|
|
||||||
logger.error('Error creating session start event', { event: payload });
|
logger.error('Error creating session start event', { event: payload });
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
|
|
||||||
import { Prisma, db } from '@openpanel/db';
|
import { db } from '@openpanel/db';
|
||||||
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
|
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
|
||||||
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
|
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 type { NotificationQueuePayload } from '@openpanel/queue';
|
||||||
import { publishEvent } from '@openpanel/redis';
|
import { getRedisPub, 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
||||||
switch (job.data.type) {
|
switch (job.data.type) {
|
||||||
@@ -25,10 +14,12 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
|||||||
|
|
||||||
if (notification.sendToApp) {
|
if (notification.sendToApp) {
|
||||||
publishEvent('notification', 'created', notification);
|
publishEvent('notification', 'created', notification);
|
||||||
|
// empty for now
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.sendToEmail) {
|
if (notification.sendToEmail) {
|
||||||
|
// empty for now
|
||||||
return;
|
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) {
|
switch (integration.config.type) {
|
||||||
case 'webhook': {
|
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, {
|
return fetch(integration.config.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...(integration.config.headers ?? {}),
|
...(integration.config.headers ?? {}),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify({
|
||||||
|
title: notification.title,
|
||||||
|
message: notification.message,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'discord': {
|
case 'discord': {
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ export const SESSION_TIMEOUT = 1000 * 60 * 30;
|
|||||||
const getSessionEndJobId = (projectId: string, deviceId: string) =>
|
const getSessionEndJobId = (projectId: string, deviceId: string) =>
|
||||||
`sessionEnd:${projectId}:${deviceId}`;
|
`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({
|
export async function createSessionEndJob({
|
||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ services:
|
|||||||
- POSTGRES_USER=postgres
|
- POSTGRES_USER=postgres
|
||||||
- POSTGRES_PASSWORD=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:
|
op-kv:
|
||||||
image: redis:7.2.5-alpine
|
image: redis:7.2.5-alpine
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -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';
|
import { parseCookieDomain } from './parse-cookie-domain';
|
||||||
|
|
||||||
describe('parseCookieDomain', () => {
|
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,34 +12,7 @@ const MULTI_PART_TLDS = [
|
|||||||
/go\.\w{2}$/,
|
/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) => {
|
export const parseCookieDomain = (url: string) => {
|
||||||
if (process.env.CUSTOM_COOKIE_DOMAIN) {
|
|
||||||
return {
|
|
||||||
domain: process.env.CUSTOM_COOKIE_DOMAIN,
|
|
||||||
secure: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return {
|
return {
|
||||||
domain: undefined,
|
domain: undefined,
|
||||||
@@ -63,7 +36,7 @@ export const parseCookieDomain = (url: string) => {
|
|||||||
// Handle multi-part TLDs like co.uk, com.au, etc.
|
// Handle multi-part TLDs like co.uk, com.au, etc.
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
const potentialTLD = parts.slice(-2).join('.');
|
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
|
// For domains like example.co.uk or subdomain.example.co.uk
|
||||||
// Use the last 3 parts: .example.co.uk
|
// Use the last 3 parts: .example.co.uk
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { differenceInDays, isSameDay, isSameMonth } from 'date-fns';
|
|||||||
export const DEFAULT_ASPECT_RATIO = 0.5625;
|
export const DEFAULT_ASPECT_RATIO = 0.5625;
|
||||||
export const NOT_SET_VALUE = '(not set)';
|
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 = {
|
export const timeWindows = {
|
||||||
'30min': {
|
'30min': {
|
||||||
@@ -505,12 +508,6 @@ export function getCountry(code?: string) {
|
|||||||
return countries[code as keyof typeof countries];
|
return countries[code as keyof typeof countries];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const emailCategories = {
|
|
||||||
onboarding: 'Onboarding',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type EmailCategory = keyof typeof emailCategories;
|
|
||||||
|
|
||||||
export const chartColors = [
|
export const chartColors = [
|
||||||
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
|
{ main: '#2563EB', translucent: 'rgba(37, 99, 235, 0.1)' },
|
||||||
{ main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' },
|
{ main: '#ff7557', translucent: 'rgba(255, 117, 87, 0.1)' },
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "public"."organizations"
|
|
||||||
ADD COLUMN "onboarding" TEXT NOT NULL DEFAULT 'completed';
|
|
||||||
@@ -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");
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "public"."organizations" ALTER COLUMN "onboarding" DROP NOT NULL;
|
|
||||||
@@ -62,7 +62,6 @@ model Organization {
|
|||||||
integrations Integration[]
|
integrations Integration[]
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
timezone String?
|
timezone String?
|
||||||
onboarding String? @default("completed")
|
|
||||||
|
|
||||||
// Subscription
|
// Subscription
|
||||||
subscriptionId String?
|
subscriptionId String?
|
||||||
@@ -611,13 +610,3 @@ model InsightEvent {
|
|||||||
@@index([insightId, createdAt])
|
@@index([insightId, createdAt])
|
||||||
@@map("insight_events")
|
@@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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -109,9 +109,7 @@ export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({
|
|||||||
interval: zTimeInterval,
|
interval: zTimeInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type IGetTopGenericSeriesInput = z.infer<
|
export type IGetTopGenericSeriesInput = z.infer<typeof zGetTopGenericSeriesInput> & {
|
||||||
typeof zGetTopGenericSeriesInput
|
|
||||||
> & {
|
|
||||||
timezone: string;
|
timezone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -736,7 +734,7 @@ export class OverviewService {
|
|||||||
}>;
|
}>;
|
||||||
}> {
|
}> {
|
||||||
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
|
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
|
||||||
const TOP_LIMIT = 500;
|
const TOP_LIMIT = 15;
|
||||||
const fillConfig = this.getFillConfig(interval, startDate, endDate);
|
const fillConfig = this.getFillConfig(interval, startDate, endDate);
|
||||||
|
|
||||||
// Step 1: Get top 15 items
|
// Step 1: Get top 15 items
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openpanel/db": "workspace:*",
|
|
||||||
"@react-email/components": "^0.5.6",
|
"@react-email/components": "^0.5.6",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ import React from 'react';
|
|||||||
|
|
||||||
const baseUrl = 'https://openpanel.dev';
|
const baseUrl = 'https://openpanel.dev';
|
||||||
|
|
||||||
export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Hr />
|
<Hr />
|
||||||
@@ -71,17 +71,15 @@ export function Footer({ unsubscribeUrl }: { unsubscribeUrl?: string }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{unsubscribeUrl && (
|
{/* <Row>
|
||||||
<Row>
|
|
||||||
<Link
|
<Link
|
||||||
className="text-[#707070] text-[14px]"
|
className="text-[#707070] text-[14px]"
|
||||||
href={unsubscribeUrl}
|
href="https://dashboard.openpanel.dev/settings/notifications"
|
||||||
title="Unsubscribe"
|
title="Unsubscribe"
|
||||||
>
|
>
|
||||||
Notification preferences
|
Notification preferences
|
||||||
</Link>
|
</Link>
|
||||||
</Row>
|
</Row> */}
|
||||||
)}
|
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ import {
|
|||||||
Section,
|
Section,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
// biome-ignore lint/style/useImportType: <explanation>
|
// biome-ignore lint/style/useImportType: resend needs React
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Footer } from './footer';
|
import { Footer } from './footer';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
unsubscribeUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Layout({ children, unsubscribeUrl }: Props) {
|
export function Layout({ children }: Props) {
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Tailwind>
|
<Tailwind>
|
||||||
@@ -58,7 +57,7 @@ export function Layout({ children, unsubscribeUrl }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section className="p-6">{children}</Section>
|
<Section className="p-6">{children}</Section>
|
||||||
<Footer unsubscribeUrl={unsubscribeUrl} />
|
<Footer />
|
||||||
</Container>
|
</Container>
|
||||||
</Body>
|
</Body>
|
||||||
</Tailwind>
|
</Tailwind>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,10 +13,9 @@ export default EmailInvite;
|
|||||||
export function EmailInvite({
|
export function EmailInvite({
|
||||||
organizationName = 'Acme Co',
|
organizationName = 'Acme Co',
|
||||||
url = 'https://openpanel.dev',
|
url = 'https://openpanel.dev',
|
||||||
unsubscribeUrl,
|
}: Props) {
|
||||||
}: Props & { unsubscribeUrl?: string }) {
|
|
||||||
return (
|
return (
|
||||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
<Layout>
|
||||||
<Text>You've been invited to join {organizationName}!</Text>
|
<Text>You've been invited to join {organizationName}!</Text>
|
||||||
<Text>
|
<Text>
|
||||||
If you don't have an account yet, click the button below to create one
|
If you don't have an account yet, click the button below to create one
|
||||||
|
|||||||
@@ -9,12 +9,9 @@ export const zEmailResetPassword = z.object({
|
|||||||
|
|
||||||
export type Props = z.infer<typeof zEmailResetPassword>;
|
export type Props = z.infer<typeof zEmailResetPassword>;
|
||||||
export default EmailResetPassword;
|
export default EmailResetPassword;
|
||||||
export function EmailResetPassword({
|
export function EmailResetPassword({ url = 'https://openpanel.dev' }: Props) {
|
||||||
url = 'https://openpanel.dev',
|
|
||||||
unsubscribeUrl,
|
|
||||||
}: Props & { unsubscribeUrl?: string }) {
|
|
||||||
return (
|
return (
|
||||||
<Layout unsubscribeUrl={unsubscribeUrl}>
|
<Layout>
|
||||||
<Text>
|
<Text>
|
||||||
You have requested to reset your password. Follow the link below to
|
You have requested to reset your password. Follow the link below to
|
||||||
reset your password:
|
reset your password:
|
||||||
|
|||||||
@@ -3,22 +3,6 @@ import { EmailInvite, zEmailInvite } from './email-invite';
|
|||||||
import EmailResetPassword, {
|
import EmailResetPassword, {
|
||||||
zEmailResetPassword,
|
zEmailResetPassword,
|
||||||
} from './email-reset-password';
|
} 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';
|
import TrailEndingSoon, { zTrailEndingSoon } from './trial-ending-soon';
|
||||||
|
|
||||||
export const templates = {
|
export const templates = {
|
||||||
@@ -40,40 +24,6 @@ export const templates = {
|
|||||||
Component: TrailEndingSoon,
|
Component: TrailEndingSoon,
|
||||||
schema: zTrailEndingSoon,
|
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;
|
} as const;
|
||||||
|
|
||||||
export type Templates = typeof templates;
|
export type Templates = typeof templates;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user