Compare commits
156 Commits
feature/if
...
feature/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcddc6f284 | ||
|
|
286f8e160b | ||
|
|
f8f470adf9 | ||
|
|
e7c2834ea0 | ||
|
|
753d6dce4c | ||
|
|
9e5b482447 | ||
|
|
32ea28b2f6 | ||
|
|
b39d076b32 | ||
|
|
ec5937e55c | ||
|
|
f83fe7a0fc | ||
|
|
6c56efdf37 | ||
|
|
e5be28a49d | ||
|
|
e645c094b2 | ||
|
|
67301d928c | ||
|
|
deb3c3d20c | ||
|
|
6e997e62f1 | ||
|
|
2c5ca8adec | ||
|
|
3e573ae27f | ||
|
|
5b29f7502c | ||
|
|
d32a279949 | ||
|
|
ed6e5cd334 | ||
|
|
cf1bf95388 | ||
|
|
5830277ba9 | ||
|
|
aa13c87e87 | ||
|
|
83c3647f66 | ||
|
|
927613c09d | ||
|
|
24ee6b0b6c | ||
|
|
13d8b92cf3 | ||
|
|
4b2db351c4 | ||
|
|
334adec9f2 | ||
|
|
9a54daae55 | ||
|
|
7cd5f84c58 | ||
|
|
470ddbe8e7 | ||
|
|
c63578b35b | ||
|
|
b5792df69f | ||
|
|
00f2e2937d | ||
|
|
0d1773eb74 | ||
|
|
ed1c57dbb8 | ||
|
|
39251c8598 | ||
|
|
9a4aa51975 | ||
|
|
f008fb58e5 | ||
|
|
cabfb1f3f0 | ||
|
|
4867260ece | ||
|
|
87c919f700 | ||
|
|
3c085e445d | ||
|
|
6d9e3ce8e5 | ||
|
|
f187065d75 | ||
|
|
d5e4518e32 | ||
|
|
1f088d2208 | ||
|
|
3bd1f99d28 | ||
|
|
9a916f3171 | ||
|
|
34cb186ead | ||
|
|
5f38560373 | ||
|
|
1e4f02fb5e | ||
|
|
3158ebfbda | ||
|
|
d7c6e88adc | ||
|
|
3b61b28290 | ||
|
|
8dfeaa870c | ||
|
|
329f76b7ce | ||
|
|
3b74d8ae36 | ||
|
|
a2a53cf9f7 | ||
|
|
4e7dc16619 | ||
|
|
0f9ac4508a | ||
|
|
c46cda12eb | ||
|
|
546ef6673f | ||
|
|
3b2ed3afb1 | ||
|
|
95846f80e5 | ||
|
|
1f5c648afe | ||
|
|
3d8a3e8997 | ||
|
|
28692d82ae | ||
|
|
4bdbb31180 | ||
|
|
be248717d2 | ||
|
|
56bd1197a6 | ||
|
|
9ccca322e5 | ||
|
|
86a3da869b | ||
|
|
ae383001bc | ||
|
|
9bedd39e48 | ||
|
|
7131e3f461 | ||
|
|
9665a2593f | ||
|
|
c201bea682 | ||
|
|
8312556b38 | ||
|
|
d2b22867b9 | ||
|
|
2b8bcf1ed7 | ||
|
|
e22a5b3237 | ||
|
|
315e4a59a3 | ||
|
|
969c0bc8fe | ||
|
|
4e42689115 | ||
|
|
d3522c51f8 | ||
|
|
abf5353ab3 | ||
|
|
2dda50fc7c | ||
|
|
cbdb3a62c1 | ||
|
|
4b775ff2c5 | ||
|
|
a37e37c28b | ||
|
|
64afd04f7b | ||
|
|
2468dc29ff | ||
|
|
662975dc08 | ||
|
|
0a1564c798 | ||
|
|
56b01ca6d8 | ||
|
|
e5b9865850 | ||
|
|
5576519a2a | ||
|
|
1f74ab99ae | ||
|
|
3ae7d1322e | ||
|
|
e4b919c4da | ||
|
|
50ef4c0d94 | ||
|
|
a7a357eb0f | ||
|
|
7adc2903d2 | ||
|
|
ac4429d6d9 | ||
|
|
e2536774b0 | ||
|
|
a39796a829 | ||
|
|
8b31e4cfba | ||
|
|
ae09748e4e | ||
|
|
4ef0b1afe2 | ||
|
|
620904b4d4 | ||
|
|
d4e3470f7e | ||
|
|
d154e12d66 | ||
|
|
b421474616 | ||
|
|
828c8c4f91 | ||
|
|
6da8267509 | ||
|
|
86903b1937 | ||
|
|
4f9d66693e | ||
|
|
32415d31d9 | ||
|
|
f7055c0ebd | ||
|
|
c9cf0274af | ||
|
|
44184236a8 | ||
|
|
71270b3493 | ||
|
|
00e25ed4b8 | ||
|
|
83e223a496 | ||
|
|
790801b728 | ||
|
|
d61cbf6f2c | ||
|
|
887ed09388 | ||
|
|
8ba714ce81 | ||
|
|
c8e3cf8552 | ||
|
|
18c056f3ea | ||
|
|
1562d49fd6 | ||
|
|
aa8765d627 | ||
|
|
56c74e13ff | ||
|
|
10726bf373 | ||
|
|
dcc0d0df18 | ||
|
|
da59622dce | ||
|
|
38cc53890a | ||
|
|
e4fac81d27 | ||
|
|
7719985ad1 | ||
|
|
c0cefe704b | ||
|
|
5dbb462578 | ||
|
|
2b0b62d64c | ||
|
|
87e98baeb3 | ||
|
|
2abb44831c | ||
|
|
f990cfcc18 | ||
|
|
8fbe944df0 | ||
|
|
c1801adaa2 | ||
|
|
84fd5ce22f | ||
|
|
447b7668fd | ||
|
|
e505c0ea45 | ||
|
|
e613a4e01c | ||
|
|
723ba3ef6c | ||
|
|
9cafd61b25 |
1
.github/workflows/docker-build.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
- "apps/api/**"
|
||||
- "apps/worker/**"
|
||||
- "apps/public/**"
|
||||
- "apps/start/**"
|
||||
- "packages/**"
|
||||
- "!packages/sdks/**"
|
||||
- "**Dockerfile"
|
||||
|
||||
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.secrets
|
||||
packages/db/src/generated/prisma
|
||||
packages/db/code-migrations/*.sql
|
||||
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
packages/sdk/profileId.txt
|
||||
|
||||
2
.vscode/settings.json
vendored
@@ -17,7 +17,7 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
<p align="center">
|
||||
<h1 align="center"><b>Openpanel</b></h1>
|
||||
@@ -98,6 +98,10 @@ You can find the how to [here](https://openpanel.dev/docs/self-hosting/self-host
|
||||
### Start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
echo "API_URL=http://localhost:3333" > apps/start/.env
|
||||
|
||||
pnpm dock:up
|
||||
pnpm codegen
|
||||
pnpm migrate:deploy # once to setup the db
|
||||
@@ -110,4 +114,4 @@ You can now access the following:
|
||||
- API: https://api.localhost:3333
|
||||
- Bullboard (queue): http://localhost:9999
|
||||
- `pnpm dock:ch` to access clickhouse terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
|
||||
161
admin/README.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# OpenPanel Admin CLI
|
||||
|
||||
An interactive CLI tool to help manage and lookup OpenPanel organizations, projects, and clients.
|
||||
|
||||
## Setup
|
||||
|
||||
First, install dependencies:
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the CLI from the admin directory:
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Or use the convenient shell script from anywhere:
|
||||
|
||||
```bash
|
||||
./admin/cli
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
The CLI provides 4 focused lookup commands for easier navigation:
|
||||
|
||||
### 🏢 Lookup by Organization
|
||||
|
||||
Search and view detailed information about an organization.
|
||||
|
||||
- Fuzzy search across all organizations by name or ID
|
||||
- Shows full organization details with all projects, clients, and members
|
||||
|
||||
### 📊 Lookup by Project
|
||||
|
||||
Search for a specific project and view its organization context.
|
||||
|
||||
- Fuzzy search across all projects by name or ID
|
||||
- Highlights the selected project in the organization view
|
||||
- Displays: `org → project`
|
||||
|
||||
### 🔑 Lookup by Client ID
|
||||
|
||||
Search for a specific client and view its full context.
|
||||
|
||||
- Fuzzy search across all clients by name or ID
|
||||
- Highlights the selected client and its project
|
||||
- Displays: `org → project → client`
|
||||
|
||||
### 📧 Lookup by Email
|
||||
|
||||
Search for a member by email address.
|
||||
|
||||
- Fuzzy search across all member emails
|
||||
- Shows which organization(s) the member belongs to
|
||||
- Displays member role (👑 owner, ⭐ admin, 👤 member)
|
||||
|
||||
**All lookups display:**
|
||||
- Organization information (ID, name, subscription status, timezone, event usage)
|
||||
- Organization members and their roles
|
||||
- All projects with their settings (domain, CORS, event counts)
|
||||
- All clients for each project (ID, name, type, credentials)
|
||||
- Deletion warnings if scheduled
|
||||
|
||||
---
|
||||
|
||||
### 🗑️ Clear Cache
|
||||
|
||||
Clear cache for an organization and all its projects.
|
||||
|
||||
- Fuzzy search to find the organization
|
||||
- Shows organization details and all projects
|
||||
- Confirms before clearing cache
|
||||
- Provides organization ID and all project IDs for cache clearing logic
|
||||
|
||||
**Use when:**
|
||||
- You need to invalidate cache after data changes
|
||||
- Troubleshooting caching issues
|
||||
- After manual database updates
|
||||
|
||||
**Note:** The cache clearing logic needs to be implemented. The command provides the organization and project data structure for you to add your cache clearing calls.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Delete Organization
|
||||
|
||||
Permanently delete an organization and all its data.
|
||||
|
||||
- Fuzzy search to find the organization
|
||||
- Shows detailed preview of what will be deleted (projects, members, events)
|
||||
- Requires **3 confirmations**:
|
||||
1. Initial confirmation
|
||||
2. Type organization name to confirm
|
||||
3. Final warning confirmation
|
||||
- Deletes from both PostgreSQL and ClickHouse
|
||||
|
||||
**Use when:**
|
||||
- Removing organizations that are no longer needed
|
||||
- Cleaning up test/demo organizations
|
||||
- Handling deletion requests
|
||||
|
||||
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
|
||||
|
||||
**What gets deleted:**
|
||||
- Organization record
|
||||
- All projects and their settings
|
||||
- All clients and credentials
|
||||
- All events and analytics data (from ClickHouse)
|
||||
- All member associations
|
||||
- All dashboards and reports
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Delete User
|
||||
|
||||
Permanently delete a user account and remove them from all organizations.
|
||||
|
||||
- Fuzzy search by email or name
|
||||
- Shows which organizations the user belongs to
|
||||
- Shows if user created any organizations (won't delete those orgs)
|
||||
- Requires **3 confirmations**:
|
||||
1. Initial confirmation
|
||||
2. Type user email to confirm
|
||||
3. Final warning confirmation
|
||||
|
||||
**Use when:**
|
||||
- Removing user accounts at user request
|
||||
- Cleaning up inactive accounts
|
||||
- Handling GDPR/data deletion requests
|
||||
|
||||
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
|
||||
|
||||
**What gets deleted:**
|
||||
- User account
|
||||
- All auth sessions and tokens
|
||||
- All memberships (removed from all orgs)
|
||||
- All personal data
|
||||
|
||||
**What is NOT deleted:**
|
||||
- Organizations created by the user (only the creator reference is removed)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Make sure you have the proper environment variables set up:
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `DATABASE_URL_REPLICA` (optional) - Read replica connection string
|
||||
|
||||
## Development
|
||||
|
||||
The CLI uses:
|
||||
- **jiti** - Direct TypeScript execution without build step
|
||||
- **inquirer** - Interactive prompts
|
||||
- **inquirer-autocomplete-prompt** - Fuzzy search functionality
|
||||
- **chalk** - Colored terminal output
|
||||
- **@openpanel/db** - Direct Prisma database access
|
||||
|
||||
25
admin/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@openpanel/admin",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "dotenv -e .env -c -- jiti src/cli.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/common": "workspace:*",
|
||||
"@openpanel/db": "workspace:*",
|
||||
"chalk": "^5.3.0",
|
||||
"fuzzy": "^0.1.3",
|
||||
"inquirer": "^9.3.5",
|
||||
"inquirer-autocomplete-prompt": "^3.0.1",
|
||||
"jiti": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/inquirer-autocomplete-prompt": "^3.0.3",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
118
admin/src/cli.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
import inquirer from 'inquirer';
|
||||
import { clearCache } from './commands/clear-cache';
|
||||
import { deleteOrganization } from './commands/delete-organization';
|
||||
import { deleteUser } from './commands/delete-user';
|
||||
import { lookupByClient } from './commands/lookup-client';
|
||||
import { lookupByEmail } from './commands/lookup-email';
|
||||
import { lookupByOrg } from './commands/lookup-org';
|
||||
import { lookupByProject } from './commands/lookup-project';
|
||||
|
||||
const secureEnv = (url: string) => {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.username && parsed.password) {
|
||||
return `${parsed.protocol}//${parsed.username}:${parsed.password.slice(0, 1)}...${parsed.password.slice(-1)}@${parsed.hostname}:${parsed.port}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log('\n🔧 OpenPanel Admin CLI\n');
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
||||
const REDIS_URL = process.env.REDIS_URL;
|
||||
|
||||
if (!DATABASE_URL || !CLICKHOUSE_URL || !REDIS_URL) {
|
||||
console.error('Environment variables are not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Log environment variables for debugging
|
||||
console.log('Environment:', {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
SELF_HOSTED: process.env.SELF_HOSTED ? 'Yes' : 'No',
|
||||
DATABASE_URL: secureEnv(DATABASE_URL),
|
||||
CLICKHOUSE_URL: secureEnv(CLICKHOUSE_URL),
|
||||
REDIS_URL: secureEnv(REDIS_URL),
|
||||
});
|
||||
console.log('');
|
||||
|
||||
const { command } = await inquirer.prompt([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'command',
|
||||
message: 'What would you like to do?',
|
||||
pageSize: 20,
|
||||
choices: [
|
||||
{
|
||||
name: '🏢 Lookup by Organization',
|
||||
value: 'lookup-org',
|
||||
},
|
||||
{
|
||||
name: '📊 Lookup by Project',
|
||||
value: 'lookup-project',
|
||||
},
|
||||
{
|
||||
name: '🔑 Lookup by Client ID',
|
||||
value: 'lookup-client',
|
||||
},
|
||||
{
|
||||
name: '📧 Lookup by Email',
|
||||
value: 'lookup-email',
|
||||
},
|
||||
{
|
||||
name: '🗑️ Clear Cache',
|
||||
value: 'clear-cache',
|
||||
},
|
||||
{ name: '─────────────────────', value: 'separator', disabled: true },
|
||||
{
|
||||
name: '🔴 Delete Organization',
|
||||
value: 'delete-org',
|
||||
},
|
||||
{
|
||||
name: '🔴 Delete User',
|
||||
value: 'delete-user',
|
||||
},
|
||||
{ name: '─────────────────────', value: 'separator', disabled: true },
|
||||
{ name: '❌ Exit', value: 'exit' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
switch (command) {
|
||||
case 'lookup-org':
|
||||
await lookupByOrg();
|
||||
break;
|
||||
case 'lookup-project':
|
||||
await lookupByProject();
|
||||
break;
|
||||
case 'lookup-client':
|
||||
await lookupByClient();
|
||||
break;
|
||||
case 'lookup-email':
|
||||
await lookupByEmail();
|
||||
break;
|
||||
case 'clear-cache':
|
||||
await clearCache();
|
||||
break;
|
||||
case 'delete-org':
|
||||
await deleteOrganization();
|
||||
break;
|
||||
case 'delete-user':
|
||||
await deleteUser();
|
||||
break;
|
||||
case 'exit':
|
||||
console.log('Goodbye! 👋');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Loop back to main menu
|
||||
await main();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
162
admin/src/commands/clear-cache.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
db,
|
||||
getOrganizationAccess,
|
||||
getOrganizationByProjectIdCached,
|
||||
getProjectAccess,
|
||||
getProjectByIdCached,
|
||||
} from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface OrgSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
console.log(chalk.blue('\n🗑️ Clear Cache\n'));
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations.length === 0) {
|
||||
console.log(chalk.red('No organizations found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedOrg } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedOrg',
|
||||
message: 'Search for an organization:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedOrg: OrgSearchItem };
|
||||
|
||||
// Fetch organization with all projects
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedOrg.id,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.yellow('\n📋 Organization Details:\n'));
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
|
||||
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
|
||||
|
||||
if (organization.projects.length > 0) {
|
||||
console.log(chalk.yellow('\n📊 Projects:\n'));
|
||||
for (const project of organization.projects) {
|
||||
console.log(
|
||||
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm before clearing cache
|
||||
const { confirm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `Clear cache for organization "${organization.name}" and all ${organization.projects.length} projects?`,
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirm) {
|
||||
console.log(chalk.yellow('\nCache clear cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.blue('\n🔄 Clearing cache...\n'));
|
||||
|
||||
for (const project of organization.projects) {
|
||||
// Clear project access cache for each member
|
||||
for (const member of organization.members) {
|
||||
if (!member.user?.id) continue;
|
||||
console.log(
|
||||
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`,
|
||||
);
|
||||
await getProjectAccess.clear({
|
||||
userId: member.user?.id,
|
||||
projectId: project.id,
|
||||
});
|
||||
await getOrganizationAccess.clear({
|
||||
userId: member.user?.id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Clearing cache for project: ${project.name}`);
|
||||
await getOrganizationByProjectIdCached.clear(project.id);
|
||||
await getProjectByIdCached.clear(project.id);
|
||||
}
|
||||
|
||||
console.log(chalk.gray(`Organization ID: ${organization.id}`));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Example of what you might do:
|
||||
/*
|
||||
for (const project of organization.projects) {
|
||||
console.log(`Clearing cache for project: ${project.name}...`);
|
||||
// await clearProjectCache(project.id);
|
||||
// await redis.del(`project:${project.id}:*`);
|
||||
}
|
||||
|
||||
// Clear organization-level cache
|
||||
// await clearOrganizationCache(organization.id);
|
||||
// await redis.del(`organization:${organization.id}:*`);
|
||||
|
||||
console.log(chalk.green('\n✅ Cache cleared successfully!'));
|
||||
*/
|
||||
}
|
||||
215
admin/src/commands/delete-organization.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
db,
|
||||
deleteFromClickhouse,
|
||||
deleteOrganization as deleteOrg,
|
||||
} from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface OrgSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function deleteOrganization() {
|
||||
console.log(chalk.red('\n🗑️ Delete Organization\n'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ WARNING: This will permanently delete the organization and all its data!\n',
|
||||
),
|
||||
);
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
include: {
|
||||
projects: true,
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations.length === 0) {
|
||||
console.log(chalk.red('No organizations found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedOrg } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedOrg',
|
||||
message: 'Search for an organization to delete:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedOrg: OrgSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedOrg.id,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Display what will be deleted
|
||||
console.log(chalk.red('\n⚠️ YOU ARE ABOUT TO DELETE:\n'));
|
||||
console.log(` ${chalk.bold('Organization:')} ${organization.name}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
|
||||
console.log(` ${chalk.gray('Members:')} ${organization.members.length}`);
|
||||
|
||||
if (organization.projects.length > 0) {
|
||||
console.log(chalk.red('\n Projects that will be deleted:'));
|
||||
for (const project of organization.projects) {
|
||||
console.log(
|
||||
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.members.length > 0) {
|
||||
console.log(chalk.red('\n Members who will lose access:'));
|
||||
for (const member of organization.members) {
|
||||
const email = member.user?.email || member.email || 'Unknown';
|
||||
console.log(` - ${email} ${chalk.gray(`(${member.role})`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red(
|
||||
'\n⚠️ This will delete ALL projects, clients, events, and data associated with this organization!',
|
||||
),
|
||||
);
|
||||
|
||||
// First confirmation
|
||||
const { confirmFirst } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFirst',
|
||||
message: chalk.red(
|
||||
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`,
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFirst) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Second confirmation - type organization name
|
||||
const { confirmName } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'confirmName',
|
||||
message: `Type the organization name "${organization.name}" to confirm deletion:`,
|
||||
},
|
||||
]);
|
||||
|
||||
if (confirmName !== organization.name) {
|
||||
console.log(
|
||||
chalk.red('\n❌ Organization name does not match. Deletion cancelled.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Final confirmation
|
||||
const { confirmFinal } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFinal',
|
||||
message: chalk.red(
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFinal) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.red('\n🗑️ Deleting organization...\n'));
|
||||
|
||||
try {
|
||||
const projectIds = organization.projects.map((p) => p.id);
|
||||
|
||||
// Step 1: Delete from ClickHouse (events, profiles, etc.)
|
||||
if (projectIds.length > 0) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`Deleting data from ClickHouse for ${projectIds.length} projects...`,
|
||||
),
|
||||
);
|
||||
await deleteFromClickhouse(projectIds);
|
||||
console.log(chalk.green('✓ ClickHouse data deletion initiated'));
|
||||
}
|
||||
|
||||
// Step 2: Delete the organization from PostgreSQL (cascade will handle related records)
|
||||
console.log(chalk.yellow('Deleting organization from database...'));
|
||||
await deleteOrg(organization.id);
|
||||
console.log(chalk.green('✓ Organization deleted from database'));
|
||||
|
||||
console.log(chalk.green('\n✅ Organization deleted successfully!'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error deleting organization:'), error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
220
admin/src/commands/delete-user.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface UserSearchItem {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function deleteUser() {
|
||||
console.log(chalk.red('\n🗑️ Delete User\n'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n',
|
||||
),
|
||||
);
|
||||
console.log('Loading users...\n');
|
||||
|
||||
const users = await db.user.findMany({
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
accounts: true,
|
||||
},
|
||||
orderBy: {
|
||||
email: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log(chalk.red('No users found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: UserSearchItem[] = users.map((user) => {
|
||||
const fullName =
|
||||
user.firstName || user.lastName
|
||||
? `${user.firstName || ''} ${user.lastName || ''}`.trim()
|
||||
: '';
|
||||
const orgCount = user.membership.length;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
displayText: `${user.email} ${fullName ? chalk.gray(`(${fullName})`) : ''} ${chalk.cyan(`- ${orgCount} orgs`)}`,
|
||||
};
|
||||
});
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: UserSearchItem) =>
|
||||
`${item.email} ${item.firstName || ''} ${item.lastName || ''}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<UserSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedUser } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedUser',
|
||||
message: 'Search for a user to delete:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedUser: UserSearchItem };
|
||||
|
||||
// Fetch full user details
|
||||
const user = await db.user.findUnique({
|
||||
where: {
|
||||
id: selectedUser.id,
|
||||
},
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
accounts: true,
|
||||
createdOrganizations: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log(chalk.red('User not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Display what will be deleted
|
||||
console.log(chalk.red('\n⚠️ YOU ARE ABOUT TO DELETE:\n'));
|
||||
console.log(` ${chalk.bold('User:')} ${user.email}`);
|
||||
if (user.firstName || user.lastName) {
|
||||
console.log(
|
||||
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`,
|
||||
);
|
||||
}
|
||||
console.log(` ${chalk.gray('ID:')} ${user.id}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`,
|
||||
);
|
||||
console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`);
|
||||
|
||||
if (user.createdOrganizations.length > 0) {
|
||||
console.log(
|
||||
chalk.red(
|
||||
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`,
|
||||
),
|
||||
);
|
||||
for (const org of user.createdOrganizations) {
|
||||
console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`);
|
||||
}
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Note: These organizations will NOT be deleted, only the user reference.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (user.membership.length > 0) {
|
||||
console.log(
|
||||
chalk.red('\n Organizations where user will be removed from:'),
|
||||
);
|
||||
for (const member of user.membership) {
|
||||
console.log(
|
||||
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red(
|
||||
'\n⚠️ This will delete the user account, all sessions, and remove them from all organizations!',
|
||||
),
|
||||
);
|
||||
|
||||
// First confirmation
|
||||
const { confirmFirst } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFirst',
|
||||
message: chalk.red(
|
||||
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`,
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFirst) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Second confirmation - type email
|
||||
const { confirmEmail } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'confirmEmail',
|
||||
message: `Type the user email "${user.email}" to confirm deletion:`,
|
||||
},
|
||||
]);
|
||||
|
||||
if (confirmEmail !== user.email) {
|
||||
console.log(chalk.red('\n❌ Email does not match. Deletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Final confirmation
|
||||
const { confirmFinal } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirmFinal',
|
||||
message: chalk.red(
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!confirmFinal) {
|
||||
console.log(chalk.yellow('\nDeletion cancelled.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.red('\n🗑️ Deleting user...\n'));
|
||||
|
||||
try {
|
||||
// Delete the user (cascade will handle related records like sessions, accounts, memberships)
|
||||
await db.user.delete({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(chalk.green('\n✅ User deleted successfully!'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error deleting user:'), error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
104
admin/src/commands/lookup-client.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface ClientSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByClient() {
|
||||
console.log(chalk.blue('\n🔑 Lookup by Client ID\n'));
|
||||
console.log('Loading clients...\n');
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
project: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (clients.length === 0) {
|
||||
console.log(chalk.red('No clients found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: ClientSearchItem[] = clients.map((client) => ({
|
||||
id: client.id,
|
||||
name: client.name,
|
||||
organizationId: client.organizationId,
|
||||
organizationName: client.organization.name,
|
||||
projectId: client.projectId,
|
||||
projectName: client.project?.name || null,
|
||||
displayText: `${client.organization.name} → ${client.project?.name || '[No Project]'} → ${client.name} ${chalk.gray(`(${client.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: ClientSearchItem) =>
|
||||
`${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<ClientSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedClient } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedClient',
|
||||
message: 'Search for a client:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedClient: ClientSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedClient.organizationId,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
displayOrganizationDetails(organization, {
|
||||
highlightProjectId: selectedClient.projectId || undefined,
|
||||
highlightClientId: selectedClient.id,
|
||||
});
|
||||
}
|
||||
|
||||
112
admin/src/commands/lookup-email.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface EmailSearchItem {
|
||||
email: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
role: string;
|
||||
userId: string | null;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByEmail() {
|
||||
console.log(chalk.blue('\n📧 Lookup by Email\n'));
|
||||
console.log('Loading members...\n');
|
||||
|
||||
const members = await db.member.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
user: true,
|
||||
},
|
||||
orderBy: {
|
||||
email: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (members.length === 0) {
|
||||
console.log(chalk.red('No members found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by email (in case same email is in multiple orgs)
|
||||
const searchItems: EmailSearchItem[] = members.map((member) => {
|
||||
const email = member.user?.email || member.email || 'Unknown';
|
||||
const roleBadge =
|
||||
member.role === 'owner' ? '👑' : member.role === 'admin' ? '⭐' : '👤';
|
||||
|
||||
return {
|
||||
email,
|
||||
organizationId: member.organizationId,
|
||||
organizationName: member.organization.name,
|
||||
role: member.role,
|
||||
userId: member.userId,
|
||||
displayText: `${email} ${chalk.gray(`→ ${member.organization.name}`)} ${roleBadge}`,
|
||||
};
|
||||
});
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: EmailSearchItem) =>
|
||||
`${item.email} ${item.organizationName}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<EmailSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedMember } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedMember',
|
||||
message: 'Search for a member by email:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedMember: EmailSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedMember.organizationId,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`,
|
||||
),
|
||||
);
|
||||
|
||||
displayOrganizationDetails(organization);
|
||||
}
|
||||
|
||||
88
admin/src/commands/lookup-org.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface OrgSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByOrg() {
|
||||
console.log(chalk.blue('\n🏢 Lookup by Organization\n'));
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
const organizations = await db.organization.findMany({
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (organizations.length === 0) {
|
||||
console.log(chalk.red('No organizations found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedOrg } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedOrg',
|
||||
message: 'Search for an organization:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedOrg: OrgSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedOrg.id,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
displayOrganizationDetails(organization);
|
||||
}
|
||||
|
||||
98
admin/src/commands/lookup-project.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { db } from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
import fuzzy from 'fuzzy';
|
||||
import inquirer from 'inquirer';
|
||||
import autocomplete from 'inquirer-autocomplete-prompt';
|
||||
import { displayOrganizationDetails } from '../utils/display';
|
||||
|
||||
// Register autocomplete prompt
|
||||
inquirer.registerPrompt('autocomplete', autocomplete);
|
||||
|
||||
interface ProjectSearchItem {
|
||||
id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
displayText: string;
|
||||
}
|
||||
|
||||
export async function lookupByProject() {
|
||||
console.log(chalk.blue('\n📊 Lookup by Project\n'));
|
||||
console.log('Loading projects...\n');
|
||||
|
||||
const projects = await db.project.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.log(chalk.red('No projects found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchItems: ProjectSearchItem[] = projects.map((project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
organizationId: project.organizationId,
|
||||
organizationName: project.organization.name,
|
||||
displayText: `${project.organization.name} → ${project.name} ${chalk.gray(`(${project.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: ProjectSearchItem) =>
|
||||
`${item.organizationName} ${item.name} ${item.id}`,
|
||||
});
|
||||
|
||||
return fuzzyResult.map((result: fuzzy.FilterResult<ProjectSearchItem>) => ({
|
||||
name: result.original.displayText,
|
||||
value: result.original,
|
||||
}));
|
||||
};
|
||||
|
||||
const { selectedProject } = (await inquirer.prompt([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'selectedProject',
|
||||
message: 'Search for a project:',
|
||||
source: searchFunction,
|
||||
pageSize: 15,
|
||||
},
|
||||
])) as { selectedProject: ProjectSearchItem };
|
||||
|
||||
// Fetch full organization details
|
||||
const organization = await db.organization.findUnique({
|
||||
where: {
|
||||
id: selectedProject.organizationId,
|
||||
},
|
||||
include: {
|
||||
projects: {
|
||||
include: {
|
||||
clients: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
members: {
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log(chalk.red('Organization not found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
displayOrganizationDetails(organization, {
|
||||
highlightProjectId: selectedProject.id,
|
||||
});
|
||||
}
|
||||
|
||||
206
admin/src/utils/display.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
Client,
|
||||
Member,
|
||||
Organization,
|
||||
Project,
|
||||
User,
|
||||
} from '@openpanel/db';
|
||||
import chalk from 'chalk';
|
||||
|
||||
type OrganizationWithDetails = Organization & {
|
||||
projects: (Project & {
|
||||
clients: Client[];
|
||||
})[];
|
||||
members: (Member & {
|
||||
user: User | null;
|
||||
})[];
|
||||
};
|
||||
|
||||
interface DisplayOptions {
|
||||
highlightProjectId?: string;
|
||||
highlightClientId?: string;
|
||||
}
|
||||
|
||||
export function displayOrganizationDetails(
|
||||
organization: OrganizationWithDetails,
|
||||
options: DisplayOptions = {},
|
||||
) {
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log(chalk.bold.yellow(`\n📊 ORGANIZATION: ${organization.name}`));
|
||||
console.log(`${'='.repeat(80)}\n`);
|
||||
|
||||
// Organization Details
|
||||
console.log(chalk.bold('Organization Details:'));
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`,
|
||||
);
|
||||
console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`);
|
||||
|
||||
// Subscription info
|
||||
if (organization.subscriptionStatus) {
|
||||
console.log(
|
||||
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`,
|
||||
);
|
||||
if (organization.subscriptionPriceId) {
|
||||
console.log(
|
||||
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`,
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionPeriodEventsLimit) {
|
||||
const usage = `${organization.subscriptionPeriodEventsCount}/${organization.subscriptionPeriodEventsLimit}`;
|
||||
const percentage =
|
||||
(organization.subscriptionPeriodEventsCount /
|
||||
organization.subscriptionPeriodEventsLimit) *
|
||||
100;
|
||||
const color =
|
||||
percentage > 90
|
||||
? chalk.red
|
||||
: percentage > 70
|
||||
? chalk.yellow
|
||||
: chalk.green;
|
||||
console.log(
|
||||
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`,
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionStartsAt) {
|
||||
console.log(
|
||||
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionEndsAt) {
|
||||
console.log(
|
||||
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.deleteAt) {
|
||||
console.log(
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Members
|
||||
console.log(`\n${chalk.bold('Members:')}`);
|
||||
if (organization.members.length === 0) {
|
||||
console.log(' No members');
|
||||
} else {
|
||||
for (const member of organization.members) {
|
||||
const roleBadge = getRoleBadge(member.role);
|
||||
console.log(
|
||||
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Projects
|
||||
console.log(`\n${chalk.bold(`Projects (${organization.projects.length}):`)}`);
|
||||
|
||||
if (organization.projects.length === 0) {
|
||||
console.log(' No projects');
|
||||
} else {
|
||||
for (const project of organization.projects) {
|
||||
const isHighlighted = project.id === options.highlightProjectId;
|
||||
const projectPrefix = isHighlighted ? chalk.yellow.bold('→ ') : ' ';
|
||||
|
||||
console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${project.id}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`,
|
||||
);
|
||||
|
||||
if (project.domain) {
|
||||
console.log(` ${chalk.gray('Domain:')} ${project.domain}`);
|
||||
}
|
||||
|
||||
if (project.cors.length > 0) {
|
||||
console.log(` ${chalk.gray('CORS:')} ${project.cors.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`,
|
||||
);
|
||||
console.log(
|
||||
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`,
|
||||
);
|
||||
|
||||
if (project.deleteAt) {
|
||||
console.log(
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Clients for this project
|
||||
if (project.clients.length > 0) {
|
||||
console.log(` ${chalk.gray('Clients:')}`);
|
||||
for (const client of project.clients) {
|
||||
const isClientHighlighted = client.id === options.highlightClientId;
|
||||
const clientPrefix = isClientHighlighted
|
||||
? chalk.yellow.bold(' → ')
|
||||
: ' ';
|
||||
const typeBadge = getClientTypeBadge(client.type);
|
||||
|
||||
console.log(`${clientPrefix}${typeBadge} ${chalk.cyan(client.name)}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${client.id}`);
|
||||
console.log(` ${chalk.gray('Type:')} ${client.type}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`,
|
||||
);
|
||||
console.log(
|
||||
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(` ${chalk.gray('Clients:')} None`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clients without projects (organization-level clients)
|
||||
const orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
|
||||
|
||||
console.log(`\n${'='.repeat(80)}\n`);
|
||||
}
|
||||
|
||||
function getSubscriptionStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return chalk.green(status);
|
||||
case 'trialing':
|
||||
return chalk.blue(status);
|
||||
case 'canceled':
|
||||
return chalk.red(status);
|
||||
case 'past_due':
|
||||
return chalk.yellow(status);
|
||||
default:
|
||||
return chalk.gray(status);
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleBadge(role: string): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return chalk.red.bold('👑');
|
||||
case 'admin':
|
||||
return chalk.yellow.bold('⭐');
|
||||
case 'member':
|
||||
return chalk.blue('👤');
|
||||
default:
|
||||
return chalk.gray('•');
|
||||
}
|
||||
}
|
||||
|
||||
function getClientTypeBadge(type: string): string {
|
||||
switch (type) {
|
||||
case 'root':
|
||||
return chalk.red.bold('[ROOT]');
|
||||
case 'write':
|
||||
return chalk.green('[WRITE]');
|
||||
case 'read':
|
||||
return chalk.blue('[READ]');
|
||||
default:
|
||||
return chalk.gray('[UNKNOWN]');
|
||||
}
|
||||
}
|
||||
12
admin/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../tooling/typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -38,11 +38,10 @@ COPY packages/redis/package.json packages/redis/
|
||||
COPY packages/logger/package.json packages/logger/
|
||||
COPY packages/common/package.json packages/common/
|
||||
COPY packages/payments/package.json packages/payments/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/constants/package.json packages/constants/
|
||||
COPY packages/validation/package.json packages/validation/
|
||||
COPY packages/integrations/package.json packages/integrations/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/js-runtime/package.json packages/js-runtime/
|
||||
COPY patches ./patches
|
||||
|
||||
# BUILD
|
||||
@@ -107,10 +106,10 @@ COPY --from=build /app/packages/redis ./packages/redis
|
||||
COPY --from=build /app/packages/logger ./packages/logger
|
||||
COPY --from=build /app/packages/common ./packages/common
|
||||
COPY --from=build /app/packages/payments ./packages/payments
|
||||
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
|
||||
COPY --from=build /app/packages/constants ./packages/constants
|
||||
COPY --from=build /app/packages/validation ./packages/validation
|
||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
|
||||
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||
RUN pnpm db:codegen
|
||||
|
||||
|
||||
@@ -8,16 +8,17 @@
|
||||
"start": "dotenv -e ../../.env node dist/index.js",
|
||||
"build": "rm -rf dist && tsdown",
|
||||
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
|
||||
"test:manage": "jiti scripts/test-manage-api.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.10",
|
||||
"@ai-sdk/openai": "^1.3.12",
|
||||
"@fastify/compress": "^8.0.1",
|
||||
"@fastify/compress": "^8.1.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.0.0",
|
||||
"@fastify/rate-limit": "^10.2.2",
|
||||
"@fastify/websocket": "^11.0.2",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/websocket": "^11.2.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@openpanel/auth": "workspace:^",
|
||||
"@openpanel/common": "workspace:*",
|
||||
@@ -29,19 +30,18 @@
|
||||
"@openpanel/logger": "workspace:*",
|
||||
"@openpanel/payments": "workspace:*",
|
||||
"@openpanel/queue": "workspace:*",
|
||||
"groupmq": "catalog:",
|
||||
"@openpanel/redis": "workspace:*",
|
||||
"@openpanel/trpc": "workspace:*",
|
||||
"@openpanel/validation": "workspace:*",
|
||||
"@trpc/server": "^11.6.0",
|
||||
"ai": "^4.2.10",
|
||||
"fast-json-stable-hash": "^1.0.3",
|
||||
"fastify": "^5.2.1",
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-metrics": "^12.1.0",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"groupmq": "1.0.0-next.19",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"request-ip": "^3.3.0",
|
||||
"sharp": "^0.33.5",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sqlstring": "^2.3.3",
|
||||
@@ -53,12 +53,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@openpanel/sdk": "workspace:*",
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/ramda": "^0.30.2",
|
||||
"@types/request-ip": "^0.0.41",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/sqlstring": "^2.3.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
@@ -67,4 +65,4 @@
|
||||
"tsdown": "0.14.2",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,23 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
// Regex special characters that indicate we need actual regex
|
||||
const regexSpecialChars = /[|^$.*+?(){}\[\]\\]/;
|
||||
|
||||
function transformBots(bots: any[]): any[] {
|
||||
return bots.map((bot) => {
|
||||
const { regex, ...rest } = bot;
|
||||
const hasRegexChars = regexSpecialChars.test(regex);
|
||||
|
||||
if (hasRegexChars) {
|
||||
// Keep as regex
|
||||
return { regex, ...rest };
|
||||
}
|
||||
// Convert to includes
|
||||
return { includes: regex, ...rest };
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Get document, or throw exception on error
|
||||
try {
|
||||
@@ -14,6 +31,9 @@ async function main() {
|
||||
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||
).then((res) => res.text());
|
||||
|
||||
const parsedData = yaml.load(data) as any[];
|
||||
const transformedBots = transformBots(parsedData);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, '../src/bots/bots.ts'),
|
||||
[
|
||||
@@ -21,11 +41,20 @@ async function main() {
|
||||
'',
|
||||
'// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||
'',
|
||||
`const bots = ${JSON.stringify(yaml.load(data))} as const;`,
|
||||
`const bots = ${JSON.stringify(transformedBots, null, 2)} as const;`,
|
||||
'export default bots;',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
console.log(
|
||||
`✅ Generated bots.ts with ${transformedBots.length} bot entries`,
|
||||
);
|
||||
const regexCount = transformedBots.filter((b) => 'regex' in b).length;
|
||||
const includesCount = transformedBots.filter((b) => 'includes' in b).length;
|
||||
console.log(` - ${includesCount} simple string matches (includes)`);
|
||||
console.log(` - ${regexCount} regex patterns`);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
340
apps/api/scripts/test-manage-api.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* One-off script to test all /manage/ API endpoints
|
||||
*
|
||||
* Usage:
|
||||
* pnpm test:manage
|
||||
* or
|
||||
* pnpm jiti scripts/test-manage-api.ts
|
||||
*
|
||||
* Set API_URL environment variable to test against a different server:
|
||||
* API_URL=http://localhost:3000 pnpm test:manage
|
||||
*/
|
||||
|
||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
|
||||
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
|
||||
|
||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
||||
console.error('CLIENT_ID and CLIENT_SECRET must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
method: string;
|
||||
url: string;
|
||||
status: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function makeRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: any,
|
||||
): Promise<TestResult> {
|
||||
const url = `${API_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'openpanel-client-id': CLIENT_ID,
|
||||
'openpanel-client-secret': CLIENT_SECRET,
|
||||
};
|
||||
|
||||
// Only set Content-Type if there's a body
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
success: response.ok,
|
||||
error: response.ok ? undefined : data.message || 'Request failed',
|
||||
data: response.ok ? data : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: 0,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function testProjects() {
|
||||
console.log('\n📁 Testing Projects endpoints...\n');
|
||||
|
||||
// Create project
|
||||
const createResult = await makeRequest('POST', '/manage/projects', {
|
||||
name: `Test Project ${Date.now()}`,
|
||||
domain: 'https://example.com',
|
||||
cors: ['https://example.com', 'https://www.example.com'],
|
||||
crossDomain: false,
|
||||
types: ['website'],
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const projectId = createResult.data?.data?.id;
|
||||
const clientId = createResult.data?.data?.client?.id;
|
||||
const clientSecret = createResult.data?.data?.client?.secret;
|
||||
|
||||
if (projectId) {
|
||||
console.log(` Created project: ${projectId}`);
|
||||
if (clientId) console.log(` Created client: ${clientId}`);
|
||||
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
|
||||
}
|
||||
|
||||
// List projects
|
||||
const listResult = await makeRequest('GET', '/manage/projects');
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} projects`);
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
// Get project
|
||||
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update project
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/projects/${projectId}`,
|
||||
{
|
||||
name: 'Updated Test Project',
|
||||
crossDomain: true,
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete project (soft delete)
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/projects/${projectId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { projectId, clientId };
|
||||
}
|
||||
|
||||
async function testClients(projectId?: string) {
|
||||
console.log('\n🔑 Testing Clients endpoints...\n');
|
||||
|
||||
// Create client
|
||||
const createResult = await makeRequest('POST', '/manage/clients', {
|
||||
name: `Test Client ${Date.now()}`,
|
||||
projectId: projectId || undefined,
|
||||
type: 'read',
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const clientId = createResult.data?.data?.id;
|
||||
const clientSecret = createResult.data?.data?.secret;
|
||||
|
||||
if (clientId) {
|
||||
console.log(` Created client: ${clientId}`);
|
||||
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
|
||||
}
|
||||
|
||||
// List clients
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients',
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} clients`);
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
// Get client
|
||||
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update client
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/clients/${clientId}`,
|
||||
{
|
||||
name: 'Updated Test Client',
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete client
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/clients/${clientId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testReferences(projectId?: string) {
|
||||
console.log('\n📚 Testing References endpoints...\n');
|
||||
|
||||
if (!projectId) {
|
||||
console.log(' ⚠️ Skipping references tests - no project ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create reference
|
||||
const createResult = await makeRequest('POST', '/manage/references', {
|
||||
projectId,
|
||||
title: `Test Reference ${Date.now()}`,
|
||||
description: 'This is a test reference',
|
||||
datetime: new Date().toISOString(),
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
||||
);
|
||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
||||
|
||||
const referenceId = createResult.data?.data?.id;
|
||||
|
||||
if (referenceId) {
|
||||
console.log(` Created reference: ${referenceId}`);
|
||||
}
|
||||
|
||||
// List references
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references?projectId=${projectId}`,
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} references`);
|
||||
}
|
||||
|
||||
if (referenceId) {
|
||||
// Get reference
|
||||
const getResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references/${referenceId}`,
|
||||
);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
||||
);
|
||||
|
||||
// Update reference
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/references/${referenceId}`,
|
||||
{
|
||||
title: 'Updated Test Reference',
|
||||
description: 'Updated description',
|
||||
datetime: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
||||
);
|
||||
|
||||
// Delete reference
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/references/${referenceId}`,
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Testing Manage API Endpoints\n');
|
||||
console.log(`API Base URL: ${API_BASE_URL}`);
|
||||
console.log(`Client ID: ${CLIENT_ID}\n`);
|
||||
|
||||
try {
|
||||
// Test projects first (creates a project we can use for other tests)
|
||||
const { projectId } = await testProjects();
|
||||
|
||||
// Test clients
|
||||
await testClients(projectId);
|
||||
|
||||
// Test references (requires a project)
|
||||
await testReferences(projectId);
|
||||
|
||||
// Summary
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log('📊 Test Summary\n');
|
||||
const successful = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
console.log(`Total tests: ${results.length}`);
|
||||
console.log(`✅ Successful: ${successful}`);
|
||||
console.log(`❌ Failed: ${failed}\n`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('Failed tests:');
|
||||
results
|
||||
.filter((r) => !r.success)
|
||||
.forEach((r) => {
|
||||
console.log(` ❌ ${r.name} (${r.status})`);
|
||||
if (r.error) console.log(` Error: ${r.error}`);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -40,8 +40,6 @@ async function main() {
|
||||
properties: {
|
||||
hash: 'test-hash',
|
||||
'query.utm_source': 'test',
|
||||
__reqId: `req_${Math.floor(Math.random() * 1000)}`,
|
||||
__user_agent: 'Mozilla/5.0 (Test)',
|
||||
},
|
||||
created_at: formatClickhouseDate(eventTime),
|
||||
country: 'US',
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import bots from './bots';
|
||||
|
||||
export function isBot(ua: string) {
|
||||
const res = bots.find((bot) => {
|
||||
if (new RegExp(bot.regex).test(ua)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
return null;
|
||||
// Pre-compile regex patterns at module load time
|
||||
const compiledBots = bots.map((bot) => {
|
||||
if ('regex' in bot) {
|
||||
return {
|
||||
...bot,
|
||||
compiledRegex: new RegExp(bot.regex),
|
||||
};
|
||||
}
|
||||
return bot;
|
||||
});
|
||||
|
||||
return {
|
||||
name: res.name,
|
||||
type: 'category' in res ? res.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
|
||||
const includesBots = compiledBots.filter((bot) => 'includes' in bot);
|
||||
|
||||
export const isBot = cacheableLru(
|
||||
'is-bot',
|
||||
(ua: string) => {
|
||||
// Check simple string patterns first (fast)
|
||||
for (const bot of includesBots) {
|
||||
if (ua.includes(bot.includes)) {
|
||||
return {
|
||||
name: bot.name,
|
||||
type: 'category' in bot ? bot.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check regex patterns (slower)
|
||||
for (const bot of regexBots) {
|
||||
if (bot.compiledRegex.test(ua)) {
|
||||
return {
|
||||
name: bot.name,
|
||||
type: 'category' in bot ? bot.category : 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
{
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { getSalts } from '@openpanel/db';
|
||||
import { eventsGroupQueue } from '@openpanel/queue';
|
||||
import type { PostEventPayload } from '@openpanel/sdk';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
|
||||
import { checkDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { generateId, slug } from '@openpanel/common';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
|
||||
import { getStringHeaders, getTimestamp } from './track.controller';
|
||||
|
||||
export async function postEvent(
|
||||
request: FastifyRequest<{
|
||||
Body: PostEventPayload;
|
||||
Body: DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -21,8 +19,8 @@ export async function postEvent(
|
||||
request.timestamp,
|
||||
request.body,
|
||||
);
|
||||
const ip = getClientIp(request)!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const projectId = request.client?.projectId;
|
||||
const headers = getStringHeaders(request.headers);
|
||||
|
||||
@@ -32,33 +30,22 @@ export async function postEvent(
|
||||
}
|
||||
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
previousDeviceId,
|
||||
currentDeviceId,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
|
||||
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
@@ -66,7 +53,16 @@ export async function postEvent(
|
||||
? `${projectId}:${request.body?.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
await eventsGroupQueue.add({
|
||||
const jobId = [
|
||||
slug(request.body.name),
|
||||
timestamp,
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
groupId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
await getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: new Date(timestamp).getTime(),
|
||||
data: {
|
||||
projectId,
|
||||
@@ -76,11 +72,13 @@ export async function postEvent(
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
});
|
||||
|
||||
reply.status(202).send('ok');
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
getEventsCountCached,
|
||||
getSettingsForProject,
|
||||
} from '@openpanel/db';
|
||||
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
||||
import { zChartEvent, zChartInput } from '@openpanel/validation';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { zChartEvent, zReport } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
|
||||
async function getProjectId(
|
||||
@@ -139,7 +139,7 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const chartSchemeFull = zChartInput
|
||||
const chartSchemeFull = zReport
|
||||
.pick({
|
||||
breakdowns: true,
|
||||
interval: true,
|
||||
@@ -151,14 +151,27 @@ const chartSchemeFull = zChartInput
|
||||
.extend({
|
||||
project_id: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
events: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
),
|
||||
series: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
// Backward compatibility - events will be migrated to series via preprocessing
|
||||
events: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function charts(
|
||||
@@ -179,9 +192,17 @@ export async function charts(
|
||||
|
||||
const projectId = await getProjectId(request, reply);
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const { events, ...rest } = query.data;
|
||||
const { events, series, ...rest } = query.data;
|
||||
|
||||
return getChart({
|
||||
// Use series if available, otherwise fall back to events (backward compat)
|
||||
const eventSeries = (series ?? events ?? []).map((event: any) => ({
|
||||
...event,
|
||||
type: event.type ?? 'event',
|
||||
segment: event.segment ?? 'event',
|
||||
filters: event.filters ?? [],
|
||||
}));
|
||||
|
||||
return ChartEngine.execute({
|
||||
...rest,
|
||||
startDate: rest.startDate
|
||||
? DateTime.fromISO(rest.startDate)
|
||||
@@ -194,11 +215,7 @@ export async function charts(
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss')
|
||||
: undefined,
|
||||
projectId,
|
||||
events: events.map((event) => ({
|
||||
...event,
|
||||
segment: event.segment ?? 'event',
|
||||
filters: event.filters ?? [],
|
||||
})),
|
||||
series: eventSeries,
|
||||
chartType: 'linear',
|
||||
metric: 'sum',
|
||||
});
|
||||
|
||||
@@ -96,8 +96,6 @@ export async function getPages(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,8 +168,6 @@ export function getOverviewGeneric(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import superjson from 'superjson';
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import {
|
||||
eventBuffer,
|
||||
getProfileByIdCached,
|
||||
getProfileById,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
@@ -92,10 +92,7 @@ export async function wsProjectEvents(
|
||||
type,
|
||||
async (event) => {
|
||||
if (event.projectId === params.projectId) {
|
||||
const profile = await getProfileByIdCached(
|
||||
event.profileId,
|
||||
event.projectId,
|
||||
);
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
socket.send(
|
||||
superjson.stringify(
|
||||
access
|
||||
|
||||
649
apps/api/src/controllers/manage.controller.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { stripTrailingSlash } from '@openpanel/common';
|
||||
import { hashPassword } from '@openpanel/common/server';
|
||||
import {
|
||||
db,
|
||||
getClientByIdCached,
|
||||
getId,
|
||||
getProjectByIdCached,
|
||||
} from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Validation schemas
|
||||
const zCreateProject = z.object({
|
||||
name: z.string().min(1),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).default([]),
|
||||
crossDomain: z.boolean().optional().default(false),
|
||||
types: z
|
||||
.array(z.enum(['website', 'app', 'backend']))
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const zUpdateProject = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).optional(),
|
||||
crossDomain: z.boolean().optional(),
|
||||
allowUnsafeRevenueTracking: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const zCreateClient = z.object({
|
||||
name: z.string().min(1),
|
||||
projectId: z.string().optional(),
|
||||
type: z.enum(['read', 'write', 'root']).optional().default('write'),
|
||||
});
|
||||
|
||||
const zUpdateClient = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const zCreateReference = z.object({
|
||||
projectId: z.string(),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string(),
|
||||
});
|
||||
|
||||
const zUpdateReference = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string().optional(),
|
||||
});
|
||||
|
||||
// Projects CRUD
|
||||
export async function listProjects(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
deleteAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: projects });
|
||||
}
|
||||
|
||||
export async function getProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, domain, cors, crossDomain, types } = parsed.data;
|
||||
|
||||
// Generate a default client secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
const clientData = {
|
||||
organizationId: request.client!.organizationId,
|
||||
name: 'First client',
|
||||
type: 'write' as const,
|
||||
secret: await hashPassword(secret),
|
||||
};
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
id: await getId('project', name),
|
||||
organizationId: request.client!.organizationId,
|
||||
name,
|
||||
domain: domain ? stripTrailingSlash(domain) : null,
|
||||
cors: cors.map((c) => stripTrailingSlash(c)),
|
||||
crossDomain: crossDomain ?? false,
|
||||
allowUnsafeRevenueTracking: false,
|
||||
filters: [],
|
||||
types,
|
||||
clients: {
|
||||
create: clientData,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
project.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...project,
|
||||
client: project.clients[0]
|
||||
? {
|
||||
id: project.clients[0].id,
|
||||
secret,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateProject>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify project exists and belongs to organization
|
||||
const existing = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
if (parsed.data.domain !== undefined) {
|
||||
updateData.domain = parsed.data.domain
|
||||
? stripTrailingSlash(parsed.data.domain)
|
||||
: null;
|
||||
}
|
||||
if (parsed.data.cors !== undefined) {
|
||||
updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c));
|
||||
}
|
||||
if (parsed.data.crossDomain !== undefined) {
|
||||
updateData.crossDomain = parsed.data.crossDomain;
|
||||
}
|
||||
if (parsed.data.allowUnsafeRevenueTracking !== undefined) {
|
||||
updateData.allowUnsafeRevenueTracking =
|
||||
parsed.data.allowUnsafeRevenueTracking;
|
||||
}
|
||||
|
||||
const project = await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Clear cache
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
existing.clients.map((client) => {
|
||||
getClientByIdCached.clear(client.id);
|
||||
}),
|
||||
]);
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: {
|
||||
deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
await getProjectByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// Clients CRUD
|
||||
export async function listClients(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {
|
||||
organizationId: request.client!.organizationId,
|
||||
};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
}
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: clients });
|
||||
}
|
||||
|
||||
export async function getClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function createClient(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, projectId, type } = parsed.data;
|
||||
|
||||
// If projectId is provided, verify it belongs to organization
|
||||
if (projectId) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
|
||||
const client = await db.client.create({
|
||||
data: {
|
||||
organizationId: request.client!.organizationId,
|
||||
projectId: projectId || null,
|
||||
name,
|
||||
type: type || 'write',
|
||||
secret: await hashPassword(secret),
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...client,
|
||||
secret, // Return plain secret only once
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateClient(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateClient>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify client exists and belongs to organization
|
||||
const existing = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
|
||||
const client = await db.client.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function deleteClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.client.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// References CRUD
|
||||
export async function listReferences(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const where: any = {};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
} else {
|
||||
// If no projectId, get all projects in org and filter references
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
where.projectId = {
|
||||
in: projects.map((p) => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
const references = await db.reference.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: references });
|
||||
}
|
||||
|
||||
export async function getReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function createReference(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zCreateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { projectId, title, description, datetime } = parsed.data;
|
||||
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const reference = await db.reference.create({
|
||||
data: {
|
||||
projectId,
|
||||
title,
|
||||
description: description || null,
|
||||
date: new Date(datetime),
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function updateReference(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateReference>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const parsed = zUpdateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reference exists and belongs to organization
|
||||
const existing = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (existing.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.title !== undefined) {
|
||||
updateData.title = parsed.data.title;
|
||||
}
|
||||
if (parsed.data.description !== undefined) {
|
||||
updateData.description = parsed.data.description ?? null;
|
||||
}
|
||||
if (parsed.data.datetime !== undefined) {
|
||||
updateData.date = new Date(parsed.data.datetime);
|
||||
}
|
||||
|
||||
const reference = await db.reference.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function deleteReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.reference.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import {
|
||||
DEFAULT_IP_HEADER_ORDER,
|
||||
getClientIpFromHeaders,
|
||||
} from '@openpanel/common/server/get-client-ip';
|
||||
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { getCache, getRedisCache } from '@openpanel/redis';
|
||||
|
||||
interface GetFaviconParams {
|
||||
@@ -115,7 +118,11 @@ async function fetchImage(
|
||||
|
||||
// Check if URL is an ICO file
|
||||
function isIcoFile(url: string, contentType?: string): boolean {
|
||||
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
|
||||
return (
|
||||
url.toLowerCase().endsWith('.ico') ||
|
||||
contentType === 'image/x-icon' ||
|
||||
contentType === 'image/vnd.microsoft.icon'
|
||||
);
|
||||
}
|
||||
function isSvgFile(url: string, contentType?: string): boolean {
|
||||
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
|
||||
@@ -129,7 +136,7 @@ async function processImage(
|
||||
): Promise<Buffer> {
|
||||
// If it's an ICO file, just return it as-is (no conversion needed)
|
||||
if (originalUrl && isIcoFile(originalUrl, contentType)) {
|
||||
logger.info('Serving ICO file directly', {
|
||||
logger.debug('Serving ICO file directly', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -137,7 +144,7 @@ async function processImage(
|
||||
}
|
||||
|
||||
if (originalUrl && isSvgFile(originalUrl, contentType)) {
|
||||
logger.info('Serving SVG file directly', {
|
||||
logger.debug('Serving SVG file directly', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -146,7 +153,7 @@ async function processImage(
|
||||
|
||||
// If buffer isnt to big just return it as well
|
||||
if (buffer.length < 5000) {
|
||||
logger.info('Serving image directly without processing', {
|
||||
logger.debug('Serving image directly without processing', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -190,7 +197,7 @@ async function processOgImage(
|
||||
): Promise<Buffer> {
|
||||
// If buffer is small enough, return it as-is
|
||||
if (buffer.length < 10000) {
|
||||
logger.info('Serving OG image directly without processing', {
|
||||
logger.debug('Serving OG image directly without processing', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
@@ -236,7 +243,9 @@ export async function getFavicon(
|
||||
try {
|
||||
const url = validateUrl(request.query.url);
|
||||
if (!url) {
|
||||
return createFallbackImage();
|
||||
reply.header('Content-Type', 'image/png');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
return reply.send(createFallbackImage());
|
||||
}
|
||||
|
||||
const cacheKey = createCacheKey(url.toString());
|
||||
@@ -257,21 +266,65 @@ export async function getFavicon(
|
||||
} else {
|
||||
// For website URLs, extract favicon from HTML
|
||||
const meta = await parseUrlMeta(url.toString());
|
||||
logger.info('parseUrlMeta result', {
|
||||
url: url.toString(),
|
||||
favicon: meta?.favicon,
|
||||
});
|
||||
if (meta?.favicon) {
|
||||
imageUrl = new URL(meta.favicon);
|
||||
} else {
|
||||
// Fallback to Google's favicon service
|
||||
const { hostname } = url;
|
||||
imageUrl = new URL(
|
||||
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
|
||||
);
|
||||
// Try standard favicon location first
|
||||
const { origin } = url;
|
||||
imageUrl = new URL(`${origin}/favicon.ico`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the image
|
||||
const { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||
logger.info('Fetching favicon', {
|
||||
originalUrl: url.toString(),
|
||||
imageUrl: imageUrl.toString(),
|
||||
});
|
||||
|
||||
if (status !== 200 || buffer.length === 0) {
|
||||
// Fetch the image
|
||||
let { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||
|
||||
logger.info('Favicon fetch result', {
|
||||
originalUrl: url.toString(),
|
||||
imageUrl: imageUrl.toString(),
|
||||
status,
|
||||
bufferLength: buffer.length,
|
||||
contentType,
|
||||
});
|
||||
|
||||
// If the direct favicon fetch failed and it's not from DuckDuckGo's service,
|
||||
// try DuckDuckGo's favicon service as a fallback
|
||||
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
|
||||
const { hostname } = url;
|
||||
const duckduckgoUrl = new URL(
|
||||
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
|
||||
);
|
||||
|
||||
logger.info('Trying DuckDuckGo favicon service', {
|
||||
originalUrl: url.toString(),
|
||||
duckduckgoUrl: duckduckgoUrl.toString(),
|
||||
});
|
||||
|
||||
const duckduckgoResult = await fetchImage(duckduckgoUrl);
|
||||
buffer = duckduckgoResult.buffer;
|
||||
contentType = duckduckgoResult.contentType;
|
||||
status = duckduckgoResult.status;
|
||||
imageUrl = duckduckgoUrl;
|
||||
|
||||
logger.info('DuckDuckGo favicon result', {
|
||||
status,
|
||||
bufferLength: buffer.length,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
// Accept any response as long as we have valid image data
|
||||
if (buffer.length === 0) {
|
||||
reply.header('Content-Type', 'image/png');
|
||||
reply.header('Cache-Control', 'public, max-age=3600');
|
||||
return reply.send(createFallbackImage());
|
||||
}
|
||||
|
||||
@@ -282,9 +335,31 @@ export async function getFavicon(
|
||||
contentType,
|
||||
);
|
||||
|
||||
logger.info('Favicon processing result', {
|
||||
originalUrl: url.toString(),
|
||||
originalBufferLength: buffer.length,
|
||||
processedBufferLength: processedBuffer.length,
|
||||
});
|
||||
|
||||
// Determine the correct content type for caching and response
|
||||
const isIco = isIcoFile(imageUrl.toString(), contentType);
|
||||
const responseContentType = isIco ? 'image/x-icon' : contentType;
|
||||
const isSvg = isSvgFile(imageUrl.toString(), contentType);
|
||||
let responseContentType = contentType;
|
||||
|
||||
if (isIco) {
|
||||
responseContentType = 'image/x-icon';
|
||||
} else if (isSvg) {
|
||||
responseContentType = 'image/svg+xml';
|
||||
} else if (
|
||||
processedBuffer.length < 5000 &&
|
||||
buffer.length === processedBuffer.length
|
||||
) {
|
||||
// Image was returned as-is, keep original content type
|
||||
responseContentType = contentType;
|
||||
} else {
|
||||
// Image was processed by Sharp, it's now a PNG
|
||||
responseContentType = 'image/png';
|
||||
}
|
||||
|
||||
// Cache the result with correct content type
|
||||
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
|
||||
@@ -394,12 +469,36 @@ export async function stats(request: FastifyRequest, reply: FastifyReply) {
|
||||
}
|
||||
|
||||
export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
||||
const ip = getClientIp(request);
|
||||
const { ip, header } = getClientIpFromHeaders(request.headers);
|
||||
const others = await Promise.all(
|
||||
DEFAULT_IP_HEADER_ORDER.map(async (header) => {
|
||||
const { ip } = getClientIpFromHeaders(request.headers, header);
|
||||
return {
|
||||
header,
|
||||
ip,
|
||||
geo: await getGeoLocation(ip),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (!ip) {
|
||||
return reply.status(400).send('Bad Request');
|
||||
}
|
||||
const geo = await getGeoLocation(ip);
|
||||
return reply.status(200).send(geo);
|
||||
return reply.status(200).send({
|
||||
selected: {
|
||||
geo,
|
||||
ip,
|
||||
header,
|
||||
},
|
||||
...others.reduce(
|
||||
(acc, other) => {
|
||||
acc[other.header] = other;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOgImage(
|
||||
|
||||
@@ -1,62 +1,58 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, pathOr } from 'ramda';
|
||||
|
||||
import { checkDuplicatedEvent, isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, upsertProfile } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import type {
|
||||
IncrementProfilePayload,
|
||||
UpdateProfilePayload,
|
||||
} from '@openpanel/sdk';
|
||||
DeprecatedIncrementProfilePayload,
|
||||
DeprecatedUpdateProfilePayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
export async function updateProfile(
|
||||
request: FastifyRequest<{
|
||||
Body: UpdateProfilePayload;
|
||||
Body: DeprecatedUpdateProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { profileId, properties, ...rest } = request.body;
|
||||
const payload = request.body;
|
||||
const projectId = request.client!.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
const ip = getClientIp(request)!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const uaInfo = parseUserAgent(ua, properties);
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||
const geo = await getGeoLocation(ip);
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertProfile({
|
||||
id: profileId,
|
||||
...payload,
|
||||
id: payload.profileId,
|
||||
isExternal: true,
|
||||
projectId,
|
||||
properties: {
|
||||
...(properties ?? {}),
|
||||
...(ip ? geo : {}),
|
||||
...uaInfo,
|
||||
...(payload.properties ?? {}),
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
longitude: geo.longitude,
|
||||
latitude: geo.latitude,
|
||||
os: uaInfo.os,
|
||||
os_version: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
browser_version: uaInfo.browserVersion,
|
||||
device: uaInfo.device,
|
||||
brand: uaInfo.brand,
|
||||
model: uaInfo.model,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
|
||||
reply.status(202).send(profileId);
|
||||
reply.status(202).send(payload.profileId);
|
||||
}
|
||||
|
||||
export async function incrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -66,18 +62,6 @@ export async function incrementProfileProperty(
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
@@ -110,7 +94,7 @@ export async function incrementProfileProperty(
|
||||
|
||||
export async function decrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -120,18 +104,6 @@ export async function decrementProfileProperty(
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
return reply.status(404).send('Not found');
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, assocPath, pathOr, pick } from 'ramda';
|
||||
import { assocPath, pathOr, pick } from 'ramda';
|
||||
|
||||
import { checkDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { generateId, slug } from '@openpanel/common';
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { eventsGroupQueue } from '@openpanel/queue';
|
||||
import type {
|
||||
DecrementPayload,
|
||||
IdentifyPayload,
|
||||
IncrementPayload,
|
||||
TrackHandlerPayload,
|
||||
TrackPayload,
|
||||
} from '@openpanel/sdk';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import { getRedisCache, getRedisQueue } from '@openpanel/redis';
|
||||
|
||||
import {
|
||||
type IDecrementPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type ITrackHandlerPayload,
|
||||
type ITrackPayload,
|
||||
zTrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
return Object.entries(
|
||||
@@ -37,284 +39,224 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
);
|
||||
}
|
||||
|
||||
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
|
||||
const identity = path<IdentifyPayload>(
|
||||
['properties', '__identify'],
|
||||
body.payload,
|
||||
);
|
||||
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
||||
if (body.type === 'track') {
|
||||
const identity = body.payload.properties?.__identify as
|
||||
| IIdentifyPayload
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
identity ||
|
||||
(body?.payload?.profileId
|
||||
? {
|
||||
profileId: body.payload.profileId,
|
||||
}
|
||||
: undefined)
|
||||
);
|
||||
return (
|
||||
identity ||
|
||||
(body.payload.profileId
|
||||
? {
|
||||
profileId: String(body.payload.profileId),
|
||||
}
|
||||
: undefined)
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getTimestamp(
|
||||
timestamp: FastifyRequest['timestamp'],
|
||||
payload: TrackHandlerPayload['payload'],
|
||||
payload: ITrackHandlerPayload['payload'],
|
||||
) {
|
||||
const safeTimestamp = new Date(timestamp || Date.now()).toISOString();
|
||||
const userDefinedTimestamp = path<string>(
|
||||
['properties', '__timestamp'],
|
||||
payload,
|
||||
);
|
||||
const safeTimestamp = timestamp || Date.now();
|
||||
const userDefinedTimestamp =
|
||||
'properties' in payload
|
||||
? (payload?.properties?.__timestamp as string | undefined)
|
||||
: undefined;
|
||||
|
||||
if (!userDefinedTimestamp) {
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
const clientTimestamp = new Date(userDefinedTimestamp);
|
||||
const clientTimestampNumber = clientTimestamp.getTime();
|
||||
|
||||
// Constants for time validation
|
||||
const ONE_MINUTE_MS = 60 * 1000;
|
||||
const FIFTEEN_MINUTES_MS = 15 * ONE_MINUTE_MS;
|
||||
|
||||
// Use safeTimestamp if invalid or more than 1 minute in the future
|
||||
if (
|
||||
Number.isNaN(clientTimestamp.getTime()) ||
|
||||
clientTimestamp > new Date(safeTimestamp)
|
||||
Number.isNaN(clientTimestampNumber) ||
|
||||
clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS
|
||||
) {
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
|
||||
const isTimestampFromThePast =
|
||||
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
|
||||
|
||||
return {
|
||||
timestamp: clientTimestamp.toISOString(),
|
||||
isTimestampFromThePast: true,
|
||||
timestamp: clientTimestampNumber,
|
||||
isTimestampFromThePast,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: TrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
||||
const ip =
|
||||
path<string>(['properties', '__ip'], request.body.payload) ||
|
||||
getClientIp(request)!;
|
||||
const ua = request.headers['user-agent']!;
|
||||
const projectId = request.client?.projectId;
|
||||
interface TrackContext {
|
||||
projectId: string;
|
||||
ip: string;
|
||||
ua?: string;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: { value: number; isFromPast: boolean };
|
||||
identity?: IIdentifyPayload;
|
||||
currentDeviceId?: string;
|
||||
previousDeviceId?: string;
|
||||
geo: GeoLocation;
|
||||
}
|
||||
|
||||
async function buildContext(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}>,
|
||||
validatedBody: ITrackHandlerPayload,
|
||||
): Promise<TrackContext> {
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Missing projectId',
|
||||
});
|
||||
return;
|
||||
throw new HttpError('Missing projectId', { status: 400 });
|
||||
}
|
||||
|
||||
const identity = getIdentity(request.body);
|
||||
const timestamp = getTimestamp(request.timestamp, validatedBody.payload);
|
||||
const ip =
|
||||
validatedBody.type === 'track' && validatedBody.payload.properties?.__ip
|
||||
? (validatedBody.payload.properties.__ip as string)
|
||||
: request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const headers = getStringHeaders(request.headers);
|
||||
|
||||
const identity = getIdentity(validatedBody);
|
||||
const profileId = identity?.profileId;
|
||||
|
||||
// We might get a profileId from the alias table
|
||||
// If we do, we should use that instead of the one from the payload
|
||||
if (profileId) {
|
||||
request.body.payload.profileId = profileId;
|
||||
if (profileId && validatedBody.type === 'track') {
|
||||
validatedBody.payload.profileId = profileId;
|
||||
}
|
||||
|
||||
switch (request.body.type) {
|
||||
case 'track': {
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = ua
|
||||
// Get geo location (needed for track and identify)
|
||||
const geo = await getGeoLocation(ip);
|
||||
|
||||
// Generate device IDs if needed (for track)
|
||||
let currentDeviceId: string | undefined;
|
||||
let previousDeviceId: string | undefined;
|
||||
|
||||
if (validatedBody.type === 'track') {
|
||||
const overrideDeviceId =
|
||||
typeof validatedBody.payload.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload.properties.__deviceId
|
||||
: undefined;
|
||||
|
||||
const [salts] = await Promise.all([getSalts()]);
|
||||
currentDeviceId =
|
||||
overrideDeviceId ||
|
||||
(ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
previousDeviceId,
|
||||
currentDeviceId,
|
||||
},
|
||||
projectId,
|
||||
: '');
|
||||
previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = [
|
||||
track({
|
||||
payload: request.body.payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers: getStringHeaders(request.headers),
|
||||
timestamp: timestamp.timestamp,
|
||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||
}),
|
||||
];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
if (identity && Object.keys(identity).length > 1) {
|
||||
promises.push(
|
||||
identify({
|
||||
payload: identity,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
break;
|
||||
}
|
||||
case 'identify': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geo = await getGeoLocation(ip);
|
||||
await identify({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'alias': {
|
||||
reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'increment': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await increment({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'decrement': {
|
||||
if (
|
||||
await checkDuplicatedEvent({
|
||||
reply,
|
||||
payload: {
|
||||
...request.body,
|
||||
timestamp,
|
||||
},
|
||||
projectId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await decrement({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
break;
|
||||
}
|
||||
: '';
|
||||
}
|
||||
|
||||
reply.status(200).send();
|
||||
return {
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
headers,
|
||||
timestamp: {
|
||||
value: timestamp.timestamp,
|
||||
isFromPast: timestamp.isTimestampFromThePast,
|
||||
},
|
||||
identity,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
geo,
|
||||
};
|
||||
}
|
||||
|
||||
async function track({
|
||||
payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
}: {
|
||||
payload: TrackPayload;
|
||||
currentDeviceId: string;
|
||||
previousDeviceId: string;
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: string;
|
||||
isTimestampFromThePast: boolean;
|
||||
}) {
|
||||
async function handleTrack(
|
||||
payload: ITrackPayload,
|
||||
context: TrackContext,
|
||||
): Promise<void> {
|
||||
const {
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
geo,
|
||||
headers,
|
||||
timestamp,
|
||||
} = context;
|
||||
|
||||
if (!currentDeviceId || !previousDeviceId) {
|
||||
throw new HttpError('Device ID generation failed', { status: 500 });
|
||||
}
|
||||
|
||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
? payload.profileId
|
||||
? `${projectId}:${payload.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
await eventsGroupQueue.add({
|
||||
orderMs: new Date(timestamp).getTime(),
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
},
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
const jobId = [
|
||||
slug(payload.name),
|
||||
timestamp.value,
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
groupId,
|
||||
});
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
|
||||
const promises = [];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
if (context.identity && Object.keys(context.identity).length > 1) {
|
||||
promises.push(handleIdentify(context.identity, context));
|
||||
}
|
||||
|
||||
promises.push(
|
||||
getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp.value,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp: timestamp.value,
|
||||
isTimestampFromThePast: timestamp.isFromPast,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async function identify({
|
||||
payload,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
}: {
|
||||
payload: IdentifyPayload;
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
ua?: string;
|
||||
}) {
|
||||
async function handleIdentify(
|
||||
payload: IIdentifyPayload,
|
||||
context: TrackContext,
|
||||
): Promise<void> {
|
||||
const { projectId, geo, ua } = context;
|
||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||
await upsertProfile({
|
||||
...payload,
|
||||
@@ -323,23 +265,31 @@ async function identify({
|
||||
projectId,
|
||||
properties: {
|
||||
...(payload.properties ?? {}),
|
||||
...(geo ?? {}),
|
||||
...uaInfo,
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
longitude: geo.longitude,
|
||||
latitude: geo.latitude,
|
||||
os: uaInfo.os,
|
||||
os_version: uaInfo.osVersion,
|
||||
browser: uaInfo.browser,
|
||||
browser_version: uaInfo.browserVersion,
|
||||
device: uaInfo.device,
|
||||
brand: uaInfo.brand,
|
||||
model: uaInfo.model,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function increment({
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
payload: IncrementPayload;
|
||||
projectId: string;
|
||||
}) {
|
||||
async function adjustProfileProperty(
|
||||
payload: IIncrementPayload | IDecrementPayload,
|
||||
projectId: string,
|
||||
direction: 1 | -1,
|
||||
): Promise<void> {
|
||||
const { profileId, property, value } = payload;
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
throw new Error('Not found');
|
||||
throw new HttpError('Profile not found', { status: 404 });
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(
|
||||
@@ -348,12 +298,12 @@ async function increment({
|
||||
);
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error('Not number');
|
||||
throw new HttpError('Property value is not a number', { status: 400 });
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed + (value || 1),
|
||||
parsed + direction * (value || 1),
|
||||
profile.properties,
|
||||
);
|
||||
|
||||
@@ -365,38 +315,134 @@ async function increment({
|
||||
});
|
||||
}
|
||||
|
||||
async function decrement({
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
payload: DecrementPayload;
|
||||
projectId: string;
|
||||
}) {
|
||||
const { profileId, property, value } = payload;
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
throw new Error('Not found');
|
||||
async function handleIncrement(
|
||||
payload: IIncrementPayload,
|
||||
context: TrackContext,
|
||||
): Promise<void> {
|
||||
await adjustProfileProperty(payload, context.projectId, 1);
|
||||
}
|
||||
|
||||
async function handleDecrement(
|
||||
payload: IDecrementPayload,
|
||||
context: TrackContext,
|
||||
): Promise<void> {
|
||||
await adjustProfileProperty(payload, context.projectId, -1);
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
// Validate request body with Zod
|
||||
const validationResult = zTrackHandlerPayload.safeParse(request.body);
|
||||
if (!validationResult.success) {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Validation failed',
|
||||
errors: validationResult.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(
|
||||
pathOr<string>('0', property.split('.'), profile.properties),
|
||||
10,
|
||||
);
|
||||
const validatedBody = validationResult.data;
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error('Not number');
|
||||
// Handle alias (not supported)
|
||||
if (validatedBody.type === 'alias') {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed - (value || 1),
|
||||
profile.properties,
|
||||
);
|
||||
// Build request context
|
||||
const context = await buildContext(request, validatedBody);
|
||||
|
||||
await upsertProfile({
|
||||
id: profile.id,
|
||||
projectId,
|
||||
properties: profile.properties,
|
||||
isExternal: true,
|
||||
// Dispatch to appropriate handler
|
||||
switch (validatedBody.type) {
|
||||
case 'track':
|
||||
await handleTrack(validatedBody.payload, context);
|
||||
break;
|
||||
case 'identify':
|
||||
await handleIdentify(validatedBody.payload, context);
|
||||
break;
|
||||
case 'increment':
|
||||
await handleIncrement(validatedBody.payload, context);
|
||||
break;
|
||||
case 'decrement':
|
||||
await handleDecrement(validatedBody.payload, context);
|
||||
break;
|
||||
default:
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(200).send();
|
||||
}
|
||||
|
||||
export async function fetchDeviceId(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const salts = await getSalts();
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send('No projectId');
|
||||
}
|
||||
|
||||
const ip = request.clientIp;
|
||||
if (!ip) {
|
||||
return reply.status(400).send('Missing ip address');
|
||||
}
|
||||
|
||||
const ua = request.headers['user-agent'];
|
||||
if (!ua) {
|
||||
return reply.status(400).send('Missing header: user-agent');
|
||||
}
|
||||
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
try {
|
||||
const multi = getRedisQueue().multi();
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||
const res = await multi.exec();
|
||||
|
||||
if (res?.[0]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'current session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
if (res?.[1]?.[1]) {
|
||||
return reply.status(200).send({
|
||||
deviceId: previousDeviceId,
|
||||
message: 'previous session exists for this device id',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.error('Error getting session end GET /track/device-id', error);
|
||||
}
|
||||
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
message: 'No session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,6 +169,11 @@ export async function polarWebhook(
|
||||
.parse(event.data.metadata);
|
||||
|
||||
const product = await getProduct(event.data.productId);
|
||||
const organization = await db.organization.findUniqueOrThrow({
|
||||
where: {
|
||||
id: metadata.organizationId,
|
||||
},
|
||||
});
|
||||
const eventsLimit = product.metadata?.eventsLimit;
|
||||
const subscriptionPeriodEventsLimit =
|
||||
typeof eventsLimit === 'number' ? eventsLimit : undefined;
|
||||
@@ -186,7 +191,9 @@ export async function polarWebhook(
|
||||
where: {
|
||||
subscriptionCustomerId: event.data.customer.id,
|
||||
subscriptionId: event.data.id,
|
||||
subscriptionStatus: 'active',
|
||||
subscriptionStatus: {
|
||||
in: ['active', 'past_due', 'unpaid'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,6 +223,13 @@ export async function polarWebhook(
|
||||
subscriptionCreatedByUserId: metadata.userId,
|
||||
subscriptionInterval: event.data.recurringInterval,
|
||||
subscriptionPeriodEventsLimit,
|
||||
subscriptionPeriodEventsCountExceededAt:
|
||||
subscriptionPeriodEventsLimit &&
|
||||
organization.subscriptionPeriodEventsCountExceededAt &&
|
||||
organization.subscriptionPeriodEventsLimit <
|
||||
subscriptionPeriodEventsLimit
|
||||
? null
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function clientHook(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
|
||||
31
apps/api/src/hooks/duplicate.hook.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function duplicateHook(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const ip = req.clientIp;
|
||||
const origin = req.headers.origin;
|
||||
const clientId = req.headers['openpanel-client-id'];
|
||||
const shouldCheck = ip && origin && clientId;
|
||||
|
||||
const isDuplicate = shouldCheck
|
||||
? await isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload: req.body,
|
||||
projectId: clientId as string,
|
||||
})
|
||||
: false;
|
||||
|
||||
if (isDuplicate) {
|
||||
return reply.status(200).send('Duplicate event');
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function fixHook(request: FastifyRequest) {
|
||||
const ua = request.headers['user-agent'];
|
||||
// Swift SDK issue: https://github.com/Openpanel-dev/swift-sdk/commit/d588fa761a36a33f3b78eb79d83bfd524e3c7144
|
||||
if (ua) {
|
||||
const regex = /OpenPanel\/(\d+\.\d+\.\d+)\sOpenPanel\/(\d+\.\d+\.\d+)/;
|
||||
const match = ua.match(regex);
|
||||
if (match) {
|
||||
request.headers['user-agent'] = ua.replace(
|
||||
regex,
|
||||
`OpenPanel/${match[1]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { getClientIp } from '@/utils/get-client-ip';
|
||||
import type {
|
||||
FastifyReply,
|
||||
FastifyRequest,
|
||||
HookHandlerDoneFunction,
|
||||
} from 'fastify';
|
||||
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function ipHook(request: FastifyRequest) {
|
||||
const ip = getClientIp(request);
|
||||
const { ip, header } = getClientIpFromHeaders(request.headers);
|
||||
|
||||
if (ip) {
|
||||
request.clientIp = ip;
|
||||
request.clientIpHeader = header;
|
||||
} else {
|
||||
request.clientIp = '';
|
||||
request.clientIpHeader = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { isBot } from '@/bots';
|
||||
import { createBotEvent } from '@openpanel/db';
|
||||
import type { TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
type DeprecatedEventPayload = {
|
||||
name: string;
|
||||
properties: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
};
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function isBotHook(
|
||||
req: FastifyRequest<{
|
||||
Body: TrackHandlerPayload | DeprecatedEventPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
@@ -46,6 +44,6 @@ export async function isBotHook(
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(202).send('OK');
|
||||
return reply.status(202).send();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, pick } from 'ramda';
|
||||
|
||||
@@ -38,12 +39,7 @@ export async function requestLoggingHook(
|
||||
method: request.method,
|
||||
elapsed: reply.elapsedTime,
|
||||
headers: pick(
|
||||
[
|
||||
'openpanel-client-id',
|
||||
'openpanel-sdk-name',
|
||||
'openpanel-sdk-version',
|
||||
'user-agent',
|
||||
],
|
||||
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
|
||||
request.headers,
|
||||
),
|
||||
body: request.body,
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
liveness,
|
||||
readiness,
|
||||
} from './controllers/healthcheck.controller';
|
||||
import { fixHook } from './hooks/fix.hook';
|
||||
import { ipHook } from './hooks/ip.hook';
|
||||
import { requestIdHook } from './hooks/request-id.hook';
|
||||
import { requestLoggingHook } from './hooks/request-logging.hook';
|
||||
@@ -39,6 +38,7 @@ import exportRouter from './routes/export.router';
|
||||
import importRouter from './routes/import.router';
|
||||
import insightsRouter from './routes/insights.router';
|
||||
import liveRouter from './routes/live.router';
|
||||
import manageRouter from './routes/manage.router';
|
||||
import miscRouter from './routes/misc.router';
|
||||
import oauthRouter from './routes/oauth-callback.router';
|
||||
import profileRouter from './routes/profile.router';
|
||||
@@ -55,7 +55,8 @@ process.env.TZ = 'UTC';
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
client: IServiceClientWithProject | null;
|
||||
clientIp?: string;
|
||||
clientIp: string;
|
||||
clientIpHeader: string;
|
||||
timestamp?: number;
|
||||
session: SessionValidationResult;
|
||||
}
|
||||
@@ -125,7 +126,6 @@ const startServer = async () => {
|
||||
fastify.addHook('onRequest', requestIdHook);
|
||||
fastify.addHook('onRequest', timestampHook);
|
||||
fastify.addHook('onRequest', ipHook);
|
||||
fastify.addHook('onRequest', fixHook);
|
||||
fastify.addHook('onResponse', requestLoggingHook);
|
||||
|
||||
fastify.register(compress, {
|
||||
@@ -144,7 +144,7 @@ const startServer = async () => {
|
||||
instance.addHook('onRequest', async (req) => {
|
||||
if (req.cookies?.session) {
|
||||
try {
|
||||
const sessionId = decodeSessionToken(req.cookies.session);
|
||||
const sessionId = decodeSessionToken(req.cookies?.session);
|
||||
const session = await runWithAlsSession(sessionId, () =>
|
||||
validateSessionToken(req.cookies.session),
|
||||
);
|
||||
@@ -152,6 +152,15 @@ const startServer = async () => {
|
||||
} catch (e) {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else if (process.env.DEMO_USER_ID) {
|
||||
try {
|
||||
const session = await runWithAlsSession('1', () =>
|
||||
validateSessionToken(null),
|
||||
);
|
||||
req.session = session;
|
||||
} catch (e) {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
@@ -195,6 +204,7 @@ const startServer = async () => {
|
||||
instance.register(importRouter, { prefix: '/import' });
|
||||
instance.register(insightsRouter, { prefix: '/insights' });
|
||||
instance.register(trackRouter, { prefix: '/track' });
|
||||
instance.register(manageRouter, { prefix: '/manage' });
|
||||
// Keep existing endpoints for backward compatibility
|
||||
instance.get('/healthcheck', healthcheck);
|
||||
// New Kubernetes-style health endpoints
|
||||
|
||||
@@ -2,9 +2,11 @@ import * as controller from '@/controllers/event.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
|
||||
const eventRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preValidation', duplicateHook);
|
||||
fastify.addHook('preHandler', clientHook);
|
||||
fastify.addHook('preHandler', isBotHook);
|
||||
|
||||
|
||||
132
apps/api/src/routes/manage.router.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as controller from '@/controllers/manage.controller';
|
||||
import { validateManageRequest } from '@/utils/auth';
|
||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
|
||||
const manageRouter: FastifyPluginCallback = async (fastify) => {
|
||||
await activateRateLimiter({
|
||||
fastify,
|
||||
max: 20,
|
||||
timeWindow: '10 seconds',
|
||||
});
|
||||
|
||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||
try {
|
||||
const client = await validateManageRequest(req.headers);
|
||||
req.client = client;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return reply.status(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Client ID seems to be malformed',
|
||||
});
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: e.message });
|
||||
}
|
||||
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: 'Unexpected error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Projects routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects',
|
||||
handler: controller.listProjects,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects/:id',
|
||||
handler: controller.getProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/projects',
|
||||
handler: controller.createProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/projects/:id',
|
||||
handler: controller.updateProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/projects/:id',
|
||||
handler: controller.deleteProject,
|
||||
});
|
||||
|
||||
// Clients routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients',
|
||||
handler: controller.listClients,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients/:id',
|
||||
handler: controller.getClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/clients',
|
||||
handler: controller.createClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/clients/:id',
|
||||
handler: controller.updateClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/clients/:id',
|
||||
handler: controller.deleteClient,
|
||||
});
|
||||
|
||||
// References routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references',
|
||||
handler: controller.listReferences,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references/:id',
|
||||
handler: controller.getReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/references',
|
||||
handler: controller.createReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/references/:id',
|
||||
handler: controller.updateReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/references/:id',
|
||||
handler: controller.deleteReference,
|
||||
});
|
||||
};
|
||||
|
||||
export default manageRouter;
|
||||
@@ -1,10 +1,12 @@
|
||||
import { handler } from '@/controllers/track.controller';
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
|
||||
const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preValidation', duplicateHook);
|
||||
fastify.addHook('preHandler', clientHook);
|
||||
fastify.addHook('preHandler', isBotHook);
|
||||
|
||||
@@ -12,18 +14,19 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
handler: handler,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/device-id',
|
||||
handler: fetchDeviceId,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['type', 'payload'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
|
||||
},
|
||||
payload: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deviceId: { type: 'string' },
|
||||
message: { type: 'string', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
ch,
|
||||
clix,
|
||||
} from '@openpanel/db';
|
||||
import { ChartEngine } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
||||
import { zChartInputAI } from '@openpanel/validation';
|
||||
import { zReportInput } from '@openpanel/validation';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -27,7 +27,10 @@ export function getReport({
|
||||
- ${chartTypes.metric}
|
||||
- ${chartTypes.bar}
|
||||
`,
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -72,7 +75,10 @@ export function getConversionReport({
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -94,7 +100,10 @@ export function getFunnelReport({
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
|
||||
@@ -3,10 +3,12 @@ import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
||||
import { verifyPassword } from '@openpanel/common/server';
|
||||
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
IProjectFilterIp,
|
||||
IProjectFilterProfileId,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import { path } from 'ramda';
|
||||
|
||||
@@ -40,7 +42,7 @@ export class SdkAuthError extends Error {
|
||||
|
||||
export async function validateSdkRequest(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const { headers, clientIp } = req;
|
||||
@@ -104,6 +106,22 @@ export async function validateSdkRequest(
|
||||
throw createError('Ingestion: Profile id is blocked by project filter');
|
||||
}
|
||||
|
||||
const revenue =
|
||||
path(['payload', 'properties', '__revenue'], req.body) ??
|
||||
path(['properties', '__revenue'], req.body);
|
||||
|
||||
// Only allow revenue tracking if it was sent with a client secret
|
||||
// or if the project has allowUnsafeRevenueTracking enabled
|
||||
if (
|
||||
!client.project.allowUnsafeRevenueTracking &&
|
||||
!clientSecret &&
|
||||
typeof revenue !== 'undefined'
|
||||
) {
|
||||
throw createError(
|
||||
'Ingestion: Revenue tracking is not allowed without a client secret',
|
||||
);
|
||||
}
|
||||
|
||||
if (client.ignoreCorsAndSecret) {
|
||||
return client;
|
||||
}
|
||||
@@ -135,7 +153,13 @@ export async function validateSdkRequest(
|
||||
}
|
||||
|
||||
if (client.secret && clientSecret) {
|
||||
if (await verifyPassword(clientSecret, client.secret)) {
|
||||
const isVerified = await getCache(
|
||||
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
|
||||
60 * 5,
|
||||
async () => await verifyPassword(clientSecret, client.secret!),
|
||||
true,
|
||||
);
|
||||
if (isVerified) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -212,3 +236,40 @@ export async function validateImportRequest(
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function validateManageRequest(
|
||||
headers: RawRequestDefaultExpression['headers'],
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const clientId = headers['openpanel-client-id'] as string;
|
||||
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
clientId,
|
||||
)
|
||||
) {
|
||||
throw new Error('Manage: Client ID must be a valid UUIDv4');
|
||||
}
|
||||
|
||||
const client = await getClientByIdCached(clientId);
|
||||
|
||||
if (!client) {
|
||||
throw new Error('Manage: Invalid client id');
|
||||
}
|
||||
|
||||
if (!client.secret) {
|
||||
throw new Error('Manage: Client has no secret');
|
||||
}
|
||||
|
||||
if (client.type !== ClientType.root) {
|
||||
throw new Error(
|
||||
'Manage: Only root clients are allowed to manage resources',
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyPassword(clientSecret, client.secret))) {
|
||||
throw new Error('Manage: Invalid client secret');
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { getLock } from '@openpanel/redis';
|
||||
import fastJsonStableHash from 'fast-json-stable-hash';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
|
||||
export async function isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
ip: string;
|
||||
origin: string;
|
||||
payload: Record<string, any>;
|
||||
projectId: string;
|
||||
}) {
|
||||
@@ -13,6 +16,8 @@ export async function isDuplicatedEvent({
|
||||
`fastify:deduplicate:${fastJsonStableHash.hash(
|
||||
{
|
||||
...payload,
|
||||
ip,
|
||||
origin,
|
||||
projectId,
|
||||
},
|
||||
'md5',
|
||||
@@ -27,24 +32,3 @@ export async function isDuplicatedEvent({
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function checkDuplicatedEvent({
|
||||
reply,
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
reply: FastifyReply;
|
||||
payload: Record<string, any>;
|
||||
projectId: string;
|
||||
}) {
|
||||
if (await isDuplicatedEvent({ payload, projectId })) {
|
||||
reply.log.info('duplicated event', {
|
||||
payload,
|
||||
projectId,
|
||||
});
|
||||
reply.status(200).send('duplicated');
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import requestIp from 'request-ip';
|
||||
|
||||
const ignore = ['127.0.0.1', '::1'];
|
||||
|
||||
export function getClientIp(req: FastifyRequest) {
|
||||
return requestIp.getClientIp(req);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ch, db } from '@openpanel/db';
|
||||
import {
|
||||
cronQueue,
|
||||
eventsGroupQueue,
|
||||
eventsGroupQueues,
|
||||
miscQueue,
|
||||
notificationQueue,
|
||||
sessionsQueue,
|
||||
@@ -71,7 +71,7 @@ export async function shutdown(
|
||||
// Step 6: Close Bull queues (graceful shutdown of queue state)
|
||||
try {
|
||||
await Promise.all([
|
||||
eventsGroupQueue.close(),
|
||||
...eventsGroupQueues.map((queue) => queue.close()),
|
||||
sessionsQueue.close(),
|
||||
cronQueue.close(),
|
||||
miscQueue.close(),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import urlMetadata from 'url-metadata';
|
||||
|
||||
function fallbackFavicon(url: string) {
|
||||
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
|
||||
} catch {
|
||||
// If URL parsing fails, use the original string
|
||||
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
|
||||
}
|
||||
}
|
||||
|
||||
function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
||||
|
||||
BIN
apps/justfuckinguseopenpanel/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
505
apps/justfuckinguseopenpanel/index.html
Normal file
@@ -0,0 +1,505 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
|
||||
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
|
||||
<meta name="author" content="OpenPanel">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta property="og:image" content="/ogimage.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="twitter:image" content="/ogimage.png">
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.75;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
margin: 0 -4rem 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.screenshot {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-inner {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.window-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.window-dot.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.window-dot.yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.window-dot.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.screenshot-image-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: #131313;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin: 3rem 0;
|
||||
text-align: center;
|
||||
margin: 0 -4rem 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.cta {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta a:hover {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #374151;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: left;
|
||||
margin-top: 4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
color: #8f8f8f;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Just Fucking Use OpenPanel</h1>
|
||||
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
|
||||
|
||||
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
|
||||
|
||||
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
|
||||
<ul>
|
||||
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
|
||||
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
|
||||
</ul>
|
||||
|
||||
<p>"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.</p>
|
||||
|
||||
<p>Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.</p>
|
||||
|
||||
<h2>The Web-Only Analytics Trap</h2>
|
||||
|
||||
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
|
||||
|
||||
<blockquote>
|
||||
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
|
||||
</blockquote>
|
||||
|
||||
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
|
||||
|
||||
<p>Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.</p>
|
||||
|
||||
<p>Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.</p>
|
||||
|
||||
<p>And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a <strong>SECOND tool</strong> on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.</p>
|
||||
|
||||
<h2>Counter One Dollar Stats</h2>
|
||||
|
||||
<p>"$1/month for page views. Adorable."</p>
|
||||
|
||||
<p>Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.</p>
|
||||
|
||||
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
|
||||
|
||||
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>You get:</p>
|
||||
<ul>
|
||||
<li>Funnels to see where users drop off</li>
|
||||
<li>Retention analysis to see who comes back</li>
|
||||
<li>Cohorts to segment your users</li>
|
||||
<li>User profiles to understand individual behavior</li>
|
||||
<li>Custom dashboards to see what matters to YOU</li>
|
||||
<li>Revenue tracking to see what actually makes money</li>
|
||||
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
|
||||
</ul>
|
||||
|
||||
<p>One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.</p>
|
||||
|
||||
<h2>Why OpenPanel is the Answer</h2>
|
||||
|
||||
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
|
||||
|
||||
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
|
||||
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
|
||||
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
|
||||
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
|
||||
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
|
||||
</ul>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
|
||||
|
||||
<p>Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?</p>
|
||||
|
||||
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.</p>
|
||||
|
||||
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
|
||||
|
||||
<p>Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.</p>
|
||||
|
||||
<h2>The Comparison Table (The Brutal Truth)</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tool</th>
|
||||
<th>Price at 20M events</th>
|
||||
<th>What You Get</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Mixpanel</strong></td>
|
||||
<td>$2,300+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>PostHog</strong></td>
|
||||
<td>$1,982+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Plausible</strong></td>
|
||||
<td>Various pricing</td>
|
||||
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>One Dollar Stats</strong></td>
|
||||
<td>$1/month</td>
|
||||
<td>Page views (but cheaper!)</td>
|
||||
</tr>
|
||||
<tr style="background: #131313; border: 2px solid #3b82f6;">
|
||||
<td><strong>OpenPanel</strong></td>
|
||||
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
|
||||
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The Bottom Fucking Line</h2>
|
||||
|
||||
<p>If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.</p>
|
||||
|
||||
<p>You have three choices:</p>
|
||||
|
||||
<ol>
|
||||
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
|
||||
<li>Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from</li>
|
||||
<li>Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do</li>
|
||||
</ol>
|
||||
|
||||
<p>If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.</p>
|
||||
|
||||
<p>But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.</p>
|
||||
|
||||
<div class="cta">
|
||||
<h2>Ready to understand what your users actually do?</h2>
|
||||
<p>Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.</p>
|
||||
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
|
||||
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
|
||||
</figure>
|
||||
|
||||
<footer>
|
||||
<p><strong>Just Fucking Use OpenPanel</strong></p>
|
||||
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
|
||||
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
||||
window.op('init', {
|
||||
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/justfuckinguseopenpanel/ogimage.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/dashboard-dark.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/overview-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/profile-dark.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/realtime-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/report-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
7
apps/justfuckinguseopenpanel/wrangler.jsonc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "justfuckinguseopenpanel",
|
||||
"compatibility_date": "2025-12-19",
|
||||
"assets": {
|
||||
"directory": "."
|
||||
}
|
||||
}
|
||||
2
apps/public/.gitignore
vendored
@@ -2,8 +2,6 @@
|
||||
/node_modules
|
||||
|
||||
# generated content
|
||||
.contentlayer
|
||||
.content-collections
|
||||
.source
|
||||
|
||||
# test & build
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
ARG NODE_VERSION=20.15.1
|
||||
|
||||
FROM --platform=linux/amd64 node:${NODE_VERSION}-slim AS base
|
||||
|
||||
ARG DATABASE_URL
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
|
||||
ARG REDIS_URL
|
||||
ENV REDIS_URL=$REDIS_URL
|
||||
|
||||
ARG CLICKHOUSE_URL
|
||||
ENV CLICKHOUSE_URL=$CLICKHOUSE_URL
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN apt update \
|
||||
&& apt install -y curl \
|
||||
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
|
||||
&& bash n $NODE_VERSION \
|
||||
&& rm n \
|
||||
&& npm install -g n
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package.json
|
||||
COPY pnpm-lock.yaml pnpm-lock.yaml
|
||||
COPY pnpm-workspace.yaml pnpm-workspace.yaml
|
||||
COPY apps/public/package.json apps/public/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/redis/package.json packages/redis/package.json
|
||||
COPY packages/queue/package.json packages/queue/package.json
|
||||
COPY packages/common/package.json packages/common/package.json
|
||||
COPY packages/constants/package.json packages/constants/package.json
|
||||
COPY packages/validation/package.json packages/validation/package.json
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json
|
||||
COPY packages/sdks/_info/package.json packages/sdks/_info/package.json
|
||||
|
||||
# BUILD
|
||||
FROM base AS build
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app
|
||||
COPY apps/public apps/public
|
||||
COPY packages packages
|
||||
COPY tooling tooling
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm run build
|
||||
|
||||
# PROD
|
||||
FROM base AS prod
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
# FINAL
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=prod /app/node_modules /app/node_modules
|
||||
# Apps
|
||||
COPY --from=build /app/apps/public /app/apps/public
|
||||
# Apps node_modules
|
||||
COPY --from=prod /app/apps/public/node_modules /app/apps/public/node_modules
|
||||
# Packages
|
||||
COPY --from=build /app/packages/db /app/packages/db
|
||||
COPY --from=build /app/packages/redis /app/packages/redis
|
||||
COPY --from=build /app/packages/common /app/packages/common
|
||||
COPY --from=build /app/packages/queue /app/packages/queue
|
||||
COPY --from=build /app/packages/constants /app/packages/constants
|
||||
COPY --from=build /app/packages/validation /app/packages/validation
|
||||
COPY --from=build /app/packages/sdks/sdk /app/packages/sdks/sdk
|
||||
COPY --from=build /app/packages/sdks/_info /app/packages/sdks/_info
|
||||
# Packages node_modules
|
||||
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
|
||||
COPY --from=prod /app/packages/redis/node_modules /app/packages/redis/node_modules
|
||||
COPY --from=prod /app/packages/common/node_modules /app/packages/common/node_modules
|
||||
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
|
||||
COPY --from=prod /app/packages/validation/node_modules /app/packages/validation/node_modules
|
||||
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -15,6 +15,25 @@ yarn dev
|
||||
|
||||
Open http://localhost:3000 with your browser to see the result.
|
||||
|
||||
## Explore
|
||||
|
||||
In the project, you can see:
|
||||
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||
|
||||
| Route | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `app/(home)` | The route group for your landing page and other pages. |
|
||||
| `app/docs` | The documentation layout and pages. |
|
||||
| `app/api/search/route.ts` | The Route Handler for search. |
|
||||
|
||||
### Fumadocs MDX
|
||||
|
||||
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
|
||||
|
||||
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js and Fumadocs, take a look at the following
|
||||
@@ -23,4 +42,4 @@ resources:
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
|
||||
features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
|
||||
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { url, getAuthor } from '@/app/layout.config';
|
||||
import { SingleSwirl } from '@/components/Swirls';
|
||||
import { ArticleCard } from '@/components/article-card';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { SectionHeader } from '@/components/section';
|
||||
import { Toc } from '@/components/toc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { articleSource } from '@/lib/source';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ articleSlug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { articleSlug } = await params;
|
||||
const article = await articleSource.getPage([articleSlug]);
|
||||
const author = getAuthor(article?.data.team);
|
||||
|
||||
if (!article) {
|
||||
return {
|
||||
title: 'Article Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
authors: [{ name: author.name }],
|
||||
alternates: {
|
||||
canonical: url(article.url),
|
||||
},
|
||||
openGraph: {
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
type: 'article',
|
||||
publishedTime: article.data.date.toISOString(),
|
||||
authors: author.name,
|
||||
images: url(article.data.cover),
|
||||
url: url(article.url),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
images: url(article.data.cover),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ articleSlug: string }>;
|
||||
}) {
|
||||
const { articleSlug } = await params;
|
||||
const article = await articleSource.getPage([articleSlug]);
|
||||
const Body = article?.data.body;
|
||||
const author = getAuthor(article?.data.team);
|
||||
const goBackUrl = '/articles';
|
||||
|
||||
const relatedArticles = (await articleSource.getPages())
|
||||
.filter(
|
||||
(item) =>
|
||||
item.data.tag === article?.data.tag && item.url !== article?.url,
|
||||
)
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
|
||||
if (!Body) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Create the JSON-LD data
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article?.data.title,
|
||||
datePublished: article?.data.date.toISOString(),
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url(article.url),
|
||||
},
|
||||
image: {
|
||||
'@type': 'ImageObject',
|
||||
url: url(article.data.cover),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
id="article-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<article className="container max-w-5xl col">
|
||||
<div className="py-16">
|
||||
<Link
|
||||
href={goBackUrl}
|
||||
className="flex items-center gap-2 mb-4 text-muted-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
<span>Back to all articles</span>
|
||||
</Link>
|
||||
<div className="flex-col-reverse col md:row gap-8">
|
||||
<div className="col flex-1">
|
||||
<h1 className="text-5xl font-bold leading-tight">
|
||||
{article?.data.title}
|
||||
</h1>
|
||||
|
||||
<div className="row gap-4 items-center mt-8">
|
||||
<div className="size-10 center-center bg-black rounded-full">
|
||||
{author.image ? (
|
||||
<Image
|
||||
className="size-10 object-cover rounded-full"
|
||||
src={author.image}
|
||||
alt={author.name}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
) : (
|
||||
<Logo className="w-6 h-6 fill-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="col">
|
||||
<p className="font-medium">{author.name}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{article?.data.date.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
|
||||
<div className="min-w-0">
|
||||
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
|
||||
<Body />
|
||||
</div>
|
||||
</div>
|
||||
<aside className="pl-12 pb-12 gap-8 col">
|
||||
<Toc toc={article?.data.toc} />
|
||||
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl py-16">
|
||||
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0 size-[300px]" />
|
||||
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50 size-[300px]" />
|
||||
<div className="container center-center col">
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
title="Try it"
|
||||
description="Give it a spin for free. No credit card required."
|
||||
/>
|
||||
<Button size="lg" variant="secondary" asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started today!
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{relatedArticles.length > 0 && (
|
||||
<div className="my-16">
|
||||
<h3 className="text-2xl font-bold mb-8">Related articles</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{relatedArticles.map((item) => (
|
||||
<ArticleCard
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
title={item.data.title}
|
||||
tag={item.data.tag}
|
||||
cover={item.data.cover}
|
||||
team={item.data.team}
|
||||
date={item.data.date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { url } from '@/app/layout.config';
|
||||
import { ArticleCard } from '@/components/article-card';
|
||||
import { articleSource } from '@/lib/source';
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
const title = 'Articles';
|
||||
const description = 'Read our latest articles';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: url('/articles'),
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const articles = (await articleSource.getPages()).sort(
|
||||
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="container col">
|
||||
<div className="py-16">
|
||||
<h1 className="text-center text-7xl font-bold">Articles</h1>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
|
||||
{articles.map((item) => (
|
||||
<ArticleCard
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
title={item.data.title}
|
||||
tag={item.data.tag}
|
||||
cover={item.data.cover}
|
||||
team={item.data.team}
|
||||
date={item.data.date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Footer } from '@/components/footer';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="overflow-hidden">
|
||||
<HeroContainer className="h-screen pointer-events-none" />
|
||||
<div className="absolute h-screen inset-0 radial-gradient-dot-pages select-none pointer-events-none" />
|
||||
<div className="-mt-[calc(100vh-100px)] relative min-h-[500px] pb-12 -mb-24">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { url } from '@/app/layout.config';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Faq } from '@/components/sections/faq';
|
||||
import { SupporterPerks } from '@/components/sections/supporter-perks';
|
||||
import { Testimonials } from '@/components/sections/testimonials';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
HeartHandshakeIcon,
|
||||
SparklesIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Script from 'next/script';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
alternates: {
|
||||
canonical: url('/supporter'),
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
type: 'website',
|
||||
url: url('/supporter'),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
},
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Become a Supporter',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url('/supporter'),
|
||||
},
|
||||
};
|
||||
|
||||
export default function SupporterPage() {
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
id="supporter-schema"
|
||||
strategy="beforeInteractive"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<HeroContainer>
|
||||
<div className="container relative z-10 col sm:py-44 max-sm:pt-32">
|
||||
<div className="col gap-8 text-center">
|
||||
<div className="col gap-4">
|
||||
<Tag className="self-center">
|
||||
<HeartHandshakeIcon className="size-4 text-rose-600" />
|
||||
Support Open-Source Analytics
|
||||
</Tag>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold leading-[1.1]">
|
||||
Help us build the future of{' '}
|
||||
<span className="text-primary">open analytics</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Your support accelerates development, funds infrastructure, and
|
||||
helps us build features faster. Plus, you get exclusive perks
|
||||
and early access to everything we ship.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col gap-4 justify-center items-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
|
||||
Become a Supporter
|
||||
<SparklesIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Starting at $20/month • Cancel anytime
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
<div className="container max-w-7xl">
|
||||
{/* Main Content with Sidebar */}
|
||||
<div className="grid lg:grid-cols-[1fr_380px] gap-8 mb-16">
|
||||
{/* Main Content */}
|
||||
<div className="col gap-12">
|
||||
{/* Why Support Section */}
|
||||
<section className="col gap-6">
|
||||
<h2 className="text-3xl font-bold">Why your support matters</h2>
|
||||
<div className="col gap-6 text-muted-foreground">
|
||||
<p className="text-lg">
|
||||
We're not a big corporation – just a small team passionate
|
||||
about building something useful for developers. OpenPanel
|
||||
started because we believed analytics tools shouldn't be
|
||||
complicated or locked behind expensive enterprise
|
||||
subscriptions.
|
||||
</p>
|
||||
<p>When you become a supporter, you're directly funding:</p>
|
||||
<ul className="col gap-3 list-none pl-0">
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">
|
||||
Active Development
|
||||
</strong>
|
||||
<p className="text-sm mt-1">
|
||||
More time fixing bugs, adding features, and improving
|
||||
documentation
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">
|
||||
Infrastructure
|
||||
</strong>
|
||||
<p className="text-sm mt-1">
|
||||
Keeping servers running, CI/CD pipelines, and
|
||||
development tools
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">Independence</strong>
|
||||
<p className="text-sm mt-1">
|
||||
Staying focused on what matters: building a tool
|
||||
developers actually want
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
No corporate speak, no fancy promises – just honest work on
|
||||
making OpenPanel better for everyone. Every contribution, no
|
||||
matter the size, helps us stay independent and focused on what
|
||||
matters.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What You Get Section */}
|
||||
<section className="col gap-6">
|
||||
<h2 className="text-3xl font-bold">
|
||||
What you get as a supporter
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
🚀 Latest Docker Images
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Get bleeding-edge builds on every commit. Access new
|
||||
features weeks before public release.
|
||||
</p>
|
||||
<Link
|
||||
href="/docs/self-hosting/supporter-access-latest-docker-images"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Learn more →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
💬 Prioritized Support
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Get help faster with priority support in our Discord
|
||||
community. Your questions get answered first.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
✨ Feature Requests
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Your ideas and feature requests get prioritized in our
|
||||
roadmap. Shape the future of OpenPanel.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
⭐ Exclusive Discord Role
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Special badge and recognition in our community. Show your
|
||||
support with pride.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Impact Section */}
|
||||
<section className="p-8 rounded-xl border bg-gradient-to-br from-primary/5 to-primary/10">
|
||||
<h2 className="text-2xl font-bold mb-4">Your impact</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Every dollar you contribute goes directly into development,
|
||||
infrastructure, and making OpenPanel better. Here's what your
|
||||
support enables:
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">
|
||||
100%
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Open Source
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">
|
||||
24/7
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Active Development
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">∞</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Self-Hostable
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:block hidden">
|
||||
<SupporterPerks />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Mobile Perks */}
|
||||
<div className="lg:hidden mb-16">
|
||||
<SupporterPerks />
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<Section className="container my-0 py-20">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ArrowDownIcon className="size-4" strokeWidth={1.5} />
|
||||
Starting at $20/month
|
||||
</Tag>
|
||||
}
|
||||
title="Ready to support OpenPanel?"
|
||||
description="Join our community of supporters and help us build the best open-source alternative to Mixpanel. Every contribution helps accelerate development and make OpenPanel better for everyone."
|
||||
/>
|
||||
<div className="center-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
|
||||
Become a Supporter Now
|
||||
<HeartHandshakeIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="lg:-mx-20 xl:-mx-40 not-prose mt-16">
|
||||
<Testimonials />
|
||||
<Faq />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
@@ -1,67 +0,0 @@
|
||||
import { url, siteName } from '@/app/layout.config';
|
||||
import { source } from '@/lib/source';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
} from 'fumadocs-ui/page';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: {
|
||||
canonical: url(page.url),
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
url: url(page.url),
|
||||
type: 'article',
|
||||
images: [
|
||||
{
|
||||
url: url('/ogimage.jpg'),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteName,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--green: 156 71% 67%;
|
||||
--red: 351 89% 72%;
|
||||
--background: 0 0% 98%;
|
||||
--background-light: 0 0% 100%;
|
||||
--background-dark: 0 0% 96%;
|
||||
--foreground: 0 0% 9%;
|
||||
--foreground-dark: 0 0% 7.5%;
|
||||
--foreground-light: 0 0% 11%;
|
||||
--card: 0 0% 98%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
--popover: 0 0% 98%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 9%;
|
||||
--background-dark: 0 0% 7.5%;
|
||||
--background-light: 0 0% 11%;
|
||||
--foreground: 0 0% 98%;
|
||||
--foreground-light: 0 0% 100%;
|
||||
--foreground-dark: 0 0% 96%;
|
||||
--card: 0 0% 9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply !bg-[hsl(var(--background))] text-foreground font-sans text-base antialiased flex flex-col min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
@apply max-w-6xl mx-auto px-6 md:px-10 lg:px-14 w-full;
|
||||
}
|
||||
|
||||
.pulled {
|
||||
@apply -mx-2 md:-mx-6 lg:-mx-10 xl:-mx-20;
|
||||
}
|
||||
|
||||
.row {
|
||||
@apply flex flex-row;
|
||||
}
|
||||
|
||||
.col {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.center-center {
|
||||
@apply flex items-center justify-center text-center;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
|
||||
.radial-gradient {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at bottom, hsl(var(--background-light)), hsl(var(--background)));
|
||||
}
|
||||
|
||||
.radial-gradient-dot-1 {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at 50% 20%, hsl(var(--background-light)), transparent);
|
||||
}
|
||||
|
||||
.radial-gradient-dot-pages {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at 50% 50%, hsl(var(--background)), hsl(var(--background)/0.2));
|
||||
}
|
||||
|
||||
.animated-iframe-gradient {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.animated-iframe-gradient:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
background: linear-gradient(250deg, hsl(var(--foreground)/0.9), transparent);
|
||||
|
||||
animation: GradientRotate 8s linear infinite;
|
||||
}
|
||||
@keyframes GradientRotate {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
.line-before {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-before:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
.line-after {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-after:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
right: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: animateFadeUp 0.5s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes animateFadeUp {
|
||||
0% { transform: translateY(0.5rem); scale: 0.95; }
|
||||
100% { transform: translateY(0); scale: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-down {
|
||||
animation: animateFadeDown 0.5s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes animateFadeDown {
|
||||
0% { transform: translateY(-1rem); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Docs */
|
||||
|
||||
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: hsl(var(--background-dark));
|
||||
border: 1px solid hsl(var(--background-light));
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] pre{
|
||||
max-height: none;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
/**
|
||||
* Shared layout configurations
|
||||
*
|
||||
* you can configure layouts individually from:
|
||||
* Home Layout: app/(home)/layout.tsx
|
||||
* Docs Layout: app/docs/layout.tsx
|
||||
*/
|
||||
|
||||
export const siteName = 'OpenPanel';
|
||||
export const baseUrl = 'https://openpanel.dev';
|
||||
export const url = (path: string) => `${baseUrl}${path}`;
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: siteName,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Home',
|
||||
url: '/',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Pricing',
|
||||
url: '/pricing',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Supporter',
|
||||
url: '/supporter',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Documentation',
|
||||
url: '/docs',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Articles',
|
||||
url: '/articles',
|
||||
active: 'nested-url',
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const authors = [
|
||||
{
|
||||
name: 'OpenPanel Team',
|
||||
url: 'https://openpanel.com',
|
||||
},
|
||||
{
|
||||
name: 'Carl-Gerhard Lindesvärd',
|
||||
url: 'https://openpanel.com',
|
||||
image: '/twitter-carl.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
export const getAuthor = (author?: string) => {
|
||||
return authors.find((a) => a.name === author)!;
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import { RootProvider } from 'fumadocs-ui/provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import './global.css';
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { OpenPanelComponent } from '@openpanel/nextjs';
|
||||
import { cn } from 'fumadocs-ui/components/api';
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import Script from 'next/script';
|
||||
import { url, baseUrl, siteName } from './layout.config';
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
userScalable: true,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#fafafa' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#171717' },
|
||||
],
|
||||
};
|
||||
|
||||
const description = `${siteName} is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: siteName,
|
||||
template: `%s | ${siteName}`,
|
||||
},
|
||||
description,
|
||||
alternates: {
|
||||
canonical: baseUrl,
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
title: siteName,
|
||||
description,
|
||||
siteName: siteName,
|
||||
url: baseUrl,
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: url('/ogimage.jpg'),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteName,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={cn(GeistSans.variable, GeistMono.variable)}>
|
||||
<RootProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</RootProvider>
|
||||
<Script
|
||||
defer
|
||||
src="http://localhost:3000/script.js"
|
||||
data-website-id="44d65df1-e9cb-4c2c-917d-4bf1c7850948"
|
||||
/>
|
||||
<OpenPanelComponent
|
||||
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
|
||||
trackAttributes
|
||||
trackScreenViews
|
||||
trackOutgoingLinks
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { metadata } from './layout';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: metadata.title as string,
|
||||
short_name: 'Openpanel.dev',
|
||||
description: metadata.description!,
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#fff',
|
||||
theme_color: '#fff',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.ico',
|
||||
sizes: 'any',
|
||||
type: 'image/x-icon',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { Footer } from '@/components/footer';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function NotFound({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<HeroContainer className="h-screen center-center">
|
||||
<div className="relative z-10 col gap-2">
|
||||
<div className="text-[150px] font-mono font-bold opacity-5 -mb-4">
|
||||
404
|
||||
</div>
|
||||
<h1 className="text-6xl font-bold">Not Found</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Awkward, we couldn't find what you were looking for.
|
||||
</p>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Footer } from '@/components/footer';
|
||||
import { Hero } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import { Faq } from '@/components/sections/faq';
|
||||
import { Features } from '@/components/sections/features';
|
||||
import { Pricing } from '@/components/sections/pricing';
|
||||
import { Sdks } from '@/components/sections/sdks';
|
||||
import { Stats, StatsPure } from '@/components/sections/stats';
|
||||
import { Testimonials } from '@/components/sections/testimonials';
|
||||
import { WhyOpenPanel } from '@/components/why-openpanel';
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OpenPanel | An open-source alternative to Mixpanel',
|
||||
};
|
||||
|
||||
// export const experimental_ppr = true;
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main>
|
||||
<Hero />
|
||||
<WhyOpenPanel />
|
||||
<Features />
|
||||
<Testimonials />
|
||||
<Faq />
|
||||
<Pricing />
|
||||
<Sdks />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image, { type ImageProps } from 'next/image';
|
||||
|
||||
type SwirlProps = Omit<ImageProps, 'src' | 'alt'>;
|
||||
|
||||
export function SingleSwirl({ className, ...props }: SwirlProps) {
|
||||
return (
|
||||
<Image
|
||||
{...props}
|
||||
src="/swirl-2.png"
|
||||
alt="Swirl"
|
||||
className={cn(
|
||||
'pointer-events-none w-full h-full object-cover',
|
||||
className,
|
||||
)}
|
||||
width={1200}
|
||||
height={1200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DoubleSwirl({ className, ...props }: SwirlProps) {
|
||||
return (
|
||||
<Image
|
||||
{...props}
|
||||
src="/swirl.png"
|
||||
alt="Swirl"
|
||||
className={cn(
|
||||
'pointer-events-none w-full h-full object-cover',
|
||||
className,
|
||||
)}
|
||||
width={1200}
|
||||
height={1200}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronRightIcon, ConeIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function SmallFeature({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background-light rounded-lg p-1 border border-border group',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-background-dark rounded-lg p-8 h-full group-hover:bg-background-light transition-colors">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Feature({
|
||||
children,
|
||||
media,
|
||||
reverse = false,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
media?: React.ReactNode;
|
||||
reverse?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border rounded-lg bg-background-light overflow-hidden p-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 md:grid-cols-2 gap-4 items-center',
|
||||
!media && '!grid-cols-1',
|
||||
)}
|
||||
>
|
||||
<div className={cn(reverse && 'md:order-last', 'p-10')}>{children}</div>
|
||||
{media && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background-dark h-full rounded-md overflow-hidden',
|
||||
reverse && 'md:order-first',
|
||||
)}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureContent({
|
||||
icon,
|
||||
title,
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
content: React.ReactNode[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{icon && (
|
||||
<div
|
||||
data-icon
|
||||
className="bg-foreground text-background rounded-md p-4 inline-block mb-6 transition-colors"
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-lg font-medium mb-2">{title}</h2>
|
||||
<div className="col gap-2">
|
||||
{content.map((c, i) => (
|
||||
<p className="text-muted-foreground" key={i.toString()}>
|
||||
{c}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureListItem({
|
||||
icon: Icon,
|
||||
title,
|
||||
}: { icon: React.ComponentType<any>; title: string }) {
|
||||
return (
|
||||
<div className="row items-center gap-2" key="funnel">
|
||||
<Icon className="size-4 text-foreground/70" strokeWidth={1.5} /> {title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureList({
|
||||
title,
|
||||
items,
|
||||
className,
|
||||
cols = 2,
|
||||
}: {
|
||||
title: string;
|
||||
items: React.ReactNode[];
|
||||
className?: string;
|
||||
cols?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className="font-semibold text-sm mb-2">{title}</h3>
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-2 [&>div]:p-2 [&>div]:row [&>div]:items-center [&>div]:gap-2 grid',
|
||||
cols === 1 && 'grid-cols-1',
|
||||
cols === 2 && 'grid-cols-2',
|
||||
cols === 3 && 'grid-cols-3',
|
||||
)}
|
||||
>
|
||||
{items.map((i, j) => (
|
||||
<div key={j.toString()}>{i}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeatureMore({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'font-medium items-center row justify-between border-t py-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children} <ChevronRightIcon className="size-4" strokeWidth={1.5} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { MailIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { SingleSwirl } from './Swirls';
|
||||
import { Logo } from './logo';
|
||||
import { SectionHeader } from './section';
|
||||
import { Tag } from './tag';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<div className="mt-32">
|
||||
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto">
|
||||
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0" />
|
||||
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50" />
|
||||
<div className="container center-center col">
|
||||
<SectionHeader
|
||||
tag={<Tag>Discover User Insights</Tag>}
|
||||
title="Effortless web & product analytics"
|
||||
description="Simplify your web & product analytics with our user-friendly platform. Collect, analyze, and optimize your data in minutes."
|
||||
/>
|
||||
<Button size="lg" variant="secondary" asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started today!
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="pt-32 text-sm relative overflow-hidden">
|
||||
<div className="absolute -bottom-20 md:-bottom-32 left-0 right-0 center-center opacity-5 pointer-events-none">
|
||||
<Logo className="w-[900px] shrink-0" />
|
||||
</div>
|
||||
<div className="container grid grid-cols-1 md:grid-cols-5 gap-12 md:gap-8 relative">
|
||||
<div>
|
||||
<Link href="/" className="row items-center font-medium">
|
||||
<Logo className="h-6" />
|
||||
{baseOptions.nav?.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3">
|
||||
<h3 className="font-medium">Useful links</h3>
|
||||
<ul className="gap-2 col text-muted-foreground">
|
||||
<li>
|
||||
<Link href="/pricing">Pricing</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/about">About</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact">Contact</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/supporter">Become a supporter</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="col gap-3 ">
|
||||
<h3 className="font-medium">Articles</h3>
|
||||
<ul className="gap-2 col text-muted-foreground">
|
||||
<li>
|
||||
<Link href="/articles/vs-mixpanel">OpenPanel vs Mixpanel</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/articles/mixpanel-alternatives">
|
||||
Mixpanel alternatives
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 md:items-end col gap-4">
|
||||
<div className="[&_svg]:size-6 row gap-4">
|
||||
<Link
|
||||
title="Go to GitHub"
|
||||
href="https://github.com/Openpanel-dev/openpanel"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Go to X"
|
||||
href="https://x.com/openpaneldev"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>X</title>
|
||||
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H3.298Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Join Discord"
|
||||
href="https://go.openpanel.dev/discord"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="fill-current"
|
||||
>
|
||||
<title>Discord</title>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
title="Send an email"
|
||||
href="mailto:hello@openpanel.dev"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<MailIcon className="size-6" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://status.openpanel.dev"
|
||||
className="row gap-2 items-center border rounded-full px-2 py-1 max-md:self-start"
|
||||
rel="noreferrer noopener nofollow"
|
||||
>
|
||||
<span>Operational</span>
|
||||
<div className="size-2 bg-emerald-500 rounded-full" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground border-t pt-4 mt-16 gap-8 relative bg-background/70 pb-32">
|
||||
<div className="container col md:row justify-between">
|
||||
<div>Copyright © {year} OpenPanel. All rights reserved.</div>
|
||||
<div className="col lg:row gap-2 md:gap-4">
|
||||
<Link href="/sitemap.xml">Sitemap</Link>
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
<Link href="/terms">Terms of Service</Link>
|
||||
<Link href="/cookies">Cookie Policy (just kidding)</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { motion } from 'framer-motion';
|
||||
import NextImage from 'next/image';
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Frame = {
|
||||
id: string;
|
||||
label: string;
|
||||
key: string;
|
||||
Component: React.ComponentType;
|
||||
};
|
||||
|
||||
function LivePreview() {
|
||||
return (
|
||||
<iframe
|
||||
src={
|
||||
'https://dashboard.openpanel.dev/share/overview/zef2XC?header=0&range=30d'
|
||||
}
|
||||
className="w-full h-full"
|
||||
title="Live preview"
|
||||
scrolling="no"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Image({ src }: { src: string }) {
|
||||
return (
|
||||
<div>
|
||||
<NextImage
|
||||
className="w-full h-full block dark:hidden"
|
||||
src={`/${src}-light.png`}
|
||||
alt={`${src} light`}
|
||||
width={1200}
|
||||
height={800}
|
||||
/>
|
||||
<NextImage
|
||||
className="w-full h-full hidden dark:block"
|
||||
src={`/${src}-dark.png`}
|
||||
alt={`${src} dark`}
|
||||
width={1200}
|
||||
height={800}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroCarousel() {
|
||||
const frames: Frame[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
key: 'overview',
|
||||
label: 'Live preview',
|
||||
Component: LivePreview,
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
key: 'analytics',
|
||||
label: 'Product analytics',
|
||||
Component: () => <Image src="dashboard" />,
|
||||
},
|
||||
{
|
||||
id: 'funnels',
|
||||
key: 'funnels',
|
||||
label: 'Funnels',
|
||||
Component: () => <Image src="funnel" />,
|
||||
},
|
||||
{
|
||||
id: 'retention',
|
||||
key: 'retention',
|
||||
label: 'Retention',
|
||||
Component: () => <Image src="retention" />,
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
key: 'profile',
|
||||
label: 'Inspect profile',
|
||||
Component: () => <Image src="profile" />,
|
||||
},
|
||||
];
|
||||
|
||||
const [activeFrames, setActiveFrames] = useState<Frame[]>([frames[0]]);
|
||||
const activeFrame = activeFrames[activeFrames.length - 1];
|
||||
|
||||
return (
|
||||
<div className="col gap-6 w-full">
|
||||
<div className="flex-wrap row gap-x-4 gap-y-2 justify-center [&>div]:font-medium mt-1">
|
||||
{frames.map((frame) => (
|
||||
<div key={frame.id} className="relative">
|
||||
<Button
|
||||
variant="naked"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeFrame.id === frame.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newFrame = {
|
||||
...frame,
|
||||
key: Math.random().toString().slice(2, 11),
|
||||
};
|
||||
|
||||
setActiveFrames((p) => [...p.slice(-2), newFrame]);
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{frame.label}
|
||||
</Button>
|
||||
<motion.div
|
||||
className="h-1 bg-foreground rounded-full"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: activeFrame.id === frame.id ? '100%' : '0%',
|
||||
opacity: activeFrame.id === frame.id ? 1 : 0,
|
||||
}}
|
||||
whileHover={{
|
||||
width: '100%',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pulled animated-iframe-gradient p-px pb-0 rounded-t-xl">
|
||||
<div className="overflow-hidden rounded-xl rounded-b-none w-full bg-background">
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full h-[750px]',
|
||||
activeFrame.id !== 'overview' && 'h-auto aspect-[5/3]',
|
||||
)}
|
||||
>
|
||||
{activeFrames.slice(-1).map((frame) => (
|
||||
<div key={frame.key} className="absolute inset-0 w-full h-full">
|
||||
<div className="bg-background rounded-xl h-full w-full">
|
||||
<frame.Component />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { WorldMap } from './world-map';
|
||||
|
||||
export function HeroMap() {
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
const y = useTransform(scrollY, [0, 250], [0, 50], { clamp: true });
|
||||
const scale = useTransform(scrollY, [0, 250], [1, 1.1], { clamp: true });
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
style={{ y, scale }}
|
||||
className="absolute inset-0 top-20 center-center items-start select-none"
|
||||
>
|
||||
<div className="min-w-[1400px] w-full">
|
||||
<WorldMap />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CalendarIcon,
|
||||
ChevronRightIcon,
|
||||
CookieIcon,
|
||||
CreditCardIcon,
|
||||
DatabaseIcon,
|
||||
FlaskRoundIcon,
|
||||
GithubIcon,
|
||||
ServerIcon,
|
||||
StarIcon,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Competition } from './competition';
|
||||
import { Tag } from './tag';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const perks = [
|
||||
{ text: 'Free trial 30 days', icon: CalendarIcon },
|
||||
{ text: 'No credit card required', icon: CreditCardIcon },
|
||||
{ text: 'Cookie-less tracking', icon: CookieIcon },
|
||||
{ text: 'Open-source', icon: GithubIcon },
|
||||
{ text: 'Your data, your rules', icon: DatabaseIcon },
|
||||
{ text: 'Self-hostable', icon: ServerIcon },
|
||||
];
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<HeroContainer>
|
||||
<div className="container relative z-10 col sm:row sm:py-44 max-sm:pt-32">
|
||||
<div className="col gap-8 w-full sm:w-1/2 sm:pr-12">
|
||||
<div className="col gap-4">
|
||||
<Tag className="self-start">
|
||||
<StarIcon className="size-4 fill-yellow-500 text-yellow-500" />
|
||||
Trusted by +2000 projects
|
||||
</Tag>
|
||||
<h1
|
||||
className="text-4xl md:text-5xl font-extrabold leading-[1.1]"
|
||||
title="An open-source alternative to Mixpanel"
|
||||
>
|
||||
An open-source alternative to <Competition />
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
An open-source web and product analytics platform that combines
|
||||
the power of Mixpanel with the ease of Plausible and one of the
|
||||
best Google Analytics replacements.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" asChild className="group w-72">
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started now
|
||||
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<ul className="grid grid-cols-2 gap-2">
|
||||
{perks.map((perk) => (
|
||||
<li key={perk.text} className="text-sm text-muted-foreground">
|
||||
<perk.icon className="size-4 inline-block mr-1" />
|
||||
{perk.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="col sm:w-1/2 relative group">
|
||||
<div
|
||||
className={cn([
|
||||
'overflow-hidden rounded-lg border border-border bg-background shadow-lg',
|
||||
'sm:absolute sm:left-0 sm:-top-12 sm:w-[800px] sm:-bottom-32',
|
||||
'max-sm:h-[800px] max-sm:-mx-4 max-sm:mt-12 relative',
|
||||
])}
|
||||
>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/50 h-12">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
</div>
|
||||
{/* URL bar */}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
href="https://demo.openpanel.dev/demo/shoey"
|
||||
className="group flex-1 mx-4 px-3 py-1 text-sm bg-background rounded-md border border-border flex items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground flex-1">
|
||||
https://demo.openpanel.dev
|
||||
</span>
|
||||
<ArrowRightIcon className="size-4 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
<iframe
|
||||
src={'https://demo.openpanel.dev/demo/shoey?range=lastHour'}
|
||||
className="w-full h-full"
|
||||
title="Live preview"
|
||||
scrolling="no"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 top-12 center-center group-hover:bg-foreground/20 transition-colors">
|
||||
<Button
|
||||
asChild
|
||||
className="group-hover:opacity-100 opacity-0 transition-opacity pointer-events-auto"
|
||||
>
|
||||
<Link
|
||||
href="https://demo.openpanel.dev/demo/shoey"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
Test live demo
|
||||
<FlaskRoundIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className={cn('radial-gradient overflow-hidden relative')}>
|
||||
<div className={cn('relative z-10', className)}>{children}</div>
|
||||
|
||||
{/* Shadow bottom */}
|
||||
<div className="w-full absolute bottom-0 h-32 bg-gradient-to-t from-background to-transparent" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function PlusLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('absolute', className)}>
|
||||
<div className="relative">
|
||||
<div className="w-px h-8 bg-foreground/40 -bottom-4 left-0 absolute animate-pulse" />
|
||||
<div className="w-8 h-px bg-foreground/40 -bottom-px -left-4 absolute animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerticalLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-px bg-gradient-to-t from-transparent via-foreground/30 to-transparent absolute -top-12 -bottom-12',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HorizontalLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-px bg-gradient-to-r from-transparent via-foreground/30 to-transparent absolute left-0 right-0',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
'use client';
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { MenuIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { GithubButton } from './github-button';
|
||||
import { Logo } from './logo';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const Navbar = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const navbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If click outside of the menu, close it
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (isMobileMenuOpen && !navbarRef.current?.contains(e.target as Node)) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', handleClick);
|
||||
return () => window.removeEventListener('click', handleClick);
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
return (
|
||||
<nav className="fixed top-4 z-50 w-full" ref={navbarRef}>
|
||||
<div className="container">
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between border border-transparent backdrop-blur-lg items-center p-4 -mx-4 rounded-full transition-colors',
|
||||
isScrolled
|
||||
? 'bg-background/90 border-foreground/10'
|
||||
: 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link href="/" className="row items-center font-medium">
|
||||
<Logo className="h-6" />
|
||||
{baseOptions.nav?.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="row items-center gap-8">
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-8 text-sm">
|
||||
{baseOptions.links?.map((link) => {
|
||||
if (link.type !== 'main') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url!}
|
||||
className="text-foreground/80 hover:text-foreground font-medium"
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right side buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubButton />
|
||||
{/* Sign in button */}
|
||||
<Button asChild>
|
||||
<Link
|
||||
className="hidden md:flex"
|
||||
href="https://dashboard.openpanel.dev/login"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
className="md:hidden -my-2"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen((p) => !p);
|
||||
}}
|
||||
>
|
||||
<MenuIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile menu */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden -mx-4"
|
||||
>
|
||||
<div className="rounded-xl bg-background/90 border border-foreground/10 mt-4 md:hidden backdrop-blur-lg">
|
||||
<div className="col text-sm divide-y divide-foreground/10">
|
||||
{baseOptions.links?.map((link) => {
|
||||
if (link.type !== 'main') return null;
|
||||
return (
|
||||
<Link
|
||||
key={link.url}
|
||||
href={link.url!}
|
||||
className="text-foreground/80 hover:text-foreground text-xl font-medium p-4 px-4"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -1,31 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <section className={cn('my-32 col', className)}>{children}</section>;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
tag,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
}: {
|
||||
tag?: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('col gap-4 center-center mb-16', className)}>
|
||||
{tag}
|
||||
<h2 className="text-4xl font-medium">{title}</h2>
|
||||
<p className="text-muted-foreground max-w-screen-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { ShieldQuestionIcon } from 'lucide-react';
|
||||
import Script from 'next/script';
|
||||
import { Section, SectionHeader } from '../section';
|
||||
import { Tag } from '../tag';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '../ui/accordion';
|
||||
|
||||
const questions = [
|
||||
{
|
||||
question: 'Does OpenPanel have a free tier?',
|
||||
answer: [
|
||||
'For our Cloud plan we offer a 14 days free trial, this is mostly for you to be able to try out OpenPanel before committing to a paid plan.',
|
||||
'OpenPanel is also open-source and you can self-host it for free!',
|
||||
'',
|
||||
'Why does OpenPanel not have a free tier?',
|
||||
'We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Is everything really unlimited?',
|
||||
answer: [
|
||||
'Everything except the amount of events is unlimited.',
|
||||
'We do not limit the amount of users, projects, dashboards, etc. We want a transparent and fair pricing model and we think unlimited is the best way to do this.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between web and product analytics?',
|
||||
answer: [
|
||||
'Web analytics focuses on website traffic metrics like page views, bounce rates, and visitor sources. Product analytics goes deeper into user behavior, tracking specific actions, user journeys, and feature usage within your application.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Do I need to modify my code to use OpenPanel?',
|
||||
answer: [
|
||||
'Minimal setup is required. Simply add our lightweight JavaScript snippet to your website or use one of our SDKs for your preferred framework. Most common frameworks like React, Vue, and Next.js are supported.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Is my data GDPR compliant?',
|
||||
answer: [
|
||||
'Yes, OpenPanel is fully GDPR compliant. We collect only essential data, do not use cookies for tracking, and provide tools to help you maintain compliance with privacy regulations.',
|
||||
'You can self-host OpenPanel to keep full control of your data.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Mixpanel?',
|
||||
answer: [
|
||||
'OpenPanel offers most of Mixpanel report features such as funnels, retention and visualizations of your data. If you miss something, please let us know. The biggest difference is that OpenPanel offers better web analytics.',
|
||||
'Other than that OpenPanel is way cheaper and can also be self-hosted.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Plausible?',
|
||||
answer: [
|
||||
`OpenPanel's web analytics is inspired by Plausible like many other analytics tools. The difference is that OpenPanel offers more tools for product analytics and better support for none web devices (iOS,Android and servers).`,
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'How does OpenPanel compare to Google Analytics?',
|
||||
answer: [
|
||||
'OpenPanel offers a more privacy-focused, user-friendly alternative to Google Analytics. We provide real-time data, no sampling, and more intuitive product analytics features.',
|
||||
'Unlike GA4, our interface is designed to be simple yet powerful, making it easier to find the insights you need.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Can I export my data?',
|
||||
answer: [
|
||||
'Currently you can export your data with our API. Depending on how many events you have this can be an issue.',
|
||||
'We are working on better export options and will be finished around Q1 2025.',
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'What kind of support do you offer?',
|
||||
answer: ['Currently we offer support through GitHub and Discord.'],
|
||||
},
|
||||
];
|
||||
|
||||
export default Faq;
|
||||
export function Faq() {
|
||||
// Create the JSON-LD structured data
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: questions.map((q) => ({
|
||||
'@type': 'Question',
|
||||
name: q.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: q.answer.join(' '),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<Section className="container">
|
||||
{/* Add the JSON-LD script */}
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
id="faq-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
|
||||
/>
|
||||
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
|
||||
Get answers today
|
||||
</Tag>
|
||||
}
|
||||
title="FAQ"
|
||||
description="Some of the most common questions we get asked."
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full max-w-screen-md self-center"
|
||||
>
|
||||
{questions.map((q) => (
|
||||
<AccordionItem value={q.question} key={q.question}>
|
||||
<AccordionTrigger className="text-left">
|
||||
{q.question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="max-w-2xl col gap-2">
|
||||
{q.answer.map((a) => (
|
||||
<p key={a}>{a}</p>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import {
|
||||
Feature,
|
||||
FeatureContent,
|
||||
FeatureList,
|
||||
FeatureListItem,
|
||||
FeatureMore,
|
||||
SmallFeature,
|
||||
} from '@/components/feature';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import {
|
||||
ActivityIcon,
|
||||
AreaChartIcon,
|
||||
BarChart2Icon,
|
||||
BarChartIcon,
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
CloudIcon,
|
||||
ConeIcon,
|
||||
CookieIcon,
|
||||
DatabaseIcon,
|
||||
GithubIcon,
|
||||
LayersIcon,
|
||||
LineChartIcon,
|
||||
LockIcon,
|
||||
MapIcon,
|
||||
PieChartIcon,
|
||||
ServerIcon,
|
||||
Share2Icon,
|
||||
ShieldIcon,
|
||||
UserIcon,
|
||||
WalletIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import { BatteryIcon } from '../battery-icon';
|
||||
import { EventsFeature } from './features/events-feature';
|
||||
import { ProductAnalyticsFeature } from './features/product-analytics-feature';
|
||||
import { ProfilesFeature } from './features/profiles-feature';
|
||||
import { WebAnalyticsFeature } from './features/web-analytics-feature';
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
className="mb-16"
|
||||
tag={
|
||||
<Tag>
|
||||
<BatteryIcon className="size-4" strokeWidth={1.5} />
|
||||
Batteries included
|
||||
</Tag>
|
||||
}
|
||||
title="Everything you need"
|
||||
description="We have combined the best features from the most popular analytics tools into one simple to use platform."
|
||||
/>
|
||||
<div className="col gap-16">
|
||||
<Feature media={<WebAnalyticsFeature />}>
|
||||
<FeatureContent
|
||||
title="Web analytics"
|
||||
content={[
|
||||
'Privacy-friendly analytics with all the important metrics you need, in a simple and modern interface.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
className="mt-4"
|
||||
title="Get a quick overview"
|
||||
items={[
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Visitors" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Referrals" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Top pages" />,
|
||||
<FeatureListItem
|
||||
key="line"
|
||||
icon={CheckIcon}
|
||||
title="Top entries"
|
||||
/>,
|
||||
<FeatureListItem
|
||||
key="line"
|
||||
icon={CheckIcon}
|
||||
title="Top exists"
|
||||
/>,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Devices" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Sessions" />,
|
||||
<FeatureListItem
|
||||
key="line"
|
||||
icon={CheckIcon}
|
||||
title="Bounce rate"
|
||||
/>,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Duration" />,
|
||||
<FeatureListItem key="line" icon={CheckIcon} title="Geography" />,
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
|
||||
<Feature reverse media={<ProductAnalyticsFeature />}>
|
||||
<FeatureContent
|
||||
title="Product analytics"
|
||||
content={[
|
||||
'Turn data into decisions with powerful visualizations and real-time insights.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
className="mt-4"
|
||||
title="Understand your product"
|
||||
items={[
|
||||
<FeatureListItem key="funnel" icon={ConeIcon} title="Funnel" />,
|
||||
<FeatureListItem
|
||||
key="retention"
|
||||
icon={UserIcon}
|
||||
title="Retention"
|
||||
/>,
|
||||
<FeatureListItem
|
||||
key="bar"
|
||||
icon={BarChartIcon}
|
||||
title="A/B tests"
|
||||
/>,
|
||||
<FeatureListItem
|
||||
key="pie"
|
||||
icon={PieChartIcon}
|
||||
title="Conversion"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
|
||||
<FeatureList
|
||||
className="mt-4"
|
||||
title="Supported charts"
|
||||
items={[
|
||||
<FeatureListItem key="line" icon={LineChartIcon} title="Line" />,
|
||||
<FeatureListItem key="bar" icon={BarChartIcon} title="Bar" />,
|
||||
<FeatureListItem key="pie" icon={PieChartIcon} title="Pie" />,
|
||||
<FeatureListItem key="area" icon={AreaChartIcon} title="Area" />,
|
||||
<FeatureListItem
|
||||
key="histogram"
|
||||
icon={BarChart2Icon}
|
||||
title="Histogram"
|
||||
/>,
|
||||
<FeatureListItem key="map" icon={MapIcon} title="Map" />,
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-blue-500">
|
||||
<FeatureContent
|
||||
icon={<ClockIcon className="size-8" strokeWidth={1} />}
|
||||
title="Real time analytics"
|
||||
content={[
|
||||
'Get instant insights into your data. No need to wait for data to be processed, like other tools out there, looking at you GA4...',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-purple-500">
|
||||
<FeatureContent
|
||||
icon={<DatabaseIcon className="size-8" strokeWidth={1} />}
|
||||
title="Own your own data"
|
||||
content={[
|
||||
'Own your data, no vendor lock-in. Export all your data with our export API.',
|
||||
'Self-host it on your own infrastructure to have complete control.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
|
||||
<FeatureContent
|
||||
icon={<CloudIcon className="size-8" strokeWidth={1} />}
|
||||
title="Cloud or self-hosted"
|
||||
content={[
|
||||
'We offer a cloud version of the platform, but you can also self-host it on your own infrastructure.',
|
||||
]}
|
||||
/>
|
||||
<FeatureMore
|
||||
href="/docs/self-hosting/self-hosting"
|
||||
className="mt-4 -mb-4"
|
||||
>
|
||||
More about self-hosting
|
||||
</FeatureMore>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-green-500">
|
||||
<FeatureContent
|
||||
icon={<CookieIcon className="size-8" strokeWidth={1} />}
|
||||
title="No cookies"
|
||||
content={[
|
||||
'We care about your privacy, so our tracker does not use cookies. This keeps your data safe and secure.',
|
||||
'We follow GDPR guidelines closely, ensuring your personal information is protected without using invasive technologies.',
|
||||
]}
|
||||
/>
|
||||
<FeatureMore
|
||||
href="/articles/cookieless-analytics"
|
||||
className="mt-4 -mb-4"
|
||||
>
|
||||
Cookieless analytics
|
||||
</FeatureMore>
|
||||
</SmallFeature>
|
||||
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-gray-500">
|
||||
<FeatureContent
|
||||
icon={<GithubIcon className="size-8" strokeWidth={1} />}
|
||||
title="Open-source"
|
||||
content={[
|
||||
'Our code is open and transparent. Contribute, fork, or learn from our implementation.',
|
||||
]}
|
||||
/>
|
||||
<FeatureMore
|
||||
href="https://git.new/openpanel"
|
||||
className="mt-4 -mb-4"
|
||||
>
|
||||
View the code
|
||||
</FeatureMore>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-purple-500">
|
||||
<FeatureContent
|
||||
icon={<LockIcon className="size-8" strokeWidth={1} />}
|
||||
title="Your data, your rules"
|
||||
content={[
|
||||
'Complete control over your data. Export, delete, or manage it however you need.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-yellow-500">
|
||||
<FeatureContent
|
||||
icon={<WalletIcon className="size-8" strokeWidth={1} />}
|
||||
title="Affordably priced"
|
||||
content={[
|
||||
'Transparent pricing that scales with your needs. No hidden fees or surprise charges.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-orange-500">
|
||||
<FeatureContent
|
||||
icon={<ZapIcon className="size-8" strokeWidth={1} />}
|
||||
title="Moving fast"
|
||||
content={[
|
||||
'Regular updates and improvements. We move quickly to add features you need.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-blue-500">
|
||||
<FeatureContent
|
||||
icon={<ActivityIcon className="size-8" strokeWidth={1} />}
|
||||
title="Real-time data"
|
||||
content={[
|
||||
'See your analytics as they happen. No waiting for data processing or updates.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
|
||||
<FeatureContent
|
||||
icon={<Share2Icon className="size-8" strokeWidth={1} />}
|
||||
title="Sharable reports"
|
||||
content={[
|
||||
'Easily share insights with your team. Export and distribute reports with a single click.',
|
||||
<i key="soon">Coming soon</i>,
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-pink-500">
|
||||
<FeatureContent
|
||||
icon={<BarChart2Icon className="size-8" strokeWidth={1} />}
|
||||
title="Visualize your data"
|
||||
content={[
|
||||
'Beautiful, interactive visualizations that make your data easy to understand.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
|
||||
<FeatureContent
|
||||
icon={<LayersIcon className="size-8" strokeWidth={1} />}
|
||||
title="Best of both worlds"
|
||||
content={[
|
||||
'Combine the power of self-hosting with the convenience of cloud deployment.',
|
||||
]}
|
||||
/>
|
||||
</SmallFeature>
|
||||
</div>
|
||||
|
||||
<Feature media={<EventsFeature />}>
|
||||
<FeatureContent
|
||||
title="Your events"
|
||||
content={[
|
||||
'Track every user interaction with powerful real-time event analytics. See all event properties, user actions, and conversion data in one place.',
|
||||
'From pageviews to custom events, get complete visibility into how users actually use your product.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
cols={1}
|
||||
className="mt-4"
|
||||
title="Some goodies"
|
||||
items={[
|
||||
'• Events arrive within seconds',
|
||||
'• Filter on any property or attribute',
|
||||
'• Get notified on important events',
|
||||
'• Export and analyze event data',
|
||||
'• Track user journeys and conversions',
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
<Feature reverse media={<ProfilesFeature />}>
|
||||
<FeatureContent
|
||||
title="Profiles and sessions"
|
||||
content={[
|
||||
'Get detailed insights into how users interact with your product through comprehensive profile and session tracking. See everything from basic metrics to detailed behavioral patterns.',
|
||||
'Track session duration, page views, and user journeys to understand how people actually use your product.',
|
||||
]}
|
||||
/>
|
||||
<FeatureList
|
||||
cols={1}
|
||||
className="mt-4"
|
||||
title="What can you see?"
|
||||
items={[
|
||||
'• First and last seen dates',
|
||||
'• Session duration and counts',
|
||||
'• Page views and activity patterns',
|
||||
'• User location and device info',
|
||||
'• Browser and OS details',
|
||||
'• Event history and interactions',
|
||||
'• Real-time activity tracking',
|
||||
]}
|
||||
/>
|
||||
</Feature>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
'use client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
BellIcon,
|
||||
BookOpenIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
HeartIcon,
|
||||
LogOutIcon,
|
||||
MessageSquareIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
Share2Icon,
|
||||
ShoppingCartIcon,
|
||||
StarIcon,
|
||||
ThumbsUpIcon,
|
||||
UserPlusIcon,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Event {
|
||||
id: number;
|
||||
action: string;
|
||||
location: string;
|
||||
platform: string;
|
||||
icon: any;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const locations = [
|
||||
'Gothenburg',
|
||||
'Stockholm',
|
||||
'Oslo',
|
||||
'Copenhagen',
|
||||
'Berlin',
|
||||
'New York',
|
||||
'Singapore',
|
||||
'London',
|
||||
'Paris',
|
||||
'Madrid',
|
||||
'Rome',
|
||||
'Barcelona',
|
||||
'Amsterdam',
|
||||
'Vienna',
|
||||
];
|
||||
const platforms = ['iOS', 'Android', 'Windows', 'macOS'];
|
||||
const browsers = ['WebKit', 'Chrome', 'Firefox', 'Safari'];
|
||||
|
||||
const getCountryFlag = (country: (typeof locations)[number]) => {
|
||||
switch (country) {
|
||||
case 'Gothenburg':
|
||||
return '🇸🇪';
|
||||
case 'Stockholm':
|
||||
return '🇸🇪';
|
||||
case 'Oslo':
|
||||
return '🇳🇴';
|
||||
case 'Copenhagen':
|
||||
return '🇩🇰';
|
||||
case 'Berlin':
|
||||
return '🇩🇪';
|
||||
case 'New York':
|
||||
return '🇺🇸';
|
||||
case 'Singapore':
|
||||
return '🇸🇬';
|
||||
case 'London':
|
||||
return '🇬🇧';
|
||||
case 'Paris':
|
||||
return '🇫🇷';
|
||||
case 'Madrid':
|
||||
return '🇪🇸';
|
||||
case 'Rome':
|
||||
return '🇮🇹';
|
||||
case 'Barcelona':
|
||||
return '🇪🇸';
|
||||
case 'Amsterdam':
|
||||
return '🇳🇱';
|
||||
case 'Vienna':
|
||||
return '🇦🇹';
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platform: (typeof platforms)[number]) => {
|
||||
switch (platform) {
|
||||
case 'iOS':
|
||||
return '🍎';
|
||||
case 'Android':
|
||||
return '🤖';
|
||||
case 'Windows':
|
||||
return '💻';
|
||||
case 'macOS':
|
||||
return '🍎';
|
||||
}
|
||||
};
|
||||
|
||||
const TOTAL_EVENTS = 10;
|
||||
|
||||
export function EventsFeature() {
|
||||
const [events, setEvents] = useState<Event[]>([
|
||||
{
|
||||
id: 1730663803358.4075,
|
||||
action: 'purchase',
|
||||
location: 'New York',
|
||||
platform: 'macOS',
|
||||
icon: ShoppingCartIcon,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
id: 1730663801358.3079,
|
||||
action: 'logout',
|
||||
location: 'Copenhagen',
|
||||
platform: 'Windows',
|
||||
icon: LogOutIcon,
|
||||
color: 'bg-red-500',
|
||||
},
|
||||
{
|
||||
id: 1730663799358.0283,
|
||||
action: 'sign up',
|
||||
location: 'Berlin',
|
||||
platform: 'Android',
|
||||
icon: UserPlusIcon,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
id: 1730663797357.2036,
|
||||
action: 'share',
|
||||
location: 'Barcelona',
|
||||
platform: 'macOS',
|
||||
icon: Share2Icon,
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
id: 1730663795358.763,
|
||||
action: 'sign up',
|
||||
location: 'New York',
|
||||
platform: 'macOS',
|
||||
icon: UserPlusIcon,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
id: 1730663792067.689,
|
||||
action: 'share',
|
||||
location: 'New York',
|
||||
platform: 'macOS',
|
||||
icon: Share2Icon,
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
id: 1730663790075.3435,
|
||||
action: 'like',
|
||||
location: 'Copenhagen',
|
||||
platform: 'iOS',
|
||||
icon: HeartIcon,
|
||||
color: 'bg-pink-500',
|
||||
},
|
||||
{
|
||||
id: 1730663788070.351,
|
||||
action: 'recommend',
|
||||
location: 'Oslo',
|
||||
platform: 'Android',
|
||||
icon: ThumbsUpIcon,
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
id: 1730663786074.429,
|
||||
action: 'read',
|
||||
location: 'New York',
|
||||
platform: 'Windows',
|
||||
icon: BookOpenIcon,
|
||||
color: 'bg-teal-500',
|
||||
},
|
||||
{
|
||||
id: 1730663784065.6309,
|
||||
action: 'sign up',
|
||||
location: 'Gothenburg',
|
||||
platform: 'iOS',
|
||||
icon: UserPlusIcon,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prepend new event every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
setEvents((prevEvents) => [
|
||||
generateEvent(),
|
||||
...prevEvents.slice(0, TOTAL_EVENTS - 1),
|
||||
]);
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden p-8 max-h-[700px]">
|
||||
<div
|
||||
className="min-w-[500px] gap-4 flex flex-col overflow-hidden relative isolate"
|
||||
// style={{ height: 60 * TOTAL_EVENTS + 16 * (TOTAL_EVENTS - 1) }}
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{events.map((event) => (
|
||||
<motion.div
|
||||
key={event.id}
|
||||
className="flex items-center shadow bg-background-light rounded"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: '60px' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 50,
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-[200px] py-2 px-4">
|
||||
<div
|
||||
className={`size-8 rounded-full bg-background flex items-center justify-center ${event.color} text-white `}
|
||||
>
|
||||
{event.icon && <event.icon size={16} />}
|
||||
</div>
|
||||
<span className="font-medium truncate">{event.action}</span>
|
||||
</div>
|
||||
<div className="w-[150px] py-2 px-4 truncate">
|
||||
<span className="mr-2 text-xl relative top-px">
|
||||
{getCountryFlag(event.location)}
|
||||
</span>
|
||||
{event.location}
|
||||
</div>
|
||||
<div className="w-[150px] py-2 px-4 truncate">
|
||||
<span className="mr-2 text-xl relative top-px">
|
||||
{getPlatformIcon(event.platform)}
|
||||
</span>
|
||||
{event.platform}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to generate events (moved outside component)
|
||||
function generateEvent() {
|
||||
const actions = [
|
||||
{ text: 'sign up', icon: UserPlusIcon, color: 'bg-green-500' },
|
||||
{ text: 'purchase', icon: ShoppingCartIcon, color: 'bg-blue-500' },
|
||||
{ text: 'screen view', icon: EyeIcon, color: 'bg-purple-500' },
|
||||
{ text: 'logout', icon: LogOutIcon, color: 'bg-red-500' },
|
||||
{ text: 'like', icon: HeartIcon, color: 'bg-pink-500' },
|
||||
{ text: 'comment', icon: MessageSquareIcon, color: 'bg-indigo-500' },
|
||||
{ text: 'share', icon: Share2Icon, color: 'bg-cyan-500' },
|
||||
{ text: 'download', icon: DownloadIcon, color: 'bg-emerald-500' },
|
||||
{ text: 'notification', icon: BellIcon, color: 'bg-violet-500' },
|
||||
{ text: 'settings', icon: SettingsIcon, color: 'bg-slate-500' },
|
||||
{ text: 'search', icon: SearchIcon, color: 'bg-violet-500' },
|
||||
{ text: 'read', icon: BookOpenIcon, color: 'bg-teal-500' },
|
||||
{ text: 'recommend', icon: ThumbsUpIcon, color: 'bg-orange-500' },
|
||||
{ text: 'favorite', icon: StarIcon, color: 'bg-yellow-500' },
|
||||
];
|
||||
|
||||
const selectedAction = actions[Math.floor(Math.random() * actions.length)];
|
||||
|
||||
return {
|
||||
id: Date.now() + Math.random(),
|
||||
action: selectedAction.text,
|
||||
location: locations[Math.floor(Math.random() * locations.length)],
|
||||
platform: platforms[Math.floor(Math.random() * platforms.length)],
|
||||
icon: selectedAction.icon,
|
||||
color: selectedAction.color,
|
||||
};
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
'use client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Mock data structure for retention cohort
|
||||
const COHORT_DATA = [
|
||||
{
|
||||
week: 'Week 1',
|
||||
users: '2,543',
|
||||
retention: [100, 84, 73, 67, 62, 58],
|
||||
},
|
||||
{
|
||||
week: 'Week 2',
|
||||
users: '2,148',
|
||||
retention: [100, 80, 69, 63, 59, 55],
|
||||
},
|
||||
{
|
||||
week: 'Week 3',
|
||||
users: '1,958',
|
||||
retention: [100, 82, 71, 64, 60, 56],
|
||||
},
|
||||
{
|
||||
week: 'Week 4',
|
||||
users: '2,034',
|
||||
retention: [100, 83, 72, 65, 61, 57],
|
||||
},
|
||||
{
|
||||
week: 'Week 5',
|
||||
users: '1,987',
|
||||
retention: [100, 81, 70, 64, 60, 56],
|
||||
},
|
||||
{
|
||||
week: 'Week 6',
|
||||
users: '2,245',
|
||||
retention: [100, 85, 74, 68, 64, 60],
|
||||
},
|
||||
{
|
||||
week: 'Week 7',
|
||||
users: '2,108',
|
||||
retention: [100, 82, 71, 65, 61],
|
||||
},
|
||||
{
|
||||
week: 'Week 8',
|
||||
users: '1,896',
|
||||
retention: [100, 83, 72, 66],
|
||||
},
|
||||
{
|
||||
week: 'Week 9',
|
||||
users: '2,156',
|
||||
retention: [100, 81, 70],
|
||||
},
|
||||
];
|
||||
const COHORT_DATA_ALT = [
|
||||
{
|
||||
week: 'Week 1',
|
||||
users: '2,876',
|
||||
retention: [100, 79, 76, 70, 65, 61],
|
||||
},
|
||||
{
|
||||
week: 'Week 2',
|
||||
users: '2,543',
|
||||
retention: [100, 85, 73, 67, 62, 58],
|
||||
},
|
||||
{
|
||||
week: 'Week 3',
|
||||
users: '2,234',
|
||||
retention: [100, 79, 75, 68, 63, 59],
|
||||
},
|
||||
{
|
||||
week: 'Week 4',
|
||||
users: '2,456',
|
||||
retention: [100, 88, 77, 69, 65, 61],
|
||||
},
|
||||
{
|
||||
week: 'Week 5',
|
||||
users: '2,321',
|
||||
retention: [100, 77, 73, 67, 54, 42],
|
||||
},
|
||||
{
|
||||
week: 'Week 6',
|
||||
users: '2,654',
|
||||
retention: [100, 91, 83, 69, 66, 62],
|
||||
},
|
||||
{
|
||||
week: 'Week 7',
|
||||
users: '2,432',
|
||||
retention: [100, 93, 88, 72, 64],
|
||||
},
|
||||
{
|
||||
week: 'Week 8',
|
||||
users: '2,123',
|
||||
retention: [100, 78, 76, 69],
|
||||
},
|
||||
{
|
||||
week: 'Week 9',
|
||||
users: '2,567',
|
||||
retention: [100, 70, 64],
|
||||
},
|
||||
];
|
||||
|
||||
function RetentionCell({ percentage }: { percentage: number }) {
|
||||
// Calculate color intensity based on percentage
|
||||
const getBackgroundColor = (value: number) => {
|
||||
if (value === 0) return 'bg-transparent';
|
||||
// Using CSS color mixing to create a gradient from light to dark blue
|
||||
return `rgb(${Math.round(239 - value * 1.39)} ${Math.round(246 - value * 1.46)} ${Math.round(255 - value * 0.55)})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center p-px text-sm font-medium w-[80px]">
|
||||
<div
|
||||
className="flex text-white items-center justify-center w-full h-full rounded"
|
||||
style={{
|
||||
backgroundColor: getBackgroundColor(percentage),
|
||||
}}
|
||||
>
|
||||
<motion.span
|
||||
key={percentage}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{percentage}%
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductAnalyticsFeature() {
|
||||
const [currentData, setCurrentData] = useState(COHORT_DATA);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentData((current) =>
|
||||
current === COHORT_DATA ? COHORT_DATA_ALT : COHORT_DATA,
|
||||
);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full overflow-hidden">
|
||||
<div className="flex">
|
||||
{/* Header row */}
|
||||
<div className="min-w-[70px] flex flex-col">
|
||||
<div className="p-2 font-medium text-xs text-muted-foreground">
|
||||
Cohort
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Week numbers - changed length to 6 */}
|
||||
<div className="flex">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i.toString()}
|
||||
className="text-muted-foreground w-[80px] text-xs text-center p-2 font-medium"
|
||||
>
|
||||
W{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data rows */}
|
||||
<div className="flex flex-col">
|
||||
{currentData.map((cohort, rowIndex) => (
|
||||
<div key={rowIndex.toString()} className="flex">
|
||||
<div className="min-w-[70px] flex flex-col">
|
||||
<div className="p-2 text-sm whitespace-nowrap text-muted-foreground">
|
||||
{cohort.week}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{cohort.retention.map((value, cellIndex) => (
|
||||
<RetentionCell key={cellIndex.toString()} percentage={value} />
|
||||
))}
|
||||
{/* Fill empty cells - changed length to 6 */}
|
||||
{Array.from({ length: 6 - cohort.retention.length }).map(
|
||||
(_, i) => (
|
||||
<div key={`empty-${i.toString()}`} className="w-[80px] p-px">
|
||||
<div className="h-full w-full rounded bg-background" />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const PROFILES = [
|
||||
{
|
||||
name: 'Joe Bloggs',
|
||||
email: 'joe@bloggs.com',
|
||||
avatar: '/avatar.jpg',
|
||||
stats: {
|
||||
firstSeen: 'about 2 months',
|
||||
lastSeen: '41 minutes',
|
||||
sessions: '8',
|
||||
avgSession: '5m 59s',
|
||||
p90Session: '7m 42s',
|
||||
pageViews: '41',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@smith.com',
|
||||
avatar: '/avatar-2.jpg',
|
||||
stats: {
|
||||
firstSeen: 'about 1 month',
|
||||
lastSeen: '2 hours',
|
||||
sessions: '12',
|
||||
avgSession: '4m 32s',
|
||||
p90Session: '6m 15s',
|
||||
pageViews: '35',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Alex Johnson',
|
||||
email: 'alex@johnson.com',
|
||||
avatar: '/avatar-3.jpg',
|
||||
stats: {
|
||||
firstSeen: 'about 3 months',
|
||||
lastSeen: '15 minutes',
|
||||
sessions: '15',
|
||||
avgSession: '6m 20s',
|
||||
p90Session: '8m 10s',
|
||||
pageViews: '52',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function ProfilesFeature() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isTransitioning, setIsTransitioning] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (currentIndex === PROFILES.length) {
|
||||
setIsTransitioning(false);
|
||||
setCurrentIndex(0);
|
||||
setTimeout(() => setIsTransitioning(true), 50);
|
||||
} else {
|
||||
setCurrentIndex((current) => current + 1);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [currentIndex]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className={`flex ${isTransitioning ? 'transition-transform duration-500 ease-in-out' : ''}`}
|
||||
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
|
||||
>
|
||||
{[...PROFILES, PROFILES[0]].map((profile, index) => (
|
||||
<div
|
||||
key={profile.name + index.toString()}
|
||||
className="w-full flex-shrink-0 p-8"
|
||||
>
|
||||
<div className="col md:row justify-center md:justify-start items-center gap-4">
|
||||
<Image
|
||||
src={profile.avatar}
|
||||
className="size-32 rounded-full"
|
||||
width={128}
|
||||
height={128}
|
||||
alt={profile.name}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-3xl font-semibold">{profile.name}</div>
|
||||
<div className="text-muted-foreground text-center md:text-left">
|
||||
{profile.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">First seen</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.firstSeen}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">Last seen</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.lastSeen}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">Sessions</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.sessions}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Avg. Session
|
||||
</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.avgSession}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
P90. Session
|
||||
</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.p90Session}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4 bg-background-light">
|
||||
<div className="text-sm text-muted-foreground">Page views</div>
|
||||
<div className="text-lg font-medium">
|
||||
{profile.stats.pageViews}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRICING } from '@openpanel/payments/src/prices';
|
||||
import { CheckIcon, ChevronRightIcon, DollarSignIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { DoubleSwirl } from '../Swirls';
|
||||
import { Section, SectionHeader } from '../section';
|
||||
import { Tag } from '../tag';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltiper } from '../ui/tooltip';
|
||||
|
||||
export default Pricing;
|
||||
export function Pricing({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={cn(
|
||||
'overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground xl:rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DoubleSwirl className="absolute top-0 left-0" />
|
||||
<div className="container relative z-10 col">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag variant={'dark'}>
|
||||
<DollarSignIcon className="size-4" />
|
||||
Simple and predictable
|
||||
</Tag>
|
||||
}
|
||||
title="Simple pricing"
|
||||
description="Just pick how many events you want to track each month. No hidden costs."
|
||||
/>
|
||||
|
||||
<div className="grid self-center md:grid-cols-[200px_1fr] lg:grid-cols-[300px_1fr] gap-8">
|
||||
<div className="col gap-4">
|
||||
<h3 className="font-medium text-xl text-background/90 dark:text-foreground/90">
|
||||
Stop overpaying for features
|
||||
</h3>
|
||||
<ul className="gap-1 col text-background/70 dark:text-foreground/70">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />
|
||||
Unlimited websites or apps
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited users
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited dashboards
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited charts
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Unlimited tracked profiles
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
|
||||
Yes, we have no limits or hidden costs
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
asChild
|
||||
className="self-start mt-4 px-8 group"
|
||||
>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started now
|
||||
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="col justify-between gap-4 max-w-lg">
|
||||
<div className="space-y-2">
|
||||
{PRICING.map((tier) => (
|
||||
<div
|
||||
key={tier.events}
|
||||
className={cn(
|
||||
'group col',
|
||||
'backdrop-blur-3xl bg-foreground/70 dark:bg-background-dark/70',
|
||||
'p-4 py-2 border border-background/20 dark:border-foreground/20 rounded-lg hover:bg-background/5 dark:hover:bg-foreground/5 transition-colors',
|
||||
'mx-2',
|
||||
tier.discount &&
|
||||
'mx-0 px-6 py-3 !bg-emerald-900/20 hover:!bg-emerald-900/30',
|
||||
tier.popular &&
|
||||
'mx-0 px-6 py-3 !bg-orange-900/20 hover:!bg-orange-900/30',
|
||||
)}
|
||||
>
|
||||
<div className="row justify-between">
|
||||
<div>
|
||||
{new Intl.NumberFormat('en-US', {}).format(tier.events)}{' '}
|
||||
<span className="text-muted-foreground text-sm max-[420px]:hidden">
|
||||
events / month
|
||||
</span>
|
||||
</div>
|
||||
<div className="row gap-4">
|
||||
{tier.popular && (
|
||||
<>
|
||||
<Tag variant="dark" className="hidden md:inline-flex">
|
||||
🔥 Popular
|
||||
</Tag>
|
||||
<span className="md:hidden">🔥</span>
|
||||
</>
|
||||
)}
|
||||
{tier.discount && (
|
||||
<>
|
||||
<Tag
|
||||
variant="dark"
|
||||
className="hidden md:inline-flex whitespace-nowrap"
|
||||
>
|
||||
💸 Discount
|
||||
</Tag>
|
||||
<span className="md:hidden">💸</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="row gap-1">
|
||||
{tier.discount && (
|
||||
<span className={cn('text-md font-semibold')}>
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(tier.price * (1 - tier.discount.amount))}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-md font-semibold',
|
||||
tier.discount && 'line-through opacity-50',
|
||||
)}
|
||||
>
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(tier.price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tier.discount && (
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
Limited discount code available:{' '}
|
||||
<Tooltiper
|
||||
content={`Get ${tier.discount.amount * 100}% off your first year`}
|
||||
delayDuration={0}
|
||||
side="bottom"
|
||||
>
|
||||
<strong>{tier.discount.code}</strong>
|
||||
</Tooltiper>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'group',
|
||||
'row justify-between p-4 py-2 border border-background/20 dark:border-foreground/20 rounded-lg hover:bg-background/5 dark:hover:bg-foreground/5 transition-colors',
|
||||
'mx-2',
|
||||
)}
|
||||
>
|
||||
<div className="whitespace-nowrap">
|
||||
Over{' '}
|
||||
{new Intl.NumberFormat('en-US', {}).format(
|
||||
PRICING[PRICING.length - 1].events,
|
||||
)}
|
||||
</div>
|
||||
<div className="text-md font-semibold">
|
||||
<Link
|
||||
href="mailto:support@openpanel.dev"
|
||||
className="group-hover:underline"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="self-center text-sm text-muted-foreground mt-4 text-center max-w-[70%] w-full">
|
||||
<strong className="text-background/80 dark:text-foreground/80">
|
||||
All features are included upfront - no hidden costs.
|
||||
</strong>{' '}
|
||||
You choose how many events to track each month.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { type Framework, frameworks } from '@openpanel/sdk-info';
|
||||
import { CodeIcon, ShieldQuestionIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { HorizontalLine, PlusLine, VerticalLine } from '../line';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function Sdks() {
|
||||
return (
|
||||
<Section className="container overflow-hidden">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
|
||||
Easy to use
|
||||
</Tag>
|
||||
}
|
||||
title="SDKs"
|
||||
description="Use our modules to integrate with your favourite framework and start collecting events with ease. Enjoy quick and seamless setup."
|
||||
/>
|
||||
<div className="col gap-16">
|
||||
<div className="relative">
|
||||
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
|
||||
{frameworks.slice(0, 5).map((sdk, index) => (
|
||||
<SdkCard key={sdk.name} sdk={sdk} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<HorizontalLine className="opacity-40 -left-32 -right-32" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
|
||||
{frameworks.slice(5, 10).map((sdk, index) => (
|
||||
<SdkCard key={sdk.name} sdk={sdk} index={index} />
|
||||
))}
|
||||
</div>
|
||||
<HorizontalLine className="opacity-40 -left-32 -right-32" />
|
||||
</div>
|
||||
|
||||
<div className="center-center gap-2 col">
|
||||
<h3 className="text-muted-foreground text-sm">And many more!</h3>
|
||||
<Button asChild>
|
||||
<Link href="/docs">Read our docs</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function SdkCard({
|
||||
sdk,
|
||||
index,
|
||||
}: {
|
||||
sdk: Framework;
|
||||
index: number;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
key={sdk.name}
|
||||
href={sdk.href}
|
||||
className="group relative z-10 col gap-2 uppercase center-center aspect-video bg-background-light rounded-lg shadow-[inset_0_0_0_1px_theme(colors.border),0_0_30px_0px_hsl(var(--border)/0.5)] transition-all hover:scale-105 hover:bg-background-dark"
|
||||
>
|
||||
{index === 0 && <PlusLine className="opacity-30 top-0 left-0" />}
|
||||
{index === 2 && <PlusLine className="opacity-80 bottom-0 right-0" />}
|
||||
<VerticalLine className="left-0 opacity-40" />
|
||||
<VerticalLine className="right-0 opacity-40" />
|
||||
<div className="absolute inset-0 center-center overflow-hidden opacity-20">
|
||||
<sdk.IconComponent className="size-32 top-[33%] relative group-hover:top-[30%] group-hover:scale-105 transition-all" />
|
||||
</div>
|
||||
<div
|
||||
className="center-center gap-1 col w-full h-full relative rounded-lg"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle, hsl(var(--background)) 0%, hsl(var(--background)/0.7) 100%)',
|
||||
}}
|
||||
>
|
||||
<sdk.IconComponent className="size-8" />
|
||||
{/* <h4 className="text-muted-foreground text-[10px]">{sdk.name}</h4> */}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import { VerticalLine } from '../line';
|
||||
import { PlusLine } from '../line';
|
||||
import { HorizontalLine } from '../line';
|
||||
import { Section } from '../section';
|
||||
import { Button } from '../ui/button';
|
||||
import { WorldMap } from '../world-map';
|
||||
|
||||
function shortNumber(num: number) {
|
||||
if (num < 1e3) return num;
|
||||
if (num >= 1e3 && num < 1e6) return `${+(num / 1e3).toFixed(1)}K`;
|
||||
if (num >= 1e6 && num < 1e9) return `${+(num / 1e6).toFixed(1)}M`;
|
||||
if (num >= 1e9 && num < 1e12) return `${+(num / 1e9).toFixed(1)}B`;
|
||||
if (num >= 1e12) return `${+(num / 1e12).toFixed(1)}T`;
|
||||
}
|
||||
|
||||
export async function Stats() {
|
||||
const { projectsCount, eventsCount, eventsLast24hCount } = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/misc/stats`,
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.catch(() => ({
|
||||
projectsCount: 0,
|
||||
eventsCount: 0,
|
||||
eventsLast24hCount: 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<StatsPure
|
||||
projectCount={projectsCount}
|
||||
eventCount={eventsCount}
|
||||
last24hCount={eventsLast24hCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsPure({
|
||||
projectCount,
|
||||
eventCount,
|
||||
last24hCount,
|
||||
}: { projectCount: number; eventCount: number; last24hCount: number }) {
|
||||
return (
|
||||
<Section className="bg-gradient-to-b from-background via-background-dark to-background-dark py-64 pt-44 relative overflow-hidden -mt-16">
|
||||
{/* Map */}
|
||||
<div className="absolute inset-0 -top-20 center-center items-start select-none opacity-10">
|
||||
<div className="min-w-[1400px] w-full">
|
||||
<WorldMap />
|
||||
{/* Gradient over Map */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background via-transparent to-background" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<HorizontalLine />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 container center-center">
|
||||
<div className="col gap-2 uppercase center-center relative p-4">
|
||||
<VerticalLine className="hidden lg:block left-0" />
|
||||
<PlusLine className="hidden lg:block top-0 left-0" />
|
||||
<div className="text-muted-foreground text-xs">Active projects</div>
|
||||
<div className="text-5xl font-bold font-mono">{projectCount}</div>
|
||||
</div>
|
||||
<div className="col gap-2 uppercase center-center relative p-4">
|
||||
<VerticalLine className="hidden lg:block left-0" />
|
||||
<div className="text-muted-foreground text-xs">Total events</div>
|
||||
<div className="text-5xl font-bold font-mono">
|
||||
{shortNumber(eventCount)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-2 uppercase center-center relative p-4">
|
||||
<VerticalLine className="hidden lg:block left-0" />
|
||||
<VerticalLine className="hidden lg:block right-0" />
|
||||
<PlusLine className="hidden lg:block bottom-0 left-0" />
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Events last 24 h
|
||||
</div>
|
||||
<div className="text-5xl font-bold font-mono">
|
||||
{shortNumber(last24hCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalLine />
|
||||
</div>
|
||||
<div className="center-center col gap-4 absolute bottom-20 left-0 right-0 z-10">
|
||||
<p>Get the analytics you deserve</p>
|
||||
<Button asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Try it for free
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { TwitterCard } from '@/components/twitter-card';
|
||||
import { MessageCircleIcon } from 'lucide-react';
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-steven.jpg',
|
||||
name: 'Steven Tey',
|
||||
handle: 'steventey',
|
||||
content: [
|
||||
'Open-source Mixpanel alternative just dropped → http://git.new/openpanel',
|
||||
'It combines the power of Mixpanel + the ease of use of @PlausibleHQ into a fully open-source product.',
|
||||
'Built by @CarlLindesvard and it’s already tracking 750K+ events 🤩',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-pontus.jpg',
|
||||
name: 'Pontus Abrahamsson — oss/acc',
|
||||
handle: 'pontusab',
|
||||
content: ['Thanks, OpenPanel is a beast, love it!'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-piotr.jpg',
|
||||
name: 'Piotr Kulpinski',
|
||||
handle: 'piotrkulpinski',
|
||||
content: [
|
||||
'The Overview tab in OpenPanel is great. It has everything I need from my analytics: the stats, the graph, traffic sources, locations, devices, etc.',
|
||||
'The UI is beautiful ✨ Clean, modern look, very pleasing to the eye.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-greg.png',
|
||||
name: 'greg hodson 🍜',
|
||||
handle: 'h0dson',
|
||||
content: ['i second this, openpanel is killing it'],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-jacob.jpg',
|
||||
name: 'Jacob 🍀 Build in Public',
|
||||
handle: 'javayhuwx',
|
||||
content: [
|
||||
"🤯 wow, it's amazing! Just integrate @OpenPanelDev into http://indiehackers.site last night, and now I can see visitors coming from all round the world.",
|
||||
'OpenPanel has a more beautiful UI and much more powerful features when compared to Umami.',
|
||||
'#buildinpublic #indiehackers',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
{
|
||||
verified: true,
|
||||
avatarUrl: '/twitter-lee.jpg',
|
||||
name: 'Lee',
|
||||
handle: 'DutchEngIishman',
|
||||
content: [
|
||||
'Day two of marketing.',
|
||||
'I like this upward trend..',
|
||||
'P.S. website went live on Sunday',
|
||||
'P.P.S. Openpanel by @CarlLindesvard is awesome.',
|
||||
],
|
||||
replies: 25,
|
||||
retweets: 68,
|
||||
likes: 648,
|
||||
},
|
||||
];
|
||||
|
||||
export default Testimonials;
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<Section className="container">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<MessageCircleIcon className="size-4" strokeWidth={1.5} />
|
||||
Testimonials
|
||||
</Tag>
|
||||
}
|
||||
title="What people say"
|
||||
description="What our customers say about us."
|
||||
/>
|
||||
<div className="col md:row gap-4">
|
||||
<div className="col gap-4 flex-1">
|
||||
{testimonials.slice(0, testimonials.length / 2).map((testimonial) => (
|
||||
<TwitterCard key={testimonial.handle} {...testimonial} />
|
||||
))}
|
||||
</div>
|
||||
<div className="col gap-4 flex-1">
|
||||
{testimonials.slice(testimonials.length / 2).map((testimonial) => (
|
||||
<TwitterCard key={testimonial.handle} {...testimonial} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowDownIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { Section, SectionHeader } from './section';
|
||||
import { Tag } from './tag';
|
||||
import { Tooltip } from './ui/tooltip';
|
||||
|
||||
const images = [
|
||||
{
|
||||
name: 'Helpy UI',
|
||||
url: 'https://helpy-ui.com',
|
||||
logo: 'helpy-ui.png',
|
||||
border: true,
|
||||
},
|
||||
{
|
||||
name: 'KiddoKitchen',
|
||||
url: 'https://kiddokitchen.se',
|
||||
logo: 'kiddokitchen.png',
|
||||
border: false,
|
||||
},
|
||||
{
|
||||
name: 'Maneken',
|
||||
url: 'https://maneken.app',
|
||||
logo: 'maneken.jpg',
|
||||
border: false,
|
||||
},
|
||||
{
|
||||
name: 'Midday',
|
||||
url: 'https://midday.ai',
|
||||
logo: 'midday.png',
|
||||
border: true,
|
||||
},
|
||||
{
|
||||
name: 'Screenzen',
|
||||
url: 'https://www.screenzen.co',
|
||||
logo: 'screenzen.avif',
|
||||
border: true,
|
||||
},
|
||||
{
|
||||
name: 'Tiptip',
|
||||
url: 'https://tiptip.id',
|
||||
logo: 'tiptip.jpg',
|
||||
border: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function WhyOpenPanel() {
|
||||
return (
|
||||
<div className="bg-background-light my-12 col">
|
||||
<Section className="container my-0 py-20">
|
||||
<SectionHeader
|
||||
title="Why OpenPanel?"
|
||||
description="We built OpenPanel to get the best of both web and product analytics. With that in mind we have created a simple but very powerful platform that can handle most companies needs."
|
||||
/>
|
||||
<div className="center-center col gap-4 -mt-4">
|
||||
<Tag>
|
||||
<ArrowDownIcon className="size-4" strokeWidth={1.5} />
|
||||
With 2000+ registered projects
|
||||
</Tag>
|
||||
<div className="row gap-4 justify-center flex-wrap">
|
||||
{images.map((image) => (
|
||||
<a
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
key={image.logo}
|
||||
className={cn(
|
||||
'group rounded-lg bg-white center-center size-20 hover:scale-110 transition-all duration-300',
|
||||
image.border && 'p-2 border border-border shadow-sm',
|
||||
)}
|
||||
title={image.name}
|
||||
>
|
||||
<Image
|
||||
src={`/logos/${image.logo}`}
|
||||
alt={image.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-lg grayscale group-hover:grayscale-0 transition-all duration-300"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
'use client';
|
||||
import DottedMap from 'dotted-map/without-countries';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { mapJsonString } from './world-map-string';
|
||||
|
||||
// Static coordinates list with 50 points
|
||||
const COORDINATES = [
|
||||
// Western Hemisphere (Focused on West Coast)
|
||||
{ lat: 47.6062, lng: -122.3321 }, // Seattle, USA
|
||||
{ lat: 45.5155, lng: -122.6789 }, // Portland, USA
|
||||
{ lat: 37.7749, lng: -122.4194 }, // San Francisco, USA
|
||||
{ lat: 34.0522, lng: -118.2437 }, // Los Angeles, USA
|
||||
{ lat: 32.7157, lng: -117.1611 }, // San Diego, USA
|
||||
{ lat: 49.2827, lng: -123.1207 }, // Vancouver, Canada
|
||||
{ lat: 58.3019, lng: -134.4197 }, // Juneau, Alaska
|
||||
{ lat: 61.2181, lng: -149.9003 }, // Anchorage, Alaska
|
||||
{ lat: 64.8378, lng: -147.7164 }, // Fairbanks, Alaska
|
||||
{ lat: 71.2906, lng: -156.7886 }, // Utqiaġvik (Barrow), Alaska
|
||||
{ lat: 60.5544, lng: -151.2583 }, // Kenai, Alaska
|
||||
{ lat: 61.5815, lng: -149.444 }, // Wasilla, Alaska
|
||||
{ lat: 66.1666, lng: -153.3707 }, // Bettles, Alaska
|
||||
{ lat: 63.8659, lng: -145.637 }, // Delta Junction, Alaska
|
||||
{ lat: 55.3422, lng: -131.6461 }, // Ketchikan, Alaska
|
||||
|
||||
// Eastern Hemisphere (Focused on East Asia)
|
||||
{ lat: 35.6762, lng: 139.6503 }, // Tokyo, Japan
|
||||
{ lat: 43.0621, lng: 141.3544 }, // Sapporo, Japan
|
||||
{ lat: 26.2286, lng: 127.6809 }, // Naha, Japan
|
||||
{ lat: 31.2304, lng: 121.4737 }, // Shanghai, China
|
||||
{ lat: 22.3193, lng: 114.1694 }, // Hong Kong
|
||||
{ lat: 37.5665, lng: 126.978 }, // Seoul, South Korea
|
||||
{ lat: 25.033, lng: 121.5654 }, // Taipei, Taiwan
|
||||
|
||||
// Russian Far East
|
||||
{ lat: 64.7336, lng: 177.5169 }, // Anadyr, Russia
|
||||
{ lat: 59.5613, lng: 150.8086 }, // Magadan, Russia
|
||||
{ lat: 43.1332, lng: 131.9113 }, // Vladivostok, Russia
|
||||
{ lat: 53.0444, lng: 158.6478 }, // Petropavlovsk-Kamchatsky, Russia
|
||||
{ lat: 62.0355, lng: 129.6755 }, // Yakutsk, Russia
|
||||
{ lat: 48.4827, lng: 135.0846 }, // Khabarovsk, Russia
|
||||
{ lat: 46.9589, lng: 142.7319 }, // Yuzhno-Sakhalinsk, Russia
|
||||
{ lat: 52.9651, lng: 158.2728 }, // Yelizovo, Russia
|
||||
{ lat: 56.1304, lng: 101.614 }, // Bratsk, Russia
|
||||
|
||||
// Australia & New Zealand (Main Cities)
|
||||
{ lat: -33.8688, lng: 151.2093 }, // Sydney, Australia
|
||||
{ lat: -37.8136, lng: 144.9631 }, // Melbourne, Australia
|
||||
{ lat: -27.4698, lng: 153.0251 }, // Brisbane, Australia
|
||||
{ lat: -31.9505, lng: 115.8605 }, // Perth, Australia
|
||||
{ lat: -12.4634, lng: 130.8456 }, // Darwin, Australia
|
||||
{ lat: -34.9285, lng: 138.6007 }, // Adelaide, Australia
|
||||
{ lat: -42.8821, lng: 147.3272 }, // Hobart, Australia
|
||||
{ lat: -16.9186, lng: 145.7781 }, // Cairns, Australia
|
||||
{ lat: -23.7041, lng: 133.8814 }, // Alice Springs, Australia
|
||||
{ lat: -41.2865, lng: 174.7762 }, // Wellington, New Zealand
|
||||
{ lat: -36.8485, lng: 174.7633 }, // Auckland, New Zealand
|
||||
{ lat: -43.532, lng: 172.6306 }, // Christchurch, New Zealand
|
||||
];
|
||||
|
||||
const getRandomCoordinates = (count: number) => {
|
||||
const shuffled = [...COORDINATES].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, count);
|
||||
};
|
||||
|
||||
export function WorldMap() {
|
||||
const [visiblePins, setVisiblePins] = useState<typeof COORDINATES>([
|
||||
{ lat: 61.2181, lng: -149.9003 },
|
||||
{ lat: 31.2304, lng: 121.4737 },
|
||||
{ lat: 59.5613, lng: 150.8086 },
|
||||
{ lat: 64.8378, lng: -147.7164 },
|
||||
{ lat: -33.8688, lng: 151.2093 },
|
||||
{ lat: 43.0621, lng: 141.3544 },
|
||||
{ lat: 58.3019, lng: -134.4197 },
|
||||
{ lat: 37.5665, lng: 126.978 },
|
||||
{ lat: -41.2865, lng: 174.7762 },
|
||||
{ lat: -36.8485, lng: 174.7633 },
|
||||
{ lat: -31.9505, lng: 115.8605 },
|
||||
{ lat: 35.6762, lng: 139.6503 },
|
||||
{ lat: 49.2827, lng: -123.1207 },
|
||||
{ lat: -12.4634, lng: 130.8456 },
|
||||
{ lat: 56.1304, lng: 101.614 },
|
||||
{ lat: 22.3193, lng: 114.1694 },
|
||||
{ lat: 55.3422, lng: -131.6461 },
|
||||
{ lat: 32.7157, lng: -117.1611 },
|
||||
{ lat: 61.5815, lng: -149.444 },
|
||||
{ lat: 60.5544, lng: -151.2583 },
|
||||
]);
|
||||
const activePinColor = '#2265EC';
|
||||
const inactivePinColor = '#818181';
|
||||
const visiblePinsCount = 20;
|
||||
|
||||
// Helper function to update pins
|
||||
const updatePins = () => {
|
||||
setVisiblePins((current) => {
|
||||
const newPins = [...current];
|
||||
// Remove 2 random pins
|
||||
const pinsToAdd = 4;
|
||||
if (newPins.length >= pinsToAdd) {
|
||||
for (let i = 0; i < pinsToAdd; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * newPins.length);
|
||||
newPins.splice(randomIndex, 1);
|
||||
}
|
||||
}
|
||||
// Add 2 new random pins from the main coordinates
|
||||
const availablePins = COORDINATES.filter(
|
||||
(coord) =>
|
||||
!newPins.some(
|
||||
(pin) => pin.lat === coord.lat && pin.lng === coord.lng,
|
||||
),
|
||||
);
|
||||
const newRandomPins = availablePins
|
||||
.sort(() => 0.5 - Math.random())
|
||||
.slice(0, pinsToAdd);
|
||||
return [...newPins, ...newRandomPins].slice(0, visiblePinsCount);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Update pins every 4 seconds
|
||||
const interval = setInterval(updatePins, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const map = useMemo(() => {
|
||||
const map = new DottedMap({ map: mapJsonString as any });
|
||||
|
||||
visiblePins.forEach((coord) => {
|
||||
map.addPin({
|
||||
lat: coord.lat,
|
||||
lng: coord.lng,
|
||||
svgOptions: { color: activePinColor, radius: 0.3 },
|
||||
});
|
||||
});
|
||||
|
||||
return map.getSVG({
|
||||
radius: 0.2,
|
||||
color: inactivePinColor,
|
||||
shape: 'circle',
|
||||
});
|
||||
}, [visiblePins]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<img
|
||||
loading="lazy"
|
||||
alt="World map with active users"
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(map)}`}
|
||||
className="object-contain w-full h-full"
|
||||
width={1200}
|
||||
height={630}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
title: Find an alternative to Mixpanel
|
||||
description: A list of alternatives to Mixpanel, including open source and paid options.
|
||||
date: 2024-11-12
|
||||
updated: 2025-12-02
|
||||
team: OpenPanel Team
|
||||
tag: Comparison
|
||||
cover: /content/cover-alternatives.jpg
|
||||
@@ -42,11 +43,13 @@ Its dashboard shows real-time data clearly, helping teams make better decisions.
|
||||
|
||||
Mixpanel remains a strong player in analytics, helping businesses improve their online presence.
|
||||
|
||||
> See our detailed comparison: [OpenPanel vs Mixpanel](/compare/mixpanel-alternative)
|
||||
|
||||
## Limitations of Mixpanel
|
||||
|
||||
Despite its strengths, Mixpanel has several problems users need to deal with.
|
||||
|
||||
First, Mixpanel's pricing is often too high. The cost of all its features may not make sense for smaller companies or startups, making it hard for growing businesses to use. Simply put, you might not get enough value for what you pay.
|
||||
First, [Mixpanel's pricing](/articles/mixpanel-pricing) is often too high. The cost of all its features may not make sense for smaller companies or startups, making it hard for growing businesses to use. Simply put, you might not get enough value for what you pay.
|
||||
|
||||
Second, Mixpanel is hard to learn. New users often struggle with its complex interface.
|
||||
|
||||
@@ -177,4 +180,8 @@ With new privacy laws like GDPR and CCPA, companies are finding new ways to get
|
||||
**Quick data updates**
|
||||
Getting data quickly helps businesses make faster, better decisions.
|
||||
|
||||
By using these new tools, businesses can better understand their users and do better online.
|
||||
By using these new tools, businesses can better understand their users and do better online.
|
||||
|
||||
## Related Articles
|
||||
|
||||
Looking for more options? Read our comprehensive guide on [9 best open source web analytics tools](/articles/open-source-web-analytics).
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
title: Better compliance with self-hosted analytics
|
||||
description: A practical guide to GDPR, CCPA, HIPAA, and other privacy regulations for analytics. Learn how OpenPanel and self-hosting can simplify compliance.
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
date: 2025-12-08
|
||||
cover: /content/compliance.jpg
|
||||
---
|
||||
|
||||
Privacy regulations are everywhere now. GDPR in Europe, CCPA in California, HIPAA for healthcare, and the list keeps growing. If you're running a website or app, you've probably wondered: "Am I actually compliant with all this stuff?"
|
||||
|
||||
The good news? Analytics compliance doesn't have to be complicated or expensive. The bad news? Most traditional analytics tools make it way harder than it needs to be.
|
||||
|
||||
In this guide, we'll break down the major compliance frameworks, explain what they actually mean for your analytics setup, and show you how [OpenPanel](/) can help you stay compliant without the headache.
|
||||
|
||||
## Why Analytics Compliance Matters
|
||||
|
||||
Let's start with the basics. When someone visits your website, you're collecting data about them. Maybe it's their location, what pages they viewed, how long they stayed, or what buttons they clicked. Under most privacy laws, this counts as personal data.
|
||||
|
||||
The consequences of getting compliance wrong are real. GDPR fines can reach €20 million or 4% of global revenue, whichever is higher. CCPA violations cost up to $7,988 per intentional violation. And beyond the fines, there's the reputation damage and loss of customer trust.
|
||||
|
||||
Here's the thing though: most compliance issues with analytics come down to a few core problems.
|
||||
|
||||
**Third-party data sharing.** When you use Google Analytics or similar tools, your visitors' data flows through their servers. That creates a chain of custody problem. You're responsible for what happens to that data, even when it's sitting on someone else's infrastructure.
|
||||
|
||||
**Cookies and consent.** Traditional analytics tools rely heavily on cookies. Under GDPR, PECR, and similar regulations, you need explicit consent before dropping most cookies. That means cookie banners, consent management, and all the friction that comes with it.
|
||||
|
||||
**International data transfers.** If you're collecting data from EU residents and it ends up on US servers, you've got a potential compliance issue. This is exactly why Google Analytics has been ruled illegal in several EU countries.
|
||||
|
||||
The solution? Either use a [privacy-first analytics tool](/articles/cookieless-analytics) that sidesteps these issues, or self-host your analytics so data never leaves your infrastructure.
|
||||
|
||||
## GDPR: The One Everyone Knows About
|
||||
|
||||
The General Data Protection Regulation is the big one. It applies to any organization that processes personal data of EU residents, regardless of where that organization is based. So if you have visitors from Europe, GDPR applies to you.
|
||||
|
||||
### What GDPR Requires for Analytics
|
||||
|
||||
GDPR is built around a few key principles that directly impact how you can do analytics.
|
||||
|
||||
**Lawful basis for processing.** You need a legal reason to collect and process personal data. For analytics, this usually means either getting consent or demonstrating "legitimate interest." Consent is cleaner but requires those annoying cookie banners. Legitimate interest is possible but requires documentation and balancing tests.
|
||||
|
||||
**Data minimization.** Only collect what you actually need. If you're tracking 50 different user properties but only looking at 5 of them, you've got a problem.
|
||||
|
||||
**Right to erasure.** Users can request that you delete their data. You need to be able to actually do this, which is tricky when your data is sitting in a third-party's database.
|
||||
|
||||
**Transparency.** Users need to know what you're collecting and why. This means clear privacy policies and, in most cases, cookie consent interfaces.
|
||||
|
||||
### Why Google Analytics Keeps Getting Banned
|
||||
|
||||
Google Analytics has been declared non-compliant with GDPR by data protection authorities in Austria, France, Italy, and other EU countries. The core issue is that GA transfers personal data (including IP addresses) to US servers, where it can potentially be accessed by US intelligence agencies. This violates Chapter V of GDPR, which governs international data transfers.
|
||||
|
||||
Even with IP anonymization enabled, the data still hits Google's servers before being anonymized. That's a problem.
|
||||
|
||||
<WindowImage
|
||||
srcDark="/screenshots/overview-dark.webp"
|
||||
srcLight="/screenshots/overview-light.webp"
|
||||
alt="OpenPanel Dashboard Overview"
|
||||
caption="This is how OpenPanel dashboard looks like, the self-hosting version has all features that our cloud version has. The release lifecycle is 2-3 months behind cloud version."
|
||||
/>
|
||||
|
||||
### How OpenPanel Handles GDPR
|
||||
|
||||
OpenPanel takes a different approach. We built it with privacy as the foundation, not an afterthought.
|
||||
|
||||
**Cookieless by default.** OpenPanel doesn't use cookies for tracking. No cookies means no cookie consent banners required for basic analytics. Your visitors get a cleaner experience, and you avoid the consent management complexity. Learn more about how this works in our [cookieless analytics guide](/articles/cookieless-analytics).
|
||||
|
||||
**No third-party data sharing.** With OpenPanel Cloud, your data stays in our EU-based infrastructure. With [self-hosting](/articles/how-to-self-host-openpanel), data never leaves your servers at all.
|
||||
|
||||
**Built-in data export and deletion.** Need to handle a data subject request? OpenPanel's [Export API](/docs/api/export) makes it straightforward to export user data. You can delete your entire project's data through the dashboard, and if you need to delete a specific identified profile, you can request that from us.
|
||||
|
||||
**Transparent and open source.** You can [audit the code yourself](https://github.com/Openpanel-dev/openpanel) to see exactly what's being collected and how it's processed.
|
||||
|
||||
## CCPA: California's Privacy Law
|
||||
|
||||
The California Consumer Privacy Act (and its amendment, CPRA) gives California residents specific rights over their personal information. If you do business in California or collect data from California residents, this one matters.
|
||||
|
||||
### Key CCPA Requirements
|
||||
|
||||
**Right to know.** Consumers can ask what personal information you've collected about them, where it came from, and who you've shared it with.
|
||||
|
||||
**Right to delete.** Similar to GDPR, consumers can request deletion of their personal information.
|
||||
|
||||
**Right to opt-out.** Here's the big one for analytics. Consumers can opt out of the "sale" or "sharing" of their personal information. And under CCPA, "sharing" includes providing data to third parties for cross-context behavioral advertising, which is exactly what many analytics tools do.
|
||||
|
||||
**No discrimination.** You can't treat consumers differently because they exercised their privacy rights.
|
||||
|
||||
### The "Do Not Sell" Problem
|
||||
|
||||
Many traditional analytics tools technically "share" your user data with third parties. When you use Google Analytics, user data flows through Google's systems and can be used for their own purposes. Under CCPA, this could be considered sharing, which means you need to honor "Do Not Sell or Share" requests.
|
||||
|
||||
This creates a real operational burden. You need systems to track opt-out requests, communicate them to all your vendors, and verify compliance.
|
||||
|
||||
### How OpenPanel Simplifies CCPA
|
||||
|
||||
With OpenPanel, there's no sharing to opt out of.
|
||||
|
||||
When you use OpenPanel Cloud, your data is processed solely for your analytics purposes. We don't sell or share your data with anyone. When you [self-host OpenPanel](/docs/self-hosting/self-hosting), you control the entire data pipeline. There's no third party involved at all.
|
||||
|
||||
This architectural difference eliminates most CCPA complexity. You still need proper privacy disclosures, but you don't need to worry about vendor management for your analytics data.
|
||||
|
||||
## HIPAA: Healthcare's Special Rules
|
||||
|
||||
If you're in healthcare or handle Protected Health Information (PHI), HIPAA adds another layer of compliance requirements. This is where things get expensive with traditional analytics providers.
|
||||
|
||||
### The BAA Requirement
|
||||
|
||||
HIPAA requires that any third party with access to PHI must sign a Business Associate Agreement (BAA). This is a legal contract that establishes what the vendor can and can't do with health information.
|
||||
|
||||
The problem? Most analytics providers either don't offer BAAs at all, or charge significant premiums for them. We're talking enterprise-tier pricing that can run into tens of thousands of dollars annually.
|
||||
|
||||
Google Analytics doesn't offer a BAA. Mixpanel does, but only on enterprise plans. The same goes for most major analytics platforms.
|
||||
|
||||
### What Counts as PHI in Analytics
|
||||
|
||||
This is where many healthcare organizations get tripped up. PHI isn't just medical records. Under HHS guidance, when someone visits a healthcare website's authenticated pages, their IP address combined with the fact that they're viewing health-related content can constitute PHI.
|
||||
|
||||
This means that if you're using cookie-based tracking on a patient portal or healthcare app, you might be sharing PHI with your analytics provider without realizing it.
|
||||
|
||||
### The Self-Hosting Solution
|
||||
|
||||
Here's where self-hosting completely changes the equation: if you host your own analytics, you don't need a BAA.
|
||||
|
||||
Think about it. A BAA is required when you're sharing PHI with a business associate. But if you [self-host OpenPanel](/articles/how-to-self-host-openpanel) on your own HIPAA-compliant infrastructure, there's no third party involved. The data never leaves your environment. There's no business associate relationship to manage.
|
||||
|
||||
This approach lets you get meaningful analytics from your healthcare applications without the enterprise pricing or legal complexity. You deploy OpenPanel on your existing HIPAA-compliant servers using [Docker Compose](/docs/self-hosting/deploy-docker-compose), [Kubernetes](/docs/self-hosting/deploy-kubernetes), or your preferred deployment method, and you're done.
|
||||
|
||||
## PECR: The UK's Cookie Law
|
||||
|
||||
If you have visitors from the UK, you need to think about PECR (Privacy and Electronic Communications Regulations) alongside UK GDPR. PECR specifically regulates cookies and similar tracking technologies.
|
||||
|
||||
### What PECR Requires
|
||||
|
||||
PECR has a simple but strict rule: you need consent before storing or accessing information on a user's device. This includes cookies, local storage, and similar technologies.
|
||||
|
||||
There are only two exemptions. The "communication exemption" covers technologies essential for transmitting a communication. The "strictly necessary exemption" covers technologies essential for providing a service the user explicitly requested.
|
||||
|
||||
Here's the important part: **analytics cookies are not exempt.** The UK's Information Commissioner's Office has been clear about this. If you're using cookie-based analytics, you need consent.
|
||||
|
||||
### Fines Are Increasing
|
||||
|
||||
PECR fines used to be capped at £500,000. The new Data (Use and Access) Act aligns PECR penalties with UK GDPR, meaning potential fines of up to £17.5 million. The ICO has also been increasingly active in enforcing cookie compliance.
|
||||
|
||||
### Cookieless Analytics Bypasses PECR
|
||||
|
||||
Since [OpenPanel's tracking is cookieless](/articles/cookieless-analytics), the PECR consent requirement simply doesn't apply to basic analytics. You're not storing anything on the user's device, so there's nothing to consent to.
|
||||
|
||||
This doesn't mean you can track whatever you want. UK GDPR still applies to the processing of personal data. But it does mean you can skip the cookie banners and consent management platforms that PECR would otherwise require.
|
||||
|
||||
## The Self-Hosting Advantage
|
||||
|
||||
We've mentioned self-hosting several times now, and for good reason. It's the single most effective way to simplify analytics compliance across almost every framework.
|
||||
|
||||
### What Self-Hosting Actually Means
|
||||
|
||||
When you self-host OpenPanel, you run the entire analytics platform on your own infrastructure. This could be your own servers, your cloud account (AWS, GCP, Azure, etc.), or even a simple VPS.
|
||||
|
||||
The data flow is completely different from traditional analytics.
|
||||
|
||||
**Traditional analytics:** User → Your website → Analytics provider's servers → Provider dashboard
|
||||
|
||||
**Self-hosted analytics:** User → Your website → Your servers → Your dashboard
|
||||
|
||||
That middle step makes all the difference. With traditional analytics, you're sharing data with a third party. With self-hosting, data never leaves your control.
|
||||
|
||||
### Compliance Benefits Across Frameworks
|
||||
|
||||
**GDPR:** No international data transfers if you host in the EU. Full control over data retention and deletion. No third-party data sharing to manage.
|
||||
|
||||
**CCPA:** No "selling" or "sharing" by definition. You're not providing data to any third party.
|
||||
|
||||
**HIPAA:** No BAA required because there's no business associate. PHI stays within your HIPAA-compliant environment.
|
||||
|
||||
**PECR:** Cookieless tracking means no consent requirements for basic analytics.
|
||||
|
||||
**SOC 2:** Easier vendor risk management when you control the analytics infrastructure. Your existing security controls apply.
|
||||
|
||||
### Beyond Compliance
|
||||
|
||||
Self-hosting isn't just about compliance. There are real practical benefits too.
|
||||
|
||||
**Cost predictability.** No per-event pricing surprises. Your costs are your server costs, which are typically much lower than SaaS analytics pricing at scale.
|
||||
|
||||
**No vendor lock-in.** Your data is in your database. You can query it however you want, integrate it with other systems, or migrate away anytime.
|
||||
|
||||
**Performance.** Data stays close to your users. No external requests that might get blocked by ad blockers.
|
||||
|
||||
**Full transparency.** OpenPanel is [open source](https://github.com/Openpanel-dev/openpanel). You can audit exactly what's being collected and how.
|
||||
|
||||
### Getting Started with Self-Hosting
|
||||
|
||||
We've tried to make self-hosting as simple as possible. The basic process is:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openpanel-dev/openpanel.git
|
||||
cd openpanel/self-hosting
|
||||
./setup
|
||||
./start
|
||||
```
|
||||
|
||||
We have detailed guides for different deployment options including [Docker Compose](/docs/self-hosting/deploy-docker-compose), [Coolify](/docs/self-hosting/deploy-coolify), [Dokploy](/docs/self-hosting/deploy-dokploy), and [Kubernetes](/docs/self-hosting/deploy-kubernetes).
|
||||
|
||||
Check out our full [self-hosting guide](/articles/how-to-self-host-openpanel) for a walkthrough of the entire process.
|
||||
|
||||
## The Hidden Cost of "Free" Analytics
|
||||
|
||||
Let's talk about Google Analytics for a moment. It's free, which is great. But that "free" comes with significant compliance costs that most organizations don't account for.
|
||||
|
||||
**Cookie consent management.** You need a consent management platform, ongoing maintenance, and likely degraded data quality from users who opt out.
|
||||
|
||||
**Privacy policy and legal review.** Your lawyers need to review how GA processes data and update your privacy documentation accordingly.
|
||||
|
||||
**Vendor assessment overhead.** For regulated industries, you need to continuously assess Google's practices and compliance posture.
|
||||
|
||||
**GDPR risk.** Given the ongoing regulatory actions against GA in Europe, you're taking on legal risk that's hard to quantify.
|
||||
|
||||
**Data subject requests.** Handling deletion requests through GA's tools is cumbersome and incomplete.
|
||||
|
||||
When you add up these costs, "free" analytics often isn't free at all. A transparent, paid solution like [OpenPanel](/pricing) or a self-hosted setup frequently works out cheaper while being more compliant.
|
||||
|
||||
## Other Regulations Worth Knowing
|
||||
|
||||
While GDPR, CCPA, HIPAA, and PECR are the big ones, there are others depending on your audience.
|
||||
|
||||
**LGPD (Brazil):** Similar to GDPR, with requirements for consent, data minimization, and user rights.
|
||||
|
||||
**PIPEDA (Canada):** Requires consent for collection and use of personal information, with some exceptions.
|
||||
|
||||
**US State Laws:** Over 20 US states now have comprehensive privacy laws, including Virginia, Colorado, Connecticut, and more. Most follow patterns similar to CCPA.
|
||||
|
||||
The good news is that if you're compliant with GDPR and CCPA, you're probably in good shape for most of these. And if you're using cookieless, self-hosted analytics, you're ahead of the game for all of them.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Ready to simplify your analytics compliance? You have two paths with OpenPanel.
|
||||
|
||||
**OpenPanel Cloud** is the fastest way to get started. We handle the infrastructure, and your data is processed in compliance with GDPR and CCPA. You can be up and running in minutes with just a [simple script tag](/docs/get-started/install-openpanel).
|
||||
|
||||
**Self-hosted OpenPanel** gives you maximum control and compliance flexibility. It's ideal for healthcare organizations, enterprises with strict data residency requirements, or anyone who wants complete ownership of their analytics data.
|
||||
|
||||
Either way, you get [cookieless tracking](/articles/cookieless-analytics), [real-time dashboards](/docs), [funnels](/articles/how-to-create-a-funnel), user profiles, and all the features you need to understand your users without the compliance complexity.
|
||||
|
||||
[Get started with OpenPanel Cloud](https://dashboard.openpanel.dev/onboarding) or check out our [self-hosting documentation](/docs/self-hosting/self-hosting).
|
||||
|
||||
<Faqs>
|
||||
<FaqItem question="Does OpenPanel use cookies?">
|
||||
No. OpenPanel uses cookieless tracking by default. This means you don't need cookie consent banners for basic analytics under most privacy regulations, including GDPR and PECR.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is OpenPanel GDPR compliant?">
|
||||
Yes. OpenPanel is designed for GDPR compliance with cookieless tracking, data minimization, and full support for data subject rights. With self-hosting, you also eliminate international data transfer concerns entirely.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Do I need a BAA to use OpenPanel for healthcare analytics?">
|
||||
If you use OpenPanel Cloud, you would need to discuss BAA requirements with us. However, if you self-host OpenPanel on your own HIPAA-compliant infrastructure, no BAA is required because the data never leaves your environment.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I use OpenPanel without a cookie banner?">
|
||||
Yes. Since OpenPanel doesn't use cookies, you don't need a cookie consent banner for your analytics. However, you should still have a privacy policy that explains what data you collect.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Where is OpenPanel Cloud data stored?">
|
||||
OpenPanel Cloud infrastructure is based in the EU. For specific data residency requirements, self-hosting gives you complete control over where your data lives.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="How does self-hosting help with compliance?">
|
||||
Self-hosting eliminates third-party data sharing, which simplifies compliance with GDPR, CCPA, HIPAA, and other regulations. Your data never leaves your infrastructure, so there's no vendor management, no international data transfers to worry about, and no BAAs required.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Can I migrate from Google Analytics to OpenPanel?">
|
||||
Yes. OpenPanel can replace Google Analytics for most use cases. We offer both web analytics and product analytics features. Check our comparison with other platforms like the [Google Analytics alternative](/compare/google-analytics-alternative) page.
|
||||
</FaqItem>
|
||||
|
||||
<FaqItem question="Is OpenPanel open source?">
|
||||
Yes. OpenPanel is fully open source and available on GitHub. You can audit the code, contribute, or fork it for your own needs.
|
||||
</FaqItem>
|
||||
</Faqs>
|
||||
@@ -181,7 +181,7 @@ It might not be the best fit if:
|
||||
|
||||
Installing GroupMQ is straightforward:
|
||||
|
||||
```bash
|
||||
```npm
|
||||
npm install groupmq
|
||||
```
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ description: Discover how to gather meaningful insights without cookies and why
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
date: 2025-06-17
|
||||
updated: 2025-12-02
|
||||
cover: /content/cookieless-analytics.jpg
|
||||
---
|
||||
|
||||
@@ -58,6 +59,8 @@ When you rely on your own data sources:
|
||||
|
||||
We built OpenPanel from the ground up with privacy at its heart—and with features you actually need:
|
||||
|
||||
> See how OpenPanel compares to other cookieless analytics tools: [OpenPanel vs Plausible](/compare/plausible-alternative) | [OpenPanel vs Fathom](/compare/fathom-alternative)
|
||||
|
||||
### Privacy by Default
|
||||
|
||||
* **Zero cookies.** Ever.
|
||||
@@ -88,9 +91,7 @@ We built OpenPanel from the ground up with privacy at its heart—and with featu
|
||||
|
||||
```html
|
||||
<script>
|
||||
window.op = window.op || function(...args) {
|
||||
(window.op.q = window.op.q || []).push(args);
|
||||
};
|
||||
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
||||
window.op('init', {
|
||||
clientId: 'YOUR_CLIENT_ID',
|
||||
trackScreenViews: true,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: How to Self-Host OpenPanel Analytics Platform
|
||||
description: Learn how to self-host OpenPanel web analytics platform. Step-by-step guide to install and configure your own analytics server for better privacy and cost savings.
|
||||
date: 2025-02-28
|
||||
updated: 2025-12-02
|
||||
cover: /content/how-to-self-host-openpanel.jpg
|
||||
tag: Guide
|
||||
team: OpenPanel Team
|
||||
@@ -23,6 +24,8 @@ cd openpanel/self-hosting
|
||||
|
||||
Looking for a [Mixpanel alternative](/articles/alternatives-to-mixpanel)? Self-hosting your own web analytics and product analytics platform comes with several benefits. Let's break down the pros and cons of running your own analytics server.
|
||||
|
||||
For a comparison of all open source analytics platforms, see our [comprehensive guide to open source web analytics tools](/articles/open-source-web-analytics).
|
||||
|
||||
### Cost Benefits
|
||||
|
||||
Self-hosting your own web analytics solution is typically much more cost-effective than cloud-based alternatives, especially as your traffic grows. This is one of the primary reasons organizations choose to self-host their analytics.
|
||||
|
||||