35 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
d0abf16e73 remove wait_for_async_insert 2025-11-15 22:03:57 +01:00
Carl-Gerhard Lindesvärd
fc531ce971 fix lock 2025-11-15 20:09:59 +01:00
Carl-Gerhard Lindesvärd
e6c7dec048 remove reqid and user agent 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
2ddc1754e0 better logger 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
3d1cdadb6f wip 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
2286c331d3 fix 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
9ee3d61a25 remove cluster names and add it behind env flag (if someone want to scale) 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
d8661acd66 fix: sync cachable 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
bb0e413b06 wip 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
37246f57f0 fix: groupmq 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
843f95f237 fix 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
ed8deeec3c fix comments 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
310a867cfa fix: comments 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
9bae0fb2db add: cleanup scripts 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
730c953bbc fix: default to 1 events queue shard 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
742ee8dc1c fix: simply event buffer 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
1285ad85a2 fix: performance related fixes 2025-11-15 20:09:04 +01:00
Carl-Gerhard Lindesvärd
8bb0c87ec9 fix: ignore private ips 2025-11-15 20:07:57 +01:00
Carl-Gerhard Lindesvärd
38cc53890a fix: update ip headers order 2025-11-14 14:30:40 +01:00
Carl-Gerhard Lindesvärd
e4fac81d27 fix: use latest sdks and proxy both request and op1.js in public 2025-11-14 13:30:39 +01:00
Carl-Gerhard Lindesvärd
7719985ad1 bump: @openpanel/nextjs 1.0.12 2025-11-14 13:29:01 +01:00
Carl-Gerhard Lindesvärd
c0cefe704b fix: exports in nextjs package 2025-11-14 13:28:30 +01:00
Carl-Gerhard Lindesvärd
5dbb462578 bump: @openpanel/nextjs 1.0.11 2025-11-14 13:26:40 +01:00
Carl-Gerhard Lindesvärd
2b0b62d64c fix: ts issue for nextjs sdk 2025-11-14 13:26:08 +01:00
Carl-Gerhard Lindesvärd
87e98baeb3 fix: lock file 2025-11-14 13:25:08 +01:00
Carl-Gerhard Lindesvärd
2abb44831c bump: @openpanel/nextjs 1.0.10 2025-11-14 13:20:33 +01:00
Carl-Gerhard Lindesvärd
f990cfcc18 bump: @openpanel/express 1.0.1 2025-11-14 13:20:00 +01:00
Carl-Gerhard Lindesvärd
8fbe944df0 fix: use correct client ip header 2025-11-14 13:16:57 +01:00
Carl-Gerhard Lindesvärd
c1801adaa2 fix: better formula support 2025-11-12 23:15:21 +01:00
Carl-Gerhard Lindesvärd
84fd5ce22f fix: metric chart total count 2025-11-12 23:15:20 +01:00
Carl-Gerhard Lindesvärd
447b7668fd fix: show prompt if usage limit is exceeded 2025-11-12 15:10:28 +01:00
Carl-Gerhard Lindesvärd
e505c0ea45 fix: max age on ui cookies 2025-11-12 15:10:12 +01:00
Carl-Gerhard Lindesvärd
e613a4e01c fix: dont count session events for current billing period 2025-11-12 14:57:03 +01:00
Carl-Gerhard Lindesvärd
723ba3ef6c add: admin cli 2025-11-12 14:57:02 +01:00
Carl-Gerhard Lindesvärd
9cafd61b25 feat: new billing and restrict access when trial has ended
* fix: simply billing

* fix usage graph

* imporve billing more + supporter prompt on self-hosting

* revert service change

* revert query builder

* fix: comments
2025-11-11 11:09:11 +01:00
169 changed files with 9392 additions and 5436 deletions

161
admin/README.md Normal file
View File

