1 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
931ae55a1c fix: iframe resize #224 2025-11-10 20:29:02 +01:00
593 changed files with 43712 additions and 50315 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,5 @@
.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

View File

@@ -17,7 +17,7 @@
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
"editor.defaultFormatter": "biomejs.biome"
},
"editor.formatOnSave": true,
"tailwindCSS.experimental.classRegex": [

View File

@@ -1,4 +1,4 @@
![hero](apps/public/public/ogimage.png)
![hero](apps/public/public/ogimage.jpg)
<p align="center">
<h1 align="center"><b>Openpanel</b></h1>

View File

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

View File

@@ -1,25 +0,0 @@
{
"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:"
}
}

View File

@@ -1,118 +0,0 @@
#!/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);
});

View File

@@ -1,162 +0,0 @@
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!'));
*/
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
{
"extends": "../tooling/typescript/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src"]
}

View File

@@ -13,11 +13,11 @@
"dependencies": {
"@ai-sdk/anthropic": "^1.2.10",
"@ai-sdk/openai": "^1.3.12",
"@fastify/compress": "^8.1.0",
"@fastify/compress": "^8.0.1",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.2.0",
"@fastify/cors": "^11.0.0",
"@fastify/rate-limit": "^10.2.2",
"@fastify/websocket": "^11.0.2",
"@node-rs/argon2": "^2.0.2",
"@openpanel/auth": "workspace:^",
"@openpanel/common": "workspace:*",
@@ -29,18 +29,19 @@
"@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.6.1",
"fastify": "^5.2.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",
@@ -57,6 +58,7 @@
"@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",
@@ -65,4 +67,4 @@
"tsdown": "0.14.2",
"typescript": "catalog:"
}
}
}

View File

@@ -7,23 +7,6 @@ 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 {
@@ -31,9 +14,6 @@ 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'),
[
@@ -41,20 +21,11 @@ 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(transformedBots, null, 2)} as const;`,
`const bots = ${JSON.stringify(yaml.load(data))} 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);
}

View File

@@ -40,6 +40,8 @@ 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',

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,19 @@
import { cacheable, cacheableLru } from '@openpanel/redis';
import bots from './bots';
// 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;
});
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',
};
}
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;
},
{
maxSize: 1000,
ttl: 60 * 5,
},
);
}
return {
name: res.name,
type: 'category' in res ? res.category : 'Unknown',
};
}

View File