@@ -0,0 +1,161 @@
# OpenPanel Admin CLI
An interactive CLI tool to help manage and lookup OpenPanel organizations, projects, and clients.
## Setup
First, install dependencies:
```bash
cd admin
pnpm install
```
## Usage
Run the CLI from the admin directory:
```bash
pnpm start
```
Or use the convenient shell script from anywhere:
```bash
./admin/cli
```
## Features
The CLI provides 4 focused lookup commands for easier navigation:
### 🏢 Lookup by Organization
Search and view detailed information about an organization.
- Fuzzy search across all organizations by name or ID
- Shows full organization details with all projects, clients, and members
### 📊 Lookup by Project
Search for a specific project and view its organization context.
- Fuzzy search across all projects by name or ID
- Highlights the selected project in the organization view
- Displays: `org → project`
### 🔑 Lookup by Client ID
Search for a specific client and view its full context.
- Fuzzy search across all clients by name or ID
- Highlights the selected client and its project
- Displays: `org → project → client`
### 📧 Lookup by Email
Search for a member by email address.
- Fuzzy search across all member emails
- Shows which organization(s) the member belongs to
- Displays member role (👑 owner, ⭐ admin, 👤 member)
**All lookups display:**
- Organization information (ID, name, subscription status, timezone, event usage)
- Organization members and their roles
- All projects with their settings (domain, CORS, event counts)
- All clients for each project (ID, name, type, credentials)
- Deletion warnings if scheduled
---
### 🗑️ Clear Cache
Clear cache for an organization and all its projects.
- Fuzzy search to find the organization
- Shows organization details and all projects
- Confirms before clearing cache
- Provides organization ID and all project IDs for cache clearing logic
**Use when:**
- You need to invalidate cache after data changes
- Troubleshooting caching issues
- After manual database updates
**Note:** The cache clearing logic needs to be implemented. The command provides the organization and project data structure for you to add your cache clearing calls.
---
### 🔴 Delete Organization
Permanently delete an organization and all its data.
- Fuzzy search to find the organization
- Shows detailed preview of what will be deleted (projects, members, events)
- Requires **3 confirmations**:
1. Initial confirmation
2. Type organization name to confirm
3. Final warning confirmation
- Deletes from both PostgreSQL and ClickHouse
**Use when:**
- Removing organizations that are no longer needed
- Cleaning up test/demo organizations
- Handling deletion requests
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
**What gets deleted:**
- Organization record
- All projects and their settings
- All clients and credentials
- All events and analytics data (from ClickHouse)
- All member associations
- All dashboards and reports
---
### 🔴 Delete User
Permanently delete a user account and remove them from all organizations.
- Fuzzy search by email or name
- Shows which organizations the user belongs to
- Shows if user created any organizations (won't delete those orgs)
- Requires **3 confirmations**:
1. Initial confirmation
2. Type user email to confirm
3. Final warning confirmation
**Use when:**
- Removing user accounts at user request
- Cleaning up inactive accounts
- Handling GDPR/data deletion requests
**⚠️ WARNING:** This action is PERMANENT and cannot be undone!
**What gets deleted:**
- User account
- All auth sessions and tokens
- All memberships (removed from all orgs)
- All personal data
**What is NOT deleted:**
- Organizations created by the user (only the creator reference is removed)
## Environment Variables
Make sure you have the proper environment variables set up:
- `DATABASE_URL` - PostgreSQL connection string
- `DATABASE_URL_REPLICA` (optional) - Read replica connection string
## Development
The CLI uses:
- **jiti** - Direct TypeScript execution without build step
- **inquirer** - Interactive prompts
- **inquirer-autocomplete-prompt** - Fuzzy search functionality
- **chalk** - Colored terminal output
- **@openpanel/db** - Direct Prisma database access

25
admin/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@openpanel/admin",
"version": "0.0.1",
"type": "module",
"private": true,
"scripts": {
"start": "dotenv -e .env -c -- jiti src/cli.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@openpanel/common": "workspace:*",
"@openpanel/db": "workspace:*",
"chalk": "^5.3.0",
"fuzzy": "^0.1.3",
"inquirer": "^9.3.5",
"inquirer-autocomplete-prompt": "^3.0.1",
"jiti": "^2.4.2"
},
"devDependencies": {
"@types/inquirer": "^9.0.7",
"@types/inquirer-autocomplete-prompt": "^3.0.3",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}

118
admin/src/cli.ts Normal file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env node
import inquirer from 'inquirer';
import { clearCache } from './commands/clear-cache';
import { deleteOrganization } from './commands/delete-organization';
import { deleteUser } from './commands/delete-user';
import { lookupByClient } from './commands/lookup-client';
import { lookupByEmail } from './commands/lookup-email';
import { lookupByOrg } from './commands/lookup-org';
import { lookupByProject } from './commands/lookup-project';
const secureEnv = (url: string) => {
const parsed = new URL(url);
if (parsed.username && parsed.password) {
return `${parsed.protocol}//${parsed.username}:${parsed.password.slice(0, 1)}...${parsed.password.slice(-1)}@${parsed.hostname}:${parsed.port}`;
}
return url;
};
async function main() {
console.log('\n🔧 OpenPanel Admin CLI\n');
const DATABASE_URL = process.env.DATABASE_URL;
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
const REDIS_URL = process.env.REDIS_URL;
if (!DATABASE_URL || !CLICKHOUSE_URL || !REDIS_URL) {
console.error('Environment variables are not set');
process.exit(1);
}
// Log environment variables for debugging
console.log('Environment:', {
NODE_ENV: process.env.NODE_ENV,
SELF_HOSTED: process.env.SELF_HOSTED ? 'Yes' : 'No',
DATABASE_URL: secureEnv(DATABASE_URL),
CLICKHOUSE_URL: secureEnv(CLICKHOUSE_URL),
REDIS_URL: secureEnv(REDIS_URL),
});
console.log('');
const { command } = await inquirer.prompt([
{
type: 'list',
name: 'command',
message: 'What would you like to do?',
pageSize: 20,
choices: [
{
name: '🏢 Lookup by Organization',
value: 'lookup-org',
},
{
name: '📊 Lookup by Project',
value: 'lookup-project',
},
{
name: '🔑 Lookup by Client ID',
value: 'lookup-client',
},
{
name: '📧 Lookup by Email',
value: 'lookup-email',
},
{
name: '🗑️ Clear Cache',
value: 'clear-cache',
},
{ name: '─────────────────────', value: 'separator', disabled: true },
{
name: '🔴 Delete Organization',
value: 'delete-org',
},
{
name: '🔴 Delete User',
value: 'delete-user',
},
{ name: '─────────────────────', value: 'separator', disabled: true },
{ name: '❌ Exit', value: 'exit' },
],
},
]);
switch (command) {
case 'lookup-org':
await lookupByOrg();
break;
case 'lookup-project':
await lookupByProject();
break;
case 'lookup-client':
await lookupByClient();
break;
case 'lookup-email':
await lookupByEmail();
break;
case 'clear-cache':
await clearCache();
break;
case 'delete-org':
await deleteOrganization();
break;
case 'delete-user':
await deleteUser();
break;
case 'exit':
console.log('Goodbye! 👋');
process.exit(0);
}
// Loop back to main menu
await main();
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,162 @@
import {
db,
getOrganizationAccess,
getOrganizationByProjectIdCached,
getProjectAccess,
getProjectByIdCached,
} from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface OrgSearchItem {
id: string;
name: string;
displayText: string;
}
export async function clearCache() {
console.log(chalk.blue('\n🗑 Clear Cache\n'));
console.log('Loading organizations...\n');
const organizations = await db.organization.findMany({
orderBy: {
name: 'asc',
},
});
if (organizations.length === 0) {
console.log(chalk.red('No organizations found.'));
return;
}
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
id: org.id,
name: org.name,
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedOrg } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedOrg',
message: 'Search for an organization:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedOrg: OrgSearchItem };
// Fetch organization with all projects
const organization = await db.organization.findUnique({
where: {
id: selectedOrg.id,
},
include: {
projects: {
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
console.log(chalk.yellow('\n📋 Organization Details:\n'));
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
if (organization.projects.length > 0) {
console.log(chalk.yellow('\n📊 Projects:\n'));
for (const project of organization.projects) {
console.log(
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`,
);
}
}
// Confirm before clearing cache
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `Clear cache for organization "${organization.name}" and all ${organization.projects.length} projects?`,
default: false,
},
]);
if (!confirm) {
console.log(chalk.yellow('\nCache clear cancelled.'));
return;
}
console.log(chalk.blue('\n🔄 Clearing cache...\n'));
for (const project of organization.projects) {
// Clear project access cache for each member
for (const member of organization.members) {
if (!member.user?.id) continue;
console.log(
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`,
);
await getProjectAccess.clear({
userId: member.user?.id,
projectId: project.id,
});
await getOrganizationAccess.clear({
userId: member.user?.id,
organizationId: organization.id,
});
}
console.log(`Clearing cache for project: ${project.name}`);
await getOrganizationByProjectIdCached.clear(project.id);
await getProjectByIdCached.clear(project.id);
}
console.log(chalk.gray(`Organization ID: ${organization.id}`));
console.log(
chalk.gray(
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`,
),
);
// Example of what you might do:
/*
for (const project of organization.projects) {
console.log(`Clearing cache for project: ${project.name}...`);
// await clearProjectCache(project.id);
// await redis.del(`project:${project.id}:*`);
}
// Clear organization-level cache
// await clearOrganizationCache(organization.id);
// await redis.del(`organization:${organization.id}:*`);
console.log(chalk.green('\n✅ Cache cleared successfully!'));
*/
}

View File

@@ -0,0 +1,215 @@
import {
db,
deleteFromClickhouse,
deleteOrganization as deleteOrg,
} from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface OrgSearchItem {
id: string;
name: string;
displayText: string;
}
export async function deleteOrganization() {
console.log(chalk.red('\n🗑 Delete Organization\n'));
console.log(
chalk.yellow(
'⚠️ WARNING: This will permanently delete the organization and all its data!\n',
),
);
console.log('Loading organizations...\n');
const organizations = await db.organization.findMany({
include: {
projects: true,
members: {
include: {
user: true,
},
},
},
orderBy: {
name: 'asc',
},
});
if (organizations.length === 0) {
console.log(chalk.red('No organizations found.'));
return;
}
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
id: org.id,
name: org.name,
displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedOrg } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedOrg',
message: 'Search for an organization to delete:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedOrg: OrgSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedOrg.id,
},
include: {
projects: {
include: {
clients: true,
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
// Display what will be deleted
console.log(chalk.red('\n⚠ YOU ARE ABOUT TO DELETE:\n'));
console.log(` ${chalk.bold('Organization:')} ${organization.name}`);
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
console.log(` ${chalk.gray('Projects:')} ${organization.projects.length}`);
console.log(` ${chalk.gray('Members:')} ${organization.members.length}`);
if (organization.projects.length > 0) {
console.log(chalk.red('\n Projects that will be deleted:'));
for (const project of organization.projects) {
console.log(
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`,
);
}
}
if (organization.members.length > 0) {
console.log(chalk.red('\n Members who will lose access:'));
for (const member of organization.members) {
const email = member.user?.email || member.email || 'Unknown';
console.log(` - ${email} ${chalk.gray(`(${member.role})`)}`);
}
}
console.log(
chalk.red(
'\n⚠ This will delete ALL projects, clients, events, and data associated with this organization!',
),
);
// First confirmation
const { confirmFirst } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmFirst',
message: chalk.red(
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`,
),
default: false,
},
]);
if (!confirmFirst) {
console.log(chalk.yellow('\nDeletion cancelled.'));
return;
}
// Second confirmation - type organization name
const { confirmName } = await inquirer.prompt([
{
type: 'input',
name: 'confirmName',
message: `Type the organization name "${organization.name}" to confirm deletion:`,
},
]);
if (confirmName !== organization.name) {
console.log(
chalk.red('\n❌ Organization name does not match. Deletion cancelled.'),
);
return;
}
// Final confirmation
const { confirmFinal } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmFinal',
message: chalk.red(
'FINAL WARNING: This action CANNOT be undone. Delete now?',
),
default: false,
},
]);
if (!confirmFinal) {
console.log(chalk.yellow('\nDeletion cancelled.'));
return;
}
console.log(chalk.red('\n🗑 Deleting organization...\n'));
try {
const projectIds = organization.projects.map((p) => p.id);
// Step 1: Delete from ClickHouse (events, profiles, etc.)
if (projectIds.length > 0) {
console.log(
chalk.yellow(
`Deleting data from ClickHouse for ${projectIds.length} projects...`,
),
);
await deleteFromClickhouse(projectIds);
console.log(chalk.green('✓ ClickHouse data deletion initiated'));
}
// Step 2: Delete the organization from PostgreSQL (cascade will handle related records)
console.log(chalk.yellow('Deleting organization from database...'));
await deleteOrg(organization.id);
console.log(chalk.green('✓ Organization deleted from database'));
console.log(chalk.green('\n✅ Organization deleted successfully!'));
console.log(
chalk.gray(
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`,
),
);
console.log(
chalk.gray(
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.',
),
);
} catch (error) {
console.error(chalk.red('\n❌ Error deleting organization:'), error);
throw error;
}
}

View File

@@ -0,0 +1,220 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface UserSearchItem {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
displayText: string;
}
export async function deleteUser() {
console.log(chalk.red('\n🗑 Delete User\n'));
console.log(
chalk.yellow(
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n',
),
);
console.log('Loading users...\n');
const users = await db.user.findMany({
include: {
membership: {
include: {
organization: true,
},
},
accounts: true,
},
orderBy: {
email: 'asc',
},
});
if (users.length === 0) {
console.log(chalk.red('No users found.'));
return;
}
const searchItems: UserSearchItem[] = users.map((user) => {
const fullName =
user.firstName || user.lastName
? `${user.firstName || ''} ${user.lastName || ''}`.trim()
: '';
const orgCount = user.membership.length;
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
displayText: `${user.email} ${fullName ? chalk.gray(`(${fullName})`) : ''} ${chalk.cyan(`- ${orgCount} orgs`)}`,
};
});
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: UserSearchItem) =>
`${item.email} ${item.firstName || ''} ${item.lastName || ''}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<UserSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedUser } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedUser',
message: 'Search for a user to delete:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedUser: UserSearchItem };
// Fetch full user details
const user = await db.user.findUnique({
where: {
id: selectedUser.id,
},
include: {
membership: {
include: {
organization: true,
},
},
accounts: true,
createdOrganizations: true,
},
});
if (!user) {
console.log(chalk.red('User not found.'));
return;
}
// Display what will be deleted
console.log(chalk.red('\n⚠ YOU ARE ABOUT TO DELETE:\n'));
console.log(` ${chalk.bold('User:')} ${user.email}`);
if (user.firstName || user.lastName) {
console.log(
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`,
);
}
console.log(` ${chalk.gray('ID:')} ${user.id}`);
console.log(
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`,
);
console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`);
if (user.createdOrganizations.length > 0) {
console.log(
chalk.red(
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`,
),
);
for (const org of user.createdOrganizations) {
console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`);
}
console.log(
chalk.yellow(
' Note: These organizations will NOT be deleted, only the user reference.',
),
);
}
if (user.membership.length > 0) {
console.log(
chalk.red('\n Organizations where user will be removed from:'),
);
for (const member of user.membership) {
console.log(
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`,
);
}
}
console.log(
chalk.red(
'\n⚠ This will delete the user account, all sessions, and remove them from all organizations!',
),
);
// First confirmation
const { confirmFirst } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmFirst',
message: chalk.red(
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`,
),
default: false,
},
]);
if (!confirmFirst) {
console.log(chalk.yellow('\nDeletion cancelled.'));
return;
}
// Second confirmation - type email
const { confirmEmail } = await inquirer.prompt([
{
type: 'input',
name: 'confirmEmail',
message: `Type the user email "${user.email}" to confirm deletion:`,
},
]);
if (confirmEmail !== user.email) {
console.log(chalk.red('\n❌ Email does not match. Deletion cancelled.'));
return;
}
// Final confirmation
const { confirmFinal } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmFinal',
message: chalk.red(
'FINAL WARNING: This action CANNOT be undone. Delete now?',
),
default: false,
},
]);
if (!confirmFinal) {
console.log(chalk.yellow('\nDeletion cancelled.'));
return;
}
console.log(chalk.red('\n🗑 Deleting user...\n'));
try {
// Delete the user (cascade will handle related records like sessions, accounts, memberships)
await db.user.delete({
where: {
id: user.id,
},
});
console.log(chalk.green('\n✅ User deleted successfully!'));
console.log(
chalk.gray(
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`,
),
);
} catch (error) {
console.error(chalk.red('\n❌ Error deleting user:'), error);
throw error;
}
}

View File

@@ -0,0 +1,104 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
import { displayOrganizationDetails } from '../utils/display';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface ClientSearchItem {
id: string;
name: string;
organizationId: string;
organizationName: string;
projectId: string | null;
projectName: string | null;
displayText: string;
}
export async function lookupByClient() {
console.log(chalk.blue('\n🔑 Lookup by Client ID\n'));
console.log('Loading clients...\n');
const clients = await db.client.findMany({
include: {
organization: true,
project: true,
},
orderBy: {
name: 'asc',
},
});
if (clients.length === 0) {
console.log(chalk.red('No clients found.'));
return;
}
const searchItems: ClientSearchItem[] = clients.map((client) => ({
id: client.id,
name: client.name,
organizationId: client.organizationId,
organizationName: client.organization.name,
projectId: client.projectId,
projectName: client.project?.name || null,
displayText: `${client.organization.name}${client.project?.name || '[No Project]'}${client.name} ${chalk.gray(`(${client.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: ClientSearchItem) =>
`${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<ClientSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedClient } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedClient',
message: 'Search for a client:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedClient: ClientSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedClient.organizationId,
},
include: {
projects: {
include: {
clients: true,
},
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
displayOrganizationDetails(organization, {
highlightProjectId: selectedClient.projectId || undefined,
highlightClientId: selectedClient.id,
});
}

View File

@@ -0,0 +1,112 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
import { displayOrganizationDetails } from '../utils/display';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface EmailSearchItem {
email: string;
organizationId: string;
organizationName: string;
role: string;
userId: string | null;
displayText: string;
}
export async function lookupByEmail() {
console.log(chalk.blue('\n📧 Lookup by Email\n'));
console.log('Loading members...\n');
const members = await db.member.findMany({
include: {
organization: true,
user: true,
},
orderBy: {
email: 'asc',
},
});
if (members.length === 0) {
console.log(chalk.red('No members found.'));
return;
}
// Group by email (in case same email is in multiple orgs)
const searchItems: EmailSearchItem[] = members.map((member) => {
const email = member.user?.email || member.email || 'Unknown';
const roleBadge =
member.role === 'owner' ? '👑' : member.role === 'admin' ? '⭐' : '👤';
return {
email,
organizationId: member.organizationId,
organizationName: member.organization.name,
role: member.role,
userId: member.userId,
displayText: `${email} ${chalk.gray(`${member.organization.name}`)} ${roleBadge}`,
};
});
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: EmailSearchItem) =>
`${item.email} ${item.organizationName}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<EmailSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedMember } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedMember',
message: 'Search for a member by email:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedMember: EmailSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedMember.organizationId,
},
include: {
projects: {
include: {
clients: true,
},
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
console.log(
chalk.yellow(
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`,
),
);
displayOrganizationDetails(organization);
}

View File

@@ -0,0 +1,88 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
import { displayOrganizationDetails } from '../utils/display';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface OrgSearchItem {
id: string;
name: string;
displayText: string;
}
export async function lookupByOrg() {
console.log(chalk.blue('\n🏢 Lookup by Organization\n'));
console.log('Loading organizations...\n');
const organizations = await db.organization.findMany({
orderBy: {
name: 'asc',
},
});
if (organizations.length === 0) {
console.log(chalk.red('No organizations found.'));
return;
}
const searchItems: OrgSearchItem[] = organizations.map((org) => ({
id: org.id,
name: org.name,
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<OrgSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedOrg } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedOrg',
message: 'Search for an organization:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedOrg: OrgSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedOrg.id,
},
include: {
projects: {
include: {
clients: true,
},
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
displayOrganizationDetails(organization);
}

View File

@@ -0,0 +1,98 @@
import { db } from '@openpanel/db';
import chalk from 'chalk';
import fuzzy from 'fuzzy';
import inquirer from 'inquirer';
import autocomplete from 'inquirer-autocomplete-prompt';
import { displayOrganizationDetails } from '../utils/display';
// Register autocomplete prompt
inquirer.registerPrompt('autocomplete', autocomplete);
interface ProjectSearchItem {
id: string;
name: string;
organizationId: string;
organizationName: string;
displayText: string;
}
export async function lookupByProject() {
console.log(chalk.blue('\n📊 Lookup by Project\n'));
console.log('Loading projects...\n');
const projects = await db.project.findMany({
include: {
organization: true,
},
orderBy: {
name: 'asc',
},
});
if (projects.length === 0) {
console.log(chalk.red('No projects found.'));
return;
}
const searchItems: ProjectSearchItem[] = projects.map((project) => ({
id: project.id,
name: project.name,
organizationId: project.organizationId,
organizationName: project.organization.name,
displayText: `${project.organization.name}${project.name} ${chalk.gray(`(${project.id})`)}`,
}));
const searchFunction = async (_answers: unknown, input = '') => {
const fuzzyResult = fuzzy.filter(input, searchItems, {
extract: (item: ProjectSearchItem) =>
`${item.organizationName} ${item.name} ${item.id}`,
});
return fuzzyResult.map((result: fuzzy.FilterResult<ProjectSearchItem>) => ({
name: result.original.displayText,
value: result.original,
}));
};
const { selectedProject } = (await inquirer.prompt([
{
type: 'autocomplete',
name: 'selectedProject',
message: 'Search for a project:',
source: searchFunction,
pageSize: 15,
},
])) as { selectedProject: ProjectSearchItem };
// Fetch full organization details
const organization = await db.organization.findUnique({
where: {
id: selectedProject.organizationId,
},
include: {
projects: {
include: {
clients: true,
},
orderBy: {
name: 'asc',
},
},
members: {
include: {
user: true,
},
},
},
});
if (!organization) {
console.log(chalk.red('Organization not found.'));
return;
}
displayOrganizationDetails(organization, {
highlightProjectId: selectedProject.id,
});
}

206
admin/src/utils/display.ts Normal file
View File

@@ -0,0 +1,206 @@
import type {
Client,
Member,
Organization,
Project,
User,
} from '@openpanel/db';
import chalk from 'chalk';
type OrganizationWithDetails = Organization & {
projects: (Project & {
clients: Client[];
})[];
members: (Member & {
user: User | null;
})[];
};
interface DisplayOptions {
highlightProjectId?: string;
highlightClientId?: string;
}
export function displayOrganizationDetails(
organization: OrganizationWithDetails,
options: DisplayOptions = {},
) {
console.log(`\n${'='.repeat(80)}`);
console.log(chalk.bold.yellow(`\n📊 ORGANIZATION: ${organization.name}`));
console.log(`${'='.repeat(80)}\n`);
// Organization Details
console.log(chalk.bold('Organization Details:'));
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
console.log(
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`,
);
console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`);
// Subscription info
if (organization.subscriptionStatus) {
console.log(
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`,
);
if (organization.subscriptionPriceId) {
console.log(
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`,
);
}
if (organization.subscriptionPeriodEventsLimit) {
const usage = `${organization.subscriptionPeriodEventsCount}/${organization.subscriptionPeriodEventsLimit}`;
const percentage =
(organization.subscriptionPeriodEventsCount /
organization.subscriptionPeriodEventsLimit) *
100;
const color =
percentage > 90
? chalk.red
: percentage > 70
? chalk.yellow
: chalk.green;
console.log(
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`,
);
}
if (organization.subscriptionStartsAt) {
console.log(
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`,
);
}
if (organization.subscriptionEndsAt) {
console.log(
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`,
);
}
}
if (organization.deleteAt) {
console.log(
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`,
);
}
// Members
console.log(`\n${chalk.bold('Members:')}`);
if (organization.members.length === 0) {
console.log(' No members');
} else {
for (const member of organization.members) {
const roleBadge = getRoleBadge(member.role);
console.log(
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`,
);
}
}
// Projects
console.log(`\n${chalk.bold(`Projects (${organization.projects.length}):`)}`);
if (organization.projects.length === 0) {
console.log(' No projects');
} else {
for (const project of organization.projects) {
const isHighlighted = project.id === options.highlightProjectId;
const projectPrefix = isHighlighted ? chalk.yellow.bold('→ ') : ' ';
console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`);
console.log(` ${chalk.gray('ID:')} ${project.id}`);
console.log(
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`,
);
if (project.domain) {
console.log(` ${chalk.gray('Domain:')} ${project.domain}`);
}
if (project.cors.length > 0) {
console.log(` ${chalk.gray('CORS:')} ${project.cors.join(', ')}`);
}
console.log(
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`,
);
console.log(
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`,
);
if (project.deleteAt) {
console.log(
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`,
);
}
// Clients for this project
if (project.clients.length > 0) {
console.log(` ${chalk.gray('Clients:')}`);
for (const client of project.clients) {
const isClientHighlighted = client.id === options.highlightClientId;
const clientPrefix = isClientHighlighted
? chalk.yellow.bold(' → ')
: ' ';
const typeBadge = getClientTypeBadge(client.type);
console.log(`${clientPrefix}${typeBadge} ${chalk.cyan(client.name)}`);
console.log(` ${chalk.gray('ID:')} ${client.id}`);
console.log(` ${chalk.gray('Type:')} ${client.type}`);
console.log(
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`,
);
console.log(
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`,
);
}
} else {
console.log(` ${chalk.gray('Clients:')} None`);
}
}
}
// Clients without projects (organization-level clients)
const orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
console.log(`\n${'='.repeat(80)}\n`);
}
function getSubscriptionStatusColor(status: string): string {
switch (status) {
case 'active':
return chalk.green(status);
case 'trialing':
return chalk.blue(status);
case 'canceled':
return chalk.red(status);
case 'past_due':
return chalk.yellow(status);
default:
return chalk.gray(status);
}
}
function getRoleBadge(role: string): string {
switch (role) {
case 'owner':
return chalk.red.bold('👑');
case 'admin':
return chalk.yellow.bold('⭐');
case 'member':
return chalk.blue('👤');
default:
return chalk.gray('•');
}
}
function getClientTypeBadge(type: string): string {
switch (type) {
case 'root':
return chalk.red.bold('[ROOT]');
case 'write':
return chalk.green('[WRITE]');
case 'read':
return chalk.blue('[READ]');
default:
return chalk.gray('[UNKNOWN]');
}
}

12
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"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": { "dependencies": {
"@ai-sdk/anthropic": "^1.2.10", "@ai-sdk/anthropic": "^1.2.10",
"@ai-sdk/openai": "^1.3.12", "@ai-sdk/openai": "^1.3.12",
"@fastify/compress": "^8.0.1", "@fastify/compress": "^8.1.0",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.0", "@fastify/cors": "^11.1.0",
"@fastify/rate-limit": "^10.2.2", "@fastify/rate-limit": "^10.3.0",
"@fastify/websocket": "^11.0.2", "@fastify/websocket": "^11.2.0",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@openpanel/auth": "workspace:^", "@openpanel/auth": "workspace:^",
"@openpanel/common": "workspace:*", "@openpanel/common": "workspace:*",
@@ -35,13 +35,12 @@
"@trpc/server": "^11.6.0", "@trpc/server": "^11.6.0",
"ai": "^4.2.10", "ai": "^4.2.10",
"fast-json-stable-hash": "^1.0.3", "fast-json-stable-hash": "^1.0.3",
"fastify": "^5.2.1", "fastify": "^5.6.1",
"fastify-metrics": "^12.1.0", "fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0", "fastify-raw-body": "^5.0.0",
"groupmq": "1.0.0-next.19", "groupmq": "1.1.0-next.6",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"request-ip": "^3.3.0",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"sqlstring": "^2.3.3", "sqlstring": "^2.3.3",
@@ -58,7 +57,6 @@
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
"@types/ramda": "^0.30.2", "@types/ramda": "^0.30.2",
"@types/request-ip": "^0.0.41",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/sqlstring": "^2.3.2", "@types/sqlstring": "^2.3.2",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",

View File

@@ -7,6 +7,23 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
import yaml from 'js-yaml'; 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() { async function main() {
// Get document, or throw exception on error // Get document, or throw exception on error
try { try {
@@ -14,6 +31,9 @@ async function main() {
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml', 'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
).then((res) => res.text()); ).then((res) => res.text());
const parsedData = yaml.load(data) as any[];
const transformedBots = transformBots(parsedData);
fs.writeFileSync( fs.writeFileSync(
path.resolve(__dirname, '../src/bots/bots.ts'), path.resolve(__dirname, '../src/bots/bots.ts'),
[ [
@@ -21,11 +41,20 @@ async function main() {
'', '',
'// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml', '// The data is fetch from device-detector https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
'', '',
`const bots = ${JSON.stringify(yaml.load(data))} as const;`, `const bots = ${JSON.stringify(transformedBots, null, 2)} as const;`,
'export default bots;', 'export default bots;',
'',
].join('\n'), ].join('\n'),
'utf-8', '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) { } catch (e) {
console.log(e); console.log(e);
} }

View File

@@ -40,8 +40,6 @@ async function main() {
properties: { properties: {
hash: 'test-hash', hash: 'test-hash',
'query.utm_source': 'test', 'query.utm_source': 'test',
__reqId: `req_${Math.floor(Math.random() * 1000)}`,
__user_agent: 'Mozilla/5.0 (Test)',
}, },
created_at: formatClickhouseDate(eventTime), created_at: formatClickhouseDate(eventTime),
country: 'US', country: 'US',

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,47 @@
import { cacheable, cacheableLru } from '@openpanel/redis';
import bots from './bots'; import bots from './bots';
export function isBot(ua: string) { // Pre-compile regex patterns at module load time
const res = bots.find((bot) => { const compiledBots = bots.map((bot) => {
if (new RegExp(bot.regex).test(ua)) { if ('regex' in bot) {
return true;
}
return false;
});
if (!res) {
return null;
}
return { return {
name: res.name, ...bot,
type: 'category' in res ? res.category : 'Unknown', 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',
};
}
}
return null;
},
{
maxSize: 1000,
ttl: 60 * 5,
},
);

View File

@@ -1,12 +1,10 @@
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db'; import { getSalts } from '@openpanel/db';
import { eventsGroupQueue } from '@openpanel/queue'; import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { PostEventPayload } from '@openpanel/sdk'; import type { PostEventPayload } from '@openpanel/sdk';
import { checkDuplicatedEvent } from '@/utils/deduplicate';
import { generateId } from '@openpanel/common'; import { generateId } from '@openpanel/common';
import { getGeoLocation } from '@openpanel/geo'; import { getGeoLocation } from '@openpanel/geo';
import { getStringHeaders, getTimestamp } from './track.controller'; import { getStringHeaders, getTimestamp } from './track.controller';
@@ -21,7 +19,7 @@ export async function postEvent(
request.timestamp, request.timestamp,
request.body, request.body,
); );
const ip = getClientIp(request)!; const ip = request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent']!;
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers); const headers = getStringHeaders(request.headers);
@@ -45,28 +43,22 @@ export async function postEvent(
ua, ua,
}); });
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
previousDeviceId,
currentDeviceId,
},
projectId,
})
) {
return;
}
const uaInfo = parseUserAgent(ua, request.body?.properties); const uaInfo = parseUserAgent(ua, request.body?.properties);
const groupId = uaInfo.isServer const groupId = uaInfo.isServer
? request.body?.profileId ? request.body?.profileId
? `${projectId}:${request.body?.profileId}` ? `${projectId}:${request.body?.profileId}`
: `${projectId}:${generateId()}` : `${projectId}:${generateId()}`
: currentDeviceId; : currentDeviceId;
await eventsGroupQueue.add({ const jobId = [
request.body.name,
timestamp,
projectId,
currentDeviceId,
groupId,
]
.filter(Boolean)
.join('-');
await getEventsGroupQueueShard(groupId).add({
orderMs: new Date(timestamp).getTime(), orderMs: new Date(timestamp).getTime(),
data: { data: {
projectId, projectId,
@@ -76,11 +68,13 @@ export async function postEvent(
timestamp, timestamp,
isTimestampFromThePast, isTimestampFromThePast,
}, },
uaInfo,
geo, geo,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
}, },
groupId, groupId,
jobId,
}); });
reply.status(202).send('ok'); reply.status(202).send('ok');

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr } from 'ramda'; import { assocPath, pathOr } from 'ramda';
import { checkDuplicatedEvent, isDuplicatedEvent } from '@/utils/deduplicate';
import { parseUserAgent } from '@openpanel/common/server'; import { parseUserAgent } from '@openpanel/common/server';
import { getProfileById, upsertProfile } from '@openpanel/db'; import { getProfileById, upsertProfile } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo'; import { getGeoLocation } from '@openpanel/geo';
@@ -17,41 +15,39 @@ export async function updateProfile(
}>, }>,
reply: FastifyReply, reply: FastifyReply,
) { ) {
const { profileId, properties, ...rest } = request.body; const payload = request.body;
const projectId = request.client!.projectId; const projectId = request.client!.projectId;
if (!projectId) { if (!projectId) {
return reply.status(400).send('No projectId'); return reply.status(400).send('No projectId');
} }
const ip = getClientIp(request)!; const ip = request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent']!;
const uaInfo = parseUserAgent(ua, properties); const uaInfo = parseUserAgent(ua, payload.properties);
const geo = await getGeoLocation(ip); const geo = await getGeoLocation(ip);
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
},
projectId,
})
) {
return;
}
await upsertProfile({ await upsertProfile({
id: profileId, ...payload,
id: payload.profileId,
isExternal: true, isExternal: true,
projectId, projectId,
properties: { properties: {
...(properties ?? {}), ...(payload.properties ?? {}),
...(ip ? geo : {}), country: geo.country,
...uaInfo, city: geo.city,
region: geo.region,
longitude: geo.longitude,
latitude: geo.latitude,
os: uaInfo.os,
os_version: uaInfo.osVersion,
browser: uaInfo.browser,
browser_version: uaInfo.browserVersion,
device: uaInfo.device,
brand: uaInfo.brand,
model: uaInfo.model,
}, },
...rest,
}); });
reply.status(202).send(profileId); reply.status(202).send(payload.profileId);
} }
export async function incrementProfileProperty( export async function incrementProfileProperty(
@@ -66,18 +62,6 @@ export async function incrementProfileProperty(
return reply.status(400).send('No projectId'); return reply.status(400).send('No projectId');
} }
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
},
projectId,
})
) {
return;
}
const profile = await getProfileById(profileId, projectId); const profile = await getProfileById(profileId, projectId);
if (!profile) { if (!profile) {
return reply.status(404).send('Not found'); return reply.status(404).send('Not found');
@@ -120,18 +104,6 @@ export async function decrementProfileProperty(
return reply.status(400).send('No projectId'); return reply.status(400).send('No projectId');
} }
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
},
projectId,
})
) {
return;
}
const profile = await getProfileById(profileId, projectId); const profile = await getProfileById(profileId, projectId);
if (!profile) { if (!profile) {
return reply.status(404).send('Not found'); return reply.status(404).send('Not found');

View File

@@ -1,13 +1,11 @@
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, assocPath, pathOr, pick } from 'ramda'; import { assocPath, pathOr, pick } from 'ramda';
import { checkDuplicatedEvent } from '@/utils/deduplicate';
import { generateId } from '@openpanel/common'; import { generateId } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db'; import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { eventsGroupQueue } from '@openpanel/queue'; import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { import type {
DecrementPayload, DecrementPayload,
IdentifyPayload, IdentifyPayload,
@@ -38,10 +36,10 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
} }
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined { function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
const identity = path<IdentifyPayload>( const identity =
['properties', '__identify'], 'properties' in body.payload
body.payload, ? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
); : undefined;
return ( return (
identity || identity ||
@@ -57,27 +55,28 @@ export function getTimestamp(
timestamp: FastifyRequest['timestamp'], timestamp: FastifyRequest['timestamp'],
payload: TrackHandlerPayload['payload'], payload: TrackHandlerPayload['payload'],
) { ) {
const safeTimestamp = new Date(timestamp || Date.now()).toISOString(); const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp = path<string>( const userDefinedTimestamp =
['properties', '__timestamp'], 'properties' in payload
payload, ? (payload?.properties?.__timestamp as string | undefined)
); : undefined;
if (!userDefinedTimestamp) { if (!userDefinedTimestamp) {
return { timestamp: safeTimestamp, isTimestampFromThePast: false }; return { timestamp: safeTimestamp, isTimestampFromThePast: false };
} }
const clientTimestamp = new Date(userDefinedTimestamp); const clientTimestamp = new Date(userDefinedTimestamp);
const clientTimestampNumber = clientTimestamp.getTime();
if ( if (
Number.isNaN(clientTimestamp.getTime()) || Number.isNaN(clientTimestampNumber) ||
clientTimestamp > new Date(safeTimestamp) clientTimestampNumber > safeTimestamp
) { ) {
return { timestamp: safeTimestamp, isTimestampFromThePast: false }; return { timestamp: safeTimestamp, isTimestampFromThePast: false };
} }
return { return {
timestamp: clientTimestamp.toISOString(), timestamp: clientTimestampNumber,
isTimestampFromThePast: true, isTimestampFromThePast: true,
}; };
} }
@@ -90,18 +89,19 @@ export async function handler(
) { ) {
const timestamp = getTimestamp(request.timestamp, request.body.payload); const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip = const ip =
path<string>(['properties', '__ip'], request.body.payload) || 'properties' in request.body.payload &&
getClientIp(request)!; request.body.payload.properties?.__ip
? (request.body.payload.properties.__ip as string)
: request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent']!;
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
if (!projectId) { if (!projectId) {
reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,
error: 'Bad Request', error: 'Bad Request',
message: 'Missing projectId', message: 'Missing projectId',
}); });
return;
} }
const identity = getIdentity(request.body); const identity = getIdentity(request.body);
@@ -133,33 +133,7 @@ export async function handler(
}) })
: ''; : '';
if ( const promises = [];
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 // 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 // Otherwise its only a profileId and we should not identify the user
@@ -174,23 +148,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); await Promise.all(promises);
break; break;
} }
case 'identify': { case 'identify': {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
const geo = await getGeoLocation(ip); const geo = await getGeoLocation(ip);
await identify({ await identify({
payload: request.body.payload, payload: request.body.payload,
@@ -201,27 +175,13 @@ export async function handler(
break; break;
} }
case 'alias': { case 'alias': {
reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,
error: 'Bad Request', error: 'Bad Request',
message: 'Alias is not supported', message: 'Alias is not supported',
}); });
break;
} }
case 'increment': { case 'increment': {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
await increment({ await increment({
payload: request.body.payload, payload: request.body.payload,
projectId, projectId,
@@ -229,19 +189,6 @@ export async function handler(
break; break;
} }
case 'decrement': { case 'decrement': {
if (
await checkDuplicatedEvent({
reply,
payload: {
...request.body,
timestamp,
},
projectId,
})
) {
return;
}
await decrement({ await decrement({
payload: request.body.payload, payload: request.body.payload,
projectId, projectId,
@@ -249,12 +196,11 @@ export async function handler(
break; break;
} }
default: { default: {
reply.status(400).send({ return reply.status(400).send({
status: 400, status: 400,
error: 'Bad Request', error: 'Bad Request',
message: 'Invalid type', message: 'Invalid type',
}); });
break;
} }
} }
@@ -277,7 +223,7 @@ async function track({
projectId: string; projectId: string;
geo: GeoLocation; geo: GeoLocation;
headers: Record<string, string | undefined>; headers: Record<string, string | undefined>;
timestamp: string; timestamp: number;
isTimestampFromThePast: boolean; isTimestampFromThePast: boolean;
}) { }) {
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties); const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
@@ -286,8 +232,11 @@ async function track({
? `${projectId}:${payload.profileId}` ? `${projectId}:${payload.profileId}`
: `${projectId}:${generateId()}` : `${projectId}:${generateId()}`
: currentDeviceId; : currentDeviceId;
await eventsGroupQueue.add({ const jobId = [payload.name, timestamp, projectId, currentDeviceId, groupId]
orderMs: new Date(timestamp).getTime(), .filter(Boolean)
.join('-');
await getEventsGroupQueueShard(groupId).add({
orderMs: timestamp,
data: { data: {
projectId, projectId,
headers, headers,
@@ -296,11 +245,13 @@ async function track({
timestamp, timestamp,
isTimestampFromThePast, isTimestampFromThePast,
}, },
uaInfo,
geo, geo,
currentDeviceId, currentDeviceId,
previousDeviceId, previousDeviceId,
}, },
groupId, groupId,
jobId,
}); });
} }
@@ -323,8 +274,18 @@ async function identify({
projectId, projectId,
properties: { properties: {
...(payload.properties ?? {}), ...(payload.properties ?? {}),
...(geo ?? {}), country: geo.country,
...uaInfo, 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,
}, },
}); });
} }

View File

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

@@ -1,16 +0,0 @@
import type { FastifyRequest } from 'fastify';
export async function fixHook(request: FastifyRequest) {
const ua = request.headers['user-agent'];
// Swift SDK issue: https://github.com/Openpanel-dev/swift-sdk/commit/d588fa761a36a33f3b78eb79d83bfd524e3c7144
if (ua) {
const regex = /OpenPanel\/(\d+\.\d+\.\d+)\sOpenPanel\/(\d+\.\d+\.\d+)/;
const match = ua.match(regex);
if (match) {
request.headers['user-agent'] = ua.replace(
regex,
`OpenPanel/${match[1]}`,
);
}
}
}

View File

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

View File

@@ -28,7 +28,6 @@ import {
liveness, liveness,
readiness, readiness,
} from './controllers/healthcheck.controller'; } from './controllers/healthcheck.controller';
import { fixHook } from './hooks/fix.hook';
import { ipHook } from './hooks/ip.hook'; import { ipHook } from './hooks/ip.hook';
import { requestIdHook } from './hooks/request-id.hook'; import { requestIdHook } from './hooks/request-id.hook';
import { requestLoggingHook } from './hooks/request-logging.hook'; import { requestLoggingHook } from './hooks/request-logging.hook';
@@ -55,7 +54,7 @@ process.env.TZ = 'UTC';
declare module 'fastify' { declare module 'fastify' {
interface FastifyRequest { interface FastifyRequest {
client: IServiceClientWithProject | null; client: IServiceClientWithProject | null;
clientIp?: string; clientIp: string;
timestamp?: number; timestamp?: number;
session: SessionValidationResult; session: SessionValidationResult;
} }
@@ -125,7 +124,6 @@ const startServer = async () => {
fastify.addHook('onRequest', requestIdHook); fastify.addHook('onRequest', requestIdHook);
fastify.addHook('onRequest', timestampHook); fastify.addHook('onRequest', timestampHook);
fastify.addHook('onRequest', ipHook); fastify.addHook('onRequest', ipHook);
fastify.addHook('onRequest', fixHook);
fastify.addHook('onResponse', requestLoggingHook); fastify.addHook('onResponse', requestLoggingHook);
fastify.register(compress, { fastify.register(compress, {

View File

@@ -2,9 +2,11 @@ import * as controller from '@/controllers/event.controller';
import type { FastifyPluginCallback } from 'fastify'; import type { FastifyPluginCallback } from 'fastify';
import { clientHook } from '@/hooks/client.hook'; import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook';
import { isBotHook } from '@/hooks/is-bot.hook'; import { isBotHook } from '@/hooks/is-bot.hook';
const eventRouter: FastifyPluginCallback = async (fastify) => { const eventRouter: FastifyPluginCallback = async (fastify) => {
fastify.addHook('preValidation', duplicateHook);
fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', clientHook);
fastify.addHook('preHandler', isBotHook); fastify.addHook('preHandler', isBotHook);

View File

@@ -2,9 +2,11 @@ import { handler } from '@/controllers/track.controller';
import type { FastifyPluginCallback } from 'fastify'; import type { FastifyPluginCallback } from 'fastify';
import { clientHook } from '@/hooks/client.hook'; import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook';
import { isBotHook } from '@/hooks/is-bot.hook'; import { isBotHook } from '@/hooks/is-bot.hook';
const trackRouter: FastifyPluginCallback = async (fastify) => { const trackRouter: FastifyPluginCallback = async (fastify) => {
fastify.addHook('preValidation', duplicateHook);
fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', clientHook);
fastify.addHook('preHandler', isBotHook); fastify.addHook('preHandler', isBotHook);

View File

@@ -3,6 +3,7 @@ import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
import { verifyPassword } from '@openpanel/common/server'; import { verifyPassword } from '@openpanel/common/server';
import type { IServiceClientWithProject } from '@openpanel/db'; import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientType, getClientByIdCached } from '@openpanel/db'; import { ClientType, getClientByIdCached } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk'; import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { import type {
IProjectFilterIp, IProjectFilterIp,
@@ -135,7 +136,13 @@ export async function validateSdkRequest(
} }
if (client.secret && clientSecret) { if (client.secret && clientSecret) {
if (await verifyPassword(clientSecret, client.secret)) { const isVerified = await getCache(
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
60 * 5,
async () => await verifyPassword(clientSecret, client.secret!),
true,
);
if (isVerified) {
return client; return client;
} }
} }

View File

@@ -1,11 +1,14 @@
import { getLock } from '@openpanel/redis'; import { getLock } from '@openpanel/redis';
import fastJsonStableHash from 'fast-json-stable-hash'; import fastJsonStableHash from 'fast-json-stable-hash';
import type { FastifyReply } from 'fastify';
export async function isDuplicatedEvent({ export async function isDuplicatedEvent({
ip,
origin,
payload, payload,
projectId, projectId,
}: { }: {
ip: string;
origin: string;
payload: Record<string, any>; payload: Record<string, any>;
projectId: string; projectId: string;
}) { }) {
@@ -13,6 +16,8 @@ export async function isDuplicatedEvent({
`fastify:deduplicate:${fastJsonStableHash.hash( `fastify:deduplicate:${fastJsonStableHash.hash(
{ {
...payload, ...payload,
ip,
origin,
projectId, projectId,
}, },
'md5', 'md5',
@@ -27,24 +32,3 @@ export async function isDuplicatedEvent({
return true; 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

@@ -1,8 +0,0 @@
import type { FastifyRequest } from 'fastify';
import requestIp from 'request-ip';
const ignore = ['127.0.0.1', '::1'];
export function getClientIp(req: FastifyRequest) {
return requestIp.getClientIp(req);
}

View File

@@ -1,7 +1,7 @@
import { ch, db } from '@openpanel/db'; import { ch, db } from '@openpanel/db';
import { import {
cronQueue, cronQueue,
eventsGroupQueue, eventsGroupQueues,
miscQueue, miscQueue,
notificationQueue, notificationQueue,
sessionsQueue, sessionsQueue,
@@ -71,7 +71,7 @@ export async function shutdown(
// Step 6: Close Bull queues (graceful shutdown of queue state) // Step 6: Close Bull queues (graceful shutdown of queue state)
try { try {
await Promise.all([ await Promise.all([
eventsGroupQueue.close(), ...eventsGroupQueues.map((queue) => queue.close()),
sessionsQueue.close(), sessionsQueue.close(),
cronQueue.close(), cronQueue.close(),
miscQueue.close(), miscQueue.close(),

View File

@@ -0,0 +1,7 @@
import {
createNextRouteHandler,
createScriptHandler,
} from '@openpanel/nextjs/server';
export const POST = createNextRouteHandler();
export const GET = createScriptHandler();

View File

@@ -61,12 +61,9 @@ export default async function Layout({ children }: { children: ReactNode }) {
<RootProvider> <RootProvider>
<TooltipProvider>{children}</TooltipProvider> <TooltipProvider>{children}</TooltipProvider>
</RootProvider> </RootProvider>
<Script
defer
src="http://localhost:3000/script.js"
data-website-id="44d65df1-e9cb-4c2c-917d-4bf1c7850948"
/>
<OpenPanelComponent <OpenPanelComponent
apiUrl="/api/op"
cdnUrl="/api/op/op1.js"
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615" clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
trackAttributes trackAttributes
trackScreenViews trackScreenViews

View File

@@ -13,7 +13,7 @@ const questions = [
{ {
question: 'Does OpenPanel have a free tier?', question: 'Does OpenPanel have a free tier?',
answer: [ 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.', 'For our Cloud plan we offer a 30 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!', 'OpenPanel is also open-source and you can self-host it for free!',
'', '',
'Why does OpenPanel not have a free tier?', 'Why does OpenPanel not have a free tier?',

View File

@@ -273,19 +273,21 @@ export function GET() {
### Proxy events ### Proxy events
With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains. With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains. You'll also need to either host our tracking script or you can use `createScriptHandler` function which proxies this as well.
```typescript title="/app/api/[...op]/route.ts" ```typescript title="/app/api/[...op]/route.ts"
import { createNextRouteHandler } from '@openpanel/nextjs/server'; import { createNextRouteHandler, createScriptHandler } from '@openpanel/nextjs/server';
export const POST = createNextRouteHandler(); export const POST = createNextRouteHandler();
export const GET = createScriptHandler()
``` ```
Remember to change the `apiUrl` in the `OpenPanelComponent` to your own server. Remember to change the `apiUrl` and `cdnUrl` in the `OpenPanelComponent` to your own server.
```tsx ```tsx
<OpenPanelComponent <OpenPanelComponent
apiUrl="/api/op" // [!code highlight] apiUrl="/api/op" // [!code highlight]
cdnUrl="/api/op/op1.js" // [!code highlight]
clientId="your-client-id" clientId="your-client-id"
trackScreenViews={true} trackScreenViews={true}
/> />

View File

@@ -13,7 +13,8 @@
"dependencies": { "dependencies": {
"@hyperdx/node-opentelemetry": "^0.8.1", "@hyperdx/node-opentelemetry": "^0.8.1",
"@number-flow/react": "0.3.5", "@number-flow/react": "0.3.5",
"@openpanel/nextjs": "^1.0.5", "@openpanel/common": "workspace:*",
"@openpanel/nextjs": "^1.0.12",
"@openpanel/payments": "workspace:^", "@openpanel/payments": "workspace:^",
"@openpanel/sdk-info": "workspace:^", "@openpanel/sdk-info": "workspace:^",
"@openstatus/react": "0.0.3", "@openstatus/react": "0.0.3",

View File

@@ -19,7 +19,6 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^1.2.5", "@ai-sdk/react": "^1.2.5",
"@clickhouse/client": "^1.2.0",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",

View File

@@ -2,6 +2,34 @@ import { createContext, useContext as useBaseContext } from 'react';
import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts'; import { Tooltip as RechartsTooltip, type TooltipProps } from 'recharts';
export const ChartTooltipContainer = ({
children,
}: { children: React.ReactNode }) => {
return (
<div className="min-w-[180px] col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm">
{children}
</div>
);
};
export const ChartTooltipHeader = ({
children,
}: { children: React.ReactNode }) => {
return <div className="flex justify-between gap-8">{children}</div>;
};
export const ChartTooltipItem = ({
children,
color,
}: { children: React.ReactNode; color: string }) => {
return (
<div className="flex gap-2">
<div className="w-[3px] rounded-full" style={{ background: color }} />
<div className="col flex-1 gap-1">{children}</div>
</div>
);
};
export function createChartTooltip< export function createChartTooltip<
PropsFromTooltip, PropsFromTooltip,
PropsFromContext extends Record<string, unknown>, PropsFromContext extends Record<string, unknown>,
@@ -31,9 +59,9 @@ export function createChartTooltip<
} }
return ( return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"> <ChartTooltipContainer>
<Tooltip data={data} context={context} {...tooltip} /> <Tooltip data={data} context={context} {...tooltip} />
</div> </ChartTooltipContainer>
); );
}; };

View File

@@ -8,7 +8,7 @@ export function FeedbackButton() {
return ( return (
<Button <Button
variant={'outline'} variant={'outline'}
className="text-left justify-start" className="text-left justify-start text-[13px]"
icon={SparklesIcon} icon={SparklesIcon}
onClick={() => { onClick={() => {
op.track('feedback_button_clicked'); op.track('feedback_button_clicked');

View File

@@ -10,7 +10,7 @@ const questions = [
{ {
question: 'Does OpenPanel have a free tier?', question: 'Does OpenPanel have a free tier?',
answer: [ 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.', 'For our Cloud plan we offer a 30 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!', 'OpenPanel is also open-source and you can self-host it for free!',
'', '',
'Why does OpenPanel not have a free tier?', 'Why does OpenPanel not have a free tier?',
@@ -37,13 +37,19 @@ const questions = [
'You can change your billing information by clicking the "Manage your subscription" button in the billing section.', 'You can change your billing information by clicking the "Manage your subscription" button in the billing section.',
], ],
}, },
{
question: 'We need a custom plan, can you help us?',
answer: [
'Yes, we can help you with that. Please contact us at hello@openpanel.com to request a quote.',
],
},
]; ];
export function BillingFaq() { export function BillingFaq() {
return ( return (
<Widget className="w-full"> <Widget className="w-full">
<WidgetHead className="flex items-center justify-between"> <WidgetHead className="flex items-center justify-between">
<span className="title">Usage</span> <span className="title">Frequently asked questions</span>
</WidgetHead> </WidgetHead>
<Accordion <Accordion
type="single" type="single"

View File

@@ -0,0 +1,201 @@
import { PageHeader } from '@/components/page-header';
import { Button, LinkButton } from '@/components/ui/button';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { op } from '@/utils/op';
import type { IServiceOrganization } from '@openpanel/db';
import { useMutation, useQuery } from '@tanstack/react-query';
import {
BarChart3Icon,
DollarSignIcon,
InfinityIcon,
type LucideIcon,
MapIcon,
ShieldCheckIcon,
TrendingUpIcon,
} from 'lucide-react';
import { useEffect } from 'react';
import { toast } from 'sonner';
const COPY = {
expired: {
title: 'Subscription expired',
description:
'Reactivate your subscription to regain access to your analytics data and insights.',
body: [
"Your subscription has expired, but your data is safe and waiting for you. Reactivate now to continue tracking your users' behavior and making data-driven decisions.",
"Don't let gaps in your analytics cost you valuable insights. Every day without data is a day of missed opportunities to understand and grow your audience.",
],
},
trialEnded: {
title: 'Trial ended',
description:
'Upgrade now to keep the momentum going and continue optimizing your product.',
body: [
"You've experienced the power of OpenPanel. Keep the insights flowing and maintain continuity in your analytics data.",
"We'll still process all your incoming events for the coming 30 days.",
],
},
freePlan: {
title: 'Free plan is removed',
description:
"We've removed the free plan to focus on delivering exceptional value to our paid customers.",
body: [
"We've evolved our offering to provide better features, faster performance, and dedicated support. Our paid plans ensure we can continue building the analytics platform you deserve.",
'Simple, transparent pricing with no hidden fees. Pay for what you use, and scale as you grow. Your investment in analytics pays for itself through better decisions and improved user experiences.',
],
},
};
export default function BillingPrompt({
organization,
type,
}: {
organization: IServiceOrganization;
type: keyof typeof COPY;
}) {
const number = useNumber();
const trpc = useTRPC();
const { data: products, isLoading: isLoadingProducts } = useQuery(
trpc.subscription.products.queryOptions({
organizationId: organization.id,
}),
);
const checkout = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
}),
);
const { title, description, body } = COPY[type];
const bestProductFit = products?.find(
(product) =>
typeof product.metadata.eventsLimit === 'number' &&
product.metadata.eventsLimit >=
organization.subscriptionPeriodEventsCount,
);
useEffect(() => {
op.track('billing_prompt_viewed', {
type,
});
}, [type]);
const price = bestProductFit
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'usd',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(
bestProductFit.prices[0] && 'priceAmount' in bestProductFit.prices[0]
? bestProductFit.prices[0].priceAmount / 100
: 0,
)
: null;
return (
<div className="p-4 md:p-20 max-w-7xl mx-auto">
<div className="border rounded-lg overflow-hidden bg-def-200 p-2 items-center">
<div className="md:row">
<div className="p-6 bg-background rounded-md border col gap-4 flex-1">
<PageHeader title={title} description={description} />
{body.map((paragraph) => (
<p key={paragraph}>
{paragraph.replace(
'{{events}}',
number.format(
organization.subscriptionPeriodEventsCount ?? 0,
),
)}
</p>
))}
<div className="col gap-2 mt-auto">
{bestProductFit && (
<div className="text-sm text-muted-foreground leading-normal">
Based on your usage (
{number.format(
organization.subscriptionPeriodEventsCount ?? 0,
)}{' '}
events) we recommend upgrading <br />
to the <strong>{bestProductFit.name}</strong> plan for{' '}
<strong>{price}</strong> per month.
</div>
)}
<div className="col md:row gap-2">
<Button
size="lg"
loading={isLoadingProducts}
disabled={!bestProductFit}
onClick={() => {
if (bestProductFit) {
op.track('billing_prompt_upgrade_clicked', {
type,
price:
bestProductFit.prices[0] &&
'priceAmount' in bestProductFit.prices[0]
? bestProductFit.prices[0].priceAmount / 100
: 0,
});
checkout.mutate({
organizationId: organization.id,
productPriceId: bestProductFit.prices[0].id,
productId: bestProductFit.id,
});
}
}}
>
Upgrade to {price}
</Button>
<LinkButton
size="lg"
variant="outline"
to="/$organizationId/billing"
params={{ organizationId: organization.id }}
>
View pricing
</LinkButton>
</div>
</div>
</div>
<div className="shrink-0 flex-1 p-6 gap-4 col min-w-[200px] max-w-[300px]">
<Point icon={DollarSignIcon}>Plans start at just $2.5/month</Point>
<Point icon={InfinityIcon}>
Unlimited reports, members and projects
</Point>
<Point icon={BarChart3Icon}>Advanced funnels and conversions</Point>
<Point icon={MapIcon}>Real-time analytics</Point>
<Point icon={TrendingUpIcon}>
Track KPIs and custom events (revenue soon)
</Point>
<Point icon={ShieldCheckIcon}>
Privacy-focused and GDPR compliant
</Point>
</div>
</div>
</div>
</div>
);
}
function Point({
icon: Icon,
children,
}: { icon: LucideIcon; children: React.ReactNode }) {
return (
<div className="row gap-2">
<div className="size-6 shrink-0 center-center rounded-full bg-amber-500 text-white">
<Icon className="size-4" />
</div>
<h3 className="font-medium mt-[1.5px]">{children}</h3>
</div>
);
}

View File

@@ -18,7 +18,6 @@ import {
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Tooltip as RechartTooltip, Tooltip as RechartTooltip,
ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
XAxis, XAxis,
YAxis, YAxis,
@@ -38,7 +37,7 @@ function Card({ title, value }: { title: string; value: string }) {
); );
} }
export default function Usage({ organization }: Props) { export default function BillingUsage({ organization }: Props) {
const number = useNumber(); const number = useNumber();
const trpc = useTRPC(); const trpc = useTRPC();
const usageQuery = useQuery( const usageQuery = useQuery(
@@ -82,6 +81,7 @@ export default function Usage({ organization }: Props) {
</div>, </div>,
); );
} }
if (usageQuery.isError) { if (usageQuery.isError) {
return wrapper( return wrapper(
<div className="center-center p-8 font-medium"> <div className="center-center p-8 font-medium">
@@ -90,6 +90,12 @@ export default function Usage({ organization }: Props) {
); );
} }
if (usageQuery.data?.length === 0) {
return wrapper(
<div className="center-center p-8 font-medium">No usage data yet</div>,
);
}
const subscriptionPeriodEventsLimit = organization.hasSubscription const subscriptionPeriodEventsLimit = organization.hasSubscription
? organization.subscriptionPeriodEventsLimit ? organization.subscriptionPeriodEventsLimit
: 0; : 0;
@@ -159,7 +165,7 @@ export default function Usage({ organization }: Props) {
return wrapper( return wrapper(
<> <>
<div className="border-b divide-x divide-border -m-4 mb-4 grid grid-cols-2 md:grid-cols-4"> <div className="-m-4 mb-4 grid grid-cols-2 [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border border-b">
{organization.hasSubscription ? ( {organization.hasSubscription ? (
<> <>
<Card <Card
@@ -171,14 +177,6 @@ export default function Usage({ organization }: Props) {
: '🤷‍♂️' : '🤷‍♂️'
} }
/> />
<Card
title="Limit"
value={number.format(subscriptionPeriodEventsLimit)}
/>
<Card
title="Events count"
value={number.format(subscriptionPeriodEventsCount)}
/>
<Card <Card
title="Left to use" title="Left to use"
value={ value={
@@ -192,6 +190,14 @@ export default function Usage({ organization }: Props) {
) )
} }
/> />
<Card
title="Events count"
value={number.format(subscriptionPeriodEventsCount)}
/>
<Card
title="Limit"
value={number.format(subscriptionPeriodEventsLimit)}
/>
</> </>
) : ( ) : (
<> <>
@@ -209,7 +215,6 @@ export default function Usage({ organization }: Props) {
</> </>
)} )}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Events Chart */} {/* Events Chart */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground"> <h3 className="text-sm font-medium text-muted-foreground">
@@ -220,6 +225,10 @@ export default function Usage({ organization }: Props) {
<BarChart data={chartData} barSize={useWeeklyIntervals ? 20 : 8}> <BarChart data={chartData} barSize={useWeeklyIntervals ? 20 : 8}>
<RechartTooltip <RechartTooltip
content={<EventsTooltip useWeekly={useWeeklyIntervals} />} content={<EventsTooltip useWeekly={useWeeklyIntervals} />}
cursor={{
fill: 'var(--def-200)',
stroke: 'var(--def-200)',
}}
/> />
<Bar <Bar
dataKey="count" dataKey="count"
@@ -238,68 +247,6 @@ export default function Usage({ organization }: Props) {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
{/* Total Events vs Limit Chart */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
Total Events vs Limit
</h3>
<div className="max-h-[300px] h-[250px] w-full p-4">
<ResponsiveContainer>
<BarChart
data={[
{
name: 'Total Events',
count: subscriptionPeriodEventsCount,
limit: subscriptionPeriodEventsLimit,
},
]}
>
<RechartTooltip content={<TotalTooltip />} cursor={false} />
{organization.hasSubscription &&
subscriptionPeriodEventsLimit > 0 && (
<ReferenceLine
y={subscriptionPeriodEventsLimit}
stroke={getChartColor(1)}
strokeWidth={2}
strokeDasharray="3 3"
strokeOpacity={0.8}
strokeLinecap="round"
label={{
value: `Limit (${number.format(subscriptionPeriodEventsLimit)})`,
fill: getChartColor(1),
position: 'insideTopRight',
fontSize: 12,
}}
/>
)}
<Bar
dataKey="count"
isAnimationActive={false}
shape={BarShapeBlue}
/>
<XAxis {...X_AXIS_STYLE_PROPS} dataKey="name" />
<YAxis
{...yAxisProps}
domain={[
0,
Math.max(
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCount,
) * 1.1,
]}
/>
<CartesianGrid
horizontal={true}
vertical={false}
strokeDasharray="3 3"
strokeOpacity={0.5}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</>, </>,
); );
} }

View File

@@ -1,45 +1,55 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { useNumber } from '@/hooks/use-numer-formatter';
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/use-app-params';
import useWS from '@/hooks/use-ws'; import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals'; import { pushModal, useOnPushModal } from '@/modals';
import { op } from '@/utils/op'; import { formatDate } from '@/utils/date';
import type { IServiceOrganization } from '@openpanel/db'; import type { IServiceOrganization } from '@openpanel/db';
import type { IPolarPrice } from '@openpanel/payments';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2Icon } from 'lucide-react'; import { differenceInDays } from 'date-fns';
import { useQueryState } from 'nuqs'; import { useQueryState } from 'nuqs';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Progress } from '../ui/progress';
import { Widget, WidgetBody, WidgetHead } from '../widget';
import { BillingFaq } from './billing-faq';
import BillingUsage from './billing-usage';
type Props = { type Props = {
organization: IServiceOrganization; organization: IServiceOrganization;
}; };
export default function Billing({ organization }: Props) { export default function Billing({ organization }: Props) {
const [success, setSuccess] = useQueryState('customer_session_token');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const trpc = useTRPC(); const trpc = useTRPC();
const [customerSessionToken, setCustomerSessionToken] = useQueryState( const number = useNumber();
'customer_session_token',
);
const productsQuery = useQuery( const productsQuery = useQuery(
trpc.subscription.products.queryOptions({ trpc.subscription.products.queryOptions({
organizationId: organization.id, organizationId: organization.id,
}), }),
); );
const currentProductQuery = useQuery(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
const portalMutation = useMutation(
trpc.subscription.portal.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
}
},
onError(error) {
toast.error(error.message);
},
}),
);
useWS(`/live/organization/${organization.id}`, () => { useWS(`/live/organization/${organization.id}`, () => {
queryClient.invalidateQueries(trpc.organization.pathFilter()); queryClient.invalidateQueries(trpc.organization.pathFilter());
}); });
@@ -54,378 +64,228 @@ export default function Billing({ organization }: Props) {
.filter((product) => product.prices.some((p) => p.amountType !== 'free')); .filter((product) => product.prices.some((p) => p.amountType !== 'free'));
}, [productsQuery.data, recurringInterval]); }, [productsQuery.data, recurringInterval]);
useEffect(() => { const currentProduct = currentProductQuery.data ?? null;
if (organization.subscriptionInterval) { const currentPrice = currentProduct?.prices.flatMap((p) =>
setRecurringInterval( p.type === 'recurring' && p.amountType === 'fixed' ? [p] : [],
organization.subscriptionInterval as 'year' | 'month', )[0];
const renderStatus = () => {
if (organization.isActive && organization.subscriptionCurrentPeriodEnd) {
return (
<p>
Your subscription will be renewed on{' '}
{formatDate(organization.subscriptionCurrentPeriodEnd)}
</p>
); );
} }
}, [organization.subscriptionInterval]);
if (organization.isCanceled && organization.subscriptionCanceledAt) {
return (
<p>
Your subscription was canceled on{' '}
{formatDate(organization.subscriptionCanceledAt)}
</p>
);
}
if (
organization.isWillBeCanceled &&
organization.subscriptionCurrentPeriodEnd
) {
return (
<p>
Your subscription will be canceled on{' '}
{formatDate(organization.subscriptionCurrentPeriodEnd)}
</p>
);
}
if (
organization.subscriptionStatus === 'expired' &&
organization.subscriptionCurrentPeriodEnd
) {
return (
<p>
Your subscription expired on{' '}
{formatDate(organization.subscriptionCurrentPeriodEnd)}
</p>
);
}
if (
organization.subscriptionStatus === 'trialing' &&
organization.subscriptionEndsAt
) {
return (
<p>
Your trial will end on {formatDate(organization.subscriptionEndsAt)}
</p>
);
}
return null;
};
useEffect(() => { useEffect(() => {
if (customerSessionToken) { if (success) {
op.track('subscription_created'); pushModal('BillingSuccess');
} }
}, [customerSessionToken]); }, [success]);
const [selectedProductIndex, setSelectedProductIndex] = useState<number>(0); // Clear query state when modal is closed
useOnPushModal('BillingSuccess', (open) => {
// Check if organization has a custom product if (!open) {
const hasCustomProduct = useMemo(() => { setSuccess(null);
return products.some((product) => product.metadata?.custom === true);
}, [products]);
// Preferred default selection when there is no active subscription
const defaultSelectedIndex = useMemo(() => {
const defaultIndex = products.findIndex(
(product) => product.metadata?.eventsLimit === 100_000,
);
return defaultIndex >= 0 ? defaultIndex : 0;
}, [products]);
// Find current subscription index (-1 when no subscription)
const currentSubscriptionIndex = useMemo(() => {
if (!organization.subscriptionProductId) {
return -1;
}
return products.findIndex(
(product) => product.id === organization.subscriptionProductId,
);
}, [products, organization.subscriptionProductId]);
// Check if selected index is the "custom" option (beyond available products)
const isCustomOption = selectedProductIndex >= products.length;
// Find the highest event limit to make the custom option dynamic
const highestEventLimit = useMemo(() => {
const limits = products
.map((product) => product.metadata?.eventsLimit)
.filter((limit): limit is number => typeof limit === 'number');
return Math.max(...limits, 0);
}, [products]);
// Format the custom option label dynamically
const customOptionLabel = useMemo(() => {
if (highestEventLimit >= 1_000_000) {
return `+${(highestEventLimit / 1_000_000).toFixed(0)}M`;
}
if (highestEventLimit >= 1_000) {
return `+${(highestEventLimit / 1_000).toFixed(0)}K`;
}
return `+${highestEventLimit}`;
}, [highestEventLimit]);
// Set initial slider position to current subscription or default plan when none
useEffect(() => {
if (currentSubscriptionIndex >= 0) {
setSelectedProductIndex(currentSubscriptionIndex);
} else {
setSelectedProductIndex(defaultSelectedIndex);
}
}, [currentSubscriptionIndex, defaultSelectedIndex]);
const selectedProduct = products[selectedProductIndex];
const isUpgrade = selectedProductIndex > currentSubscriptionIndex;
const isDowngrade = selectedProductIndex < currentSubscriptionIndex;
const isCurrentPlan = selectedProductIndex === currentSubscriptionIndex;
function renderBillingSlider() {
if (productsQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productsQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
if (hasCustomProduct) {
return (
<div className="p-8 text-center">
<div className="text-muted-foreground">
Not applicable since custom product
</div>
</div>
);
} }
});
return ( return (
<div className="p-6 space-y-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4"> <div className="col gap-8">
<div className="flex justify-between items-center"> {currentProduct && currentPrice ? (
<span className="text-sm font-medium">Select your plan</span> <Widget className="w-full">
<span className="text-sm text-muted-foreground"> <WidgetHead className="flex items-center justify-between gap-4">
{selectedProduct?.name || 'No plan selected'} <div className="flex-1 title truncate">{currentProduct.name}</div>
<div className="text-lg">
<span className="font-bold">
{number.currency(currentPrice.priceAmount / 100)}
</span> </span>
</div> <span className="text-muted-foreground">
<Slider
value={[selectedProductIndex]}
onValueChange={([value]) => setSelectedProductIndex(value)}
min={0}
max={products.length} // +1 for the custom option
step={1}
className="w-full"
disabled={hasCustomProduct}
/>
<div className="flex justify-between text-xs text-muted-foreground">
{products.map((product, index) => {
const eventsLimit = product.metadata?.eventsLimit;
return (
<div key={product.id} className="text-center">
<div className="font-medium">
{eventsLimit && typeof eventsLimit === 'number'
? `${(eventsLimit / 1000).toFixed(0)}K`
: 'Free'}
</div>
<div className="text-xs">events</div>
</div>
);
})}
{/* Add the custom option label */}
<div className="text-center">
<div className="font-medium">{customOptionLabel}</div>
<div className="text-xs">events</div>
</div>
</div>
</div>
{(selectedProduct || isCustomOption) && (
<div className="border rounded-lg p-4 space-y-4">
{isCustomOption ? (
// Custom option content
<>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">Custom Plan</h3>
<p className="text-sm text-muted-foreground">
{customOptionLabel} events per {recurringInterval}
</p>
</div>
<div className="text-right">
<span className="text-lg font-semibold">
Custom Pricing
</span>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 text-center">
<p className="text-sm text-muted-foreground mb-2">
Need higher limits?
</p>
<p className="text-sm">
Reach out to{' '}
<a
className="underline font-medium"
href="mailto:hello@openpanel.dev"
>
hello@openpanel.dev
</a>{' '}
and we'll help you with a custom quota.
</p>
</div>
</>
) : (
// Regular product content
<>
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">{selectedProduct.name}</h3>
<p className="text-sm text-muted-foreground">
{selectedProduct.metadata?.eventsLimit
? `${selectedProduct.metadata.eventsLimit.toLocaleString()} events per ${recurringInterval}`
: 'Free tier'}
</p>
</div>
<div className="text-right">
{selectedProduct.prices[0]?.amountType === 'free' ? (
<span className="text-lg font-semibold">Free</span>
) : (
<span className="text-lg font-semibold">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency:
selectedProduct.prices[0]?.priceCurrency || 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(
(selectedProduct.prices[0] &&
'priceAmount' in selectedProduct.prices[0]
? selectedProduct.prices[0].priceAmount
: 0) / 100,
)}
<span className="text-sm text-muted-foreground">
{' / '} {' / '}
{recurringInterval === 'year' ? 'year' : 'month'} {recurringInterval === 'year' ? 'year' : 'month'}
</span> </span>
</span>
)}
</div>
</div>
{!isCurrentPlan && selectedProduct.prices[0] && (
<div className="flex justify-end">
<CheckoutButton
disabled={selectedProduct.disabled}
key={selectedProduct.prices[0].id}
price={selectedProduct.prices[0]}
organization={organization}
buttonText={
isUpgrade
? 'Upgrade'
: isDowngrade
? 'Downgrade'
: 'Activate'
}
/>
</div>
)}
{isCurrentPlan && (
<div className="flex justify-end">
<Button variant="outline" disabled>
Current Plan
</Button>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
return (
<>
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Billing</span>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{recurringInterval === 'year'
? 'Yearly (2 months free)'
: 'Monthly'}
</span>
<Switch
checked={recurringInterval === 'year'}
onCheckedChange={(checked) =>
setRecurringInterval(checked ? 'year' : 'month')
}
/>
</div> </div>
</WidgetHead> </WidgetHead>
<WidgetBody> <WidgetBody>
<div className="-m-4">{renderBillingSlider()}</div> {renderStatus()}
<div className="col mt-4">
<div className="font-semibold mb-2">
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
{number.format(Number(currentProduct.metadata.eventsLimit))}
</div>
<Progress
value={
(organization.subscriptionPeriodEventsCount /
Number(currentProduct.metadata.eventsLimit)) *
100
}
size="sm"
/>
<div className="row justify-between mt-4">
<Button
variant="outline"
size="sm"
onClick={() =>
portalMutation.mutate({ organizationId: organization.id })
}
>
<svg
className="size-4 mr-2"
width="300"
height="300"
viewBox="0 0 300 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1_4)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M66.4284 274.26C134.876 320.593 227.925 302.666 274.258 234.219C320.593 165.771 302.666 72.7222 234.218 26.3885C165.77 -19.9451 72.721 -2.0181 26.3873 66.4297C-19.9465 134.877 -2.01938 227.927 66.4284 274.26ZM47.9555 116.67C30.8375 169.263 36.5445 221.893 59.2454 256.373C18.0412 217.361 7.27564 150.307 36.9437 92.318C55.9152 55.2362 87.5665 29.3937 122.5 18.3483C90.5911 36.7105 62.5549 71.8144 47.9555 116.67ZM175.347 283.137C211.377 272.606 244.211 246.385 263.685 208.322C293.101 150.825 282.768 84.4172 242.427 45.2673C264.22 79.7626 269.473 131.542 252.631 183.287C237.615 229.421 208.385 265.239 175.347 283.137ZM183.627 266.229C207.945 245.418 228.016 210.604 236.936 168.79C251.033 102.693 232.551 41.1978 195.112 20.6768C214.97 47.3945 225.022 99.2902 218.824 157.333C214.085 201.724 200.814 240.593 183.627 266.229ZM63.7178 131.844C49.5155 198.43 68.377 260.345 106.374 280.405C85.9962 254.009 75.5969 201.514 81.8758 142.711C86.5375 99.0536 99.4504 60.737 116.225 35.0969C92.2678 55.983 72.5384 90.4892 63.7178 131.844ZM199.834 149.561C200.908 217.473 179.59 272.878 152.222 273.309C124.853 273.742 101.797 219.039 100.724 151.127C99.6511 83.2138 120.968 27.8094 148.337 27.377C175.705 26.9446 198.762 81.648 199.834 149.561Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_1_4">
<rect width="300" height="300" fill="white" />
</clipPath>
</defs>
</svg>
Customer portal
</Button>
<Button
size="sm"
onClick={() =>
pushModal('SelectBillingPlan', {
organization,
currentProduct,
})
}
>
{organization.isWillBeCanceled
? 'Reactivate subscription'
: 'Change subscription'}
</Button>
</div>
</div>
</WidgetBody> </WidgetBody>
</Widget> </Widget>
<Dialog ) : (
open={!!customerSessionToken} <Widget className="w-full">
onOpenChange={(open) => { <WidgetHead className="flex items-center justify-between">
setCustomerSessionToken(null); <div className="font-bold text-lg flex-1">
if (!open) { {organization.isTrial
queryClient.invalidateQueries(trpc.organization.pathFilter()); ? 'Get started'
: 'No active subscription'}
</div>
<div className="text-lg">
<span className="">
{organization.isTrial ? '30 days free trial' : ''}
</span>
</div>
</WidgetHead>
<WidgetBody>
{organization.isTrial && organization.subscriptionEndsAt ? (
<p>
Your trial will end on{' '}
{formatDate(organization.subscriptionEndsAt)} (
{differenceInDays(
organization.subscriptionEndsAt,
new Date(),
) + 1}{' '}
days left)
</p>
) : (
<p>
Your trial has expired. Please upgrade your account to
continue using Openpanel.
</p>
)}
<div className="col mt-4">
<div className="font-semibold mb-2">
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
{number.format(
Number(organization.subscriptionPeriodEventsLimit),
)}
</div>
<Progress
value={
(organization.subscriptionPeriodEventsCount /
Number(organization.subscriptionPeriodEventsLimit)) *
100
} }
}} size="sm"
> />
<DialogContent> <div className="row justify-end mt-4">
<DialogTitle>Subscription created</DialogTitle>
<DialogDescription>
We have registered your subscription. It'll be activated within a
couple of seconds.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button>OK</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
function CheckoutButton({
price,
organization,
disabled,
buttonText,
}: {
price: IPolarPrice;
organization: IServiceOrganization;
disabled?: string | null;
buttonText?: string;
}) {
const trpc = useTRPC();
const isCurrentPrice = organization.subscriptionPriceId === price.id;
const checkout = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
}),
);
const isCanceled =
organization.subscriptionStatus === 'active' &&
isCurrentPrice &&
organization.subscriptionCanceledAt;
const isActive =
organization.subscriptionStatus === 'active' && isCurrentPrice;
return (
<Tooltiper
content={disabled}
tooltipClassName="max-w-xs"
side="left"
disabled={!disabled}
>
<Button <Button
disabled={disabled !== null || (isActive && !isCanceled)} size="sm"
key={price.id} onClick={() =>
onClick={() => { pushModal('SelectBillingPlan', {
const createCheckout = () => organization,
checkout.mutate({ currentProduct,
organizationId: organization.id, })
productPriceId: price!.id,
productId: price.productId,
});
if (organization.subscriptionStatus === 'active') {
showConfirm({
title: 'Are you sure?',
text: `You're about the change your subscription.`,
onConfirm: () => {
op.track('subscription_change');
createCheckout();
},
});
} else {
op.track('subscription_checkout', {
product: price.productId,
});
createCheckout();
} }
}}
loading={checkout.isPending}
className="w-28"
variant={isActive ? 'outline' : 'default'}
> >
{buttonText || Upgrade
(isCanceled ? 'Reactivate' : isActive ? 'Active' : 'Activate')}
</Button> </Button>
</Tooltiper> </div>
</div>
</WidgetBody>
</Widget>
)}
<BillingUsage organization={organization} />
</div>
<BillingFaq />
</div>
); );
} }

View File

@@ -1,283 +0,0 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useAppParams } from '@/hooks/use-app-params';
import { useNumber } from '@/hooks/use-numer-formatter';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { showConfirm } from '@/modals';
import { cn } from '@/utils/cn';
import type { IServiceOrganization } from '@openpanel/db';
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Loader2Icon } from 'lucide-react';
import { toast } from 'sonner';
type Props = {
organization: IServiceOrganization;
};
export default function CurrentSubscription({ organization }: Props) {
const { projectId } = useAppParams();
const queryClient = useQueryClient();
const number = useNumber();
const trpc = useTRPC();
const productQuery = useQuery(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
const cancelSubscription = useMutation(
trpc.subscription.cancelSubscription.mutationOptions({
onSuccess() {
toast.success('Subscription cancelled', {
description: 'It might take a few seconds to update',
});
},
onError(error) {
toast.error(error.message);
},
}),
);
const portalMutation = useMutation(
trpc.subscription.portal.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
}
},
}),
);
const checkout = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
}
},
}),
);
useWS(`/live/organization/${organization.id}`, () => {
queryClient.invalidateQueries(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
});
function render() {
if (productQuery.isLoading) {
return (
<div className="center-center p-8">
<Loader2Icon className="animate-spin" />
</div>
);
}
if (productQuery.isError) {
return (
<div className="center-center p-8 font-medium">
Issues loading all tiers
</div>
);
}
if (!productQuery.data) {
return (
<div className="center-center p-8 font-medium">
No subscription found
</div>
);
}
const product = productQuery.data;
const price = product.prices[0]!;
return (
<>
<div className="gap-4 col">
{price.amountType === 'free' && (
<Alert variant="warning">
<AlertTitle>Free plan is removed</AlertTitle>
<AlertDescription>
We've removed the free plan. You can upgrade to a paid plan to
continue using OpenPanel.
</AlertDescription>
</Alert>
)}
<div className="row justify-between">
<div>Name</div>
<div className="text-right font-medium">{product.name}</div>
</div>
{price.amountType === 'fixed' ? (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">
{number.currency(price.priceAmount / 100)}
</div>
</div>
</>
) : (
<>
<div className="row justify-between">
<div>Price</div>
<div className="text-right font-medium font-mono">FREE</div>
</div>
</>
)}
<div className="row justify-between">
<div>Billing Cycle</div>
<div className="text-right font-medium">
{price.recurringInterval === 'month' ? 'Monthly' : 'Yearly'}
</div>
</div>
{typeof product.metadata.eventsLimit === 'number' && (
<div className="row justify-between">
<div>Events per mount</div>
<div className="text-right font-medium font-mono">
{number.format(product.metadata.eventsLimit)}
</div>
</div>
)}
</div>
{organization.subscriptionProductId &&
!FREE_PRODUCT_IDS.includes(organization.subscriptionProductId) && (
<div className="col gap-2">
{organization.isWillBeCanceled || organization.isCanceled ? (
<Button
loading={checkout.isPending}
onClick={() => {
checkout.mutate({
projectId,
organizationId: organization.id,
productPriceId: price!.id,
productId: price.productId,
});
}}
>
Reactivate subscription
</Button>
) : (
<Button
variant="destructive"
loading={cancelSubscription.isPending}
onClick={() => {
showConfirm({
title: 'Cancel subscription',
text: 'Are you sure you want to cancel your subscription?',
onConfirm() {
cancelSubscription.mutate({
organizationId: organization.id,
});
},
});
}}
>
Cancel subscription
</Button>
)}
</div>
)}
</>
);
}
return (
<div className="col gap-2 md:w-72 shrink-0">
<Widget className="w-full">
<WidgetHead className="flex items-center justify-between">
<span className="title">Current Subscription</span>
<div className="flex items-center gap-2">
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
organization.isExceeded ||
organization.isExpired ||
(organization.subscriptionStatus !== 'active' &&
'bg-destructive'),
organization.isWillBeCanceled && 'bg-orange-400',
)}
/>
</div>
</div>
</WidgetHead>
<WidgetBody className="col gap-8">
{organization.isTrial && organization.subscriptionEndsAt && (
<Alert variant="warning">
<AlertTitle>Free trial</AlertTitle>
<AlertDescription>
Your organization is on a free trial. It ends on{' '}
{format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isExpired && organization.subscriptionEndsAt && (
<Alert variant="destructive">
<AlertTitle>Subscription expired</AlertTitle>
<AlertDescription>
Your subscription has expired. You can reactivate it by choosing
a new plan below.
</AlertDescription>
<AlertDescription>
It expired on {format(organization.subscriptionEndsAt, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isWillBeCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
You have canceled your subscription. You can reactivate it by
choosing a new plan below.
</AlertDescription>
<AlertDescription className="font-medium">
It'll expire on{' '}
{format(organization.subscriptionEndsAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{organization.isCanceled && (
<Alert variant="warning">
<AlertTitle>Subscription canceled</AlertTitle>
<AlertDescription>
Your subscription was canceled on{' '}
{format(organization.subscriptionCanceledAt!, 'PPP')}
</AlertDescription>
</Alert>
)}
{render()}
</WidgetBody>
</Widget>
{organization.hasSubscription && (
<button
className="text-center mt-2 w-2/3 hover:underline self-center"
type="button"
onClick={() =>
portalMutation.mutate({
organizationId: organization.id,
})
}
>
Manage your subscription with
<span className="font-medium ml-1">Polar Customer Portal</span>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { Button, LinkButton } from '@/components/ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { AnimatePresence, motion } from 'framer-motion';
import {
AwardIcon,
HeartIcon,
type LucideIcon,
MessageCircleIcon,
RocketIcon,
SparklesIcon,
XIcon,
ZapIcon,
} from 'lucide-react';
const PERKS = [
{
icon: RocketIcon,
text: 'Latest Docker Images',
description: 'Bleeding-edge builds on every commit',
},
{
icon: MessageCircleIcon,
text: 'Prioritized Support',
description: 'Get help faster with priority Discord support',
},
{
icon: SparklesIcon,
text: 'Feature Requests',
description: 'Your ideas get prioritized in our roadmap',
},
{
icon: AwardIcon,
text: 'Exclusive Discord Role',
description: 'Special badge and recognition in our community',
},
{
icon: ZapIcon,
text: 'Early Access',
description: 'Try new features before public release',
},
{
icon: HeartIcon,
text: 'Direct Impact',
description: 'Your support directly funds development',
},
] as const;
function PerkPoint({
icon: Icon,
text,
description,
}: {
icon: LucideIcon;
text: string;
description: string;
}) {
return (
<div className="row gap-4 items-center">
<Icon className="size-4" />
<div className="flex-1 min-w-0 col gap-1.5">
<h3 className="font-medium text-sm">{text}</h3>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
);
}
export default function SupporterPrompt() {
const { isSelfHosted } = useAppContext();
const [supporterPromptClosed, setSupporterPromptClosed] = useCookieStore(
'supporter-prompt-closed',
false,
);
if (!isSelfHosted) {
return null;
}
return (
<AnimatePresence>
{!supporterPromptClosed && (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
className="fixed bottom-0 right-0 z-50 p-4 max-w-md"
>
<div className="bg-card border p-6 rounded-lg shadow-lg col gap-4">
<div>
<div className="row items-center justify-between">
<h2 className="text-xl font-semibold">Support OpenPanel</h2>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => setSupporterPromptClosed(true)}
>
<XIcon className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Help us build the future of open analytics
</p>
</div>
<div className="col gap-3">
{PERKS.map((perk) => (
<PerkPoint
key={perk.text}
icon={perk.icon}
text={perk.text}
description={perk.description}
/>
))}
</div>
<div className="pt-2">
<LinkButton
className="w-full"
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
>
Become a Supporter
</LinkButton>
<p className="text-xs text-muted-foreground text-center mt-4">
Starting at $20/month Cancel anytime {' '}
<a
href="https://openpanel.dev/supporter"
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Learn more
</a>
</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,10 +1,17 @@
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter'; import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts'; import { Area, AreaChart, Tooltip } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date'; import { formatDate, timeAgo } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common'; import { getPreviousMetric } from '@openpanel/common';
import { useState } from 'react';
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '../charts/chart-tooltip';
import { import {
PreviousDiffIndicatorPure, PreviousDiffIndicatorPure,
getDiffIndicator, getDiffIndicator,
@@ -41,6 +48,7 @@ export function OverviewMetricCard({
inverted = false, inverted = false,
isLoading = false, isLoading = false,
}: MetricCardProps) { }: MetricCardProps) {
const [value, setValue] = useState(metric.current);
const number = useNumber(); const number = useNumber();
const { current, previous } = metric; const { current, previous } = metric;
@@ -79,7 +87,7 @@ export function OverviewMetricCard({
<span> <span>
{label}:{' '} {label}:{' '}
<span className="font-semibold"> <span className="font-semibold">
{renderValue(current, 'ml-1 font-light text-xl', false)} {renderValue(value, 'ml-1 font-light text-xl', false)}
</span> </span>
</span> </span>
} }
@@ -97,7 +105,7 @@ export function OverviewMetricCard({
<div className={cn('group relative p-4')}> <div className={cn('group relative p-4')}>
<div <div
className={cn( className={cn(
'pointer-events-none absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100', 'absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
)} )}
> >
<AutoSizer> <AutoSizer>
@@ -107,6 +115,11 @@ export function OverviewMetricCard({
height={height / 4} height={height / 4}
data={data} data={data}
style={{ marginTop: (height / 4) * 3 }} style={{ marginTop: (height / 4) * 3 }}
onMouseMove={(event) => {
setValue(
event.activePayload?.[0]?.payload?.current ?? current,
);
}}
> >
<defs> <defs>
<linearGradient <linearGradient
@@ -128,6 +141,7 @@ export function OverviewMetricCard({
/> />
</linearGradient> </linearGradient>
</defs> </defs>
<Tooltip content={() => null} />
<Area <Area
dataKey={'current'} dataKey={'current'}
type="step" type="step"

View File

@@ -75,7 +75,7 @@ export function RealtimeGeo({ projectId }: RealtimeGeoProps) {
}, },
{ {
name: 'Events', name: 'Events',
width: '84px', width: '60px',
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -86,6 +86,19 @@ export function RealtimeGeo({ projectId }: RealtimeGeoProps) {
); );
}, },
}, },
{
name: 'Sessions',
width: '82px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.unique_sessions)}
</span>
</div>
);
},
},
]} ]}
/> />
</div> </div>

View File

@@ -82,7 +82,7 @@ export function RealtimePaths({ projectId }: RealtimePathsProps) {
}, },
{ {
name: 'Events', name: 'Events',
width: '84px', width: '60px',
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -93,6 +93,19 @@ export function RealtimePaths({ projectId }: RealtimePathsProps) {
); );
}, },
}, },
{
name: 'Sessions',
width: '82px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.unique_sessions)}
</span>
</div>
);
},
},
]} ]}
/> />
</div> </div>

View File

@@ -65,7 +65,7 @@ export function RealtimeReferrals({ projectId }: RealtimeReferralsProps) {
}, },
{ {
name: 'Events', name: 'Events',
width: '84px', width: '60px',
render(item) { render(item) {
return ( return (
<div className="row gap-2 justify-end"> <div className="row gap-2 justify-end">
@@ -76,6 +76,19 @@ export function RealtimeReferrals({ projectId }: RealtimeReferralsProps) {
); );
}, },
}, },
{
name: 'Sessions',
width: '82px',
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.unique_sessions)}
</span>
</div>
);
},
},
]} ]}
/> />
</div> </div>

View File

@@ -8,7 +8,7 @@ import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { last } from 'ramda'; import { last } from 'ramda';
import React, { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
Area, Area,
CartesianGrid, CartesianGrid,
@@ -25,7 +25,6 @@ import {
import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import { SolidToDashedGradient } from '../common/linear-gradient';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon'; import { SerieIcon } from '../common/serie-icon';

View File

@@ -35,7 +35,9 @@ export function Chart({ data }: Props) {
() => (isEditMode ? data.series : data.series.slice(0, limit || 10)), () => (isEditMode ? data.series : data.series.slice(0, limit || 10)),
[data, isEditMode, limit], [data, isEditMode, limit],
); );
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric])); const maxCount = Math.max(
...series.map((serie) => serie.metrics[metric] ?? 0),
);
const tableColumns = [ const tableColumns = [
{ {

View File

@@ -3,7 +3,11 @@ import { useNumber } from '@/hooks/use-numer-formatter';
import type { IRechartPayloadItem } from '@/hooks/use-rechart-data-model'; import type { IRechartPayloadItem } from '@/hooks/use-rechart-data-model';
import React from 'react'; import React from 'react';
import { createChartTooltip } from '@/components/charts/chart-tooltip'; import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import type { IInterval } from '@openpanel/validation'; import type { IInterval } from '@openpanel/validation';
import { import {
@@ -88,20 +92,15 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
const hidden = sorted.slice(limit); const hidden = sorted.slice(limit);
return ( return (
<div className="flex min-w-[180px] flex-col gap-2"> <>
{visible.map((item, index) => ( {visible.map((item, index) => (
<React.Fragment key={item.id}> <React.Fragment key={item.id}>
{index === 0 && item.date && ( {index === 0 && item.date && (
<div className="flex justify-between gap-8"> <ChartTooltipHeader>
<div>{formatDate(new Date(item.date))}</div> <div>{formatDate(new Date(item.date))}</div>
</div> </ChartTooltipHeader>
)} )}
<div className="flex gap-2"> <ChartTooltipItem color={item.color}>
<div
className="w-[3px] rounded-full"
style={{ background: item.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<SerieIcon name={item.names} /> <SerieIcon name={item.names} />
<SerieName name={item.names} /> <SerieName name={item.names} />
@@ -117,8 +116,7 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
</div> </div>
<PreviousDiffIndicator {...item.previous} /> <PreviousDiffIndicator {...item.previous} />
</div> </div>
</div> </ChartTooltipItem>
</div>
</React.Fragment> </React.Fragment>
))} ))}
{hidden.length > 0 && ( {hidden.length > 0 && (
@@ -142,7 +140,7 @@ export const ReportChartTooltip = createChartTooltip<Data, Context>(
))} ))}
</> </>
)} )}
</div> </>
); );
}, },
); );

View File

@@ -22,7 +22,7 @@ export function Chart({ data }: Props) {
() => () =>
series.map((s) => ({ series.map((s) => ({
country: s.names[0]?.toLowerCase() ?? '', country: s.names[0]?.toLowerCase() ?? '',
value: s.metrics[metric], value: s.metrics[metric] ?? 0,
})), })),
[series, metric], [series, metric],
); );

View File

@@ -12,7 +12,7 @@ interface Props {
export function Chart({ data }: Props) { export function Chart({ data }: Props) {
const { const {
isEditMode, isEditMode,
report: { metric, unit }, report: { unit },
} = useReportChartContext(); } = useReportChartContext();
const { series } = useVisibleSeries(data, isEditMode ? 20 : 4); const { series } = useVisibleSeries(data, isEditMode ? 20 : 4);
return ( return (
@@ -27,7 +27,7 @@ export function Chart({ data }: Props) {
<MetricCard <MetricCard
key={serie.id} key={serie.id}
serie={serie} serie={serie}
metric={metric} metric={'count'}
unit={unit} unit={unit}
/> />
); );

View File

@@ -2,10 +2,17 @@ import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import type { IChartData } from '@/trpc/client'; import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts'; import { Area, AreaChart, Tooltip } from 'recharts';
import type { IChartMetric } from '@openpanel/validation'; import type { IChartMetric } from '@openpanel/validation';
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '@/components/charts/chart-tooltip';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import { import {
PreviousDiffIndicator, PreviousDiffIndicator,
getDiffIndicator, getDiffIndicator,
@@ -20,6 +27,27 @@ interface MetricCardProps {
unit?: string; unit?: string;
} }
const TooltipContent = (props: { payload?: any[] }) => {
const number = useNumber();
return (
<ChartTooltipContainer>
{props.payload?.map((item) => {
const { date, count } = item.payload;
return (
<div key={item.id} className="col gap-2">
<ChartTooltipHeader>
<div>{formatDate(new Date(date))}</div>
</ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}>
<div>{number.format(count)}</div>
</ChartTooltipItem>
</div>
);
})}
</ChartTooltipContainer>
);
};
export function MetricCard({ export function MetricCard({
serie, serie,
color: _color, color: _color,
@@ -32,7 +60,11 @@ export function MetricCard({
} = useReportChartContext(); } = useReportChartContext();
const number = useNumber(); const number = useNumber();
const renderValue = (value: number, unitClassName?: string) => { const renderValue = (value: number | undefined, unitClassName?: string) => {
if (!value) {
return <div className="text-muted-foreground">N/A</div>;
}
if (unit === 'min') { if (unit === 'min') {
return <>{fancyMinutes(value)}</>; return <>{fancyMinutes(value)}</>;
} }
@@ -62,7 +94,7 @@ export function MetricCard({
> >
<div <div
className={cn( className={cn(
'pointer-events-none absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100', 'absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-100 transition-opacity duration-300 group-hover:opacity-100',
)} )}
> >
<AutoSizer> <AutoSizer>
@@ -89,6 +121,7 @@ export function MetricCard({
/> />
</linearGradient> </linearGradient>
</defs> </defs>
<Tooltip content={TooltipContent} />
<Area <Area
dataKey="count" dataKey="count"
type="step" type="step"

View File

@@ -7,6 +7,11 @@ import { truncate } from '@/utils/truncate';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '@/components/charts/chart-tooltip';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { formatDate } from '@/utils/date'; import { formatDate } from '@/utils/date';
import { AXIS_FONT_PROPS } from '../common/axis'; import { AXIS_FONT_PROPS } from '../common/axis';
@@ -24,22 +29,17 @@ interface Props {
const PieTooltip = (props: { payload?: any[] }) => { const PieTooltip = (props: { payload?: any[] }) => {
const number = useNumber(); const number = useNumber();
return ( return (
<div className="bg-background/80 p-2 rounded-md backdrop-blur-md border min-w-[180px]"> <ChartTooltipContainer>
{props.payload?.map((serie, index) => { {props.payload?.map((serie, index) => {
const item = serie.payload; const item = serie.payload;
return ( return (
<Fragment key={item.id}> <Fragment key={item.id}>
{index === 0 && item.date && ( {index === 0 && item.date && (
<div className="flex justify-between gap-8"> <ChartTooltipHeader>
<div>{formatDate(new Date(item.date))}</div> <div>{formatDate(new Date(item.date))}</div>
</div> </ChartTooltipHeader>
)} )}
<div className="flex gap-2"> <ChartTooltipItem color={item.color}>
<div
className="w-[3px] rounded-full"
style={{ background: item.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<SerieIcon name={item.name} /> <SerieIcon name={item.name} />
<SerieName name={item.names} className="font-medium" /> <SerieName name={item.names} className="font-medium" />
@@ -55,12 +55,11 @@ const PieTooltip = (props: { payload?: any[] }) => {
</div> </div>
<PreviousDiffIndicator {...item.previous?.sum} /> <PreviousDiffIndicator {...item.previous?.sum} />
</div> </div>
</div> </ChartTooltipItem>
</div>
</Fragment> </Fragment>
); );
})} })}
</div> </ChartTooltipContainer>
); );
}; };

View File

@@ -135,9 +135,12 @@ export function SidebarContainer({
<ProfileToggle /> <ProfileToggle />
</div> </div>
{isSelfHosted && ( {isSelfHosted && (
<div className={cn('text-sm w-full text-left mt-2')}> <a
Self-hosted instance href="https://openpanel.dev/supporter"
</div> className="text-center text-sm w-full mt-2 border rounded p-2 font-medium block hover:underline hover:text-primary outline-none"
>
Self-hosted instance, support us!
</a>
)} )}
</div> </div>
</div> </div>

View File

@@ -4,8 +4,8 @@ import type {
VisibilityState, VisibilityState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import { parseAsInteger, useQueryState } from 'nuqs'; import { parseAsInteger, useQueryState } from 'nuqs';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useLocalStorage } from 'usehooks-ts'; import { useLocalStorage, useReadLocalStorage } from 'usehooks-ts';
export const useDataTablePagination = (pageSize = 10) => { export const useDataTablePagination = (pageSize = 10) => {
const [page, setPage] = useQueryState( const [page, setPage] = useQueryState(
@@ -22,6 +22,12 @@ export const useDataTablePagination = (pageSize = 10) => {
return { page, setPage, state }; return { page, setPage, state };
}; };
export const useReadColumnVisibility = (persistentKey: string) => {
return useReadLocalStorage<Record<string, boolean>>(
`@op:${persistentKey}-column-visibility`,
);
};
export const useDataTableColumnVisibility = <TData,>( export const useDataTableColumnVisibility = <TData,>(
columns: ColumnDef<TData>[], columns: ColumnDef<TData>[],
persistentKey: string, persistentKey: string,
@@ -43,6 +49,13 @@ export const useDataTableColumnVisibility = <TData,>(
}, {} as VisibilityState), }, {} as VisibilityState),
); );
// somewhat hack
// Set initial column visibility,
// otherwise will not useReadColumnVisibility be updated
useEffect(() => {
setColumnVisibility(columnVisibility);
}, []);
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>( const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
`@op:${persistentKey}-column-order`, `@op:${persistentKey}-column-order`,
columns.map((column) => column.id!), columns.map((column) => column.id!),

View File

@@ -5,13 +5,26 @@ import { pick } from 'ramda';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { z } from 'zod'; import { z } from 'zod';
const VALID_COOKIES = ['ui-theme', 'chartType', 'range'] as const; const VALID_COOKIES = [
'ui-theme',
'chartType',
'range',
'supporter-prompt-closed',
] as const;
const COOKIE_EVENT_NAME = '__cookie-change'; const COOKIE_EVENT_NAME = '__cookie-change';
const setCookieFn = createServerFn({ method: 'POST' }) const setCookieFn = createServerFn({ method: 'POST' })
.inputValidator(z.object({ key: z.enum(VALID_COOKIES), value: z.string() })) .inputValidator(z.object({ key: z.enum(VALID_COOKIES), value: z.string() }))
.handler(({ data: { key, value } }) => { .handler(({ data: { key, value } }) => {
setCookie(key, value); if (!VALID_COOKIES.includes(key)) {
return;
}
const maxAge = 60 * 60 * 24 * 365 * 10;
setCookie(key, value, {
maxAge,
path: '/',
expires: new Date(Date.now() + maxAge),
});
}); });
// Called in __root.tsx beforeLoad hook to get cookies from the server // Called in __root.tsx beforeLoad hook to get cookies from the server

View File

@@ -0,0 +1,47 @@
import { Check, X } from 'lucide-react';
import { popModal } from '.';
import { ModalContent } from './Modal/Container';
export default function BillingSuccess() {
return (
<ModalContent className="max-w-2xl">
<button
type="button"
onClick={() => popModal()}
className="absolute right-6 top-6 z-10 rounded-full bg-black text-white p-2.5 hover:bg-gray-800 transition-colors"
>
<X className="h-5 w-5" />
</button>
<div className="flex flex-col items-center justify-center py-12 px-8">
{/* Success Icon with animated rings */}
<div className="relative mb-10 h-64 w-64 flex items-center justify-center">
<div className="absolute inset-0 flex items-center justify-center animate-ping-slow opacity-10">
<div className="h-64 w-64 rounded-full bg-emerald-400" />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-52 w-52 rounded-full bg-emerald-200/30" />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-40 w-40 rounded-full bg-emerald-300/40" />
</div>
<div className="relative flex items-center justify-center">
<div className="h-32 w-32 rounded-full bg-emerald-500 shadow-lg flex items-center justify-center">
<Check className="h-16 w-16 text-white stroke-[3]" />
</div>
</div>
</div>
{/* Success Message */}
<h2 className="text-3xl font-semibold mb-4 text-gray-900">
Subscription updated successfully
</h2>
<p className="text-center mb-12 max-w-md text-base leading-normal">
Thank you for your purchase! You have now full access to OpenPanel. If
you have any questions or feedback, please don't hesitate to contact
us.
</p>
</div>
</ModalContent>
);
}

View File

@@ -2,6 +2,7 @@ import { createPushModal } from 'pushmodal';
import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal'; import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal';
import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal'; import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal';
import { op } from '@/utils/op';
import Instructions from './Instructions'; import Instructions from './Instructions';
import AddClient from './add-client'; import AddClient from './add-client';
import AddDashboard from './add-dashboard'; import AddDashboard from './add-dashboard';
@@ -10,6 +11,7 @@ import AddIntegration from './add-integration';
import AddNotificationRule from './add-notification-rule'; import AddNotificationRule from './add-notification-rule';
import AddProject from './add-project'; import AddProject from './add-project';
import AddReference from './add-reference'; import AddReference from './add-reference';
import BillingSuccess from './billing-success';
import Confirm from './confirm'; import Confirm from './confirm';
import type { ConfirmProps } from './confirm'; import type { ConfirmProps } from './confirm';
import CreateInvite from './create-invite'; import CreateInvite from './create-invite';
@@ -27,6 +29,7 @@ import OverviewChartDetails from './overview-chart-details';
import OverviewFilters from './overview-filters'; import OverviewFilters from './overview-filters';
import RequestPasswordReset from './request-reset-password'; import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report'; import SaveReport from './save-report';
import SelectBillingPlan from './select-billing-plan';
import ShareOverviewModal from './share-overview-modal'; import ShareOverviewModal from './share-overview-modal';
const modals = { const modals = {
@@ -57,6 +60,8 @@ const modals = {
AddNotificationRule: AddNotificationRule, AddNotificationRule: AddNotificationRule,
OverviewFilters: OverviewFilters, OverviewFilters: OverviewFilters,
CreateInvite: CreateInvite, CreateInvite: CreateInvite,
SelectBillingPlan: SelectBillingPlan,
BillingSuccess: BillingSuccess,
}; };
export const { export const {
@@ -66,8 +71,13 @@ export const {
popAllModals, popAllModals,
ModalProvider, ModalProvider,
useOnPushModal, useOnPushModal,
onPushModal,
} = createPushModal({ } = createPushModal({
modals, modals,
}); });
onPushModal('*', (open, props, name) => {
op.screenView(`modal:${name}`, props as Record<string, unknown>);
});
export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props); export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props);

View File

@@ -40,7 +40,7 @@ export default function SaveReport({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { organizationId, projectId } = useAppParams(); const { organizationId, projectId } = useAppParams();
const searchParams = useSearch({ const searchParams = useSearch({
from: '/_app/$organizationId/$projectId_/reports', from: '/_app/$organizationId/$projectId/reports',
shouldThrow: false, shouldThrow: false,
}); });
const dashboardId = searchParams?.dashboardId; const dashboardId = searchParams?.dashboardId;

View File

@@ -0,0 +1,309 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { op } from '@/utils/op';
import type { IServiceOrganization } from '@openpanel/db';
import type { IPolarProduct } from '@openpanel/payments';
import { current } from '@reduxjs/toolkit';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckIcon, ShuffleIcon } from 'lucide-react';
import { Fragment, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
interface Props {
organization: IServiceOrganization;
currentProduct: IPolarProduct | null;
}
const getPrice = (product: IPolarProduct) => {
return product.prices[0] && 'priceAmount' in product.prices[0]
? product.prices[0].priceAmount / 100
: 0;
};
export default function SelectBillingPlan({
organization,
currentProduct,
}: Props) {
const number = useNumber();
const trpc = useTRPC();
const queryClient = useQueryClient();
const productsQuery = useQuery(
trpc.subscription.products.queryOptions({
organizationId: organization.id,
}),
);
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
(organization.subscriptionInterval as 'year' | 'month') || 'month',
);
const [selectedProductId, setSelectedProductId] = useState<string | null>(
organization.subscriptionProductId || null,
);
const products = productsQuery.data || [];
const selectedProduct = products.find(
(product) => product.id === selectedProductId,
);
const checkoutMutation = useMutation(
trpc.subscription.checkout.mutationOptions({
onSuccess(data) {
if (data?.url) {
window.location.href = data.url;
} else {
queryClient.invalidateQueries(
trpc.organization.get.queryOptions({
organizationId: organization.id,
}),
);
queryClient.invalidateQueries(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
toast.success('Subscription updated', {
description: 'It might take a few seconds to update',
});
popModal();
}
},
onError(error) {
toast.error(error.message);
},
}),
);
const cancelSubscription = useMutation(
trpc.subscription.cancelSubscription.mutationOptions({
onSuccess() {
queryClient.invalidateQueries(
trpc.organization.get.queryOptions({
organizationId: organization.id,
}),
);
queryClient.invalidateQueries(
trpc.subscription.getCurrent.queryOptions({
organizationId: organization.id,
}),
);
toast.success('Subscription canceled', {
description: 'It might take a few seconds to update',
});
popModal();
},
onError(error) {
toast.error(error.message);
},
}),
);
const handleCheckout = () => {
if (!selectedProduct) return;
op.track('subscription_checkout_started', {
organizationId: organization.id,
limit: selectedProduct.metadata.eventsLimit,
price: getPrice(selectedProduct),
});
checkoutMutation.mutate({
organizationId: organization.id,
productPriceId: selectedProduct.prices[0].id,
productId: selectedProduct.id,
});
};
const handleCancelSubscription = () => {
if (!selectedProduct) return;
op.track('subscription_canceled', {
organizationId: organization.id,
limit: selectedProduct.metadata.eventsLimit,
price: getPrice(selectedProduct),
});
cancelSubscription.mutate({
organizationId: organization.id,
});
};
const renderAction = () => {
if (!selectedProduct) {
return null;
}
const isCurrentProduct = selectedProduct.id === currentProduct?.id;
if (isCurrentProduct && organization.isActive) {
return (
<Button
className="w-full mt-4"
variant="destructive"
size="lg"
onClick={handleCancelSubscription}
>
Cancel subscription
</Button>
);
}
const payLabel = (() => {
if (
organization.isCanceled ||
organization.isWillBeCanceled ||
organization.isExpired
) {
return isCurrentProduct
? 'Reactivate subscription'
: 'Change subscription';
}
if (currentProduct) {
return 'Change subscription';
}
return 'Pay with Polar';
})();
return (
<button
type="button"
className="w-full mt-4 rounded-lg overflow-hidden hover:translate-y-[-1px] transition-all group"
onClick={handleCheckout}
>
{currentProduct && (
<div className="row justify-between p-2 px-4 border-t border-l border-r border-border rounded-t-lg bg-def-200 group-hover:bg-def-100 transition-colors line-through">
<span>{currentProduct?.name}</span>
<span>{number.currency(getPrice(currentProduct))}</span>
</div>
)}
<div
className={cn(
'row justify-between p-2 px-4 border-t border-l border-r border-border bg-def-200 group-hover:bg-def-100 transition-colors',
!currentProduct && 'rounded-t-lg',
)}
>
<span>{selectedProduct.name}</span>
<span>{number.currency(getPrice(selectedProduct))}</span>
</div>
<div className="center-center gap-4 row bg-primary text-primary-foreground p-4 group-hover:bg-primary/90 transition-colors">
<svg
className="size-6"
width="300"
height="300"
viewBox="0 0 300 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_1_4)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M66.4284 274.26C134.876 320.593 227.925 302.666 274.258 234.219C320.593 165.771 302.666 72.7222 234.218 26.3885C165.77 -19.9451 72.721 -2.0181 26.3873 66.4297C-19.9465 134.877 -2.01938 227.927 66.4284 274.26ZM47.9555 116.67C30.8375 169.263 36.5445 221.893 59.2454 256.373C18.0412 217.361 7.27564 150.307 36.9437 92.318C55.9152 55.2362 87.5665 29.3937 122.5 18.3483C90.5911 36.7105 62.5549 71.8144 47.9555 116.67ZM175.347 283.137C211.377 272.606 244.211 246.385 263.685 208.322C293.101 150.825 282.768 84.4172 242.427 45.2673C264.22 79.7626 269.473 131.542 252.631 183.287C237.615 229.421 208.385 265.239 175.347 283.137ZM183.627 266.229C207.945 245.418 228.016 210.604 236.936 168.79C251.033 102.693 232.551 41.1978 195.112 20.6768C214.97 47.3945 225.022 99.2902 218.824 157.333C214.085 201.724 200.814 240.593 183.627 266.229ZM63.7178 131.844C49.5155 198.43 68.377 260.345 106.374 280.405C85.9962 254.009 75.5969 201.514 81.8758 142.711C86.5375 99.0536 99.4504 60.737 116.225 35.0969C92.2678 55.983 72.5384 90.4892 63.7178 131.844ZM199.834 149.561C200.908 217.473 179.59 272.878 152.222 273.309C124.853 273.742 101.797 219.039 100.724 151.127C99.6511 83.2138 120.968 27.8094 148.337 27.377C175.705 26.9446 198.762 81.648 199.834 149.561Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_1_4">
<rect width="300" height="300" fill="white" />
</clipPath>
</defs>
</svg>
<span className="font-semibold">{payLabel}</span>
</div>
</button>
);
};
return (
<ModalContent>
<ModalHeader title="Select a billing plan" />
<div className="col gap-4">
{currentProduct && (
<div className="font-medium">
Your current usage is{' '}
{number.format(organization.subscriptionPeriodEventsCount)} out of{' '}
{number.format(Number(currentProduct?.metadata.eventsLimit))}{' '}
events.{' '}
<span className="text-muted-foreground">
You cannot downgrade if your usage exceeds the limit of the new
plan.
</span>
</div>
)}
<div className="row items-center justify-between gap-2 -mb-2">
<div className="font-medium">
{recurringInterval === 'year' ? (
'Switch to monthly'
) : (
<>
Switch to yearly and get{' '}
<span className="underline text-emerald-500">
2 months for free
</span>
</>
)}
</div>
<Button
variant="outline"
onClick={() =>
setRecurringInterval((p) => (p === 'year' ? 'month' : 'year'))
}
>
{recurringInterval === 'year' ? 'Monthly' : 'Yearly'}
<ShuffleIcon className="size-4 ml-2" />
</Button>
</div>
</div>
<div className="col divide-y divide-border border rounded-lg overflow-hidden">
{products
.filter((product) =>
product.prices.some((p) => p.amountType !== 'free'),
)
.filter((product) => product.metadata.eventsLimit)
.filter((product) => product.recurringInterval === recurringInterval)
.map((product) => {
const price = getPrice(product);
const limit = product.metadata.eventsLimit
? Number(product.metadata.eventsLimit)
: 0;
const isProductDisabled =
(limit > 0 &&
organization.subscriptionPeriodEventsCount >= limit) ||
!!product.disabled;
return (
<button
key={product.id}
type="button"
disabled={isProductDisabled}
className={cn(
'row justify-between p-4 py-3 hover:bg-def-100',
currentProduct?.id === product.id &&
selectedProductId !== product.id &&
'text-muted-foreground line-through',
isProductDisabled && 'opacity-50 !cursor-not-allowed',
)}
onClick={() => setSelectedProductId(product.id)}
>
<span className={'font-medium'}>{product.name}</span>
<div className="row items-center gap-2">
<span className="font-bold">{number.currency(price)}</span>
{selectedProductId === product.id && (
<div className="size-4 center-center rounded-full bg-emerald-600 text-primary-foreground">
<CheckIcon className="size-2" />
</div>
)}
</div>
</button>
);
})}
</div>
{renderAction()}
</ModalContent>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import { keepPreviousData, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import type { UIMessage } from 'ai'; import type { UIMessage } from 'ai';
export const Route = createFileRoute('/_app/$organizationId/$projectId_/chat')({ export const Route = createFileRoute('/_app/$organizationId/$projectId/chat')({
component: Component, component: Component,
pendingComponent: FullPageLoadingState, pendingComponent: FullPageLoadingState,
head: () => { head: () => {

View File

@@ -32,7 +32,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import { Link, createFileRoute } from '@tanstack/react-router'; import { Link, createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/dashboards', '/_app/$organizationId/$projectId/dashboards',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -53,7 +53,7 @@ type Layout = {
}; };
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/dashboards_/$dashboardId', '/_app/$organizationId/$projectId/dashboards_/$dashboardId',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -1,4 +1,5 @@
import { EventsTable } from '@/components/events/table'; import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters'; import { useEventQueryNamesFilter } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
@@ -6,7 +7,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs'; import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs/conversions', '/_app/$organizationId/$projectId/events/_tabs/conversions',
)({ )({
component: Component, component: Component,
}); });
@@ -20,6 +21,7 @@ function Component() {
); );
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime); const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter(); const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery( const query = useInfiniteQuery(
trpc.event.conversions.infiniteQueryOptions( trpc.event.conversions.infiniteQueryOptions(
{ {
@@ -27,6 +29,7 @@ function Component() {
startDate: startDate || undefined, startDate: startDate || undefined,
endDate: endDate || undefined, endDate: endDate || undefined,
events: eventNames, events: eventNames,
columnVisibility: columnVisibility ?? {},
}, },
{ {
getNextPageParam: (lastPage) => lastPage.meta.next, getNextPageParam: (lastPage) => lastPage.meta.next,

View File

@@ -1,4 +1,5 @@
import { EventsTable } from '@/components/events/table'; import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { import {
useEventQueryFilters, useEventQueryFilters,
useEventQueryNamesFilter, useEventQueryNamesFilter,
@@ -9,7 +10,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs'; import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs/events', '/_app/$organizationId/$projectId/events/_tabs/events',
)({ )({
component: Component, component: Component,
}); });
@@ -21,6 +22,8 @@ function Component() {
const [startDate] = useQueryState('startDate', parseAsIsoDateTime); const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime); const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter(); const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery( const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions( trpc.event.events.infiniteQueryOptions(
{ {
@@ -30,8 +33,10 @@ function Component() {
profileId: '', profileId: '',
startDate: startDate || undefined, startDate: startDate || undefined,
endDate: endDate || undefined, endDate: endDate || undefined,
columnVisibility: columnVisibility ?? {},
}, },
{ {
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next, getNextPageParam: (lastPage) => lastPage.meta.next,
}, },
), ),

View File

@@ -1,7 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs/', '/_app/$organizationId/$projectId/events/_tabs/',
)({ )({
component: Component, component: Component,
beforeLoad({ params }) { beforeLoad({ params }) {

View File

@@ -14,7 +14,7 @@ import type { IChartEvent } from '@openpanel/validation';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs/stats', '/_app/$organizationId/$projectId/events/_tabs/stats',
)({ )({
component: Component, component: Component,
}); });

View File

@@ -5,7 +5,7 @@ import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router'; import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/events/_tabs', '/_app/$organizationId/$projectId/events/_tabs',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -0,0 +1,59 @@
import {
OverviewFilterButton,
OverviewFiltersButtons,
} from '@/components/overview/filters/overview-filters-buttons';
import { LiveCounter } from '@/components/overview/live-counter';
import { OverviewInterval } from '@/components/overview/overview-interval';
import OverviewMetrics from '@/components/overview/overview-metrics';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewShare } from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/$organizationId/$projectId/')({
component: ProjectDashboard,
head: () => {
return {
meta: [
{
title: createProjectTitle(PAGE_TITLES.DASHBOARD),
},
],
};
},
});
function ProjectDashboard() {
const { projectId } = Route.useParams();
return (
<div>
<div className="col gap-2 p-4">
<div className="flex justify-between gap-2">
<div className="flex gap-2">
<OverviewRange />
<OverviewInterval />
<OverviewFilterButton mode="events" />
</div>
<div className="flex gap-2">
<LiveCounter projectId={projectId} />
<OverviewShare projectId={projectId} />
</div>
</div>
<OverviewFiltersButtons />
</div>
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/notifications/_tabs/', '/_app/$organizationId/$projectId/notifications/_tabs/',
)({ )({
component: Component, component: Component,
beforeLoad({ params }) { beforeLoad({ params }) {

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/notifications/_tabs/notifications', '/_app/$organizationId/$projectId/notifications/_tabs/notifications',
)({ )({
component: Component, component: Component,
loader: async ({ context, params }) => { loader: async ({ context, params }) => {

View File

@@ -12,7 +12,7 @@ import { PencilRulerIcon, PlusIcon } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/notifications/_tabs/rules', '/_app/$organizationId/$projectId/notifications/_tabs/rules',
)({ )({
component: Component, component: Component,
loader: async ({ context, params }) => { loader: async ({ context, params }) => {

View File

@@ -5,7 +5,7 @@ import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router'; import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/notifications/_tabs', '/_app/$organizationId/$projectId/notifications/_tabs',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -24,7 +24,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { parseAsInteger, useQueryState } from 'nuqs'; import { parseAsInteger, useQueryState } from 'nuqs';
import { memo } from 'react'; import { memo } from 'react';
export const Route = createFileRoute('/_app/$organizationId/$projectId_/pages')( export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')(
{ {
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -1,4 +1,5 @@
import { EventsTable } from '@/components/events/table'; import { EventsTable } from '@/components/events/table';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { import {
useEventQueryFilters, useEventQueryFilters,
useEventQueryNamesFilter, useEventQueryNamesFilter,
@@ -9,7 +10,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs'; import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/$profileId/_tabs/events', '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/events',
)({ )({
component: Component, component: Component,
}); });
@@ -21,6 +22,7 @@ function Component() {
const [startDate] = useQueryState('startDate', parseAsIsoDateTime); const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime); const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter(); const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery( const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions( trpc.event.events.infiniteQueryOptions(
{ {
@@ -30,8 +32,10 @@ function Component() {
startDate: startDate || undefined, startDate: startDate || undefined,
endDate: endDate || undefined, endDate: endDate || undefined,
events: eventNames, events: eventNames,
columnVisibility: columnVisibility ?? {},
}, },
{ {
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next, getNextPageParam: (lastPage) => lastPage.meta.next,
}, },
), ),

View File

@@ -11,7 +11,7 @@ import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/$profileId/_tabs/', '/_app/$organizationId/$projectId/profiles/$profileId/_tabs/',
)({ )({
component: Component, component: Component,
loader: async ({ context, params }) => { loader: async ({ context, params }) => {

View File

@@ -11,7 +11,7 @@ import { useSuspenseQuery } from '@tanstack/react-query';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router'; import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/$profileId/_tabs', '/_app/$organizationId/$projectId/profiles/$profileId/_tabs',
)({ )({
component: Component, component: Component,
loader: async ({ context, params }) => { loader: async ({ context, params }) => {

View File

@@ -7,7 +7,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs/anonymous', '/_app/$organizationId/$projectId/profiles/_tabs/anonymous',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -7,7 +7,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs/identified', '/_app/$organizationId/$projectId/profiles/_tabs/identified',
)({ )({
head: () => { head: () => {
return { return {

View File

@@ -1,7 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs/', '/_app/$organizationId/$projectId/profiles/_tabs/',
)({ )({
component: Component, component: Component,
beforeLoad({ params }) { beforeLoad({ params }) {

View File

@@ -6,7 +6,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs/power-users', '/_app/$organizationId/$projectId/profiles/_tabs/power-users',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -5,7 +5,7 @@ import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router'; import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/profiles/_tabs', '/_app/$organizationId/$projectId/profiles/_tabs',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -12,7 +12,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/realtime', '/_app/$organizationId/$projectId/realtime',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -33,7 +33,7 @@ import { PlusIcon } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/references', '/_app/$organizationId/$projectId/references',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -4,7 +4,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod'; import { z } from 'zod';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/reports', '/_app/$organizationId/$projectId/reports',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -7,7 +7,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod'; import { z } from 'zod';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/reports_/$reportId', '/_app/$organizationId/$projectId/reports_/$reportId',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -13,7 +13,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs'; import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/sessions', '/_app/$organizationId/$projectId/sessions',
)({ )({
component: Component, component: Component,
head: () => { head: () => {

View File

@@ -3,12 +3,11 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container'; import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks'; import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { import {
useEventQueryFilters, useEventQueryFilters,
useEventQueryNamesFilter, useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters'; } from '@/hooks/use-event-query-filters';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { createProjectTitle } from '@/utils/title'; import { createProjectTitle } from '@/utils/title';
import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
@@ -16,7 +15,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { parseAsIsoDateTime, useQueryState } from 'nuqs'; import { parseAsIsoDateTime, useQueryState } from 'nuqs';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/sessions_/$sessionId', '/_app/$organizationId/$projectId/sessions_/$sessionId',
)({ )({
component: Component, component: Component,
loader: async ({ context, params }) => { loader: async ({ context, params }) => {
@@ -46,8 +45,6 @@ function Component() {
const trpc = useTRPC(); const trpc = useTRPC();
const LIMIT = 50; const LIMIT = 50;
const { page } = useDataTablePagination(LIMIT);
const { debouncedSearch } = useSearchQueryState();
const { data: session } = useSuspenseQuery( const { data: session } = useSuspenseQuery(
trpc.session.byId.queryOptions({ trpc.session.byId.queryOptions({
@@ -60,7 +57,7 @@ function Component() {
const [startDate] = useQueryState('startDate', parseAsIsoDateTime); const [startDate] = useQueryState('startDate', parseAsIsoDateTime);
const [endDate] = useQueryState('endDate', parseAsIsoDateTime); const [endDate] = useQueryState('endDate', parseAsIsoDateTime);
const [eventNames] = useEventQueryNamesFilter(); const [eventNames] = useEventQueryNamesFilter();
const columnVisibility = useReadColumnVisibility('events');
const query = useInfiniteQuery( const query = useInfiniteQuery(
trpc.event.events.infiniteQueryOptions( trpc.event.events.infiniteQueryOptions(
{ {
@@ -70,8 +67,10 @@ function Component() {
events: eventNames, events: eventNames,
startDate: startDate || undefined, startDate: startDate || undefined,
endDate: endDate || undefined, endDate: endDate || undefined,
columnVisibility: columnVisibility ?? {},
}, },
{ {
enabled: columnVisibility !== null,
getNextPageParam: (lastPage) => lastPage.meta.next, getNextPageParam: (lastPage) => lastPage.meta.next,
}, },
), ),

View File

@@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/clients', '/_app/$organizationId/$projectId/settings/_tabs/clients',
)({ )({
component: Component, component: Component,
}); });

View File

@@ -7,7 +7,7 @@ import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/details', '/_app/$organizationId/$projectId/settings/_tabs/details',
)({ )({
component: Component, component: Component,
}); });

View File

@@ -6,7 +6,7 @@ import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/events', '/_app/$organizationId/$projectId/settings/_tabs/events',
)({ )({
component: Component, component: Component,
}); });

View File

@@ -33,7 +33,7 @@ import {
import { toast } from 'sonner'; import { toast } from 'sonner';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/imports', '/_app/$organizationId/$projectId/settings/_tabs/imports',
)({ )({
component: ImportsSettings, component: ImportsSettings,
}); });

View File

@@ -1,7 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/', '/_app/$organizationId/$projectId/settings/_tabs/',
)({ )({
component: Component, component: Component,
beforeLoad: ({ params }) => { beforeLoad: ({ params }) => {

View File

@@ -10,7 +10,7 @@ import {
} from '@tanstack/react-router'; } from '@tanstack/react-router';
export const Route = createFileRoute( export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs', '/_app/$organizationId/$projectId/settings/_tabs',
)({ )({
component: ProjectDashboard, component: ProjectDashboard,
head: () => { head: () => {

View File

@@ -1,19 +1,9 @@
import { import BillingPrompt from '@/components/organization/billing-prompt';
OverviewFilterButton, import { useTRPC } from '@/integrations/trpc/react';
OverviewFiltersButtons,
} from '@/components/overview/filters/overview-filters-buttons';
import { LiveCounter } from '@/components/overview/live-counter';
import { OverviewInterval } from '@/components/overview/overview-interval';
import OverviewMetrics from '@/components/overview/overview-metrics';
import { OverviewRange } from '@/components/overview/overview-range';
import { OverviewShare } from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title'; import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { createFileRoute } from '@tanstack/react-router'; import { FREE_PRODUCT_IDS } from '@openpanel/payments';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Outlet, createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/$organizationId/$projectId')({ export const Route = createFileRoute('/_app/$organizationId/$projectId')({
component: ProjectDashboard, component: ProjectDashboard,
@@ -26,34 +16,43 @@ export const Route = createFileRoute('/_app/$organizationId/$projectId')({
], ],
}; };
}, },
loader: async ({ context, params }) => {
await context.queryClient.prefetchQuery(
context.trpc.organization.get.queryOptions({
organizationId: params.organizationId,
}),
);
},
}); });
function ProjectDashboard() { function ProjectDashboard() {
const { projectId } = Route.useParams(); const { organizationId } = Route.useParams();
const trpc = useTRPC();
const { data: organization } = useSuspenseQuery(
trpc.organization.get.queryOptions({
organizationId,
}),
);
if (
organization.subscriptionProductId &&
FREE_PRODUCT_IDS.includes(organization.subscriptionProductId)
) {
return <BillingPrompt organization={organization} type={'freePlan'} />;
}
if (organization.isExpired) {
return ( return (
<div> <BillingPrompt
<div className="col gap-2 p-4"> organization={organization}
<div className="flex justify-between gap-2"> type={
<div className="flex gap-2"> organization.subscriptionStatus === 'trialing'
<OverviewRange /> ? 'trialEnded'
<OverviewInterval /> : 'expired'
<OverviewFilterButton mode="events" /> }
</div> />
<div className="flex gap-2">
<LiveCounter projectId={projectId} />
<OverviewShare projectId={projectId} />
</div>
</div>
<OverviewFiltersButtons />
</div>
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</div>
); );
} }
return <Outlet />;
}

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