@@ -1,11 +1,13 @@
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 { getEventsGroupQueueShard } from '@openpanel/queue';
import { eventsGroupQueue } from '@openpanel/queue';
import type { PostEventPayload } from '@openpanel/sdk';
import { generateId, slug } from '@openpanel/common';
import { checkDuplicatedEvent } from '@/utils/deduplicate';
import { generateId } from '@openpanel/common';
import { getGeoLocation } from '@openpanel/geo';
import { getStringHeaders, getTimestamp } from './track.controller';
@@ -19,8 +21,8 @@ export async function postEvent(
request.timestamp,
request.body,
);
const ip = request.clientIp;
const ua = request.headers['user-agent'];
const ip = getClientIp(request)!;
const ua = request.headers['user-agent']!;
const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers);
@@ -30,22 +32,33 @@ export async function postEvent(
}
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId = ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '';
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
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 uaInfo = parseUserAgent(ua, request.body?.properties);
const groupId = uaInfo.isServer
@@ -53,16 +66,7 @@ export async function postEvent(
? `${projectId}:${request.body?.profileId}`
: `${projectId}:${generateId()}`
: currentDeviceId;
const jobId = [
slug(request.body.name),
timestamp,
projectId,
currentDeviceId,
groupId,
]
.filter(Boolean)
.join('-');
await getEventsGroupQueueShard(groupId).add({
await eventsGroupQueue.add({
orderMs: new Date(timestamp).getTime(),
data: {
projectId,
@@ -72,13 +76,11 @@ export async function postEvent(
timestamp,
isTimestampFromThePast,
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,
});
reply.status(202).send('ok');

View File

@@ -12,8 +12,8 @@ import {
getEventsCountCached,
getSettingsForProject,
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
import { zChartEvent, zChartInput } from '@openpanel/validation';
import { omit } from 'ramda';
async function getProjectId(
@@ -139,7 +139,7 @@ export async function events(
});
}
const chartSchemeFull = zChartInputBase
const chartSchemeFull = zChartInput
.pick({
breakdowns: true,
interval: true,
@@ -151,27 +151,14 @@ const chartSchemeFull = zChartInputBase
.extend({
project_id: z.string().optional(),
projectId: z.string().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(),
events: z.array(
z.object({
name: z.string(),
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
}),
),
});
export async function charts(
@@ -192,17 +179,9 @@ export async function charts(
const projectId = await getProjectId(request, reply);
const { timezone } = await getSettingsForProject(projectId);
const { events, series, ...rest } = query.data;
const { events, ...rest } = query.data;
// 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({
return getChart({
...rest,
startDate: rest.startDate
? DateTime.fromISO(rest.startDate)
@@ -215,7 +194,11 @@ export async function charts(
.toFormat('yyyy-MM-dd HH:mm:ss')
: undefined,
projectId,
series: eventSeries,
events: events.map((event) => ({
...event,
segment: event.segment ?? 'event',
filters: event.filters ?? [],
})),
chartType: 'linear',
metric: 'sum',
});

View File

@@ -4,7 +4,7 @@ import superjson from 'superjson';
import type { WebSocket } from '@fastify/websocket';
import {
eventBuffer,
getProfileById,
getProfileByIdCached,
transformMinimalEvent,
} from '@openpanel/db';
import { setSuperJson } from '@openpanel/json';
@@ -92,7 +92,10 @@ export async function wsProjectEvents(
type,
async (event) => {
if (event.projectId === params.projectId) {
const profile = await getProfileById(event.profileId, event.projectId);
const profile = await getProfileByIdCached(
event.profileId,
event.projectId,
);
socket.send(
superjson.stringify(
access

View File

@@ -4,12 +4,9 @@ import { parseUrlMeta } from '@/utils/parseUrlMeta';
import type { FastifyReply, FastifyRequest } from 'fastify';
import sharp from 'sharp';
import {
DEFAULT_IP_HEADER_ORDER,
getClientIpFromHeaders,
} from '@openpanel/common/server/get-client-ip';
import { getClientIp } from '@/utils/get-client-ip';
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getGeoLocation } from '@openpanel/geo';
import { getCache, getRedisCache } from '@openpanel/redis';
interface GetFaviconParams {
@@ -132,7 +129,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.debug('Serving ICO file directly', {
logger.info('Serving ICO file directly', {
originalUrl,
bufferSize: buffer.length,
});
@@ -140,7 +137,7 @@ async function processImage(
}
if (originalUrl && isSvgFile(originalUrl, contentType)) {
logger.debug('Serving SVG file directly', {
logger.info('Serving SVG file directly', {
originalUrl,
bufferSize: buffer.length,
});
@@ -149,7 +146,7 @@ async function processImage(
// If buffer isnt to big just return it as well
if (buffer.length < 5000) {
logger.debug('Serving image directly without processing', {
logger.info('Serving image directly without processing', {
originalUrl,
bufferSize: buffer.length,
});
@@ -193,7 +190,7 @@ async function processOgImage(
): Promise<Buffer> {
// If buffer is small enough, return it as-is
if (buffer.length < 10000) {
logger.debug('Serving OG image directly without processing', {
logger.info('Serving OG image directly without processing', {
originalUrl,
bufferSize: buffer.length,
});
@@ -397,36 +394,12 @@ export async function stats(request: FastifyRequest, reply: FastifyReply) {
}
export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
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),
};
}),
);
const ip = getClientIp(request);
if (!ip) {
return reply.status(400).send('Bad Request');
}
const geo = await getGeoLocation(ip);
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 }>,
),
});
return reply.status(200).send(geo);
}
export async function getOgImage(

View File

@@ -1,6 +1,8 @@
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';
@@ -15,39 +17,41 @@ export async function updateProfile(
}>,
reply: FastifyReply,
) {
const payload = request.body;
const { profileId, properties, ...rest } = request.body;
const projectId = request.client!.projectId;
if (!projectId) {
return reply.status(400).send('No projectId');
}
const ip = request.clientIp;
const ua = request.headers['user-agent'];
const uaInfo = parseUserAgent(ua, payload.properties);
const ip = getClientIp(request)!;
const ua = request.headers['user-agent']!;
const uaInfo = parseUserAgent(ua, properties);
const geo = await getGeoLocation(ip);
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
},
projectId,
})
) {
return;
}
await upsertProfile({
...payload,
id: payload.profileId,
id: profileId,
isExternal: true,
projectId,
properties: {
...(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,
...(properties ?? {}),
...(ip ? geo : {}),
...uaInfo,
},
...rest,
});
reply.status(202).send(payload.profileId);
reply.status(202).send(profileId);
}
export async function incrementProfileProperty(
@@ -62,6 +66,18 @@ 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');
@@ -104,6 +120,18 @@ 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');

View File

@@ -1,12 +1,13 @@
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr, pick } from 'ramda';
import { path, assocPath, pathOr, pick } from 'ramda';
import { generateId, slug } from '@openpanel/common';
import { checkDuplicatedEvent } from '@/utils/deduplicate';
import { generateId } 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 { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import { eventsGroupQueue } from '@openpanel/queue';
import type {
DecrementPayload,
IdentifyPayload,
@@ -37,10 +38,10 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
}
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
const identity =
'properties' in body.payload
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
: undefined;
const identity = path<IdentifyPayload>(
['properties', '__identify'],
body.payload,
);
return (
identity ||
@@ -56,38 +57,28 @@ export function getTimestamp(
timestamp: FastifyRequest['timestamp'],
payload: TrackHandlerPayload['payload'],
) {
const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp =
'properties' in payload
? (payload?.properties?.__timestamp as string | undefined)
: undefined;
const safeTimestamp = new Date(timestamp || Date.now()).toISOString();
const userDefinedTimestamp = path<string>(
['properties', '__timestamp'],
payload,
);
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(clientTimestampNumber) ||
clientTimestampNumber > safeTimestamp + ONE_MINUTE_MS
Number.isNaN(clientTimestamp.getTime()) ||
clientTimestamp > new Date(safeTimestamp)
) {
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
}
// isTimestampFromThePast is true only if timestamp is older than 1 hour
const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
return {
timestamp: clientTimestampNumber,
isTimestampFromThePast,
timestamp: clientTimestamp.toISOString(),
isTimestampFromThePast: true,
};
}
@@ -99,33 +90,22 @@ export async function handler(
) {
const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip =
'properties' in request.body.payload &&
request.body.payload.properties?.__ip
? (request.body.payload.properties.__ip as string)
: request.clientIp;
const ua = request.headers['user-agent'];
path<string>(['properties', '__ip'], request.body.payload) ||
getClientIp(request)!;
const ua = request.headers['user-agent']!;
const projectId = request.client?.projectId;
if (!projectId) {
return reply.status(400).send({
reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Missing projectId',
});
return;
}
const identity = getIdentity(request.body);
const profileId = identity?.profileId;
const overrideDeviceId = (() => {
const deviceId =
'properties' in request.body.payload
? request.body.payload.properties?.__deviceId
: undefined;
if (typeof deviceId === 'string') {
return deviceId;
}
return undefined;
})();
// We might get a profileId from the alias table
// If we do, we should use that instead of the one from the payload
@@ -136,16 +116,14 @@ export async function handler(
switch (request.body.type) {
case 'track': {
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
const currentDeviceId = ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '';
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
@@ -155,7 +133,33 @@ export async function handler(
})
: '';
const promises = [];
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
previousDeviceId,
currentDeviceId,
},
projectId,
})
) {
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
@@ -170,23 +174,23 @@ export async function handler(
);
}
promises.push(
track({
payload: request.body.payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers: getStringHeaders(request.headers),
timestamp: timestamp.timestamp,
isTimestampFromThePast: timestamp.isTimestampFromThePast,
}),
);
await Promise.all(promises);
break;
}
case 'identify': {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
const geo = await getGeoLocation(ip);
await identify({
payload: request.body.payload,
@@ -197,13 +201,27 @@ export async function handler(
break;
}
case 'alias': {
return reply.status(400).send({
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,
@@ -211,6 +229,19 @@ export async function handler(
break;
}
case 'decrement': {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
await decrement({
payload: request.body.payload,
projectId,
@@ -218,11 +249,12 @@ export async function handler(
break;
}
default: {
return reply.status(400).send({
reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
break;
}
}
@@ -245,7 +277,7 @@ async function track({
projectId: string;
geo: GeoLocation;
headers: Record<string, string | undefined>;
timestamp: number;
timestamp: string;
isTimestampFromThePast: boolean;
}) {
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
@@ -254,17 +286,8 @@ async function track({
? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}`
: currentDeviceId;
const jobId = [
slug(payload.name),
timestamp,
projectId,
currentDeviceId,
groupId,
]
.filter(Boolean)
.join('-');
await getEventsGroupQueueShard(groupId).add({
orderMs: timestamp,
await eventsGroupQueue.add({
orderMs: new Date(timestamp).getTime(),
data: {
projectId,
headers,
@@ -273,13 +296,11 @@ async function track({
timestamp,
isTimestampFromThePast,
},
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,
});
}
@@ -302,18 +323,8 @@ async function identify({
projectId,
properties: {
...(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,
...(geo ?? {}),
...uaInfo,
},
});
}
@@ -389,65 +400,3 @@ async function decrement({
isExternal: true,
});
}
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 = getRedisCache().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',
});
}

View File

@@ -169,11 +169,6 @@ 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;
@@ -221,13 +216,6 @@ export async function polarWebhook(
subscriptionCreatedByUserId: metadata.userId,
subscriptionInterval: event.data.recurringInterval,
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCountExceededAt:
subscriptionPeriodEventsLimit &&
organization.subscriptionPeriodEventsCountExceededAt &&
organization.subscriptionPeriodEventsLimit <
subscriptionPeriodEventsLimit
? null
: undefined,
},
});

View File

@@ -1,28 +0,0 @@
import { isDuplicatedEvent } from '@/utils/deduplicate';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function duplicateHook(
req: FastifyRequest<{
Body: PostEventPayload | TrackHandlerPayload;
}>,
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');
}
}

View File

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

View File

@@ -1,14 +1,13 @@
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
import type { FastifyRequest } from 'fastify';
import { getClientIp } from '@/utils/get-client-ip';
import type {
FastifyReply,
FastifyRequest,
HookHandlerDoneFunction,
} from 'fastify';
export async function ipHook(request: FastifyRequest) {
const { ip, header } = getClientIpFromHeaders(request.headers);
const ip = getClientIp(request);
if (ip) {
request.clientIp = ip;
request.clientIpHeader = header;
} else {
request.clientIp = '';
request.clientIpHeader = '';
}
}

View File

@@ -1,4 +1,3 @@
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, pick } from 'ramda';
@@ -39,7 +38,12 @@ export async function requestLoggingHook(
method: request.method,
elapsed: reply.elapsedTime,
headers: pick(
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
[
'openpanel-client-id',
'openpanel-sdk-name',
'openpanel-sdk-version',
'user-agent',
],
request.headers,
),
body: request.body,

View File

@@ -28,6 +28,7 @@ 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';
@@ -54,8 +55,7 @@ process.env.TZ = 'UTC';
declare module 'fastify' {
interface FastifyRequest {
client: IServiceClientWithProject | null;
clientIp: string;
clientIpHeader: string;
clientIp?: string;
timestamp?: number;
session: SessionValidationResult;
}
@@ -125,6 +125,7 @@ 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, {

View File

@@ -2,11 +2,9 @@ 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);

View File

@@ -1,12 +1,10 @@
import { fetchDeviceId, handler } from '@/controllers/track.controller';
import { 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);
@@ -31,23 +29,6 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
},
},
});
fastify.route({
method: 'GET',
url: '/device-id',
handler: fetchDeviceId,
schema: {
response: {
200: {
type: 'object',
properties: {
deviceId: { type: 'string' },
message: { type: 'string', optional: true },
},
},
},
},
});
};
export default trackRouter;

View File

@@ -7,8 +7,8 @@ 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 { tool } from 'ai';
import { z } from 'zod';

View File

@@ -3,7 +3,6 @@ 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 { getCache } from '@openpanel/redis';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type {
IProjectFilterIp,
@@ -105,22 +104,6 @@ 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;
}
@@ -152,13 +135,7 @@ export async function validateSdkRequest(
}
if (client.secret && clientSecret) {
const isVerified = await getCache(
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
60 * 5,
async () => await verifyPassword(clientSecret, client.secret!),
true,
);
if (isVerified) {
if (await verifyPassword(clientSecret, client.secret)) {
return client;
}
}

View File

@@ -1,14 +1,11 @@
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;
}) {
@@ -16,8 +13,6 @@ export async function isDuplicatedEvent({
`fastify:deduplicate:${fastJsonStableHash.hash(
{
...payload,
ip,
origin,
projectId,
},
'md5',
@@ -32,3 +27,24 @@ 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;
}

View File

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

View File

@@ -1,7 +1,7 @@
import { ch, db } from '@openpanel/db';
import {
cronQueue,
eventsGroupQueues,
eventsGroupQueue,
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([
...eventsGroupQueues.map((queue) => queue.close()),
eventsGroupQueue.close(),
sessionsQueue.close(),
cronQueue.close(),
miscQueue.close(),

View File

@@ -2,6 +2,8 @@
/node_modules
# generated content
.contentlayer
.content-collections
.source
# test & build

94
apps/public/Dockerfile Normal file
View File

@@ -0,0 +1,94 @@
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"]

View File

@@ -15,25 +15,6 @@ 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
@@ -42,4 +23,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.dev) - learn about Fumadocs
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs

View File

@@ -1,9 +1,5 @@
import { HeroContainer } from '@/app/(home)/_sections/hero';
import { SectionHeader } from '@/components/section';
import { url } from '@/lib/layout.shared';
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
import { url } from '@/app/layout.config';
import { pageSource } from '@/lib/source';
import { getMDXComponents } from '@/mdx-components';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Script from 'next/script';
@@ -22,19 +18,24 @@ export async function generateMetadata({
};
}
return getPageMetadata({
return {
title: page.data.title,
url: url(page.url),
description: page.data.description,
image: getOgImageUrl(page.url),
});
}
export async function generateStaticParams() {
const pages = await pageSource.getPages();
return pages.map((page) => ({
pages: page.url.split('/').slice(1),
}));
alternates: {
canonical: url(page.url),
},
openGraph: {
title: page.data.title,
description: page.data.description,
type: 'website',
url: url(page.url),
},
twitter: {
card: 'summary_large_image',
title: page.data.title,
description: page.data.description,
},
};
}
export default async function Page({
@@ -53,10 +54,8 @@ export default async function Page({
// Create the JSON-LD data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: page.data.title,
description: page.data.description,
url: url(page.url),
'@type': 'Article',
headline: page.data.title,
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
@@ -65,6 +64,10 @@ export default async function Page({
url: url('/logo.png'),
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url(page.url),
},
};
return (
@@ -75,18 +78,19 @@ export default async function Page({
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer>
<SectionHeader
as="h1"
title={page.data.title}
description={page.data.description}
/>
</HeroContainer>
<main className="container">
<article className="prose">
<Body components={getMDXComponents()} />
</article>
</main>
<article className="container max-w-4xl col">
<div className="pt-16 pb-4 col gap-3">
<h1 className="text-5xl font-bold">{page.data.title}</h1>
{page.data.description && (
<p className="text-muted-foreground text-xl">
{page.data.description}
</p>
)}
</div>
<div className="prose">
<Body />
</div>
</article>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source);

View File

@@ -0,0 +1,67 @@
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,
},
],
},
};
}

View File

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

224
apps/public/app/global.css Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
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',
},
],
};
}

View File

@@ -0,0 +1,30 @@
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&apos;t find what you were looking for.
</p>
</div>
</HeroContainer>
<Footer />
</div>
);
}

36
apps/public/app/page.tsx Normal file
View File

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

View File

@@ -1,4 +1,3 @@
User-Agent: *
Allow: /
Allow: /og*
Sitemap: https://openpanel.dev/sitemap.xml

View File

@@ -1,6 +1,6 @@
import { url } from '@/lib/layout.shared';
import { articleSource, compareSource, pageSource, source } from '@/lib/source';
import { articleSource, pageSource, source } from '@/lib/source';
import type { MetadataRoute } from 'next';
import { url } from './layout.config';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await articleSource.getPages();
@@ -19,24 +19,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: url('/pricing'),
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: url('/articles'),
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.5,
},
{
url: url('/compare'),
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.5,
},
{
url: url('/supporter'),
lastModified: new Date(),
@@ -45,24 +33,21 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
},
...articles.map((item) => ({
url: url(item.url),
lastModified: item.data.date,
lastModified: item.data.lastModified,
changeFrequency: 'yearly' as const,
priority: 0.5,
})),
...docs.map((item) => ({
url: url(item.url),
lastModified: item.data.lastModified,
changeFrequency: 'monthly' as const,
priority: 0.3,
})),
...pages.map((item) => ({
url: url(item.url),
lastModified: item.data.lastModified,
changeFrequency: 'monthly' as const,
priority: 0.3,
})),
...compareSource.map((item) => ({
url: url(item.url),
changeFrequency: 'monthly' as const,
priority: 0.8,
})),
];
}

View File

@@ -0,0 +1,20 @@
{
"$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"
}
}

View File

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

View File

@@ -1,5 +1,4 @@
import Script from 'next/script';
import Markdown from 'react-markdown';
import {
Accordion,
AccordionContent,
@@ -11,7 +10,7 @@ export const Faqs = ({ children }: { children: React.ReactNode }) => (
<Accordion
type="single"
collapsible
className="w-full max-w-screen-md self-center border rounded-3xl [&_button]:px-4 bg-background-dark [&_div.answer]:bg-background-light"
className="w-full max-w-screen-md self-center border rounded-lg [&_button]:px-4 bg-background-dark [&_div.answer]:bg-background-light"
>
{children}
</Accordion>
@@ -20,7 +19,7 @@ export const Faqs = ({ children }: { children: React.ReactNode }) => (
export const FaqItem = ({
question,
children,
}: { question: string; children: string | React.ReactNode }) => (
}: { question: string; children: string }) => (
<AccordionItem
value={question}
itemScope
@@ -35,13 +34,8 @@ export const FaqItem = ({
itemProp="acceptedAnswer"
itemScope
itemType="https://schema.org/Answer"
className="prose"
>
{typeof children === 'string' ? (
<Markdown>{children}</Markdown>
) : (
children
)}
{children}
</AccordionContent>
</AccordionItem>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,21 @@
'use client';
import NumberFlow from '@number-flow/react';
import { cn } from '@/lib/utils';
import { PRICING } from '@openpanel/payments/prices';
import { useState } from 'react';
import { Slider } from './ui/slider';
const PRICING = [
{ price: 2.5, events: 5_000 },
{ price: 5, events: 10_000 },
{ price: 20, events: 100_000 },
{ price: 30, events: 250_000 },
{ price: 50, events: 500_000 },
{ price: 90, events: 1_000_000 },
{ price: 180, events: 2_500_000 },
{ price: 250, events: 5_000_000 },
{ price: 400, events: 10_000_000 },
];
export function PricingSlider() {
const [index, setIndex] = useState(2);
const match = PRICING[index];

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,272 @@
'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,
};
}

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import { AnimatePresence, motion } from 'framer-motion';
import { ArrowUpIcon } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
const TRAFFIC_SOURCES = [
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
@@ -41,46 +40,52 @@ const COUNTRIES = [
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 },
];
export function WebAnalyticsIllustration() {
export function WebAnalyticsFeature() {
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
const [currentCountryIndex, setCurrentCountryIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
setCurrentCountryIndex((prev) => (prev + 1) % COUNTRIES.length);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="px-12 group aspect-video">
<div className="relative h-full col">
<div className="p-8 relative col gap-4">
<div className="relative">
<MetricCard
title="Session duration"
value="3m 23s"
change="3%"
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="var(--foreground)"
className="absolute w-full rotate-0 top-2 left-2 group-hover:-translate-y-1 group-hover:-rotate-2 transition-all duration-300"
color="hsl(var(--red))"
className="w-full rotate-3 -left-2 hover:-translate-y-1 transition-all duration-300"
/>
<MetricCard
title="Bounce rate"
value="46%"
change="3%"
chartPoints={[10, 46, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="var(--foreground)"
className="absolute w-full -rotate-2 -left-2 top-12 group-hover:-translate-y-1 group-hover:rotate-0 transition-all duration-300"
color="hsl(var(--green))"
className="w-full -mt-8 -rotate-2 left-2 top-14 hover:-translate-y-1 transition-all duration-300"
/>
<div className="col gap-4 w-[80%] md:w-[70%] ml-auto mt-auto">
<BarCell
{...TRAFFIC_SOURCES[currentSourceIndex]}
className="group-hover:scale-105 transition-all duration-300"
/>
</div>
<div>
<div className="-rotate-2 bg-background-light rounded-lg col gap-2 p-2 shadow-lg">
<BarCell {...TRAFFIC_SOURCES[currentSourceIndex]} />
<BarCell
{...TRAFFIC_SOURCES[
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
]}
className="group-hover:scale-105 transition-all duration-300"
/>
</div>
<div className="rotate-1 bg-background-light rounded-lg col gap-2 p-2 shadow-lg">
<BarCell {...COUNTRIES[currentCountryIndex]} />
<BarCell
{...COUNTRIES[(currentCountryIndex + 1) % COUNTRIES.length]}
/>
</div>
</div>
@@ -104,23 +109,33 @@ function MetricCard({
className?: string;
}) {
return (
<div className={cn('col bg-card rounded-lg p-4 pb-6 border', className)}>
<div className="row items-end justify-between">
<div>
<div className="text-muted-foreground text-sm">{title}</div>
<div className="text-2xl font-semibold font-mono">{value}</div>
</div>
<div className="row gap-2 items-center font-mono font-medium">
<ArrowUpIcon className="size-3" strokeWidth={3} />
<div>{change}</div>
<div
className={cn(
'row items-end bg-background-light rounded-lg p-4 pb-6 border justify-between',
className,
)}
>
<div>
<div className="text-muted-foreground text-xl">{title}</div>
<div className="text-5xl font-bold font-mono">{value}</div>
</div>
<div className="row gap-2 items-center font-mono font-medium text-lg">
<div
className="size-6 rounded-full flex items-center justify-center"
style={{
background: color,
}}
>
<ArrowUpIcon className="size-4" strokeWidth={3} />
</div>
<div>{change}</div>
</div>
<SimpleChart
width={400}
width={500}
height={30}
points={chartPoints}
className="absolute bottom-0 left-0 right-0"
strokeColor={color}
className="mt-4"
/>
</div>
);
@@ -131,29 +146,22 @@ function BarCell({
name,
percentage,
value,
className,
}: {
icon: string;
name: string;
percentage: number;
value: number;
className?: string;
}) {
return (
<div
className={cn(
'relative p-4 py-2 bg-card rounded-lg shadow-[0_10px_30px_rgba(0,0,0,0.3)] border',
className,
)}
>
<div className="relative p-2">
<div
className="absolute bg-background bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
className="absolute bg-background-dark bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
style={{
width: `${percentage}%`,
}}
/>
<div className="relative row justify-between ">
<div className="row gap-2 items-center font-medium text-sm">
<div className="row gap-2 items-center font-medium">
{icon.startsWith('http') ? (
<Image
alt="serie icon"
@@ -177,7 +185,7 @@ function BarCell({
</motion.div>
</AnimatePresence>
</div>
<div className="row gap-3 font-mono text-sm">
<div className="row gap-3 font-mono">
<span className="text-muted-foreground">
<NumberFlow value={percentage} />%
</span>

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,7 +56,7 @@ export function SimpleChart({
</defs>
{/* Area fill */}
{/* <path d={areaPath} fill={`url(#${gradientId})`} /> */}
<path d={areaPath} fill={`url(#${gradientId})`} />
{/* Stroke line */}
<path

View File

@@ -1,7 +1,6 @@
import type { TableOfContents } from 'fumadocs-core/toc';
import type { TableOfContents } from 'fumadocs-core/server';
import { ArrowRightIcon } from 'lucide-react';
import Link from 'next/link';
import { FeatureCardContainer } from './feature-card';
interface Props {
toc: TableOfContents;
@@ -9,26 +8,26 @@ interface Props {
export const Toc: React.FC<Props> = ({ toc }) => {
return (
<FeatureCardContainer className="gap-2">
<span className="text-lg font-semibold">Table of contents</span>
<nav className="bg-background-light border rounded-lg pb-2 max-w-[280px] w-full">
<span className="block font-medium p-4 pb-2">Table of contents</span>
<ul>
{toc.map((item) => (
<li
key={item.url}
className="py-1"
style={{ marginLeft: `${(item.depth - 2) * (4 * 4)}px` }}
className="p-2 px-4"
>
<Link
href={item.url}
className="hover:underline row gap-2 items-center group/toc-item"
className="hover:underline row gap-2 items-center group"
title={item.title?.toString() ?? ''}
>
<ArrowRightIcon className="shrink-0 w-4 h-4 opacity-30 group-hover/toc-item:opacity-100 transition-opacity" />
<ArrowRightIcon className="shrink-0 w-4 h-4 opacity-30 group-hover:opacity-100 transition-opacity" />
<span className="truncate text-sm">{item.title}</span>
</Link>
</li>
))}
</ul>
</FeatureCardContainer>
</nav>
);
};

View File

@@ -31,30 +31,26 @@ export function TwitterCard({
}: TwitterCardProps) {
const renderContent = () => {
if (typeof content === 'string') {
return <p className="text-sm">{content}</p>;
return <p className="text-muted-foreground">{content}</p>;
}
if (Array.isArray(content) && typeof content[0] === 'string') {
return content.map((line) => (
<p key={line} className="text-sm">
{line}
</p>
));
return content.map((line) => <p key={line}>{line}</p>);
}
return <div className="text-sm">{content}</div>;
return content;
};
return (
<div className="border rounded-3xl p-8 col gap-4 bg-background-light">
<div className="border rounded-lg p-8 col gap-4 bg-background-light">
<div className="row gap-4">
<div className="size-12 rounded-full bg-muted overflow-hidden shrink-0">
{avatarUrl && (
<Image src={avatarUrl} alt={name} width={48} height={48} />
)}
</div>
<div className="col gap-4">
<div className="col gap-2">
<div className="col gap-1">
<div className="col">
<div className="">
<span className="font-medium">{name}</span>
{verified && (
@@ -71,13 +67,13 @@ export function TwitterCard({
</span>
</div>
{renderContent()}
<div className="row gap-4 text-muted-foreground text-sm">
<div className="row gap-4 text-muted-foreground text-sm mt-4">
<div className="row gap-2">
<MessageCircleIcon className="transition-all size-4 fill-background hover:fill-blue-500 hover:text-blue-500" />
{/* <span>{replies}</span> */}
</div>
<div className="row gap-2">
<RefreshCwIcon className="transition-all size-4 fill-background hover:text-blue-500" />
<RefreshCwIcon className="transition-all size-4 fill-background hover:text-emerald-500" />
{/* <span>{retweets}</span> */}
</div>
<div className="row gap-2">

View File

@@ -3,7 +3,6 @@ import { ChevronDown } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
import { FeatureCardBackground } from '../feature-card';
const Accordion = AccordionPrimitive.Root;
@@ -34,12 +33,11 @@ const AccordionTrigger = ({
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'group relative overflow-hidden flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=open]>svg]:rotate-180 cursor-pointer',
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
<FeatureCardBackground />
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
@@ -57,7 +55,7 @@ const AccordionContent = ({
}) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down text-muted-foreground"
className="overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div

View File

@@ -5,7 +5,7 @@ import type * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'hover:-translate-y-px inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
'hover:-translate-y-[1px] inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 shadow-[0_1px_0_0,0_-1px_0_0] shadow-foreground/10',
{
variants: {
variant: {

View File

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

File diff suppressed because it is too large Load Diff

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