12 Commits

Author SHA1 Message Date
Carl-Gerhard Lindesvärd
8cd3b89fa3 funnel 2025-11-25 22:33:58 +01:00
Carl-Gerhard Lindesvärd
95af86dc44 wip 2025-11-25 22:23:25 +01:00
Carl-Gerhard Lindesvärd
727a218e6b conversion wip 2025-11-25 10:18:26 +01:00
Carl-Gerhard Lindesvärd
958ba535d6 wip 2025-11-25 10:18:20 +01:00
Carl-Gerhard Lindesvärd
3bbeb927cc wip 2025-11-25 09:18:48 +01:00
Carl-Gerhard Lindesvärd
d99335e2f4 wip 2025-11-24 18:08:10 +01:00
Carl-Gerhard Lindesvärd
1fa61b1ae9 ts 2025-11-24 15:50:28 +01:00
Carl-Gerhard Lindesvärd
548747d826 fix typecheck events -> series 2025-11-24 13:17:01 +01:00
Carl-Gerhard Lindesvärd
7b18544085 fix report table 2025-11-24 13:06:46 +01:00
Carl-Gerhard Lindesvärd
57697a5a39 wip 2025-11-22 00:05:13 +01:00
Carl-Gerhard Lindesvärd
06fb6c4f3c wip 2025-11-21 11:21:17 +01:00
Carl-Gerhard Lindesvärd
dd71fd4e11 formulas 2025-11-20 13:56:58 +01:00
1050 changed files with 27319 additions and 102360 deletions

View File

@@ -1,170 +0,0 @@
# CLAUDE.md
NEVER CALL FORMAT! WE'LL FORMAT IN THE FUTURE WHEN WE HAVE MERGED ALL BIG PRS!
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Openpanel is an open-source web/product analytics platform (Mixpanel alternative). It's a **pnpm monorepo** with apps, packages, tooling, and SDKs.
## Common Commands
```bash
# Development
pnpm dev # Run all services (api, worker, dashboard) in parallel
pnpm dev:public # Run public/docs site only
pnpm dock:up / dock:down # Start/stop Docker (PostgreSQL, Redis, ClickHouse)
# Code quality
pnpm check # Lint check (Biome via Ultracite)
pnpm fix # Auto-fix lint/format issues
pnpm typecheck # Typecheck all packages
# Testing
pnpm test # Run all tests (vitest)
pnpm vitest run <path> # Run a single test file
# Workspace: packages/* and apps/* (excluding apps/start)
# Database
pnpm codegen # Generate Prisma types + geo data
pnpm migrate # Run Prisma migrations (dev)
pnpm migrate:deploy # Deploy migrations (production - never run this)
# Docker utilities
pnpm dock:ch # ClickHouse CLI
pnpm dock:redis # Redis CLI
```
## Architecture
### Apps
| App | Stack | Port | Purpose |
|-----|-------|------|---------|
| `apps/api` | Fastify + tRPC | 3333 | REST/RPC API server |
| `apps/start` | TanStack Start (Vite + React 19) | 3000 | Dashboard SPA |
| `apps/public` | Next.js 16 + Fumadocs | - | Marketing/docs site |
| `apps/worker` | Express + BullMQ | 9999 | Background job processor |
### Key Packages
| Package | Purpose |
|---------|---------|
| `packages/db` | Prisma ORM (PostgreSQL) + ClickHouse client |
| `packages/trpc` | tRPC router definitions, context, middleware |
| `packages/auth` | Authentication (Arctic OAuth, Oslo sessions, argon2) |
| `packages/queue` | BullMQ + GroupMQ job queue definitions |
| `packages/redis` | Redis client + LRU caching |
| `packages/validation` | Zod schemas shared across apps |
| `packages/common` | Shared utilities (date-fns, ua-parser, nanoid) |
| `packages/email` | React Email templates via Resend |
| `packages/sdks/*` | Client SDKs (web, react, next, express, react-native, etc.) |
### Data Flow
1. **Event ingestion**: Client SDKs → `apps/api` (track routes) → Redis queue
2. **Processing**: `apps/worker` picks up jobs from BullMQ, batches events into ClickHouse
3. **Dashboard queries**: `apps/start` → tRPC → `apps/api` → ClickHouse (analytics) / PostgreSQL (config)
4. **Real-time**: WebSocket via Fastify, pub/sub via Redis
### Three-Database Strategy
- **PostgreSQL**: Relational data (users, orgs, projects, dashboards). Managed by Prisma.
- **ClickHouse**: Analytics event storage (OLAP). High-volume reads/writes.
- **Redis**: Caching, job queues (BullMQ), rate limiting, pub/sub.
### Dashboard (apps/start)
Uses TanStack Router with file-based routing (`src/routes/`). State management via Redux Toolkit. UI built on Radix primitives + Tailwind v4. Charts via Recharts. Modals in `src/modals/`.
### API (apps/api)
Fastify server with tRPC integration. Route files in `src/routes/`. Hooks for IP extraction, request logging, timestamps. Built with `tsdown`.
---
## Core Principles
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
### Type Safety & Explicitness
- Use explicit types for function parameters and return values when they enhance clarity
- Prefer `unknown` over `any` when the type is genuinely unknown
- Use const assertions (`as const`) for immutable values and literal types
- Leverage TypeScript's type narrowing instead of type assertions
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
### Modern JavaScript/TypeScript
- Use arrow functions for callbacks and short functions
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
- Prefer template literals over string concatenation
- Use destructuring for object and array assignments
- Use `const` by default, `let` only when reassignment is needed, never `var`
### Async & Promises
- Always `await` promises in async functions - don't forget to use the return value
- Use `async/await` syntax instead of promise chains for better readability
- Handle errors appropriately in async code with try-catch blocks
- Don't use async functions as Promise executors
### React & JSX
- Use function components over class components
- Call hooks at the top level only, never conditionally
- Specify all dependencies in hook dependency arrays correctly
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
- Nest children between opening and closing tags instead of passing as props
- Don't define components inside other components
- Use semantic HTML and ARIA attributes for accessibility:
- Provide meaningful alt text for images
- Use proper heading hierarchy
- Add labels for form inputs
- Include keyboard event handlers alongside mouse events
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
### Error Handling & Debugging
- Remove `console.log`, `debugger`, and `alert` statements from production code
- Throw `Error` objects with descriptive messages, not strings or other values
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
- Prefer early returns over nested conditionals for error cases
### Code Organization
- Keep functions focused and under reasonable cognitive complexity limits
- Extract complex conditions into well-named boolean variables
- Use early returns to reduce nesting
- Prefer simple conditionals over nested ternary operators
- Group related code together and separate concerns
### Security
- Add `rel="noopener"` when using `target="_blank"` on links
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
- Don't use `eval()` or assign directly to `document.cookie`
- Validate and sanitize user input
### Performance
- Avoid spread syntax in accumulators within loops
- Use top-level regex literals instead of creating them in loops
- Prefer specific imports over namespace imports
- Avoid barrel files (index files that re-export everything)
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
### Framework-Specific Guidance
**Next.js:**
- Use Next.js `<Image>` component for images
- Use `next/head` or App Router metadata API for head elements
- Use Server Components for async data fetching instead of async Client Components
**React 19+:**
- Use ref as a prop instead of `React.forwardRef`
**Solid/Svelte/Vue/Qwik:**
- Use `class` and `for` attributes (not `className` or `htmlFor`)

View File

@@ -3,37 +3,54 @@ name: Docker Build and Push
on:
workflow_dispatch:
push:
paths-ignore:
# README and docs
- "**/README*"
- "**/readme*"
- "**/*.md"
- "**/docs/**"
- "**/CHANGELOG*"
- "**/LICENSE*"
# Test files
- "**/*.test.*"
- "**/*.spec.*"
- "**/__tests__/**"
- "**/tests/**"
# SDKs (published separately)
- "packages/sdks/**"
# Public app (docs/marketing, not part of Docker deploy)
# branches: [ "main" ]
paths:
- "apps/api/**"
- "apps/worker/**"
- "apps/public/**"
# Dev / tooling
- "**/.vscode/**"
- "**/.cursor/**"
- "**/.env.example"
- "**/.env.*.example"
- "**/.gitignore"
- "**/.eslintignore"
- "**/.prettierignore"
- "packages/**"
- "!packages/sdks/**"
- "**Dockerfile"
- ".github/workflows/**"
env:
repo_owner: "openpanel-dev"
jobs:
changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
worker: ${{ steps.filter.outputs.worker }}
public: ${{ steps.filter.outputs.public }}
dashboard: ${{ steps.filter.outputs.dashboard }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
base: "main"
filters: |
api:
- 'apps/api/**'
- 'packages/**'
- '.github/workflows/**'
worker:
- 'apps/worker/**'
- 'packages/**'
- '.github/workflows/**'
public:
- 'apps/public/**'
- 'packages/**'
- '.github/workflows/**'
dashboard:
- 'apps/start/**'
- 'packages/**'
- '.github/workflows/**'
lint-and-test:
needs: changes
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.worker == 'true' || needs.changes.outputs.public == 'true' || needs.changes.outputs.dashboard == 'true' }}
runs-on: ubuntu-latest
services:
redis:
@@ -88,7 +105,8 @@ jobs:
permissions:
packages: write
contents: write
needs: lint-and-test
needs: [changes, lint-and-test]
if: ${{ needs.changes.outputs.api == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -148,7 +166,8 @@ jobs:
permissions:
packages: write
contents: write
needs: lint-and-test
needs: [changes, lint-and-test]
if: ${{ needs.changes.outputs.worker == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -208,7 +227,8 @@ jobs:
permissions:
packages: write
contents: write
needs: lint-and-test
needs: [changes, lint-and-test]
if: ${{ needs.changes.outputs.dashboard == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository

1
.gitignore vendored
View File

@@ -1,7 +1,6 @@
.secrets
packages/db/src/generated/prisma
packages/db/code-migrations/*.sql
**/.open-next
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
packages/sdk/profileId.txt

View File

@@ -1,50 +0,0 @@
{
"formatter": "language_server",
"format_on_save": "on",
"lsp": {
"typescript-language-server": {
"settings": {
"typescript": {
"preferences": {
"includePackageJsonAutoImports": "on"
}
}
}
}
},
"languages": {
"JavaScript": {
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
}
},
"TypeScript": {
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
}
},
"TSX": {
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
}
}
}
}

View File

@@ -1,4 +1,4 @@
![hero](apps/public/public/ogimage.png)
![hero](apps/public/public/ogimage.jpg)
<p align="center">
<h1 align="center"><b>Openpanel</b></h1>
@@ -28,7 +28,6 @@ Openpanel is an open-source web and product analytics platform that combines the
## ✨ Features
- **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history
- **🎬 Session Replay**: Record and replay user sessions with privacy controls built in
- **📊 Real-time Dashboards**: Live data updates and interactive charts
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
- **🔔 Smart Notifications**: Event and funnel-based alerts
@@ -49,7 +48,6 @@ Openpanel is an open-source web and product analytics platform that combines the
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
@@ -58,10 +56,9 @@ Openpanel is an open-source web and product analytics platform that combines the
| 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ |
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
> ✅*** Plausible has simple goals
> ✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
## Stack
@@ -101,10 +98,6 @@ You can find the how to [here](https://openpanel.dev/docs/self-hosting/self-host
### Start
```bash
pnpm install
cp .env.example .env
echo "API_URL=http://localhost:3333" > apps/start/.env
pnpm dock:up
pnpm codegen
pnpm migrate:deploy # once to setup the db
@@ -117,4 +110,4 @@ You can now access the following:
- API: https://api.localhost:3333
- Bullboard (queue): http://localhost:9999
- `pnpm dock:ch` to access clickhouse terminal
- `pnpm dock:redis` to access redis terminal
- `pnpm dock:redis` to access redis terminal

View File

@@ -5,10 +5,7 @@
"rootDir": "src",
"target": "ES2022",
"lib": ["ES2022"],
"types": [
"node"
],
"strictNullChecks": true
"types": ["node"]
},
"include": ["src"]
}

View File

@@ -38,10 +38,11 @@ COPY packages/redis/package.json packages/redis/
COPY packages/logger/package.json packages/logger/
COPY packages/common/package.json packages/common/
COPY packages/payments/package.json packages/payments/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY packages/constants/package.json packages/constants/
COPY packages/validation/package.json packages/validation/
COPY packages/integrations/package.json packages/integrations/
COPY packages/js-runtime/package.json packages/js-runtime/
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
COPY patches ./patches
# BUILD
@@ -106,10 +107,10 @@ COPY --from=build /app/packages/redis ./packages/redis
COPY --from=build /app/packages/logger ./packages/logger
COPY --from=build /app/packages/common ./packages/common
COPY --from=build /app/packages/payments ./packages/payments
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
COPY --from=build /app/packages/constants ./packages/constants
COPY --from=build /app/packages/validation ./packages/validation
COPY --from=build /app/packages/integrations ./packages/integrations
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
COPY --from=build /app/tooling/typescript ./tooling/typescript
RUN pnpm db:codegen

View File

@@ -8,7 +8,6 @@
"start": "dotenv -e ../../.env node dist/index.js",
"build": "rm -rf dist && tsdown",
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
"test:manage": "jiti scripts/test-manage-api.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -39,7 +38,7 @@
"fastify": "^5.6.1",
"fastify-metrics": "^12.1.0",
"fastify-raw-body": "^5.0.0",
"groupmq": "catalog:",
"groupmq": "1.1.0-next.6",
"jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1",
"sharp": "^0.33.5",
@@ -47,12 +46,13 @@
"sqlstring": "^2.3.3",
"superjson": "^1.13.3",
"svix": "^1.24.0",
"url-metadata": "^5.4.1",
"url-metadata": "^4.1.1",
"uuid": "^9.0.1",
"zod": "catalog:"
},
"devDependencies": {
"@faker-js/faker": "^9.0.1",
"@openpanel/sdk": "workspace:*",
"@openpanel/tsconfig": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.9",
@@ -65,4 +65,4 @@
"tsdown": "0.14.2",
"typescript": "catalog:"
}
}
}

View File

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

View File

@@ -63,7 +63,6 @@ async function main() {
imported_at: null,
sdk_name: 'test-script',
sdk_version: '1.0.0',
groups: [],
});
}

View File

@@ -1,4 +1,4 @@
import { cacheable } from '@openpanel/redis';
import { cacheable, cacheableLru } from '@openpanel/redis';
import bots from './bots';
// Pre-compile regex patterns at module load time
@@ -15,7 +15,7 @@ const compiledBots = bots.map((bot) => {
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
const includesBots = compiledBots.filter((bot) => 'includes' in bot);
export const isBot = cacheable(
export const isBot = cacheableLru(
'is-bot',
(ua: string) => {
// Check simple string patterns first (fast)
@@ -40,5 +40,8 @@ export const isBot = cacheable(
return null;
},
60 * 5
{
maxSize: 1000,
ttl: 60 * 5,
},
);

View File

@@ -1,25 +1,26 @@
import { generateId, slug } from '@openpanel/common';
import { parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getSalts } from '@openpanel/db';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import type { PostEventPayload } from '@openpanel/sdk';
import { generateId } from '@openpanel/common';
import { getGeoLocation } from '@openpanel/geo';
import { getStringHeaders, getTimestamp } from './track.controller';
import { getDeviceId } from '@/utils/ids';
export async function postEvent(
request: FastifyRequest<{
Body: DeprecatedPostEventPayload;
Body: PostEventPayload;
}>,
reply: FastifyReply
reply: FastifyReply,
) {
const { timestamp, isTimestampFromThePast } = getTimestamp(
request.timestamp,
request.body
request.body,
);
const ip = request.clientIp;
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
const ua = request.headers['user-agent'];
const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers);
@@ -29,22 +30,34 @@ export async function postEvent(
}
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const { deviceId, sessionId } = await getDeviceId({
projectId,
ip,
ua,
salts,
});
const currentDeviceId = ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '';
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
const uaInfo = parseUserAgent(ua, request.body?.properties);
const groupId = uaInfo.isServer
? `${projectId}:${request.body?.profileId ?? generateId()}`
: deviceId;
? request.body?.profileId
? `${projectId}:${request.body?.profileId}`
: `${projectId}:${generateId()}`
: currentDeviceId;
const jobId = [
slug(request.body.name),
request.body.name,
timestamp,
projectId,
deviceId,
currentDeviceId,
groupId,
]
.filter(Boolean)
@@ -61,8 +74,8 @@ export async function postEvent(
},
uaInfo,
geo,
deviceId,
sessionId: sessionId ?? '',
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,

View File

@@ -1,18 +1,20 @@
import { parseQueryString } from '@/utils/parse-zod-query-string';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
import { DateTime } from '@openpanel/common';
import type { GetEventListOptions } from '@openpanel/db';
import {
ChartEngine,
ClientType,
db,
getEventList,
getEventsCount,
getEventsCountCached,
getSettingsForProject,
} from '@openpanel/db';
import { zChartEvent, zReport } from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { HttpError } from '@/utils/errors';
import { parseQueryString } from '@/utils/parse-zod-query-string';
import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
import { omit } from 'ramda';
async function getProjectId(
request: FastifyRequest<{
@@ -20,7 +22,8 @@ async function getProjectId(
project_id?: string;
projectId?: string;
};
}>
}>,
reply: FastifyReply,
) {
let projectId = request.query.projectId || request.query.project_id;
@@ -72,20 +75,8 @@ const eventsScheme = z.object({
limit: z.coerce.number().optional().default(50),
includes: z
.preprocess(
(arg) => {
if (arg == null) {
return undefined;
}
if (Array.isArray(arg)) {
return arg;
}
if (typeof arg === 'string') {
const parts = arg.split(',').map((s) => s.trim()).filter(Boolean);
return parts;
}
return arg;
},
z.array(z.string())
(arg) => (typeof arg === 'string' ? [arg] : arg),
z.array(z.string()),
)
.optional(),
});
@@ -94,7 +85,7 @@ export async function events(
request: FastifyRequest<{
Querystring: z.infer<typeof eventsScheme>;
}>,
reply: FastifyReply
reply: FastifyReply,
) {
const query = eventsScheme.safeParse(request.query);
@@ -106,7 +97,7 @@ export async function events(
});
}
const projectId = await getProjectId(request);
const projectId = await getProjectId(request, reply);
const limit = query.data.limit;
const page = Math.max(query.data.page, 1);
const take = Math.max(Math.min(limit, 1000), 1);
@@ -127,20 +118,20 @@ export async function events(
meta: false,
...query.data.includes?.reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
{},
),
},
};
const [data, totalCount] = await Promise.all([
getEventList(options),
getEventsCount(options),
getEventsCountCached(omit(['cursor', 'take'], options)),
]);
reply.send({
meta: {
count: data.length,
totalCount,
totalCount: totalCount,
pages: Math.ceil(totalCount / options.take),
current: cursor + 1,
},
@@ -148,7 +139,7 @@ export async function events(
});
}
const chartSchemeFull = zReport
const chartSchemeFull = zChartInputBase
.pick({
breakdowns: true,
interval: true,
@@ -167,7 +158,7 @@ const chartSchemeFull = zReport
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
})
}),
)
.optional(),
// Backward compatibility - events will be migrated to series via preprocessing
@@ -178,7 +169,7 @@ const chartSchemeFull = zReport
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
})
}),
)
.optional(),
});
@@ -187,7 +178,7 @@ export async function charts(
request: FastifyRequest<{
Querystring: Record<string, string>;
}>,
reply: FastifyReply
reply: FastifyReply,
) {
const query = chartSchemeFull.safeParse(parseQueryString(request.query));
@@ -199,7 +190,7 @@ export async function charts(
});
}
const projectId = await getProjectId(request);
const projectId = await getProjectId(request, reply);
const { timezone } = await getSettingsForProject(projectId);
const { events, series, ...rest } = query.data;

View File

@@ -1,167 +0,0 @@
import { googleGsc } from '@openpanel/auth';
import { db, encrypt } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { LogError } from '@/utils/errors';
const OAUTH_SENSITIVE_KEYS = ['code', 'state'];
function sanitizeOAuthQuery(
query: Record<string, unknown> | null | undefined
): Record<string, string> {
if (!query || typeof query !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(query).map(([k, v]) => [
k,
OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v),
])
);
}
export async function gscGoogleCallback(
req: FastifyRequest,
reply: FastifyReply
) {
try {
const schema = z.object({
code: z.string(),
state: z.string(),
});
const query = schema.safeParse(req.query);
if (!query.success) {
throw new LogError(
'Invalid GSC callback query params',
sanitizeOAuthQuery(req.query as Record<string, unknown>)
);
}
const { code, state } = query.data;
const rawStoredState = req.cookies.gsc_oauth_state ?? null;
const rawCodeVerifier = req.cookies.gsc_code_verifier ?? null;
const rawProjectId = req.cookies.gsc_project_id ?? null;
const storedStateResult =
rawStoredState !== null ? req.unsignCookie(rawStoredState) : null;
const codeVerifierResult =
rawCodeVerifier !== null ? req.unsignCookie(rawCodeVerifier) : null;
const projectIdResult =
rawProjectId !== null ? req.unsignCookie(rawProjectId) : null;
if (
!(
storedStateResult?.value &&
codeVerifierResult?.value &&
projectIdResult?.value
)
) {
throw new LogError('Missing GSC OAuth cookies', {
storedState: !storedStateResult?.value,
codeVerifier: !codeVerifierResult?.value,
projectId: !projectIdResult?.value,
});
}
if (
!(
storedStateResult?.valid &&
codeVerifierResult?.valid &&
projectIdResult?.valid
)
) {
throw new LogError('Invalid GSC OAuth cookies', {
storedState: !storedStateResult?.value,
codeVerifier: !codeVerifierResult?.value,
projectId: !projectIdResult?.value,
});
}
const stateStr = storedStateResult?.value;
const codeVerifierStr = codeVerifierResult?.value;
const projectIdStr = projectIdResult?.value;
if (state !== stateStr) {
throw new LogError('GSC OAuth state mismatch', {
hasState: true,
hasStoredState: true,
stateMismatch: true,
});
}
const tokens = await googleGsc.validateAuthorizationCode(
code,
codeVerifierStr
);
const accessToken = tokens.accessToken();
const refreshToken = tokens.hasRefreshToken()
? tokens.refreshToken()
: null;
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
if (!refreshToken) {
throw new LogError('No refresh token returned from Google GSC OAuth');
}
const project = await db.project.findUnique({
where: { id: projectIdStr },
select: { id: true, organizationId: true },
});
if (!project) {
throw new LogError('Project not found for GSC connection', {
projectId: projectIdStr,
});
}
await db.gscConnection.upsert({
where: { projectId: projectIdStr },
create: {
projectId: projectIdStr,
accessToken: encrypt(accessToken),
refreshToken: encrypt(refreshToken),
accessTokenExpiresAt,
siteUrl: '',
},
update: {
accessToken: encrypt(accessToken),
refreshToken: encrypt(refreshToken),
accessTokenExpiresAt,
lastSyncStatus: null,
lastSyncError: null,
},
});
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
const dashboardUrl =
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`;
return reply.redirect(redirectUrl);
} catch (error) {
req.log.error(error);
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
return redirectWithError(reply, error);
}
}
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
);
url.pathname = '/login';
if (error instanceof LogError) {
url.searchParams.set('error', error.message);
} else {
url.searchParams.set('error', 'Failed to connect Google Search Console');
}
url.searchParams.set('correlationId', reply.request.id);
return reply.redirect(url.toString());
}

View File

@@ -1,12 +1,12 @@
import { isShuttingDown } from '@/utils/graceful-shutdown';
import { chQuery, db } from '@openpanel/db';
import { getRedisCache } from '@openpanel/redis';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { isShuttingDown } from '@/utils/graceful-shutdown';
// For docker compose healthcheck
export async function healthcheck(
request: FastifyRequest,
reply: FastifyReply
reply: FastifyReply,
) {
try {
const redisRes = await getRedisCache().ping();
@@ -21,7 +21,6 @@ export async function healthcheck(
ch: chRes && chRes.length > 0,
});
} catch (error) {
request.log.warn('healthcheck failed', { error });
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
@@ -42,22 +41,18 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) {
// Perform lightweight dependency checks for readiness
const redisRes = await getRedisCache().ping();
const dbRes = await db.$executeRaw`SELECT 1`;
const dbRes = await db.project.findFirst();
const chRes = await chQuery('SELECT 1');
const isReady = redisRes;
const isReady = redisRes && dbRes && chRes;
if (!isReady) {
const res = {
redis: redisRes === 'PONG',
db: !!dbRes,
ch: chRes && chRes.length > 0,
};
request.log.warn('dependencies not ready', res);
return reply.status(503).send({
ready: false,
reason: 'dependencies not ready',
...res,
redis: redisRes === 'PONG',
db: !!dbRes,
ch: chRes && chRes.length > 0,
});
}

View File

@@ -96,6 +96,8 @@ export async function getPages(
startDate: startDate,
endDate: endDate,
timezone,
cursor: parsed.data.cursor,
limit: Math.min(parsed.data.limit, 50),
});
}
@@ -168,6 +170,8 @@ export function getOverviewGeneric(
startDate: startDate,
endDate: endDate,
timezone,
cursor: parsed.data.cursor,
limit: Math.min(parsed.data.limit, 50),
}),
);
};

View File

@@ -1,10 +1,23 @@
import type { FastifyRequest } from 'fastify';
import superjson from 'superjson';
import type { WebSocket } from '@fastify/websocket';
import { eventBuffer } from '@openpanel/db';
import {
eventBuffer,
getProfileById,
transformMinimalEvent,
} from '@openpanel/db';
import { setSuperJson } from '@openpanel/json';
import { subscribeToPublishedEvent } from '@openpanel/redis';
import {
psubscribeToPublishedEvent,
subscribeToPublishedEvent,
} from '@openpanel/redis';
import { getProjectAccess } from '@openpanel/trpc';
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
import type { FastifyRequest } from 'fastify';
export function getLiveEventInfo(key: string) {
return key.split(':').slice(2) as [string, string];
}
export function wsVisitors(
socket: WebSocket,
@@ -12,32 +25,32 @@ export function wsVisitors(
Params: {
projectId: string;
};
}>
}>,
) {
const { params } = req;
const sendCount = () => {
eventBuffer
.getActiveVisitorCount(params.projectId)
.then((count) => {
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => {
if (event?.projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
socket.send(String(count));
})
.catch(() => {
socket.send('0');
});
};
const unsubscribe = subscribeToPublishedEvent(
'events',
'batch',
({ projectId }) => {
if (projectId === params.projectId) {
sendCount();
}
}
});
const punsubscribe = psubscribeToPublishedEvent(
'__keyevent@0__:expired',
(key) => {
const [projectId] = getLiveEventInfo(key);
if (projectId && projectId === params.projectId) {
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
socket.send(String(count));
});
}
},
);
socket.on('close', () => {
unsubscribe();
punsubscribe();
});
}
@@ -49,10 +62,18 @@ export async function wsProjectEvents(
};
Querystring: {
token?: string;
type?: 'saved' | 'received';
};
}>
}>,
) {
const { params } = req;
const { params, query } = req;
const type = query.type || 'saved';
if (!['saved', 'received'].includes(type)) {
socket.send('Invalid type');
socket.close();
return;
}
const userId = req.session?.userId;
if (!userId) {
@@ -66,20 +87,24 @@ export async function wsProjectEvents(
projectId: params.projectId,
});
if (!access) {
socket.send('No access');
socket.close();
return;
}
const unsubscribe = subscribeToPublishedEvent(
'events',
'batch',
({ projectId, count }) => {
if (projectId === params.projectId) {
socket.send(setSuperJson({ count }));
type,
async (event) => {
if (event.projectId === params.projectId) {
const profile = await getProfileById(event.profileId, event.projectId);
socket.send(
superjson.stringify(
access
? {
...event,
profile,
}
: transformMinimalEvent(event),
),
);
}
}
},
);
socket.on('close', () => unsubscribe());
@@ -91,7 +116,7 @@ export async function wsProjectNotifications(
Params: {
projectId: string;
};
}>
}>,
) {
const { params } = req;
const userId = req.session?.userId;
@@ -118,9 +143,9 @@ export async function wsProjectNotifications(
'created',
(notification) => {
if (notification.projectId === params.projectId) {
socket.send(setSuperJson(notification));
socket.send(superjson.stringify(notification));
}
}
},
);
socket.on('close', () => unsubscribe());
@@ -132,7 +157,7 @@ export async function wsOrganizationEvents(
Params: {
organizationId: string;
};
}>
}>,
) {
const { params } = req;
const userId = req.session?.userId;
@@ -159,7 +184,7 @@ export async function wsOrganizationEvents(
'subscription_updated',
(message) => {
socket.send(setSuperJson(message));
}
},
);
socket.on('close', () => unsubscribe());

View File

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

View File

@@ -71,7 +71,7 @@ async function fetchImage(
url: URL,
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000); // 10s timeout
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await fetch(url.toString(), {
@@ -118,11 +118,7 @@ async function fetchImage(
// Check if URL is an ICO file
function isIcoFile(url: string, contentType?: string): boolean {
return (
url.toLowerCase().endsWith('.ico') ||
contentType === 'image/x-icon' ||
contentType === 'image/vnd.microsoft.icon'
);
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
}
function isSvgFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
@@ -175,10 +171,20 @@ async function processImage(
bufferSize: buffer.length,
});
throw error;
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
}
}
// Create a simple transparent fallback image when Sharp can't process the original
function createFallbackImage(): Buffer {
// 1x1 transparent PNG
return Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
}
// Process OG image with Sharp (resize to 300px width)
async function processOgImage(
buffer: Buffer,
@@ -210,7 +216,8 @@ async function processOgImage(
bufferSize: buffer.length,
});
throw error;
// If Sharp fails, try to create a simple fallback image
return createFallbackImage();
}
}
@@ -230,15 +237,9 @@ export async function getFavicon(
reply: FastifyReply,
) {
try {
logger.info('getFavicon', {
url: request.query.url,
});
const url = validateUrl(request.query.url);
if (!url) {
return reply
.status(404)
.header('Content-Type', 'text/plain')
.send('Not found');
return createFallbackImage();
}
const cacheKey = createCacheKey(url.toString());
@@ -252,76 +253,29 @@ export async function getFavicon(
}
let imageUrl: URL;
// If it's a direct image URL, use it directly
if (isDirectImage(url)) {
imageUrl = url;
} else {
logger.info('before parseUrlMeta', {
url: url.toString(),
});
// For website URLs, extract favicon from HTML
const meta = await parseUrlMeta(url.toString());
logger.info('parseUrlMeta result', {
url: url.toString(),
favicon: meta?.favicon,
});
if (meta?.favicon) {
imageUrl = new URL(meta.favicon);
} else {
// Try standard favicon location first
const { origin } = url;
imageUrl = new URL(`${origin}/favicon.ico`);
// Fallback to Google's favicon service
const { hostname } = url;
imageUrl = new URL(
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
);
}
}
logger.info('Fetching favicon', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
});
// Fetch the image
let { buffer, contentType, status } = await fetchImage(imageUrl);
const { buffer, contentType, status } = await fetchImage(imageUrl);
logger.info('Favicon fetch result', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
status,
bufferLength: buffer.length,
contentType,
});
// If the direct favicon fetch failed and it's not from DuckDuckGo's service,
// try DuckDuckGo's favicon service as a fallback
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
const { hostname } = url;
const duckduckgoUrl = new URL(
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
);
logger.info('Trying DuckDuckGo favicon service', {
originalUrl: url.toString(),
duckduckgoUrl: duckduckgoUrl.toString(),
});
const duckduckgoResult = await fetchImage(duckduckgoUrl);
buffer = duckduckgoResult.buffer;
contentType = duckduckgoResult.contentType;
status = duckduckgoResult.status;
imageUrl = duckduckgoUrl;
logger.info('DuckDuckGo favicon result', {
status,
bufferLength: buffer.length,
contentType,
});
}
// Accept any response as long as we have valid image data
if (buffer.length === 0) {
return reply
.status(404)
.header('Content-Type', 'text/plain')
.send('Not found');
if (status !== 200 || buffer.length === 0) {
return reply.send(createFallbackImage());
}
// Process the image (resize to 30x30 PNG, or serve ICO as-is)
@@ -331,31 +285,9 @@ export async function getFavicon(
contentType,
);
logger.info('Favicon processing result', {
originalUrl: url.toString(),
originalBufferLength: buffer.length,
processedBufferLength: processedBuffer.length,
});
// Determine the correct content type for caching and response
const isIco = isIcoFile(imageUrl.toString(), contentType);
const isSvg = isSvgFile(imageUrl.toString(), contentType);
let responseContentType = contentType;
if (isIco) {
responseContentType = 'image/x-icon';
} else if (isSvg) {
responseContentType = 'image/svg+xml';
} else if (
processedBuffer.length < 5000 &&
buffer.length === processedBuffer.length
) {
// Image was returned as-is, keep original content type
responseContentType = contentType;
} else {
// Image was processed by Sharp, it's now a PNG
responseContentType = 'image/png';
}
const responseContentType = isIco ? 'image/x-icon' : contentType;
// Cache the result with correct content type
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);

View File

@@ -1,17 +1,16 @@
import { LogError } from '@/utils/errors';
import {
Arctic,
type OAuth2Tokens,
createSession,
generateSessionToken,
github,
google,
type OAuth2Tokens,
setLastAuthProviderCookie,
setSessionTokenCookie,
} from '@openpanel/auth';
import { type Account, connectUserToOrganization, db } from '@openpanel/db';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { LogError } from '@/utils/errors';
async function getGithubEmail(githubAccessToken: string) {
const emailListRequest = new Request('https://api.github.com/user/emails');
@@ -75,14 +74,10 @@ async function handleExistingUser({
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt
);
setLastAuthProviderCookie(
(...args) => reply.setCookie(...args),
providerName
session.expiresAt,
);
return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
);
}
@@ -108,7 +103,7 @@ async function handleNewUser({
existingUser,
oauthUser,
providerName,
}
},
);
}
@@ -143,14 +138,10 @@ async function handleNewUser({
setSessionTokenCookie(
(...args) => reply.setCookie(...args),
sessionToken,
session.expiresAt
);
setLastAuthProviderCookie(
(...args) => reply.setCookie(...args),
providerName
session.expiresAt,
);
return reply.redirect(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
);
}
@@ -228,7 +219,7 @@ interface ValidatedOAuthQuery {
async function validateOAuthCallback(
req: FastifyRequest,
provider: Provider
provider: Provider,
): Promise<ValidatedOAuthQuery> {
const schema = z.object({
code: z.string(),
@@ -362,7 +353,7 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
const url = new URL(
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
);
url.pathname = '/login';
if (error instanceof LogError) {

View File

@@ -5,13 +5,13 @@ import { parseUserAgent } from '@openpanel/common/server';
import { getProfileById, upsertProfile } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo';
import type {
DeprecatedIncrementProfilePayload,
DeprecatedUpdateProfilePayload,
} from '@openpanel/validation';
IncrementProfilePayload,
UpdateProfilePayload,
} from '@openpanel/sdk';
export async function updateProfile(
request: FastifyRequest<{
Body: DeprecatedUpdateProfilePayload;
Body: UpdateProfilePayload;
}>,
reply: FastifyReply,
) {
@@ -52,7 +52,7 @@ export async function updateProfile(
export async function incrementProfileProperty(
request: FastifyRequest<{
Body: DeprecatedIncrementProfilePayload;
Body: IncrementProfilePayload;
}>,
reply: FastifyReply,
) {
@@ -94,7 +94,7 @@ export async function incrementProfileProperty(
export async function decrementProfileProperty(
request: FastifyRequest<{
Body: DeprecatedIncrementProfilePayload;
Body: IncrementProfilePayload;
}>,
reply: FastifyReply,
) {

View File

@@ -1,33 +1,19 @@
import { generateId, slug } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import {
getProfileById,
getSalts,
groupBuffer,
replayBuffer,
upsertProfile,
} from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import {
type EventsQueuePayloadIncomingEvent,
getEventsGroupQueueShard,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import {
type IAssignGroupPayload,
type IDecrementPayload,
type IGroupPayload,
type IIdentifyPayload,
type IIncrementPayload,
type IReplayPayload,
type ITrackHandlerPayload,
type ITrackPayload,
zTrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr, pick } from 'ramda';
import { HttpError } from '@/utils/errors';
import { getDeviceId } from '@/utils/ids';
import { generateId } from '@openpanel/common';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getEventsGroupQueueShard } from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import type {
DecrementPayload,
IdentifyPayload,
IncrementPayload,
TrackHandlerPayload,
TrackPayload,
} from '@openpanel/sdk';
export function getStringHeaders(headers: FastifyRequest['headers']) {
return Object.entries(
@@ -39,40 +25,36 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
'openpanel-client-id',
'request-id',
],
headers
)
headers,
),
).reduce(
(acc, [key, value]) => ({
...acc,
[key]: value ? String(value) : undefined,
}),
{}
{},
);
}
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
if (body.type === 'track') {
const identity = body.payload.properties?.__identify as
| IIdentifyPayload
| undefined;
if (identity) {
return identity;
}
return body.payload.profileId
? {
profileId: String(body.payload.profileId),
}
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
const identity =
'properties' in body.payload
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
: undefined;
}
return undefined;
return (
identity ||
(body?.payload?.profileId
? {
profileId: body.payload.profileId,
}
: undefined)
);
}
export function getTimestamp(
timestamp: FastifyRequest['timestamp'],
payload: ITrackHandlerPayload['payload']
payload: TrackHandlerPayload['payload'],
) {
const safeTimestamp = timestamp || Date.now();
const userDefinedTimestamp =
@@ -99,7 +81,7 @@ export function getTimestamp(
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
}
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
// isTimestampFromThePast is true only if timestamp is older than 1 hour
const isTimestampFromThePast =
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
@@ -109,141 +91,203 @@ export function getTimestamp(
};
}
interface TrackContext {
projectId: string;
ip: string;
ua?: string;
headers: Record<string, string | undefined>;
timestamp: { value: number; isFromPast: boolean };
identity?: IIdentifyPayload;
deviceId: string;
sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
geo: GeoLocation;
}
async function buildContext(
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
Body: TrackHandlerPayload;
}>,
validatedBody: ITrackHandlerPayload
): Promise<TrackContext> {
const projectId = request.client?.projectId;
if (!projectId) {
throw new HttpError('Missing projectId', { status: 400 });
}
const timestamp = getTimestamp(request.timestamp, validatedBody.payload);
reply: FastifyReply,
) {
const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip =
validatedBody.type === 'track' && validatedBody.payload.properties?.__ip
? (validatedBody.payload.properties.__ip as string)
'properties' in request.body.payload &&
request.body.payload.properties?.__ip
? (request.body.payload.properties.__ip as string)
: request.clientIp;
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
const ua = request.headers['user-agent'];
const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers);
const identity = getIdentity(validatedBody);
const profileId = identity?.profileId;
if (profileId && validatedBody.type === 'track') {
validatedBody.payload.profileId = profileId;
if (!projectId) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Missing projectId',
});
}
const overrideDeviceId =
validatedBody.type === 'track' &&
typeof validatedBody.payload?.properties?.__deviceId === 'string'
? validatedBody.payload?.properties.__deviceId
: undefined;
const identity = getIdentity(request.body);
const profileId = identity?.profileId;
const overrideDeviceId = (() => {
const deviceId =
'properties' in request.body.payload
? request.body.payload.properties?.__deviceId
: undefined;
if (typeof deviceId === 'string') {
return deviceId;
}
return undefined;
})();
// Get geo location (needed for track and identify)
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
// We might get a profileId from the alias table
// If we do, we should use that instead of the one from the payload
if (profileId) {
request.body.payload.profileId = profileId;
}
const deviceIdResult = await getDeviceId({
projectId,
ip,
ua,
salts,
overrideDeviceId,
});
switch (request.body.type) {
case 'track': {
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
const currentDeviceId =
overrideDeviceId ||
(ua
? generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
})
: '');
const previousDeviceId = ua
? generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
})
: '';
return {
projectId,
ip,
ua,
headers,
timestamp: {
value: timestamp.timestamp,
isFromPast: timestamp.isTimestampFromThePast,
},
identity,
deviceId: deviceIdResult.deviceId,
sessionId: deviceIdResult.sessionId,
session: deviceIdResult.session,
geo,
};
const promises = [];
// If we have more than one property in the identity object, we should identify the user
// Otherwise its only a profileId and we should not identify the user
if (identity && Object.keys(identity).length > 1) {
promises.push(
identify({
payload: identity,
projectId,
geo,
ua,
}),
);
}
promises.push(
track({
payload: request.body.payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers: getStringHeaders(request.headers),
timestamp: timestamp.timestamp,
isTimestampFromThePast: timestamp.isTimestampFromThePast,
}),
);
await Promise.all(promises);
break;
}
case 'identify': {
const geo = await getGeoLocation(ip);
await identify({
payload: request.body.payload,
projectId,
geo,
ua,
});
break;
}
case 'alias': {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
}
case 'increment': {
await increment({
payload: request.body.payload,
projectId,
});
break;
}
case 'decrement': {
await decrement({
payload: request.body.payload,
projectId,
});
break;
}
default: {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
}
reply.status(200).send();
}
async function handleTrack(
payload: ITrackPayload,
context: TrackContext
): Promise<void> {
const { projectId, deviceId, geo, headers, timestamp, sessionId, session } =
context;
async function track({
payload,
currentDeviceId,
previousDeviceId,
projectId,
geo,
headers,
timestamp,
isTimestampFromThePast,
}: {
payload: TrackPayload;
currentDeviceId: string;
previousDeviceId: string;
projectId: string;
geo: GeoLocation;
headers: Record<string, string | undefined>;
timestamp: number;
isTimestampFromThePast: boolean;
}) {
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
const groupId = uaInfo.isServer
? payload.profileId
? `${projectId}:${payload.profileId}`
: undefined
: deviceId;
const jobId = [
slug(payload.name),
timestamp.value,
projectId,
deviceId,
groupId,
]
: `${projectId}:${generateId()}`
: currentDeviceId;
const jobId = [payload.name, timestamp, projectId, currentDeviceId, groupId]
.filter(Boolean)
.join('-');
const promises: Promise<unknown>[] = [];
// If we have more than one property in the identity object, we should identify the user
// Otherwise its only a profileId and we should not identify the user
if (context.identity && Object.keys(context.identity).length > 1) {
promises.push(handleIdentify(context.identity, context));
}
promises.push(
getEventsGroupQueueShard(groupId || generateId()).add({
orderMs: timestamp.value,
data: {
projectId,
headers,
event: {
...payload,
groups: payload.groups ?? [],
timestamp: timestamp.value,
isTimestampFromThePast: timestamp.isFromPast,
},
uaInfo,
geo,
deviceId,
sessionId,
session,
await getEventsGroupQueueShard(groupId).add({
orderMs: timestamp,
data: {
projectId,
headers,
event: {
...payload,
timestamp,
isTimestampFromThePast,
},
groupId,
jobId,
})
);
await Promise.all(promises);
uaInfo,
geo,
currentDeviceId,
previousDeviceId,
},
groupId,
jobId,
});
}
async function handleIdentify(
payload: IIdentifyPayload,
context: TrackContext
): Promise<void> {
const { projectId, geo, ua } = context;
async function identify({
payload,
projectId,
geo,
ua,
}: {
payload: IdentifyPayload;
projectId: string;
geo: GeoLocation;
ua?: string;
}) {
const uaInfo = parseUserAgent(ua, payload.properties);
await upsertProfile({
...payload,
@@ -268,30 +312,32 @@ async function handleIdentify(
});
}
async function adjustProfileProperty(
payload: IIncrementPayload | IDecrementPayload,
projectId: string,
direction: 1 | -1
): Promise<void> {
async function increment({
payload,
projectId,
}: {
payload: IncrementPayload;
projectId: string;
}) {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
if (!profile) {
throw new HttpError('Profile not found', { status: 404 });
throw new Error('Not found');
}
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10
10,
);
if (Number.isNaN(parsed)) {
throw new HttpError('Property value is not a number', { status: 400 });
throw new Error('Not number');
}
profile.properties = assocPath(
property.split('.'),
parsed + direction * (value || 1),
profile.properties
parsed + (value || 1),
profile.properties,
);
await upsertProfile({
@@ -302,142 +348,45 @@ async function adjustProfileProperty(
});
}
async function handleIncrement(
payload: IIncrementPayload,
context: TrackContext
): Promise<void> {
await adjustProfileProperty(payload, context.projectId, 1);
}
async function handleDecrement(
payload: IDecrementPayload,
context: TrackContext
): Promise<void> {
await adjustProfileProperty(payload, context.projectId, -1);
}
async function handleReplay(
payload: IReplayPayload,
context: TrackContext
): Promise<void> {
if (!context.sessionId) {
throw new HttpError('Session ID is required for replay', { status: 400 });
async function decrement({
payload,
projectId,
}: {
payload: DecrementPayload;
projectId: string;
}) {
const { profileId, property, value } = payload;
const profile = await getProfileById(profileId, projectId);
if (!profile) {
throw new Error('Not found');
}
const row = {
project_id: context.projectId,
session_id: context.sessionId,
chunk_index: payload.chunk_index,
started_at: payload.started_at,
ended_at: payload.ended_at,
events_count: payload.events_count,
is_full_snapshot: payload.is_full_snapshot,
payload: payload.payload,
};
await replayBuffer.add(row);
}
const parsed = Number.parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10,
);
async function handleGroup(
payload: IGroupPayload,
context: TrackContext
): Promise<void> {
const { id, type, name, properties = {} } = payload;
await groupBuffer.add({
id,
projectId: context.projectId,
type,
name,
properties,
});
}
async function handleAssignGroup(
payload: IAssignGroupPayload,
context: TrackContext
): Promise<void> {
const profileId = payload.profileId ?? context.deviceId;
if (!profileId) {
return;
if (Number.isNaN(parsed)) {
throw new Error('Not number');
}
profile.properties = assocPath(
property.split('.'),
parsed - (value || 1),
profile.properties,
);
await upsertProfile({
id: String(profileId),
projectId: context.projectId,
isExternal: !!payload.profileId,
groups: payload.groupIds,
});
}
export async function handler(
request: FastifyRequest<{
Body: ITrackHandlerPayload;
}>,
reply: FastifyReply
) {
// Validate request body with Zod
const validationResult = zTrackHandlerPayload.safeParse(request.body);
if (!validationResult.success) {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Validation failed',
errors: validationResult.error.errors,
});
}
const validatedBody = validationResult.data;
// Handle alias (not supported)
if (validatedBody.type === 'alias') {
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Alias is not supported',
});
}
// Build request context
const context = await buildContext(request, validatedBody);
// Dispatch to appropriate handler
switch (validatedBody.type) {
case 'track':
await handleTrack(validatedBody.payload, context);
break;
case 'identify':
await handleIdentify(validatedBody.payload, context);
break;
case 'increment':
await handleIncrement(validatedBody.payload, context);
break;
case 'decrement':
await handleDecrement(validatedBody.payload, context);
break;
case 'replay':
await handleReplay(validatedBody.payload, context);
break;
case 'group':
await handleGroup(validatedBody.payload, context);
break;
case 'assign_group':
await handleAssignGroup(validatedBody.payload, context);
break;
default:
return reply.status(400).send({
status: 400,
error: 'Bad Request',
message: 'Invalid type',
});
}
reply.status(200).send({
deviceId: context.deviceId,
sessionId: context.sessionId,
id: profile.id,
projectId,
properties: profile.properties,
isExternal: true,
});
}
export async function fetchDeviceId(
request: FastifyRequest,
reply: FastifyReply
reply: FastifyReply,
) {
const salts = await getSalts();
const projectId = request.client?.projectId;
@@ -470,31 +419,20 @@ export async function fetchDeviceId(
try {
const multi = getRedisCache().multi();
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
'data'
);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
'data'
);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
const res = await multi.exec();
if (res?.[0]?.[1]) {
const data = JSON.parse(res?.[0]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({
deviceId: currentDeviceId,
sessionId,
message: 'current session exists for this device id',
});
}
if (res?.[1]?.[1]) {
const data = JSON.parse(res?.[1]?.[1] as string);
const sessionId = data.payload.sessionId;
return reply.status(200).send({
deviceId: previousDeviceId,
sessionId,
message: 'previous session exists for this device id',
});
}
@@ -504,7 +442,6 @@ export async function fetchDeviceId(
return reply.status(200).send({
deviceId: currentDeviceId,
sessionId: '',
message: 'No session exists for this device id',
});
}

View File

@@ -169,11 +169,6 @@ export async function polarWebhook(
.parse(event.data.metadata);
const product = await getProduct(event.data.productId);
const organization = await db.organization.findUniqueOrThrow({
where: {
id: metadata.organizationId,
},
});
const eventsLimit = product.metadata?.eventsLimit;
const subscriptionPeriodEventsLimit =
typeof eventsLimit === 'number' ? eventsLimit : undefined;
@@ -191,9 +186,7 @@ export async function polarWebhook(
where: {
subscriptionCustomerId: event.data.customer.id,
subscriptionId: event.data.id,
subscriptionStatus: {
in: ['active', 'past_due', 'unpaid'],
},
subscriptionStatus: 'active',
},
});
@@ -223,13 +216,6 @@ export async function polarWebhook(
subscriptionCreatedByUserId: metadata.userId,
subscriptionInterval: event.data.recurringInterval,
subscriptionPeriodEventsLimit,
subscriptionPeriodEventsCountExceededAt:
subscriptionPeriodEventsLimit &&
organization.subscriptionPeriodEventsCountExceededAt &&
organization.subscriptionPeriodEventsLimit <
subscriptionPeriodEventsLimit
? null
: undefined,
},
});

View File

@@ -1,13 +1,10 @@
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
export async function clientHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: PostEventPayload | TrackHandlerPayload;
}>,
reply: FastifyReply,
) {

View File

@@ -1,28 +1,23 @@
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
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: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: PostEventPayload | TrackHandlerPayload;
}>,
reply: FastifyReply
reply: FastifyReply,
) {
const ip = req.clientIp;
const origin = req.headers.origin;
const clientId = req.headers['openpanel-client-id'];
const body = req?.body;
const isTrackPayload = getIsTrackPayload(req);
const isReplay = isTrackPayload && req.body.type === 'replay';
const shouldCheck = ip && origin && clientId && !isReplay;
const shouldCheck = ip && origin && clientId;
const isDuplicate = shouldCheck
? await isDuplicatedEvent({
ip,
origin,
payload: body,
payload: req.body,
projectId: clientId as string,
})
: false;
@@ -31,25 +26,3 @@ export async function duplicateHook(
return reply.status(200).send('Duplicate event');
}
}
function getIsTrackPayload(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
}>
): req is FastifyRequest<{
Body: ITrackHandlerPayload;
}> {
if (req.method !== 'POST') {
return false;
}
if (!req.body) {
return false;
}
if (typeof req.body !== 'object' || Array.isArray(req.body)) {
return false;
}
return 'type' in req.body;
}

View File

@@ -1,19 +1,22 @@
import { createBotEvent } from '@openpanel/db';
import type {
DeprecatedPostEventPayload,
ITrackHandlerPayload,
} from '@openpanel/validation';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { isBot } from '@/bots';
import { createBotEvent } from '@openpanel/db';
import type { TrackHandlerPayload } from '@openpanel/sdk';
import type { FastifyReply, FastifyRequest } from 'fastify';
type DeprecatedEventPayload = {
name: string;
properties: Record<string, unknown>;
timestamp: string;
};
export async function isBotHook(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: TrackHandlerPayload | DeprecatedEventPayload;
}>,
reply: FastifyReply
reply: FastifyReply,
) {
const bot = req.headers['user-agent']
? await isBot(req.headers['user-agent'])
? isBot(req.headers['user-agent'])
: null;
if (bot && req.client?.projectId) {
@@ -43,6 +46,6 @@ export async function isBotHook(
}
}
return reply.status(202).send({ bot });
return reply.status(202).send('OK');
}
}

View File

@@ -1,3 +1,4 @@
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, pick } from 'ramda';
@@ -5,7 +6,7 @@ const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
const ignoreMethods = ['OPTIONS'];
const getTrpcInput = (
request: FastifyRequest
request: FastifyRequest,
): Record<string, unknown> | undefined => {
const input = path<any>(['query', 'input'], request);
try {
@@ -17,7 +18,7 @@ const getTrpcInput = (
export async function requestLoggingHook(
request: FastifyRequest,
reply: FastifyReply
reply: FastifyReply,
) {
if (ignoreMethods.includes(request.method)) {
return;
@@ -37,10 +38,19 @@ export async function requestLoggingHook(
url: request.url,
method: request.method,
elapsed: reply.elapsedTime,
clientIp: request.clientIp,
clientIpHeader: request.clientIpHeader,
headers: pick(
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
request.headers
[
'openpanel-client-id',
'openpanel-sdk-name',
'openpanel-sdk-version',
'user-agent',
...DEFAULT_IP_HEADER_ORDER,
],
request.headers,
),
body: request.body,
});
}
}

View File

@@ -1,28 +1,27 @@
/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */
process.env.TZ = 'UTC';
import compress from '@fastify/compress';
import cookie from '@fastify/cookie';
import cors, { type FastifyCorsOptions } from '@fastify/cors';
import {
decodeSessionToken,
EMPTY_SESSION,
type SessionValidationResult,
validateSessionToken,
} from '@openpanel/auth';
import { generateId } from '@openpanel/common';
import {
type IServiceClientWithProject,
runWithAlsSession,
} from '@openpanel/db';
import { getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc';
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
import Fastify from 'fastify';
import metricsPlugin from 'fastify-metrics';
import { generateId } from '@openpanel/common';
import {
type IServiceClientWithProject,
runWithAlsSession,
} from '@openpanel/db';
import { getCache, getRedisPub } from '@openpanel/redis';
import type { AppRouter } from '@openpanel/trpc';
import { appRouter, createContext } from '@openpanel/trpc';
import {
EMPTY_SESSION,
type SessionValidationResult,
decodeSessionToken,
validateSessionToken,
} from '@openpanel/auth';
import sourceMapSupport from 'source-map-support';
import {
healthcheck,
@@ -36,11 +35,9 @@ import { timestampHook } from './hooks/timestamp.hook';
import aiRouter from './routes/ai.router';
import eventRouter from './routes/event.router';
import exportRouter from './routes/export.router';
import gscCallbackRouter from './routes/gsc-callback.router';
import importRouter from './routes/import.router';
import insightsRouter from './routes/insights.router';
import liveRouter from './routes/live.router';
import manageRouter from './routes/manage.router';
import miscRouter from './routes/misc.router';
import oauthRouter from './routes/oauth-callback.router';
import profileRouter from './routes/profile.router';
@@ -52,6 +49,8 @@ import { logger } from './utils/logger';
sourceMapSupport.install();
process.env.TZ = 'UTC';
declare module 'fastify' {
interface FastifyRequest {
client: IServiceClientWithProject | null;
@@ -63,16 +62,13 @@ declare module 'fastify' {
}
const port = Number.parseInt(process.env.API_PORT || '3000', 10);
const host =
process.env.API_HOST ||
(process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost');
const startServer = async () => {
logger.info('Starting server');
try {
const fastify = Fastify({
maxParamLength: 15_000,
bodyLimit: 1_048_576 * 500, // 500MB
bodyLimit: 1048576 * 500, // 500MB
loggerInstance: logger as unknown as FastifyBaseLogger,
disableRequestLogging: true,
genReqId: (req) =>
@@ -84,7 +80,7 @@ const startServer = async () => {
fastify.register(cors, () => {
return (
req: FastifyRequest,
callback: (error: Error | null, options: FastifyCorsOptions) => void
callback: (error: Error | null, options: FastifyCorsOptions) => void,
) => {
// TODO: set prefix on dashboard routes
const corsPaths = [
@@ -97,7 +93,7 @@ const startServer = async () => {
];
const isPrivatePath = corsPaths.some((path) =>
req.url.startsWith(path)
req.url.startsWith(path),
);
if (isPrivatePath) {
@@ -118,7 +114,6 @@ const startServer = async () => {
return callback(null, {
origin: '*',
maxAge: 86_400 * 7, // cache preflight for 7 days
});
};
});
@@ -148,21 +143,12 @@ const startServer = async () => {
instance.addHook('onRequest', async (req) => {
if (req.cookies?.session) {
try {
const sessionId = decodeSessionToken(req.cookies?.session);
const sessionId = decodeSessionToken(req.cookies.session);
const session = await runWithAlsSession(sessionId, () =>
validateSessionToken(req.cookies.session)
validateSessionToken(req.cookies.session),
);
req.session = session;
} catch {
req.session = EMPTY_SESSION;
}
} else if (process.env.DEMO_USER_ID) {
try {
const session = await runWithAlsSession('1', () =>
validateSessionToken(null)
);
req.session = session;
} catch {
} catch (e) {
req.session = EMPTY_SESSION;
}
} else {
@@ -174,7 +160,7 @@ const startServer = async () => {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext,
createContext: createContext,
onError(ctx) {
if (
ctx.error.code === 'UNAUTHORIZED' &&
@@ -195,7 +181,6 @@ const startServer = async () => {
instance.register(liveRouter, { prefix: '/live' });
instance.register(webhookRouter, { prefix: '/webhook' });
instance.register(oauthRouter, { prefix: '/oauth' });
instance.register(gscCallbackRouter, { prefix: '/gsc' });
instance.register(miscRouter, { prefix: '/misc' });
instance.register(aiRouter, { prefix: '/ai' });
});
@@ -209,7 +194,6 @@ const startServer = async () => {
instance.register(importRouter, { prefix: '/import' });
instance.register(insightsRouter, { prefix: '/insights' });
instance.register(trackRouter, { prefix: '/track' });
instance.register(manageRouter, { prefix: '/manage' });
// Keep existing endpoints for backward compatibility
instance.get('/healthcheck', healthcheck);
// New Kubernetes-style health endpoints
@@ -219,50 +203,39 @@ const startServer = async () => {
reply.send({
status: 'ok',
message: 'Successfully running OpenPanel.dev API',
})
}),
);
});
const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE'];
fastify.setErrorHandler((error, request, reply) => {
if (error.statusCode === 429) {
return reply.status(429).send({
if (error instanceof HttpError) {
request.log.error(`${error.message}`, error);
if (process.env.NODE_ENV === 'production' && error.status === 500) {
request.log.error('request error', { error });
reply.status(500).send('Internal server error');
} else {
reply.status(error.status).send({
status: error.status,
error: error.error,
message: error.message,
});
}
} else if (error.statusCode === 429) {
reply.status(429).send({
status: 429,
error: 'Too Many Requests',
message: 'You have exceeded the rate limit for this endpoint.',
});
}
if (error instanceof HttpError) {
if (!SKIP_LOG_ERRORS.includes(error.code)) {
request.log.error('internal server error', { error });
}
if (process.env.NODE_ENV === 'production' && error.status === 500) {
return reply.status(500).send('Internal server error');
}
return reply.status(error.status).send({
status: error.status,
error: error.error,
message: error.message,
} else if (error.statusCode === 400) {
reply.status(400).send({
status: 400,
error,
message: 'The request was invalid.',
});
}
if (!SKIP_LOG_ERRORS.includes(error.code)) {
} else {
request.log.error('request error', { error });
reply.status(500).send('Internal server error');
}
const status = error?.statusCode ?? 500;
if (process.env.NODE_ENV === 'production' && status === 500) {
return reply.status(500).send('Internal server error');
}
return reply.status(status).send({
status,
error,
message: error.message,
});
});
if (process.env.NODE_ENV === 'production') {
@@ -279,7 +252,10 @@ const startServer = async () => {
});
}
await fastify.listen({ host, port });
await fastify.listen({
host: process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost',
port,
});
try {
// Notify when keys expires
@@ -287,7 +263,7 @@ const startServer = async () => {
} catch (error) {
logger.warn('Failed to set redis notify-keyspace-events', error);
logger.warn(
'If you use a managed Redis service, you may need to set this manually.'
'If you use a managed Redis service, you may need to set this manually.',
);
logger.warn('Otherwise some functions may not work as expected.');
}

View File

@@ -1,12 +0,0 @@
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
import type { FastifyPluginCallback } from 'fastify';
const router: FastifyPluginCallback = async (fastify) => {
fastify.route({
method: 'GET',
url: '/callback',
handler: gscGoogleCallback,
});
};
export default router;

View File

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

View File

@@ -1,5 +1,6 @@
import type { FastifyPluginCallback } from 'fastify';
import { fetchDeviceId, handler } from '@/controllers/track.controller';
import type { FastifyPluginCallback } from 'fastify';
import { clientHook } from '@/hooks/client.hook';
import { duplicateHook } from '@/hooks/duplicate.hook';
import { isBotHook } from '@/hooks/is-bot.hook';
@@ -12,7 +13,23 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
fastify.route({
method: 'POST',
url: '/',
handler,
handler: handler,
schema: {
body: {
type: 'object',
required: ['type', 'payload'],
properties: {
type: {
type: 'string',
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
},
payload: {
type: 'object',
additionalProperties: true,
},
},
},
},
});
fastify.route({
@@ -25,7 +42,6 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
type: 'object',
properties: {
deviceId: { type: 'string' },
sessionId: { type: 'string' },
message: { type: 'string', optional: true },
},
},

View File

@@ -9,7 +9,7 @@ import {
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import { zReportInput } from '@openpanel/validation';
import { zChartInputAI } from '@openpanel/validation';
import { tool } from 'ai';
import { z } from 'zod';
@@ -27,10 +27,7 @@ export function getReport({
- ${chartTypes.metric}
- ${chartTypes.bar}
`,
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',
@@ -75,10 +72,7 @@ export function getConversionReport({
return tool({
description:
'Generate a report (a chart) for conversions between two actions a unique user took.',
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',
@@ -100,10 +94,7 @@ export function getFunnelReport({
return tool({
description:
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
parameters: zReportInput.extend({
startDate: z.string().describe('The start date for the report'),
endDate: z.string().describe('The end date for the report'),
}),
parameters: zChartInputAI,
execute: async (report) => {
return {
type: 'report',

View File

@@ -4,11 +4,10 @@ import { verifyPassword } from '@openpanel/common/server';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientType, getClientByIdCached } from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
import type {
DeprecatedPostEventPayload,
IProjectFilterIp,
IProjectFilterProfileId,
ITrackHandlerPayload,
} from '@openpanel/validation';
import { path } from 'ramda';
@@ -42,7 +41,7 @@ export class SdkAuthError extends Error {
export async function validateSdkRequest(
req: FastifyRequest<{
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
Body: PostEventPayload | TrackHandlerPayload;
}>,
): Promise<IServiceClientWithProject> {
const { headers, clientIp } = req;
@@ -73,7 +72,7 @@ export async function validateSdkRequest(
clientId,
)
) {
throw createError('Ingestion: Client ID must be a valid UUIDv4');
throw createError('Ingestion: Clean ID must be a valid UUIDv4');
}
const client = await getClientByIdCached(clientId);
@@ -236,40 +235,3 @@ export async function validateImportRequest(
return client;
}
export async function validateManageRequest(
headers: RawRequestDefaultExpression['headers'],
): Promise<IServiceClientWithProject> {
const clientId = headers['openpanel-client-id'] as string;
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
if (
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
clientId,
)
) {
throw new Error('Manage: Client ID must be a valid UUIDv4');
}
const client = await getClientByIdCached(clientId);
if (!client) {
throw new Error('Manage: Invalid client id');
}
if (!client.secret) {
throw new Error('Manage: Client has no secret');
}
if (client.type !== ClientType.root) {
throw new Error(
'Manage: Only root clients are allowed to manage resources',
);
}
if (!(await verifyPassword(clientSecret, client.secret))) {
throw new Error('Manage: Invalid client secret');
}
return client;
}

View File

@@ -1,181 +0,0 @@
import crypto from 'node:crypto';
import { generateDeviceId } from '@openpanel/common/server';
import { getSafeJson } from '@openpanel/json';
import type {
EventsQueuePayloadCreateSessionEnd,
EventsQueuePayloadIncomingEvent,
} from '@openpanel/queue';
import { getRedisCache } from '@openpanel/redis';
import { pick } from 'ramda';
export async function getDeviceId({
projectId,
ip,
ua,
salts,
overrideDeviceId,
}: {
projectId: string;
ip: string;
ua: string | undefined;
salts: { current: string; previous: string };
overrideDeviceId?: string;
}) {
if (overrideDeviceId) {
return { deviceId: overrideDeviceId, sessionId: '' };
}
if (!ua) {
return { deviceId: '', sessionId: '' };
}
const currentDeviceId = generateDeviceId({
salt: salts.current,
origin: projectId,
ip,
ua,
});
const previousDeviceId = generateDeviceId({
salt: salts.previous,
origin: projectId,
ip,
ua,
});
return await getInfoFromSession({
projectId,
currentDeviceId,
previousDeviceId,
});
}
interface DeviceIdResult {
deviceId: string;
sessionId: string;
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
}
async function getInfoFromSession({
projectId,
currentDeviceId,
previousDeviceId,
}: {
projectId: string;
currentDeviceId: string;
previousDeviceId: string;
}): Promise<DeviceIdResult> {
try {
const multi = getRedisCache().multi();
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
'data'
);
multi.hget(
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
'data'
);
const res = await multi.exec();
if (res?.[0]?.[1]) {
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[0]?.[1] as string) ?? ''
);
if (data) {
return {
deviceId: currentDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
}
}
if (res?.[1]?.[1]) {
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
(res?.[1]?.[1] as string) ?? ''
);
if (data) {
return {
deviceId: previousDeviceId,
sessionId: data.payload.sessionId,
session: pick(
['referrer', 'referrerName', 'referrerType'],
data.payload
),
};
}
}
} catch (error) {
console.error('Error getting session end GET /track/device-id', error);
}
return {
deviceId: currentDeviceId,
sessionId: getSessionId({
projectId,
deviceId: currentDeviceId,
graceMs: 5 * 1000,
windowMs: 1000 * 60 * 30,
}),
};
}
/**
* Deterministic session id for (projectId, deviceId) within a time window,
* with a grace period at the *start* of each window to avoid boundary splits.
*
* - windowMs: 30 minutes by default
* - graceMs: 1 minute by default (events in first minute of a bucket map to previous bucket)
* - Output: base64url, 128-bit (16 bytes) truncated from SHA-256
*/
function getSessionId(params: {
projectId: string;
deviceId: string;
eventMs?: number; // use event timestamp; defaults to Date.now()
windowMs?: number; // default 5 min
graceMs?: number; // default 1 min
bytes?: number; // default 16 (128-bit). You can set 24 or 32 for longer ids.
}): string {
const {
projectId,
deviceId,
eventMs = Date.now(),
windowMs = 5 * 60 * 1000,
graceMs = 60 * 1000,
bytes = 16,
} = params;
if (!projectId) {
throw new Error('projectId is required');
}
if (!deviceId) {
throw new Error('deviceId is required');
}
if (windowMs <= 0) {
throw new Error('windowMs must be > 0');
}
if (graceMs < 0 || graceMs >= windowMs) {
throw new Error('graceMs must be >= 0 and < windowMs');
}
if (bytes < 8 || bytes > 32) {
throw new Error('bytes must be between 8 and 32');
}
const bucket = Math.floor(eventMs / windowMs);
const offset = eventMs - bucket * windowMs;
// Grace at the start of the bucket: stick to the previous bucket.
const chosenBucket = offset < graceMs ? bucket - 1 : bucket;
const input = `sess:v1:${projectId}:${deviceId}:${chosenBucket}`;
const digest = crypto.createHash('sha256').update(input).digest();
const truncated = digest.subarray(0, bytes);
// base64url
return truncated
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}

View File

@@ -1,13 +1,7 @@
import urlMetadata from 'url-metadata';
function fallbackFavicon(url: string) {
try {
const hostname = new URL(url).hostname;
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
} catch {
// If URL parsing fails, use the original string
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
}
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
}
function findBestFavicon(favicons: UrlMetaData['favicons']) {
@@ -72,9 +66,7 @@ interface UrlMetaData {
export async function parseUrlMeta(url: string) {
try {
const metadata = (await urlMetadata(url, {
timeout: 500,
})) as UrlMetaData;
const metadata = (await urlMetadata(url)) as UrlMetaData;
const data = transform(metadata, url);
return data;
} catch (err) {

View File

@@ -5,8 +5,7 @@
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"strictNullChecks": true
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules", "dist"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,505 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
<meta name="author" content="OpenPanel">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta property="og:image" content="/ogimage.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
<meta name="twitter:image" content="/ogimage.png">
<!-- Additional Meta Tags -->
<meta name="theme-color" content="#0a0a0a">
<meta name="color-scheme" content="dark">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
color: #e5e5e5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 18px;
line-height: 1.75;
padding: 2rem 1.5rem;
}
.container {
max-width: 700px;
margin: 0 auto;
}
h1 {
font-size: 2rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.5rem;
color: #ffffff;
}
h2 {
font-size: 1.875rem;
font-weight: 800;
line-height: 1.3;
margin-top: 3rem;
margin-bottom: 1.5rem;
color: #ffffff;
}
h3 {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.4;
margin-top: 2rem;
margin-bottom: 1rem;
color: #ffffff;
}
p {
margin-bottom: 1.25em;
}
strong {
font-weight: 700;
color: #ffffff;
}
a {
color: #3b82f6;
text-decoration: underline;
text-underline-offset: 2px;
}
a:hover {
color: #60a5fa;
}
ul, ol {
margin-left: 1.5rem;
margin-bottom: 1.25em;
}
li {
margin-bottom: 0.5em;
}
blockquote {
border-left: 4px solid #3b82f6;
padding-left: 1.5rem;
margin: 1.5rem 0;
font-style: italic;
color: #d1d5db;
}
table {
width: 100%;
border-collapse: collapse;
margin: 2rem 0;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #374151;
}
th {
font-weight: 700;
color: #ffffff;
background: #131313;
}
tr:hover {
background: #131313;
}
.screenshot {
margin: 0 -4rem 4rem;
position: relative;
z-index: 10;
}
@media (max-width: 840px) {
.screenshot {
margin: 0;
}
}
.screenshot-inner {
border-radius: 8px;
overflow: hidden;
padding: 0.5rem;
background: #1a1a1a;
border: 1px solid #2a2a2a;
}
.window-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0 0.25rem;
}
.window-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.window-dot.red {
background: #ef4444;
}
.window-dot.yellow {
background: #eab308;
}
.window-dot.green {
background: #22c55e;
}
.screenshot-image-wrapper {
width: 100%;
border: 1px solid #2a2a2a;
border-radius: 6px;
overflow: hidden;
background: #0a0a0a;
}
.screenshot img {
width: 100%;
height: auto;
display: block;
}
.cta {
background: #131313;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 2rem;
margin: 3rem 0;
text-align: center;
margin: 0 -4rem 4rem;
}
@media (max-width: 840px) {
.cta {
margin: 0;
}
}
.cta h2 {
margin-top: 0;
}
.cta a {
display: inline-block;
background: #fff;
color: #000;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
margin: 0.5rem;
transition: background 0.2s;
}
.cta a:hover {
background: #fff;
color: #000;
}
footer {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid #374151;
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
}
.hero {
text-align: left;
margin-top: 4rem;
line-height: 1.5;
}
.hero p {
font-size: 1.25rem;
color: #8f8f8f;
margin-top: 1rem;
}
figcaption {
margin-top: 1rem;
font-size: 0.875rem;
text-align: center;
color: #9ca3af;
max-width: 100%;
}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<h1>Just Fucking Use OpenPanel</h1>
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
</div>
</div>
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
</figure>
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
<ul>
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
</ul>
<p>"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.</p>
<p>Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.</p>
<h2>The Web-Only Analytics Trap</h2>
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
<blockquote>
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
</blockquote>
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
<p>Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.</p>
<p>Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.</p>
<p>And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a <strong>SECOND tool</strong> on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.</p>
<h2>Counter One Dollar Stats</h2>
<p>"$1/month for page views. Adorable."</p>
<p>Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.</p>
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
<p>You get:</p>
<ul>
<li>Funnels to see where users drop off</li>
<li>Retention analysis to see who comes back</li>
<li>Cohorts to segment your users</li>
<li>User profiles to understand individual behavior</li>
<li>Custom dashboards to see what matters to YOU</li>
<li>Revenue tracking to see what actually makes money</li>
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
</ul>
<p>One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.</p>
<h2>Why OpenPanel is the Answer</h2>
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.</p>
<ul>
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
</ul>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
</div>
</div>
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
</figure>
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
<p>Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?</p>
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
<p>That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.</p>
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
<p>Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.</p>
<h2>The Comparison Table (The Brutal Truth)</h2>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Price at 20M events</th>
<th>What You Get</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mixpanel</strong></td>
<td>$2,300+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>PostHog</strong></td>
<td>$1,982+/month</td>
<td>Not all feautres... since addons are extra</td>
</tr>
<tr>
<td><strong>Plausible</strong></td>
<td>Various pricing</td>
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
</tr>
<tr>
<td><strong>One Dollar Stats</strong></td>
<td>$1/month</td>
<td>Page views (but cheaper!)</td>
</tr>
<tr style="background: #131313; border: 2px solid #3b82f6;">
<td><strong>OpenPanel</strong></td>
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
</tr>
</tbody>
</table>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
</div>
</div>
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
</figure>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
</div>
</div>
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
</figure>
<h2>The Bottom Fucking Line</h2>
<p>If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.</p>
<p>You have three choices:</p>
<ol>
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
<li>Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from</li>
<li>Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do</li>
</ol>
<p>If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.</p>
<p>But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.</p>
<div class="cta">
<h2>Ready to understand what your users actually do?</h2>
<p>Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.</p>
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
</div>
<figure class="screenshot">
<div class="screenshot-inner">
<div class="window-controls">
<div class="window-dot red"></div>
<div class="window-dot yellow"></div>
<div class="window-dot green"></div>
</div>
<div class="screenshot-image-wrapper">
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
</div>
</div>
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
</figure>
<footer>
<p><strong>Just Fucking Use OpenPanel</strong></p>
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
</footer>
</div>
<script>
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
window.op('init', {
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
trackScreenViews: true,
trackOutgoingLinks: true,
trackAttributes: true,
});
</script>
<script src="https://openpanel.dev/op1.js" defer async></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -1,7 +0,0 @@
{
"name": "justfuckinguseopenpanel",
"compatibility_date": "2025-12-19",
"assets": {
"directory": "."
}
}

View File

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

94
apps/public/Dockerfile Normal file
View File

@@ -0,0 +1,94 @@
ARG NODE_VERSION=20.15.1
FROM --platform=linux/amd64 node:${NODE_VERSION}-slim AS base
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
ARG REDIS_URL
ENV REDIS_URL=$REDIS_URL
ARG CLICKHOUSE_URL
ENV CLICKHOUSE_URL=$CLICKHOUSE_URL
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN apt update \
&& apt install -y curl \
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
&& bash n $NODE_VERSION \
&& rm n \
&& npm install -g n
WORKDIR /app
COPY package.json package.json
COPY pnpm-lock.yaml pnpm-lock.yaml
COPY pnpm-workspace.yaml pnpm-workspace.yaml
COPY apps/public/package.json apps/public/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/redis/package.json packages/redis/package.json
COPY packages/queue/package.json packages/queue/package.json
COPY packages/common/package.json packages/common/package.json
COPY packages/constants/package.json packages/constants/package.json
COPY packages/validation/package.json packages/validation/package.json
COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json
COPY packages/sdks/_info/package.json packages/sdks/_info/package.json
# BUILD
FROM base AS build
WORKDIR /app/apps/public
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /app
COPY apps/public apps/public
COPY packages packages
COPY tooling tooling
RUN pnpm db:codegen
WORKDIR /app/apps/public
RUN pnpm run build
# PROD
FROM base AS prod
WORKDIR /app/apps/public
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
# FINAL
FROM base AS runner
COPY --from=build /app/package.json /app/package.json
COPY --from=prod /app/node_modules /app/node_modules
# Apps
COPY --from=build /app/apps/public /app/apps/public
# Apps node_modules
COPY --from=prod /app/apps/public/node_modules /app/apps/public/node_modules
# Packages
COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/redis /app/packages/redis
COPY --from=build /app/packages/common /app/packages/common
COPY --from=build /app/packages/queue /app/packages/queue
COPY --from=build /app/packages/constants /app/packages/constants
COPY --from=build /app/packages/validation /app/packages/validation
COPY --from=build /app/packages/sdks/sdk /app/packages/sdks/sdk
COPY --from=build /app/packages/sdks/_info /app/packages/sdks/_info
# Packages node_modules
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
COPY --from=prod /app/packages/redis/node_modules /app/packages/redis/node_modules
COPY --from=prod /app/packages/common/node_modules /app/packages/common/node_modules
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
COPY --from=prod /app/packages/validation/node_modules /app/packages/validation/node_modules
RUN pnpm db:codegen
WORKDIR /app/apps/public
EXPOSE 3000
CMD ["pnpm", "start"]

View File

@@ -15,25 +15,6 @@ yarn dev
Open http://localhost:3000 with your browser to see the result.
## Explore
In the project, you can see:
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
| Route | Description |
| ------------------------- | ------------------------------------------------------ |
| `app/(home)` | The route group for your landing page and other pages. |
| `app/docs` | The documentation layout and pages. |
| `app/api/search/route.ts` | The Route Handler for search. |
### Fumadocs MDX
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
## Learn More
To learn more about Next.js and Fumadocs, take a look at the following
@@ -42,4 +23,4 @@ resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs

View File

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

View File

@@ -0,0 +1,203 @@
import { url, getAuthor } from '@/app/layout.config';
import { SingleSwirl } from '@/components/Swirls';
import { ArticleCard } from '@/components/article-card';
import { Logo } from '@/components/logo';
import { SectionHeader } from '@/components/section';
import { Toc } from '@/components/toc';
import { Button } from '@/components/ui/button';
import { articleSource } from '@/lib/source';
import { ArrowLeftIcon } from 'lucide-react';
import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import Script from 'next/script';
export async function generateMetadata({
params,
}: {
params: Promise<{ articleSlug: string }>;
}): Promise<Metadata> {
const { articleSlug } = await params;
const article = await articleSource.getPage([articleSlug]);
const author = getAuthor(article?.data.team);
if (!article) {
return {
title: 'Article Not Found',
};
}
return {
title: article.data.title,
description: article.data.description,
authors: [{ name: author.name }],
alternates: {
canonical: url(article.url),
},
openGraph: {
title: article.data.title,
description: article.data.description,
type: 'article',
publishedTime: article.data.date.toISOString(),
authors: author.name,
images: url(article.data.cover),
url: url(article.url),
},
twitter: {
card: 'summary_large_image',
title: article.data.title,
description: article.data.description,
images: url(article.data.cover),
},
};
}
export default async function Page({
params,
}: {
params: Promise<{ articleSlug: string }>;
}) {
const { articleSlug } = await params;
const article = await articleSource.getPage([articleSlug]);
const Body = article?.data.body;
const author = getAuthor(article?.data.team);
const goBackUrl = '/articles';
const relatedArticles = (await articleSource.getPages())
.filter(
(item) =>
item.data.tag === article?.data.tag && item.url !== article?.url,
)
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
if (!Body) {
return notFound();
}
// Create the JSON-LD data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article?.data.title,
datePublished: article?.data.date.toISOString(),
author: {
'@type': 'Person',
name: author.name,
},
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url(article.url),
},
image: {
'@type': 'ImageObject',
url: url(article.data.cover),
},
};
return (
<div>
<Script
strategy="beforeInteractive"
id="article-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article className="container max-w-5xl col">
<div className="py-16">
<Link
href={goBackUrl}
className="flex items-center gap-2 mb-4 text-muted-foreground"
>
<ArrowLeftIcon className="w-4 h-4" />
<span>Back to all articles</span>
</Link>
<div className="flex-col-reverse col md:row gap-8">
<div className="col flex-1">
<h1 className="text-5xl font-bold leading-tight">
{article?.data.title}
</h1>
<div className="row gap-4 items-center mt-8">
<div className="size-10 center-center bg-black rounded-full">
{author.image ? (
<Image
className="size-10 object-cover rounded-full"
src={author.image}
alt={author.name}
width={48}
height={48}
/>
) : (
<Logo className="w-6 h-6 fill-white" />
)}
</div>
<div className="col">
<p className="font-medium">{author.name}</p>
<p className="text-muted-foreground text-sm">
{article?.data.date.toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
</div>
<div className="relative">
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
<div className="min-w-0">
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
<Body />
</div>
</div>
<aside className="pl-12 pb-12 gap-8 col">
<Toc toc={article?.data.toc} />
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl py-16">
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0 size-[300px]" />
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50 size-[300px]" />
<div className="container center-center col">
<SectionHeader
className="mb-8"
title="Try it"
description="Give it a spin for free. No credit card required."
/>
<Button size="lg" variant="secondary" asChild>
<Link href="https://dashboard.openpanel.dev/onboarding">
Get started today!
</Link>
</Button>
</div>
</section>
</aside>
</div>
{relatedArticles.length > 0 && (
<div className="my-16">
<h3 className="text-2xl font-bold mb-8">Related articles</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{relatedArticles.map((item) => (
<ArticleCard
key={item.url}
url={item.url}
title={item.data.title}
tag={item.data.tag}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
/>
))}
</div>
</div>
)}
</div>
</article>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { url } from '@/app/layout.config';
import { ArticleCard } from '@/components/article-card';
import { articleSource } from '@/lib/source';
import type { Metadata } from 'next';
import Image from 'next/image';
import Link from 'next/link';
const title = 'Articles';
const description = 'Read our latest articles';
export const metadata: Metadata = {
title,
description,
alternates: {
canonical: url('/articles'),
},
openGraph: {
title,
description,
type: 'website',
},
twitter: {
card: 'summary_large_image',
title,
description,
},
};
export default async function Page() {
const articles = (await articleSource.getPages()).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
return (
<div>
<div className="container col">
<div className="py-16">
<h1 className="text-center text-7xl font-bold">Articles</h1>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
{articles.map((item) => (
<ArticleCard
key={item.url}
url={item.url}
title={item.data.title}
tag={item.data.tag}
cover={item.data.cover}
team={item.data.team}
date={item.data.date}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Footer } from '@/components/footer';
import { HeroContainer } from '@/components/hero';
import Navbar from '@/components/navbar';
import type { ReactNode } from 'react';
export default function Layout({
children,
}: {
children: ReactNode;
}): React.ReactElement {
return (
<>
<Navbar />
<main className="overflow-hidden">
<HeroContainer className="h-screen pointer-events-none" />
<div className="absolute h-screen inset-0 radial-gradient-dot-pages select-none pointer-events-none" />
<div className="-mt-[calc(100vh-100px)] relative min-h-[500px] pb-12 -mb-24">
{children}
</div>
</main>
<Footer />
</>
);
}

View File

@@ -0,0 +1,288 @@
import { url } from '@/app/layout.config';
import { HeroContainer } from '@/components/hero';
import { Section, SectionHeader } from '@/components/section';
import { Faq } from '@/components/sections/faq';
import { SupporterPerks } from '@/components/sections/supporter-perks';
import { Testimonials } from '@/components/sections/testimonials';
import { Tag } from '@/components/tag';
import { Button } from '@/components/ui/button';
import {
ArrowDownIcon,
HeartHandshakeIcon,
SparklesIcon,
ZapIcon,
} from 'lucide-react';
import type { Metadata } from 'next';
import Link from 'next/link';
import Script from 'next/script';
export const metadata: Metadata = {
title: 'Become a Supporter',
description:
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
alternates: {
canonical: url('/supporter'),
},
openGraph: {
title: 'Become a Supporter',
description:
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
type: 'website',
url: url('/supporter'),
},
twitter: {
card: 'summary_large_image',
title: 'Become a Supporter',
description:
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
},
};
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'Become a Supporter',
publisher: {
'@type': 'Organization',
name: 'OpenPanel',
logo: {
'@type': 'ImageObject',
url: url('/logo.png'),
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url('/supporter'),
},
};
export default function SupporterPage() {
return (
<div>
<Script
id="supporter-schema"
strategy="beforeInteractive"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<HeroContainer>
<div className="container relative z-10 col sm:py-44 max-sm:pt-32">
<div className="col gap-8 text-center">
<div className="col gap-4">
<Tag className="self-center">
<HeartHandshakeIcon className="size-4 text-rose-600" />
Support Open-Source Analytics
</Tag>
<h1 className="text-4xl md:text-5xl font-extrabold leading-[1.1]">
Help us build the future of{' '}
<span className="text-primary">open analytics</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Your support accelerates development, funds infrastructure, and
helps us build features faster. Plus, you get exclusive perks
and early access to everything we ship.
</p>
</div>
<div className="col gap-4 justify-center items-center">
<Button size="lg" asChild>
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
Become a Supporter
<SparklesIcon className="size-4" />
</Link>
</Button>
<p className="text-sm text-muted-foreground">
Starting at $20/month Cancel anytime
</p>
</div>
</div>
</div>
</HeroContainer>
<div className="container max-w-7xl">
{/* Main Content with Sidebar */}
<div className="grid lg:grid-cols-[1fr_380px] gap-8 mb-16">
{/* Main Content */}
<div className="col gap-12">
{/* Why Support Section */}
<section className="col gap-6">
<h2 className="text-3xl font-bold">Why your support matters</h2>
<div className="col gap-6 text-muted-foreground">
<p className="text-lg">
We're not a big corporation just a small team passionate
about building something useful for developers. OpenPanel
started because we believed analytics tools shouldn't be
complicated or locked behind expensive enterprise
subscriptions.
</p>
<p>When you become a supporter, you're directly funding:</p>
<ul className="col gap-3 list-none pl-0">
<li className="flex items-start gap-3">
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<strong className="text-foreground">
Active Development
</strong>
<p className="text-sm mt-1">
More time fixing bugs, adding features, and improving
documentation
</p>
</div>
</li>
<li className="flex items-start gap-3">
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<strong className="text-foreground">
Infrastructure
</strong>
<p className="text-sm mt-1">
Keeping servers running, CI/CD pipelines, and
development tools
</p>
</div>
</li>
<li className="flex items-start gap-3">
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
<div>
<strong className="text-foreground">Independence</strong>
<p className="text-sm mt-1">
Staying focused on what matters: building a tool
developers actually want
</p>
</div>
</li>
</ul>
<p>
No corporate speak, no fancy promises just honest work on
making OpenPanel better for everyone. Every contribution, no
matter the size, helps us stay independent and focused on what
matters.
</p>
</div>
</section>
{/* What You Get Section */}
<section className="col gap-6">
<h2 className="text-3xl font-bold">
What you get as a supporter
</h2>
<div className="grid md:grid-cols-2 gap-4">
<div className="p-6 rounded-lg border bg-card">
<h3 className="font-semibold text-lg mb-2">
🚀 Latest Docker Images
</h3>
<p className="text-sm text-muted-foreground mb-3">
Get bleeding-edge builds on every commit. Access new
features weeks before public release.
</p>
<Link
href="/docs/self-hosting/supporter-access-latest-docker-images"
className="text-sm text-primary hover:underline"
>
Learn more →
</Link>
</div>
<div className="p-6 rounded-lg border bg-card">
<h3 className="font-semibold text-lg mb-2">
💬 Prioritized Support
</h3>
<p className="text-sm text-muted-foreground mb-3">
Get help faster with priority support in our Discord
community. Your questions get answered first.
</p>
</div>
<div className="p-6 rounded-lg border bg-card">
<h3 className="font-semibold text-lg mb-2">
✨ Feature Requests
</h3>
<p className="text-sm text-muted-foreground mb-3">
Your ideas and feature requests get prioritized in our
roadmap. Shape the future of OpenPanel.
</p>
</div>
<div className="p-6 rounded-lg border bg-card">
<h3 className="font-semibold text-lg mb-2">
⭐ Exclusive Discord Role
</h3>
<p className="text-sm text-muted-foreground mb-3">
Special badge and recognition in our community. Show your
support with pride.
</p>
</div>
</div>
</section>
{/* Impact Section */}
<section className="p-8 rounded-xl border bg-gradient-to-br from-primary/5 to-primary/10">
<h2 className="text-2xl font-bold mb-4">Your impact</h2>
<p className="text-muted-foreground mb-6">
Every dollar you contribute goes directly into development,
infrastructure, and making OpenPanel better. Here's what your
support enables:
</p>
<div className="grid md:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-primary mb-2">
100%
</div>
<div className="text-sm text-muted-foreground">
Open Source
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-primary mb-2">
24/7
</div>
<div className="text-sm text-muted-foreground">
Active Development
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-primary mb-2"></div>
<div className="text-sm text-muted-foreground">
Self-Hostable
</div>
</div>
</div>
</section>
</div>
{/* Sidebar */}
<aside className="lg:block hidden">
<SupporterPerks />
</aside>
</div>
{/* Mobile Perks */}
<div className="lg:hidden mb-16">
<SupporterPerks />
</div>
{/* CTA Section */}
<Section className="container my-0 py-20">
<SectionHeader
tag={
<Tag>
<ArrowDownIcon className="size-4" strokeWidth={1.5} />
Starting at $20/month
</Tag>
}
title="Ready to support OpenPanel?"
description="Join our community of supporters and help us build the best open-source alternative to Mixpanel. Every contribution helps accelerate development and make OpenPanel better for everyone."
/>
<div className="center-center">
<Button size="lg" asChild>
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
Become a Supporter Now
<HeartHandshakeIcon className="size-4" />
</Link>
</Button>
</div>
</Section>
</div>
<div className="lg:-mx-20 xl:-mx-40 not-prose mt-16">
<Testimonials />
<Faq />
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
import { url, siteName } from '@/app/layout.config';
import { source } from '@/lib/source';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
alternates: {
canonical: url(page.url),
},
icons: {
apple: '/apple-touch-icon.png',
icon: '/favicon.ico',
},
manifest: '/site.webmanifest',
openGraph: {
url: url(page.url),
type: 'article',
images: [
{
url: url('/ogimage.jpg'),
width: 1200,
height: 630,
alt: siteName,
},
],
},
};
}

View File

@@ -0,0 +1,12 @@
import { baseOptions } from '@/app/layout.config';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}

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

@@ -0,0 +1,224 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--green: 156 71% 67%;
--red: 351 89% 72%;
--background: 0 0% 98%;
--background-light: 0 0% 100%;
--background-dark: 0 0% 96%;
--foreground: 0 0% 9%;
--foreground-dark: 0 0% 7.5%;
--foreground-light: 0 0% 11%;
--card: 0 0% 98%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 98%;
--popover-foreground: 0 0% 9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 9%;
--background-dark: 0 0% 7.5%;
--background-light: 0 0% 11%;
--foreground: 0 0% 98%;
--foreground-light: 0 0% 100%;
--foreground-dark: 0 0% 96%;
--card: 0 0% 9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply !bg-[hsl(var(--background))] text-foreground font-sans text-base antialiased flex flex-col min-h-screen;
}
}
@layer components {
.container {
@apply max-w-6xl mx-auto px-6 md:px-10 lg:px-14 w-full;
}
.pulled {
@apply -mx-2 md:-mx-6 lg:-mx-10 xl:-mx-20;
}
.row {
@apply flex flex-row;
}
.col {
@apply flex flex-col;
}
.center-center {
@apply flex items-center justify-center text-center;
}
}
strong {
@apply font-semibold;
}
.radial-gradient {
background: #BECCDF;
background: radial-gradient(at bottom, hsl(var(--background-light)), hsl(var(--background)));
}
.radial-gradient-dot-1 {
background: #BECCDF;
background: radial-gradient(at 50% 20%, hsl(var(--background-light)), transparent);
}
.radial-gradient-dot-pages {
background: #BECCDF;
background: radial-gradient(at 50% 50%, hsl(var(--background)), hsl(var(--background)/0.2));
}
.animated-iframe-gradient {
position: relative;
overflow: hidden;
background: transparent;
}
.animated-iframe-gradient:before {
content: '';
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 1600px;
height: 1600px;
background: linear-gradient(250deg, hsl(var(--foreground)/0.9), transparent);
animation: GradientRotate 8s linear infinite;
}
@keyframes GradientRotate {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
.line-before {
position: relative;
padding: 16px;
}
.line-before:before {
content: '';
display: block;
position: absolute;
top: calc(4px*-32);
bottom: calc(4px*-32);
left: 0;
width: 1px;
background: hsl(var(--foreground)/0.1);
}
.line-after {
position: relative;
padding: 16px;
}
.line-after:after {
content: '';
display: block;
position: absolute;
top: calc(4px*-32);
bottom: calc(4px*-32);
right: 0;
width: 1px;
background: hsl(var(--foreground)/0.1);
}
.animate-fade-up {
animation: animateFadeUp 0.5s ease-in-out;
animation-fill-mode: both;
}
@keyframes animateFadeUp {
0% { transform: translateY(0.5rem); scale: 0.95; }
100% { transform: translateY(0); scale: 1; }
}
.animate-fade-down {
animation: animateFadeDown 0.5s ease-in-out;
animation-fill-mode: both;
}
@keyframes animateFadeDown {
0% { transform: translateY(-1rem); }
100% { transform: translateY(0); }
}
/* Docs */
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
font-size: inherit !important;
}
.prose pre {
background: hsl(var(--background-dark));
border: 1px solid hsl(var(--background-light));
padding: 10px 15px;
border-radius: 10px;
font-size: 12px;
}
.prose pre code {
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
border: none;
}
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] {
max-height: 400px;
}
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] pre{
max-height: none;
}

View File

@@ -0,0 +1,66 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
/**
* Shared layout configurations
*
* you can configure layouts individually from:
* Home Layout: app/(home)/layout.tsx
* Docs Layout: app/docs/layout.tsx
*/
export const siteName = 'OpenPanel';
export const baseUrl = 'https://openpanel.dev';
export const url = (path: string) => `${baseUrl}${path}`;
export const baseOptions: BaseLayoutProps = {
nav: {
title: siteName,
},
links: [
{
type: 'main',
text: 'Home',
url: '/',
active: 'nested-url',
},
{
type: 'main',
text: 'Pricing',
url: '/pricing',
active: 'nested-url',
},
{
type: 'main',
text: 'Supporter',
url: '/supporter',
active: 'nested-url',
},
{
type: 'main',
text: 'Documentation',
url: '/docs',
active: 'nested-url',
},
{
type: 'main',
text: 'Articles',
url: '/articles',
active: 'nested-url',
},
],
} as const;
export const authors = [
{
name: 'OpenPanel Team',
url: 'https://openpanel.com',
},
{
name: 'Carl-Gerhard Lindesvärd',
url: 'https://openpanel.com',
image: '/twitter-carl.jpg',
},
];
export const getAuthor = (author?: string) => {
return authors.find((a) => a.name === author)!;
};

View File

@@ -0,0 +1,75 @@
import { RootProvider } from 'fumadocs-ui/provider';
import type { ReactNode } from 'react';
import './global.css';
import { TooltipProvider } from '@/components/ui/tooltip';
import { OpenPanelComponent } from '@openpanel/nextjs';
import { cn } from 'fumadocs-ui/components/api';
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
import type { Metadata, Viewport } from 'next';
import Script from 'next/script';
import { url, baseUrl, siteName } from './layout.config';
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
userScalable: true,
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#fafafa' },
{ media: '(prefers-color-scheme: dark)', color: '#171717' },
],
};
const description = `${siteName} is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.`;
export const metadata: Metadata = {
title: {
default: siteName,
template: `%s | ${siteName}`,
},
description,
alternates: {
canonical: baseUrl,
},
icons: {
apple: '/apple-touch-icon.png',
icon: '/favicon.ico',
},
manifest: '/site.webmanifest',
openGraph: {
title: siteName,
description,
siteName: siteName,
url: baseUrl,
type: 'website',
images: [
{
url: url('/ogimage.jpg'),
width: 1200,
height: 630,
alt: siteName,
},
],
},
};
export default async function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className={cn(GeistSans.variable, GeistMono.variable)}>
<RootProvider>
<TooltipProvider>{children}</TooltipProvider>
</RootProvider>
<OpenPanelComponent
apiUrl="/api/op"
cdnUrl="/api/op/op1.js"
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
trackAttributes
trackScreenViews
trackOutgoingLinks
/>
</body>
</html>
);
}

View File

@@ -0,0 +1,22 @@
import type { MetadataRoute } from 'next';
import { metadata } from './layout';
export default function manifest(): MetadataRoute.Manifest {
return {
name: metadata.title as string,
short_name: 'Openpanel.dev',
description: metadata.description!,
start_url: '/',
display: 'standalone',
background_color: '#fff',
theme_color: '#fff',
icons: [
{
src: '/favicon.ico',
sizes: 'any',
type: 'image/x-icon',
},
],
};
}

View File

@@ -0,0 +1,30 @@
import { baseOptions } from '@/app/layout.config';
import { Footer } from '@/components/footer';
import { HeroContainer } from '@/components/hero';
import Navbar from '@/components/navbar';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import type { ReactNode } from 'react';
export default function NotFound({
children,
}: {
children: ReactNode;
}): React.ReactElement {
return (
<div>
<Navbar />
<HeroContainer className="h-screen center-center">
<div className="relative z-10 col gap-2">
<div className="text-[150px] font-mono font-bold opacity-5 -mb-4">
404
</div>
<h1 className="text-6xl font-bold">Not Found</h1>
<p className="text-xl text-muted-foreground">
Awkward, we couldn&apos;t find what you were looking for.
</p>
</div>
</HeroContainer>
<Footer />
</div>
);
}

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

@@ -0,0 +1,36 @@
import { Footer } from '@/components/footer';
import { Hero } from '@/components/hero';
import Navbar from '@/components/navbar';
import { Faq } from '@/components/sections/faq';
import { Features } from '@/components/sections/features';
import { Pricing } from '@/components/sections/pricing';
import { Sdks } from '@/components/sections/sdks';
import { Stats, StatsPure } from '@/components/sections/stats';
import { Testimonials } from '@/components/sections/testimonials';
import { WhyOpenPanel } from '@/components/why-openpanel';
import type { Metadata } from 'next';
import { Suspense } from 'react';
export const metadata: Metadata = {
title: 'OpenPanel | An open-source alternative to Mixpanel',
};
// export const experimental_ppr = true;
export default function HomePage() {
return (
<>
<Navbar />
<main>
<Hero />
<WhyOpenPanel />
<Features />
<Testimonials />
<Faq />
<Pricing />
<Sdks />
</main>
<Footer />
</>
);
}

View File

@@ -1,8 +1,3 @@
User-Agent: *
Allow: /
Allow: /og*
Allow: /llms.txt
Allow: /llms-full.txt
Allow: /*.md
Sitemap: https://openpanel.dev/sitemap.xml

View File

@@ -0,0 +1,53 @@
import { articleSource, pageSource, source } from '@/lib/source';
import type { MetadataRoute } from 'next';
import { url } from './layout.config';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await articleSource.getPages();
const docs = await source.getPages();
const pages = await pageSource.getPages();
return [
{
url: url('/'),
lastModified: new Date(),
changeFrequency: 'yearly',
priority: 1,
},
{
url: url('/docs'),
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: url('/articles'),
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.5,
},
{
url: url('/supporter'),
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.7,
},
...articles.map((item) => ({
url: url(item.url),
lastModified: item.data.lastModified,
changeFrequency: 'yearly' as const,
priority: 0.5,
})),
...docs.map((item) => ({
url: url(item.url),
lastModified: item.data.lastModified,
changeFrequency: 'monthly' as const,
priority: 0.3,
})),
...pages.map((item) => ({
url: url(item.url),
lastModified: item.data.lastModified,
changeFrequency: 'monthly' as const,
priority: 0.3,
})),
];
}

View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils';
import Image, { type ImageProps } from 'next/image';
type SwirlProps = Omit<ImageProps, 'src' | 'alt'>;
export function SingleSwirl({ className, ...props }: SwirlProps) {
return (
<Image
{...props}
src="/swirl-2.png"
alt="Swirl"
className={cn(
'pointer-events-none w-full h-full object-cover',
className,
)}
width={1200}
height={1200}
/>
);
}
export function DoubleSwirl({ className, ...props }: SwirlProps) {
return (
<Image
{...props}
src="/swirl.png"
alt="Swirl"
className={cn(
'pointer-events-none w-full h-full object-cover',
className,
)}
width={1200}
height={1200}
/>
);
}

View File

@@ -0,0 +1,41 @@
import Script from 'next/script';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from './ui/accordion';
export const Faqs = ({ children }: { children: React.ReactNode }) => (
<Accordion
type="single"
collapsible
className="w-full max-w-screen-md self-center border rounded-lg [&_button]:px-4 bg-background-dark [&_div.answer]:bg-background-light"
>
{children}
</Accordion>
);
export const FaqItem = ({
question,
children,
}: { question: string; children: string }) => (
<AccordionItem
value={question}
itemScope
itemProp="mainEntity"
itemType="https://schema.org/Question"
className="[&_[role=region]]:px-4"
>
<AccordionTrigger className="text-left" itemProp="name">
{question}
</AccordionTrigger>
<AccordionContent
itemProp="acceptedAnswer"
itemScope
itemType="https://schema.org/Answer"
>
{children}
</AccordionContent>
</AccordionItem>
);

View File

@@ -0,0 +1,160 @@
import { cn } from '@/lib/utils';
import { ChevronRightIcon, ConeIcon } from 'lucide-react';
import Link from 'next/link';
export function SmallFeature({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'bg-background-light rounded-lg p-1 border border-border group',
className,
)}
>
<div className="bg-background-dark rounded-lg p-8 h-full group-hover:bg-background-light transition-colors">
{children}
</div>
</div>
);
}
export function Feature({
children,
media,
reverse = false,
className,
}: {
children: React.ReactNode;
media?: React.ReactNode;
reverse?: boolean;
className?: string;
}) {
return (
<div
className={cn(
'border rounded-lg bg-background-light overflow-hidden p-1',
className,
)}
>
<div
className={cn(
'grid grid-cols-1 md:grid-cols-2 gap-4 items-center',
!media && '!grid-cols-1',
)}
>
<div className={cn(reverse && 'md:order-last', 'p-10')}>{children}</div>
{media && (
<div
className={cn(
'bg-background-dark h-full rounded-md overflow-hidden',
reverse && 'md:order-first',
)}
>
{media}
</div>
)}
</div>
</div>
);
}
export function FeatureContent({
icon,
title,
content,
className,
}: {
icon?: React.ReactNode;
title: string;
content: React.ReactNode[];
className?: string;
}) {
return (
<div className={className}>
{icon && (
<div
data-icon
className="bg-foreground text-background rounded-md p-4 inline-block mb-6 transition-colors"
>
{icon}
</div>
)}
<h2 className="text-lg font-medium mb-2">{title}</h2>
<div className="col gap-2">
{content.map((c, i) => (
<p className="text-muted-foreground" key={i.toString()}>
{c}
</p>
))}
</div>
</div>
);
}
export function FeatureListItem({
icon: Icon,
title,
}: { icon: React.ComponentType<any>; title: string }) {
return (
<div className="row items-center gap-2" key="funnel">
<Icon className="size-4 text-foreground/70" strokeWidth={1.5} /> {title}
</div>
);
}
export function FeatureList({
title,
items,
className,
cols = 2,
}: {
title: string;
items: React.ReactNode[];
className?: string;
cols?: number;
}) {
return (
<div className={className}>
<h3 className="font-semibold text-sm mb-2">{title}</h3>
<div
className={cn(
'-mx-2 [&>div]:p-2 [&>div]:row [&>div]:items-center [&>div]:gap-2 grid',
cols === 1 && 'grid-cols-1',
cols === 2 && 'grid-cols-2',
cols === 3 && 'grid-cols-3',
)}
>
{items.map((i, j) => (
<div key={j.toString()}>{i}</div>
))}
</div>
</div>
);
}
export function FeatureMore({
children,
href,
className,
}: {
children: React.ReactNode;
href: string;
className?: string;
}) {
return (
<Link
href={href}
className={cn(
'font-medium items-center row justify-between border-t py-4',
className,
)}
>
{children} <ChevronRightIcon className="size-4" strokeWidth={1.5} />
</Link>
);
}

View File

@@ -47,7 +47,7 @@ export function FlowStep({
return (
<div className="relative flex gap-4 mb-4 min-w-0">
{/* Step number and icon */}
<div className="flex flex-col items-center shrink-0">
<div className="flex flex-col items-center flex-shrink-0">
<div className="relative z-10 bg-background">
<div className="flex items-center justify-center size-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm shadow-sm">
{step}

View File

@@ -0,0 +1,158 @@
import { baseOptions } from '@/app/layout.config';
import { MailIcon } from 'lucide-react';
import Link from 'next/link';
import { SingleSwirl } from './Swirls';
import { Logo } from './logo';
import { SectionHeader } from './section';
import { Tag } from './tag';
import { Button } from './ui/button';
export function Footer() {
const year = new Date().getFullYear();
return (
<div className="mt-32">
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto">
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0" />
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50" />
<div className="container center-center col">
<SectionHeader
tag={<Tag>Discover User Insights</Tag>}
title="Effortless web & product analytics"
description="Simplify your web & product analytics with our user-friendly platform. Collect, analyze, and optimize your data in minutes."
/>
<Button size="lg" variant="secondary" asChild>
<Link href="https://dashboard.openpanel.dev/onboarding">
Get started today!
</Link>
</Button>
</div>
</section>
<footer className="pt-32 text-sm relative overflow-hidden">
<div className="absolute -bottom-20 md:-bottom-32 left-0 right-0 center-center opacity-5 pointer-events-none">
<Logo className="w-[900px] shrink-0" />
</div>
<div className="container grid grid-cols-1 md:grid-cols-5 gap-12 md:gap-8 relative">
<div>
<Link href="/" className="row items-center font-medium">
<Logo className="h-6" />
{baseOptions.nav?.title}
</Link>
</div>
<div className="col gap-3">
<h3 className="font-medium">Useful links</h3>
<ul className="gap-2 col text-muted-foreground">
<li>
<Link href="/pricing">Pricing</Link>
</li>
<li>
<Link href="/about">About</Link>
</li>
<li>
<Link href="/contact">Contact</Link>
</li>
<li>
<Link href="/supporter">Become a supporter</Link>
</li>
</ul>
</div>
<div className="col gap-3 ">
<h3 className="font-medium">Articles</h3>
<ul className="gap-2 col text-muted-foreground">
<li>
<Link href="/articles/vs-mixpanel">OpenPanel vs Mixpanel</Link>
</li>
<li>
<Link href="/articles/mixpanel-alternatives">
Mixpanel alternatives
</Link>
</li>
</ul>
</div>
<div className="md:col-span-2 md:items-end col gap-4">
<div className="[&_svg]:size-6 row gap-4">
<Link
title="Go to GitHub"
href="https://github.com/Openpanel-dev/openpanel"
rel="noreferrer noopener nofollow"
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</Link>
<Link
title="Go to X"
href="https://x.com/openpaneldev"
rel="noreferrer noopener nofollow"
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
>
<title>X</title>
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H3.298Z" />
</svg>
</Link>
<Link
title="Join Discord"
href="https://go.openpanel.dev/discord"
rel="noreferrer noopener nofollow"
>
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="fill-current"
>
<title>Discord</title>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
</Link>
<Link
title="Send an email"
href="mailto:hello@openpanel.dev"
rel="noreferrer noopener nofollow"
>
<MailIcon className="size-6" />
</Link>
</div>
<a
target="_blank"
href="https://status.openpanel.dev"
className="row gap-2 items-center border rounded-full px-2 py-1 max-md:self-start"
rel="noreferrer noopener nofollow"
>
<span>Operational</span>
<div className="size-2 bg-emerald-500 rounded-full" />
</a>
</div>
</div>
<div className="text-muted-foreground border-t pt-4 mt-16 gap-8 relative bg-background/70 pb-32">
<div className="container col md:row justify-between">
<div>Copyright © {year} OpenPanel. All rights reserved.</div>
<div className="col lg:row gap-2 md:gap-4">
<Link href="/sitemap.xml">Sitemap</Link>
<Link href="/privacy">Privacy Policy</Link>
<Link href="/terms">Terms of Service</Link>
<Link href="/cookies">Cookie Policy (just kidding)</Link>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
import NextImage from 'next/image';
import { useState } from 'react';
import { Button } from './ui/button';
type Frame = {
id: string;
label: string;
key: string;
Component: React.ComponentType;
};
function LivePreview() {
return (
<iframe
src={
'https://dashboard.openpanel.dev/share/overview/zef2XC?header=0&range=30d'
}
className="w-full h-full"
title="Live preview"
scrolling="no"
/>
);
}
function Image({ src }: { src: string }) {
return (
<div>
<NextImage
className="w-full h-full block dark:hidden"
src={`/${src}-light.png`}
alt={`${src} light`}
width={1200}
height={800}
/>
<NextImage
className="w-full h-full hidden dark:block"
src={`/${src}-dark.png`}
alt={`${src} dark`}
width={1200}
height={800}
/>
</div>
);
}
export function HeroCarousel() {
const frames: Frame[] = [
{
id: 'overview',
key: 'overview',
label: 'Live preview',
Component: LivePreview,
},
{
id: 'analytics',
key: 'analytics',
label: 'Product analytics',
Component: () => <Image src="dashboard" />,
},
{
id: 'funnels',
key: 'funnels',
label: 'Funnels',
Component: () => <Image src="funnel" />,
},
{
id: 'retention',
key: 'retention',
label: 'Retention',
Component: () => <Image src="retention" />,
},
{
id: 'profile',
key: 'profile',
label: 'Inspect profile',
Component: () => <Image src="profile" />,
},
];
const [activeFrames, setActiveFrames] = useState<Frame[]>([frames[0]]);
const activeFrame = activeFrames[activeFrames.length - 1];
return (
<div className="col gap-6 w-full">
<div className="flex-wrap row gap-x-4 gap-y-2 justify-center [&>div]:font-medium mt-1">
{frames.map((frame) => (
<div key={frame.id} className="relative">
<Button
variant="naked"
type="button"
onClick={() => {
if (activeFrame.id === frame.id) {
return;
}
const newFrame = {
...frame,
key: Math.random().toString().slice(2, 11),
};
setActiveFrames((p) => [...p.slice(-2), newFrame]);
}}
className="relative"
>
{frame.label}
</Button>
<motion.div
className="h-1 bg-foreground rounded-full"
initial={false}
animate={{
width: activeFrame.id === frame.id ? '100%' : '0%',
opacity: activeFrame.id === frame.id ? 1 : 0,
}}
whileHover={{
width: '100%',
opacity: 0.5,
}}
transition={{ duration: 0.2, ease: 'easeInOut' }}
/>
</div>
))}
</div>
<div className="pulled animated-iframe-gradient p-px pb-0 rounded-t-xl">
<div className="overflow-hidden rounded-xl rounded-b-none w-full bg-background">
<div
className={cn(
'relative w-full h-[750px]',
activeFrame.id !== 'overview' && 'h-auto aspect-[5/3]',
)}
>
{activeFrames.slice(-1).map((frame) => (
<div key={frame.key} className="absolute inset-0 w-full h-full">
<div className="bg-background rounded-xl h-full w-full">
<frame.Component />
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { motion, useScroll, useTransform } from 'framer-motion';
import { WorldMap } from './world-map';
export function HeroMap() {
const { scrollY } = useScroll();
const y = useTransform(scrollY, [0, 250], [0, 50], { clamp: true });
const scale = useTransform(scrollY, [0, 250], [1, 1.1], { clamp: true });
return (
<motion.div
style={{ y, scale }}
className="absolute inset-0 top-20 center-center items-start select-none"
>
<div className="min-w-[1400px] w-full">
<WorldMap />
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,138 @@
import { cn } from '@/lib/utils';
import {
ArrowRightIcon,
CalendarIcon,
ChevronRightIcon,
CookieIcon,
CreditCardIcon,
DatabaseIcon,
FlaskRoundIcon,
GithubIcon,
ServerIcon,
StarIcon,
} from 'lucide-react';
import Link from 'next/link';
import { Competition } from './competition';
import { Tag } from './tag';
import { Button } from './ui/button';
const perks = [
{ text: 'Free trial 30 days', icon: CalendarIcon },
{ text: 'No credit card required', icon: CreditCardIcon },
{ text: 'Cookie-less tracking', icon: CookieIcon },
{ text: 'Open-source', icon: GithubIcon },
{ text: 'Your data, your rules', icon: DatabaseIcon },
{ text: 'Self-hostable', icon: ServerIcon },
];
export function Hero() {
return (
<HeroContainer>
<div className="container relative z-10 col sm:row sm:py-44 max-sm:pt-32">
<div className="col gap-8 w-full sm:w-1/2 sm:pr-12">
<div className="col gap-4">
<Tag className="self-start">
<StarIcon className="size-4 fill-yellow-500 text-yellow-500" />
Trusted by +2000 projects
</Tag>
<h1
className="text-4xl md:text-5xl font-extrabold leading-[1.1]"
title="An open-source alternative to Mixpanel"
>
An open-source alternative to <Competition />
</h1>
<p className="text-xl text-muted-foreground">
An open-source web and product analytics platform that combines
the power of Mixpanel with the ease of Plausible and one of the
best Google Analytics replacements.
</p>
</div>
<Button size="lg" asChild className="group w-72">
<Link href="https://dashboard.openpanel.dev/onboarding">
Get started now
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
</Link>
</Button>
<ul className="grid grid-cols-2 gap-2">
{perks.map((perk) => (
<li key={perk.text} className="text-sm text-muted-foreground">
<perk.icon className="size-4 inline-block mr-1" />
{perk.text}
</li>
))}
</ul>
</div>
<div className="col sm:w-1/2 relative group">
<div
className={cn([
'overflow-hidden rounded-lg border border-border bg-background shadow-lg',
'sm:absolute sm:left-0 sm:-top-12 sm:w-[800px] sm:-bottom-32',
'max-sm:h-[800px] max-sm:-mx-4 max-sm:mt-12 relative',
])}
>
{/* Window controls */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/50 h-12">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500" />
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<div className="w-3 h-3 rounded-full bg-green-500" />
</div>
{/* URL bar */}
<a
target="_blank"
rel="noreferrer noopener nofollow"
href="https://demo.openpanel.dev/demo/shoey"
className="group flex-1 mx-4 px-3 py-1 text-sm bg-background rounded-md border border-border flex items-center gap-2"
>
<span className="text-muted-foreground flex-1">
https://demo.openpanel.dev
</span>
<ArrowRightIcon className="size-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
</div>
<iframe
src={'https://demo.openpanel.dev/demo/shoey?range=lastHour'}
className="w-full h-full"
title="Live preview"
scrolling="no"
/>
<div className="pointer-events-none absolute inset-0 top-12 center-center group-hover:bg-foreground/20 transition-colors">
<Button
asChild
className="group-hover:opacity-100 opacity-0 transition-opacity pointer-events-auto"
>
<Link
href="https://demo.openpanel.dev/demo/shoey"
rel="noreferrer noopener nofollow"
target="_blank"
>
Test live demo
<FlaskRoundIcon className="size-4" />
</Link>
</Button>
</div>
</div>
</div>
</div>
</HeroContainer>
);
}
export function HeroContainer({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}): React.ReactElement {
return (
<section className={cn('radial-gradient overflow-hidden relative')}>
<div className={cn('relative z-10', className)}>{children}</div>
{/* Shadow bottom */}
<div className="w-full absolute bottom-0 h-32 bg-gradient-to-t from-background to-transparent" />
</section>
);
}

View File

@@ -0,0 +1,34 @@
import { cn } from '@/lib/utils';
export function PlusLine({ className }: { className?: string }) {
return (
<div className={cn('absolute', className)}>
<div className="relative">
<div className="w-px h-8 bg-foreground/40 -bottom-4 left-0 absolute animate-pulse" />
<div className="w-8 h-px bg-foreground/40 -bottom-px -left-4 absolute animate-pulse" />
</div>
</div>
);
}
export function VerticalLine({ className }: { className?: string }) {
return (
<div
className={cn(
'w-px bg-gradient-to-t from-transparent via-foreground/30 to-transparent absolute -top-12 -bottom-12',
className,
)}
/>
);
}
export function HorizontalLine({ className }: { className?: string }) {
return (
<div
className={cn(
'h-px bg-gradient-to-r from-transparent via-foreground/30 to-transparent absolute left-0 right-0',
className,
)}
/>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import { baseOptions } from '@/app/layout.config';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import { MenuIcon } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { GithubButton } from './github-button';
import { Logo } from './logo';
import { Button } from './ui/button';
const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const navbarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
// If click outside of the menu, close it
const handleClick = (e: MouseEvent) => {
if (isMobileMenuOpen && !navbarRef.current?.contains(e.target as Node)) {
setIsMobileMenuOpen(false);
}
};
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, [isMobileMenuOpen]);
return (
<nav className="fixed top-4 z-50 w-full" ref={navbarRef}>
<div className="container">
<div
className={cn(
'flex justify-between border border-transparent backdrop-blur-lg items-center p-4 -mx-4 rounded-full transition-colors',
isScrolled
? 'bg-background/90 border-foreground/10'
: 'bg-transparent',
)}
>
{/* Logo */}
<div className="flex-shrink-0">
<Link href="/" className="row items-center font-medium">
<Logo className="h-6" />
{baseOptions.nav?.title}
</Link>
</div>
<div className="row items-center gap-8">
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-8 text-sm">
{baseOptions.links?.map((link) => {
if (link.type !== 'main') {
return null;
}
return (
<Link
key={link.url}
href={link.url!}
className="text-foreground/80 hover:text-foreground font-medium"
>
{link.text}
</Link>
);
})}
</div>
{/* Right side buttons */}
<div className="flex items-center gap-2">
<GithubButton />
{/* Sign in button */}
<Button asChild>
<Link
className="hidden md:flex"
href="https://dashboard.openpanel.dev/login"
>
Sign in
</Link>
</Button>
<Button
className="md:hidden -my-2"
size="icon"
variant="ghost"
onClick={() => {
setIsMobileMenuOpen((p) => !p);
}}
>
<MenuIcon className="w-4 h-4" />
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden -mx-4"
>
<div className="rounded-xl bg-background/90 border border-foreground/10 mt-4 md:hidden backdrop-blur-lg">
<div className="col text-sm divide-y divide-foreground/10">
{baseOptions.links?.map((link) => {
if (link.type !== 'main') return null;
return (
<Link
key={link.url}
href={link.url!}
className="text-foreground/80 hover:text-foreground text-xl font-medium p-4 px-4"
onClick={() => setIsMobileMenuOpen(false)}
>
{link.text}
</Link>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</nav>
);
};
export default Navbar;

View File

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

View File

@@ -0,0 +1,31 @@
import { cn } from '@/lib/utils';
export function Section({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return <section className={cn('my-32 col', className)}>{children}</section>;
}
export function SectionHeader({
tag,
title,
description,
className,
}: {
tag?: React.ReactNode;
title: string;
description: string;
className?: string;
}) {
return (
<div className={cn('col gap-4 center-center mb-16', className)}>
{tag}
<h2 className="text-4xl font-medium">{title}</h2>
<p className="text-muted-foreground max-w-screen-sm">{description}</p>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { ShieldQuestionIcon } from 'lucide-react';
import Script from 'next/script';
import { Section, SectionHeader } from '../section';
import { Tag } from '../tag';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../ui/accordion';
const questions = [
{
question: 'Does OpenPanel have a free tier?',
answer: [
'For our Cloud plan we offer a 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!',
'',
'Why does OpenPanel not have a free tier?',
'We want to make sure that OpenPanel is used by people who are serious about using it. We also need to invest time and resources to maintain the platform and provide support to our users.',
],
},
{
question: 'Is everything really unlimited?',
answer: [
'Everything except the amount of events is unlimited.',
'We do not limit the amount of users, projects, dashboards, etc. We want a transparent and fair pricing model and we think unlimited is the best way to do this.',
],
},
{
question: 'What is the difference between web and product analytics?',
answer: [
'Web analytics focuses on website traffic metrics like page views, bounce rates, and visitor sources. Product analytics goes deeper into user behavior, tracking specific actions, user journeys, and feature usage within your application.',
],
},
{
question: 'Do I need to modify my code to use OpenPanel?',
answer: [
'Minimal setup is required. Simply add our lightweight JavaScript snippet to your website or use one of our SDKs for your preferred framework. Most common frameworks like React, Vue, and Next.js are supported.',
],
},
{
question: 'Is my data GDPR compliant?',
answer: [
'Yes, OpenPanel is fully GDPR compliant. We collect only essential data, do not use cookies for tracking, and provide tools to help you maintain compliance with privacy regulations.',
'You can self-host OpenPanel to keep full control of your data.',
],
},
{
question: 'How does OpenPanel compare to Mixpanel?',
answer: [
'OpenPanel offers most of Mixpanel report features such as funnels, retention and visualizations of your data. If you miss something, please let us know. The biggest difference is that OpenPanel offers better web analytics.',
'Other than that OpenPanel is way cheaper and can also be self-hosted.',
],
},
{
question: 'How does OpenPanel compare to Plausible?',
answer: [
`OpenPanel's web analytics is inspired by Plausible like many other analytics tools. The difference is that OpenPanel offers more tools for product analytics and better support for none web devices (iOS,Android and servers).`,
],
},
{
question: 'How does OpenPanel compare to Google Analytics?',
answer: [
'OpenPanel offers a more privacy-focused, user-friendly alternative to Google Analytics. We provide real-time data, no sampling, and more intuitive product analytics features.',
'Unlike GA4, our interface is designed to be simple yet powerful, making it easier to find the insights you need.',
],
},
{
question: 'Can I export my data?',
answer: [
'Currently you can export your data with our API. Depending on how many events you have this can be an issue.',
'We are working on better export options and will be finished around Q1 2025.',
],
},
{
question: 'What kind of support do you offer?',
answer: ['Currently we offer support through GitHub and Discord.'],
},
];
export default Faq;
export function Faq() {
// Create the JSON-LD structured data
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: questions.map((q) => ({
'@type': 'Question',
name: q.question,
acceptedAnswer: {
'@type': 'Answer',
text: q.answer.join(' '),
},
})),
};
return (
<Section className="container">
{/* Add the JSON-LD script */}
<Script
strategy="beforeInteractive"
id="faq-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<SectionHeader
tag={
<Tag>
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
Get answers today
</Tag>
}
title="FAQ"
description="Some of the most common questions we get asked."
/>
<Accordion
type="single"
collapsible
className="w-full max-w-screen-md self-center"
>
{questions.map((q) => (
<AccordionItem value={q.question} key={q.question}>
<AccordionTrigger className="text-left">
{q.question}
</AccordionTrigger>
<AccordionContent>
<div className="max-w-2xl col gap-2">
{q.answer.map((a) => (
<p key={a}>{a}</p>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Section>
);
}

View File

@@ -0,0 +1,321 @@
import {
Feature,
FeatureContent,
FeatureList,
FeatureListItem,
FeatureMore,
SmallFeature,
} from '@/components/feature';
import { Section, SectionHeader } from '@/components/section';
import { Tag } from '@/components/tag';
import {
ActivityIcon,
AreaChartIcon,
BarChart2Icon,
BarChartIcon,
CheckIcon,
ClockIcon,
CloudIcon,
ConeIcon,
CookieIcon,
DatabaseIcon,
GithubIcon,
LayersIcon,
LineChartIcon,
LockIcon,
MapIcon,
PieChartIcon,
ServerIcon,
Share2Icon,
ShieldIcon,
UserIcon,
WalletIcon,
ZapIcon,
} from 'lucide-react';
import { BatteryIcon } from '../battery-icon';
import { EventsFeature } from './features/events-feature';
import { ProductAnalyticsFeature } from './features/product-analytics-feature';
import { ProfilesFeature } from './features/profiles-feature';
import { WebAnalyticsFeature } from './features/web-analytics-feature';
export function Features() {
return (
<Section className="container">
<SectionHeader
className="mb-16"
tag={
<Tag>
<BatteryIcon className="size-4" strokeWidth={1.5} />
Batteries included
</Tag>
}
title="Everything you need"
description="We have combined the best features from the most popular analytics tools into one simple to use platform."
/>
<div className="col gap-16">
<Feature media={<WebAnalyticsFeature />}>
<FeatureContent
title="Web analytics"
content={[
'Privacy-friendly analytics with all the important metrics you need, in a simple and modern interface.',
]}
/>
<FeatureList
className="mt-4"
title="Get a quick overview"
items={[
<FeatureListItem key="line" icon={CheckIcon} title="Visitors" />,
<FeatureListItem key="line" icon={CheckIcon} title="Referrals" />,
<FeatureListItem key="line" icon={CheckIcon} title="Top pages" />,
<FeatureListItem
key="line"
icon={CheckIcon}
title="Top entries"
/>,
<FeatureListItem
key="line"
icon={CheckIcon}
title="Top exists"
/>,
<FeatureListItem key="line" icon={CheckIcon} title="Devices" />,
<FeatureListItem key="line" icon={CheckIcon} title="Sessions" />,
<FeatureListItem
key="line"
icon={CheckIcon}
title="Bounce rate"
/>,
<FeatureListItem key="line" icon={CheckIcon} title="Duration" />,
<FeatureListItem key="line" icon={CheckIcon} title="Geography" />,
]}
/>
</Feature>
<Feature reverse media={<ProductAnalyticsFeature />}>
<FeatureContent
title="Product analytics"
content={[
'Turn data into decisions with powerful visualizations and real-time insights.',
]}
/>
<FeatureList
className="mt-4"
title="Understand your product"
items={[
<FeatureListItem key="funnel" icon={ConeIcon} title="Funnel" />,
<FeatureListItem
key="retention"
icon={UserIcon}
title="Retention"
/>,
<FeatureListItem
key="bar"
icon={BarChartIcon}
title="A/B tests"
/>,
<FeatureListItem
key="pie"
icon={PieChartIcon}
title="Conversion"
/>,
]}
/>
<FeatureList
className="mt-4"
title="Supported charts"
items={[
<FeatureListItem key="line" icon={LineChartIcon} title="Line" />,
<FeatureListItem key="bar" icon={BarChartIcon} title="Bar" />,
<FeatureListItem key="pie" icon={PieChartIcon} title="Pie" />,
<FeatureListItem key="area" icon={AreaChartIcon} title="Area" />,
<FeatureListItem
key="histogram"
icon={BarChart2Icon}
title="Histogram"
/>,
<FeatureListItem key="map" icon={MapIcon} title="Map" />,
]}
/>
</Feature>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<SmallFeature className="[&_[data-icon]]:hover:bg-blue-500">
<FeatureContent
icon={<ClockIcon className="size-8" strokeWidth={1} />}
title="Real time analytics"
content={[
'Get instant insights into your data. No need to wait for data to be processed, like other tools out there, looking at you GA4...',
]}
/>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-purple-500">
<FeatureContent
icon={<DatabaseIcon className="size-8" strokeWidth={1} />}
title="Own your own data"
content={[
'Own your data, no vendor lock-in. Export all your data with our export API.',
'Self-host it on your own infrastructure to have complete control.',
]}
/>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
<FeatureContent
icon={<CloudIcon className="size-8" strokeWidth={1} />}
title="Cloud or self-hosted"
content={[
'We offer a cloud version of the platform, but you can also self-host it on your own infrastructure.',
]}
/>
<FeatureMore
href="/docs/self-hosting/self-hosting"
className="mt-4 -mb-4"
>
More about self-hosting
</FeatureMore>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-green-500">
<FeatureContent
icon={<CookieIcon className="size-8" strokeWidth={1} />}
title="No cookies"
content={[
'We care about your privacy, so our tracker does not use cookies. This keeps your data safe and secure.',
'We follow GDPR guidelines closely, ensuring your personal information is protected without using invasive technologies.',
]}
/>
<FeatureMore
href="/articles/cookieless-analytics"
className="mt-4 -mb-4"
>
Cookieless analytics
</FeatureMore>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-gray-500">
<FeatureContent
icon={<GithubIcon className="size-8" strokeWidth={1} />}
title="Open-source"
content={[
'Our code is open and transparent. Contribute, fork, or learn from our implementation.',
]}
/>
<FeatureMore
href="https://git.new/openpanel"
className="mt-4 -mb-4"
>
View the code
</FeatureMore>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-purple-500">
<FeatureContent
icon={<LockIcon className="size-8" strokeWidth={1} />}
title="Your data, your rules"
content={[
'Complete control over your data. Export, delete, or manage it however you need.',
]}
/>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-yellow-500">
<FeatureContent
icon={<WalletIcon className="size-8" strokeWidth={1} />}
title="Affordably priced"
content={[
'Transparent pricing that scales with your needs. No hidden fees or surprise charges.',
]}
/>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-orange-500">
<FeatureContent
icon={<ZapIcon className="size-8" strokeWidth={1} />}
title="Moving fast"
content={[
'Regular updates and improvements. We move quickly to add features you need.',
]}
/>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-blue-500">
<FeatureContent
icon={<ActivityIcon className="size-8" strokeWidth={1} />}
title="Real-time data"
content={[
'See your analytics as they happen. No waiting for data processing or updates.',
]}
/>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
<FeatureContent
icon={<Share2Icon className="size-8" strokeWidth={1} />}
title="Sharable reports"
content={[
'Easily share insights with your team. Export and distribute reports with a single click.',
<i key="soon">Coming soon</i>,
]}
/>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-pink-500">
<FeatureContent
icon={<BarChart2Icon className="size-8" strokeWidth={1} />}
title="Visualize your data"
content={[
'Beautiful, interactive visualizations that make your data easy to understand.',
]}
/>
</SmallFeature>
<SmallFeature className="[&_[data-icon]]:hover:bg-indigo-500">
<FeatureContent
icon={<LayersIcon className="size-8" strokeWidth={1} />}
title="Best of both worlds"
content={[
'Combine the power of self-hosting with the convenience of cloud deployment.',
]}
/>
</SmallFeature>
</div>
<Feature media={<EventsFeature />}>
<FeatureContent
title="Your events"
content={[
'Track every user interaction with powerful real-time event analytics. See all event properties, user actions, and conversion data in one place.',
'From pageviews to custom events, get complete visibility into how users actually use your product.',
]}
/>
<FeatureList
cols={1}
className="mt-4"
title="Some goodies"
items={[
'• Events arrive within seconds',
'• Filter on any property or attribute',
'• Get notified on important events',
'• Export and analyze event data',
'• Track user journeys and conversions',
]}
/>
</Feature>
<Feature reverse media={<ProfilesFeature />}>
<FeatureContent
title="Profiles and sessions"
content={[
'Get detailed insights into how users interact with your product through comprehensive profile and session tracking. See everything from basic metrics to detailed behavioral patterns.',
'Track session duration, page views, and user journeys to understand how people actually use your product.',
]}
/>
<FeatureList
cols={1}
className="mt-4"
title="What can you see?"
items={[
'• First and last seen dates',
'• Session duration and counts',
'• Page views and activity patterns',
'• User location and device info',
'• Browser and OS details',
'• Event history and interactions',
'• Real-time activity tracking',
]}
/>
</Feature>
</div>
</Section>
);
}

View File

@@ -0,0 +1,272 @@
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import {
BellIcon,
BookOpenIcon,
DownloadIcon,
EyeIcon,
HeartIcon,
LogOutIcon,
MessageSquareIcon,
SearchIcon,
SettingsIcon,
Share2Icon,
ShoppingCartIcon,
StarIcon,
ThumbsUpIcon,
UserPlusIcon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
interface Event {
id: number;
action: string;
location: string;
platform: string;
icon: any;
color: string;
}
const locations = [
'Gothenburg',
'Stockholm',
'Oslo',
'Copenhagen',
'Berlin',
'New York',
'Singapore',
'London',
'Paris',
'Madrid',
'Rome',
'Barcelona',
'Amsterdam',
'Vienna',
];
const platforms = ['iOS', 'Android', 'Windows', 'macOS'];
const browsers = ['WebKit', 'Chrome', 'Firefox', 'Safari'];
const getCountryFlag = (country: (typeof locations)[number]) => {
switch (country) {
case 'Gothenburg':
return '🇸🇪';
case 'Stockholm':
return '🇸🇪';
case 'Oslo':
return '🇳🇴';
case 'Copenhagen':
return '🇩🇰';
case 'Berlin':
return '🇩🇪';
case 'New York':
return '🇺🇸';
case 'Singapore':
return '🇸🇬';
case 'London':
return '🇬🇧';
case 'Paris':
return '🇫🇷';
case 'Madrid':
return '🇪🇸';
case 'Rome':
return '🇮🇹';
case 'Barcelona':
return '🇪🇸';
case 'Amsterdam':
return '🇳🇱';
case 'Vienna':
return '🇦🇹';
}
};
const getPlatformIcon = (platform: (typeof platforms)[number]) => {
switch (platform) {
case 'iOS':
return '🍎';
case 'Android':
return '🤖';
case 'Windows':
return '💻';
case 'macOS':
return '🍎';
}
};
const TOTAL_EVENTS = 10;
export function EventsFeature() {
const [events, setEvents] = useState<Event[]>([
{
id: 1730663803358.4075,
action: 'purchase',
location: 'New York',
platform: 'macOS',
icon: ShoppingCartIcon,
color: 'bg-blue-500',
},
{
id: 1730663801358.3079,
action: 'logout',
location: 'Copenhagen',
platform: 'Windows',
icon: LogOutIcon,
color: 'bg-red-500',
},
{
id: 1730663799358.0283,
action: 'sign up',
location: 'Berlin',
platform: 'Android',
icon: UserPlusIcon,
color: 'bg-green-500',
},
{
id: 1730663797357.2036,
action: 'share',
location: 'Barcelona',
platform: 'macOS',
icon: Share2Icon,
color: 'bg-cyan-500',
},
{
id: 1730663795358.763,
action: 'sign up',
location: 'New York',
platform: 'macOS',
icon: UserPlusIcon,
color: 'bg-green-500',
},
{
id: 1730663792067.689,
action: 'share',
location: 'New York',
platform: 'macOS',
icon: Share2Icon,
color: 'bg-cyan-500',
},
{
id: 1730663790075.3435,
action: 'like',
location: 'Copenhagen',
platform: 'iOS',
icon: HeartIcon,
color: 'bg-pink-500',
},
{
id: 1730663788070.351,
action: 'recommend',
location: 'Oslo',
platform: 'Android',
icon: ThumbsUpIcon,
color: 'bg-orange-500',
},
{
id: 1730663786074.429,
action: 'read',
location: 'New York',
platform: 'Windows',
icon: BookOpenIcon,
color: 'bg-teal-500',
},
{
id: 1730663784065.6309,
action: 'sign up',
location: 'Gothenburg',
platform: 'iOS',
icon: UserPlusIcon,
color: 'bg-green-500',
},
]);
useEffect(() => {
// Prepend new event every 2 seconds
const interval = setInterval(() => {
setEvents((prevEvents) => [
generateEvent(),
...prevEvents.slice(0, TOTAL_EVENTS - 1),
]);
}, 2000);
return () => clearInterval(interval);
}, []);
return (
<div className="overflow-hidden p-8 max-h-[700px]">
<div
className="min-w-[500px] gap-4 flex flex-col overflow-hidden relative isolate"
// style={{ height: 60 * TOTAL_EVENTS + 16 * (TOTAL_EVENTS - 1) }}
>
<AnimatePresence mode="popLayout" initial={false}>
{events.map((event) => (
<motion.div
key={event.id}
className="flex items-center shadow bg-background-light rounded"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: '60px' }}
exit={{ opacity: 0, height: 0 }}
transition={{
duration: 0.3,
type: 'spring',
stiffness: 500,
damping: 50,
opacity: { duration: 0.2 },
}}
>
<div className="flex items-center gap-2 w-[200px] py-2 px-4">
<div
className={`size-8 rounded-full bg-background flex items-center justify-center ${event.color} text-white `}
>
{event.icon && <event.icon size={16} />}
</div>
<span className="font-medium truncate">{event.action}</span>
</div>
<div className="w-[150px] py-2 px-4 truncate">
<span className="mr-2 text-xl relative top-px">
{getCountryFlag(event.location)}
</span>
{event.location}
</div>
<div className="w-[150px] py-2 px-4 truncate">
<span className="mr-2 text-xl relative top-px">
{getPlatformIcon(event.platform)}
</span>
{event.platform}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}
// Helper function to generate events (moved outside component)
function generateEvent() {
const actions = [
{ text: 'sign up', icon: UserPlusIcon, color: 'bg-green-500' },
{ text: 'purchase', icon: ShoppingCartIcon, color: 'bg-blue-500' },
{ text: 'screen view', icon: EyeIcon, color: 'bg-purple-500' },
{ text: 'logout', icon: LogOutIcon, color: 'bg-red-500' },
{ text: 'like', icon: HeartIcon, color: 'bg-pink-500' },
{ text: 'comment', icon: MessageSquareIcon, color: 'bg-indigo-500' },
{ text: 'share', icon: Share2Icon, color: 'bg-cyan-500' },
{ text: 'download', icon: DownloadIcon, color: 'bg-emerald-500' },
{ text: 'notification', icon: BellIcon, color: 'bg-violet-500' },
{ text: 'settings', icon: SettingsIcon, color: 'bg-slate-500' },
{ text: 'search', icon: SearchIcon, color: 'bg-violet-500' },
{ text: 'read', icon: BookOpenIcon, color: 'bg-teal-500' },
{ text: 'recommend', icon: ThumbsUpIcon, color: 'bg-orange-500' },
{ text: 'favorite', icon: StarIcon, color: 'bg-yellow-500' },
];
const selectedAction = actions[Math.floor(Math.random() * actions.length)];
return {
id: Date.now() + Math.random(),
action: selectedAction.text,
location: locations[Math.floor(Math.random() * locations.length)],
platform: platforms[Math.floor(Math.random() * platforms.length)],
icon: selectedAction.icon,
color: selectedAction.color,
};
}

View File

@@ -0,0 +1,193 @@
'use client';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
// Mock data structure for retention cohort
const COHORT_DATA = [
{
week: 'Week 1',
users: '2,543',
retention: [100, 84, 73, 67, 62, 58],
},
{
week: 'Week 2',
users: '2,148',
retention: [100, 80, 69, 63, 59, 55],
},
{
week: 'Week 3',
users: '1,958',
retention: [100, 82, 71, 64, 60, 56],
},
{
week: 'Week 4',
users: '2,034',
retention: [100, 83, 72, 65, 61, 57],
},
{
week: 'Week 5',
users: '1,987',
retention: [100, 81, 70, 64, 60, 56],
},
{
week: 'Week 6',
users: '2,245',
retention: [100, 85, 74, 68, 64, 60],
},
{
week: 'Week 7',
users: '2,108',
retention: [100, 82, 71, 65, 61],
},
{
week: 'Week 8',
users: '1,896',
retention: [100, 83, 72, 66],
},
{
week: 'Week 9',
users: '2,156',
retention: [100, 81, 70],
},
];
const COHORT_DATA_ALT = [
{
week: 'Week 1',
users: '2,876',
retention: [100, 79, 76, 70, 65, 61],
},
{
week: 'Week 2',
users: '2,543',
retention: [100, 85, 73, 67, 62, 58],
},
{
week: 'Week 3',
users: '2,234',
retention: [100, 79, 75, 68, 63, 59],
},
{
week: 'Week 4',
users: '2,456',
retention: [100, 88, 77, 69, 65, 61],
},
{
week: 'Week 5',
users: '2,321',
retention: [100, 77, 73, 67, 54, 42],
},
{
week: 'Week 6',
users: '2,654',
retention: [100, 91, 83, 69, 66, 62],
},
{
week: 'Week 7',
users: '2,432',
retention: [100, 93, 88, 72, 64],
},
{
week: 'Week 8',
users: '2,123',
retention: [100, 78, 76, 69],
},
{
week: 'Week 9',
users: '2,567',
retention: [100, 70, 64],
},
];
function RetentionCell({ percentage }: { percentage: number }) {
// Calculate color intensity based on percentage
const getBackgroundColor = (value: number) => {
if (value === 0) return 'bg-transparent';
// Using CSS color mixing to create a gradient from light to dark blue
return `rgb(${Math.round(239 - value * 1.39)} ${Math.round(246 - value * 1.46)} ${Math.round(255 - value * 0.55)})`;
};
return (
<div className="flex items-center justify-center p-px text-sm font-medium w-[80px]">
<div
className="flex text-white items-center justify-center w-full h-full rounded"
style={{
backgroundColor: getBackgroundColor(percentage),
}}
>
<motion.span
key={percentage}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{percentage}%
</motion.span>
</div>
</div>
);
}
export function ProductAnalyticsFeature() {
const [currentData, setCurrentData] = useState(COHORT_DATA);
useEffect(() => {
const interval = setInterval(() => {
setCurrentData((current) =>
current === COHORT_DATA ? COHORT_DATA_ALT : COHORT_DATA,
);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="p-4 w-full overflow-hidden">
<div className="flex">
{/* Header row */}
<div className="min-w-[70px] flex flex-col">
<div className="p-2 font-medium text-xs text-muted-foreground">
Cohort
</div>
</div>
{/* Week numbers - changed length to 6 */}
<div className="flex">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i.toString()}
className="text-muted-foreground w-[80px] text-xs text-center p-2 font-medium"
>
W{i + 1}
</div>
))}
</div>
</div>
{/* Data rows */}
<div className="flex flex-col">
{currentData.map((cohort, rowIndex) => (
<div key={rowIndex.toString()} className="flex">
<div className="min-w-[70px] flex flex-col">
<div className="p-2 text-sm whitespace-nowrap text-muted-foreground">
{cohort.week}
</div>
</div>
<div className="flex">
{cohort.retention.map((value, cellIndex) => (
<RetentionCell key={cellIndex.toString()} percentage={value} />
))}
{/* Fill empty cells - changed length to 6 */}
{Array.from({ length: 6 - cohort.retention.length }).map(
(_, i) => (
<div key={`empty-${i.toString()}`} className="w-[80px] p-px">
<div className="h-full w-full rounded bg-background" />
</div>
),
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import Image from 'next/image';
import { useEffect, useState } from 'react';
const PROFILES = [
{
name: 'Joe Bloggs',
email: 'joe@bloggs.com',
avatar: '/avatar.jpg',
stats: {
firstSeen: 'about 2 months',
lastSeen: '41 minutes',
sessions: '8',
avgSession: '5m 59s',
p90Session: '7m 42s',
pageViews: '41',
},
},
{
name: 'Jane Smith',
email: 'jane@smith.com',
avatar: '/avatar-2.jpg',
stats: {
firstSeen: 'about 1 month',
lastSeen: '2 hours',
sessions: '12',
avgSession: '4m 32s',
p90Session: '6m 15s',
pageViews: '35',
},
},
{
name: 'Alex Johnson',
email: 'alex@johnson.com',
avatar: '/avatar-3.jpg',
stats: {
firstSeen: 'about 3 months',
lastSeen: '15 minutes',
sessions: '15',
avgSession: '6m 20s',
p90Session: '8m 10s',
pageViews: '52',
},
},
];
export function ProfilesFeature() {
const [currentIndex, setCurrentIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(true);
useEffect(() => {
const timer = setInterval(() => {
if (currentIndex === PROFILES.length) {
setIsTransitioning(false);
setCurrentIndex(0);
setTimeout(() => setIsTransitioning(true), 50);
} else {
setCurrentIndex((current) => current + 1);
}
}, 3000);
return () => clearInterval(timer);
}, [currentIndex]);
return (
<div className="overflow-hidden">
<div
className={`flex ${isTransitioning ? 'transition-transform duration-500 ease-in-out' : ''}`}
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{[...PROFILES, PROFILES[0]].map((profile, index) => (
<div
key={profile.name + index.toString()}
className="w-full flex-shrink-0 p-8"
>
<div className="col md:row justify-center md:justify-start items-center gap-4">
<Image
src={profile.avatar}
className="size-32 rounded-full"
width={128}
height={128}
alt={profile.name}
/>
<div>
<div className="text-3xl font-semibold">{profile.name}</div>
<div className="text-muted-foreground text-center md:text-left">
{profile.email}
</div>
</div>
</div>
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">First seen</div>
<div className="text-lg font-medium">
{profile.stats.firstSeen}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">Last seen</div>
<div className="text-lg font-medium">
{profile.stats.lastSeen}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">Sessions</div>
<div className="text-lg font-medium">
{profile.stats.sessions}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">
Avg. Session
</div>
<div className="text-lg font-medium">
{profile.stats.avgSession}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">
P90. Session
</div>
<div className="text-lg font-medium">
{profile.stats.p90Session}
</div>
</div>
<div className="rounded-lg border p-4 bg-background-light">
<div className="text-sm text-muted-foreground">Page views</div>
<div className="text-lg font-medium">
{profile.stats.pageViews}
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { SimpleChart } from '@/components/simple-chart';
import { cn } from '@/lib/utils';
import NumberFlow from '@number-flow/react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowUpIcon } from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState } from 'react';
const TRAFFIC_SOURCES = [
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Fgoogle.com',
name: 'Google',
percentage: 49,
value: 2039,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Finstagram.com',
name: 'Instagram',
percentage: 23,
value: 920,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ffacebook.com',
name: 'Facebook',
percentage: 18,
value: 750,
},
{
icon: 'https://api.openpanel.dev/misc/favicon?url=https%3A%2F%2Ftwitter.com',
name: 'Twitter',
percentage: 10,
value: 412,
},
];
const COUNTRIES = [
{ icon: '🇺🇸', name: 'United States', percentage: 37, value: 1842 },
{ icon: '🇩🇪', name: 'Germany', percentage: 28, value: 1391 },
{ icon: '🇬🇧', name: 'United Kingdom', percentage: 20, value: 982 },
{ icon: '🇯🇵', name: 'Japan', percentage: 15, value: 751 },
];
export function WebAnalyticsFeature() {
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
const [currentCountryIndex, setCurrentCountryIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCurrentSourceIndex((prev) => (prev + 1) % TRAFFIC_SOURCES.length);
setCurrentCountryIndex((prev) => (prev + 1) % COUNTRIES.length);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="p-8 relative col gap-4">
<div className="relative">
<MetricCard
title="Session duration"
value="3m 23s"
change="3%"
chartPoints={[40, 10, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="hsl(var(--red))"
className="w-full rotate-3 -left-2 hover:-translate-y-1 transition-all duration-300"
/>
<MetricCard
title="Bounce rate"
value="46%"
change="3%"
chartPoints={[10, 46, 20, 43, 20, 40, 30, 37, 40, 34, 50, 31]}
color="hsl(var(--green))"
className="w-full -mt-8 -rotate-2 left-2 top-14 hover:-translate-y-1 transition-all duration-300"
/>
</div>
<div>
<div className="-rotate-2 bg-background-light rounded-lg col gap-2 p-2 shadow-lg">
<BarCell {...TRAFFIC_SOURCES[currentSourceIndex]} />
<BarCell
{...TRAFFIC_SOURCES[
(currentSourceIndex + 1) % TRAFFIC_SOURCES.length
]}
/>
</div>
<div className="rotate-1 bg-background-light rounded-lg col gap-2 p-2 shadow-lg">
<BarCell {...COUNTRIES[currentCountryIndex]} />
<BarCell
{...COUNTRIES[(currentCountryIndex + 1) % COUNTRIES.length]}
/>
</div>
</div>
</div>
);
}
function MetricCard({
title,
value,
change,
chartPoints,
color,
className,
}: {
title: string;
value: string;
change: string;
chartPoints: number[];
color: string;
className?: string;
}) {
return (
<div
className={cn(
'row items-end bg-background-light rounded-lg p-4 pb-6 border justify-between',
className,
)}
>
<div>
<div className="text-muted-foreground text-xl">{title}</div>
<div className="text-5xl font-bold font-mono">{value}</div>
</div>
<div className="row gap-2 items-center font-mono font-medium text-lg">
<div
className="size-6 rounded-full flex items-center justify-center"
style={{
background: color,
}}
>
<ArrowUpIcon className="size-4" strokeWidth={3} />
</div>
<div>{change}</div>
</div>
<SimpleChart
width={500}
height={30}
points={chartPoints}
className="absolute bottom-0 left-0 right-0"
strokeColor={color}
/>
</div>
);
}
function BarCell({
icon,
name,
percentage,
value,
}: {
icon: string;
name: string;
percentage: number;
value: number;
}) {
return (
<div className="relative p-2">
<div
className="absolute bg-background-dark bottom-0 top-0 left-0 rounded-lg transition-all duration-500"
style={{
width: `${percentage}%`,
}}
/>
<div className="relative row justify-between ">
<div className="row gap-2 items-center font-medium">
{icon.startsWith('http') ? (
<Image
alt="serie icon"
className="max-h-4 rounded-[2px] object-contain"
src={icon}
width={16}
height={16}
/>
) : (
<div className="text-2xl">{icon}</div>
)}
<AnimatePresence mode="popLayout">
<motion.div
key={name}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3 }}
>
{name}
</motion.div>
</AnimatePresence>
</div>
<div className="row gap-3 font-mono">
<span className="text-muted-foreground">
<NumberFlow value={percentage} />%
</span>
<NumberFlow value={value} locales={'en-US'} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
import { cn } from '@/lib/utils';
import { PRICING } from '@openpanel/payments/src/prices';
import { CheckIcon, ChevronRightIcon, DollarSignIcon } from 'lucide-react';
import Link from 'next/link';
import { DoubleSwirl } from '../Swirls';
import { Section, SectionHeader } from '../section';
import { Tag } from '../tag';
import { Button } from '../ui/button';
import { Tooltiper } from '../ui/tooltip';
export default Pricing;
export function Pricing({ className }: { className?: string }) {
return (
<Section
className={cn(
'overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground xl:rounded-xl p-0 pb-32 pt-16 max-w-7xl mx-auto',
className,
)}
>
<DoubleSwirl className="absolute top-0 left-0" />
<div className="container relative z-10 col">
<SectionHeader
tag={
<Tag variant={'dark'}>
<DollarSignIcon className="size-4" />
Simple and predictable
</Tag>
}
title="Simple pricing"
description="Just pick how many events you want to track each month. No hidden costs."
/>
<div className="grid self-center md:grid-cols-[200px_1fr] lg:grid-cols-[300px_1fr] gap-8">
<div className="col gap-4">
<h3 className="font-medium text-xl text-background/90 dark:text-foreground/90">
Stop overpaying for features
</h3>
<ul className="gap-1 col text-background/70 dark:text-foreground/70">
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />
Unlimited websites or apps
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited users
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited dashboards
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited charts
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Unlimited tracked profiles
</li>
<li className="flex items-center gap-2">
<CheckIcon className="text-background/30 dark:text-foreground/30 size-4" />{' '}
Yes, we have no limits or hidden costs
</li>
</ul>
<Button
variant="secondary"
size="lg"
asChild
className="self-start mt-4 px-8 group"
>
<Link href="https://dashboard.openpanel.dev/onboarding">
Get started now
<ChevronRightIcon className="size-4 group-hover:translate-x-1 transition-transform group-hover:scale-125" />
</Link>
</Button>
</div>
<div className="col justify-between gap-4 max-w-lg">
<div className="space-y-2">
{PRICING.map((tier) => (
<div
key={tier.events}
className={cn(
'group col',
'backdrop-blur-3xl bg-foreground/70 dark:bg-background-dark/70',
'p-4 py-2 border border-background/20 dark:border-foreground/20 rounded-lg hover:bg-background/5 dark:hover:bg-foreground/5 transition-colors',
'mx-2',
tier.discount &&
'mx-0 px-6 py-3 !bg-emerald-900/20 hover:!bg-emerald-900/30',
tier.popular &&
'mx-0 px-6 py-3 !bg-orange-900/20 hover:!bg-orange-900/30',
)}
>
<div className="row justify-between">
<div>
{new Intl.NumberFormat('en-US', {}).format(tier.events)}{' '}
<span className="text-muted-foreground text-sm max-[420px]:hidden">
events / month
</span>
</div>
<div className="row gap-4">
{tier.popular && (
<>
<Tag variant="dark" className="hidden md:inline-flex">
🔥 Popular
</Tag>
<span className="md:hidden">🔥</span>
</>
)}
{tier.discount && (
<>
<Tag
variant="dark"
className="hidden md:inline-flex whitespace-nowrap"
>
💸 Discount
</Tag>
<span className="md:hidden">💸</span>
</>
)}
<div className="row gap-1">
{tier.discount && (
<span className={cn('text-md font-semibold')}>
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(tier.price * (1 - tier.discount.amount))}
</span>
)}
<span
className={cn(
'text-md font-semibold',
tier.discount && 'line-through opacity-50',
)}
>
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(tier.price)}
</span>
</div>
</div>
</div>
{tier.discount && (
<div className="text-sm text-muted-foreground mt-2">
Limited discount code available:{' '}
<Tooltiper
content={`Get ${tier.discount.amount * 100}% off your first year`}
delayDuration={0}
side="bottom"
>
<strong>{tier.discount.code}</strong>
</Tooltiper>
</div>
)}
</div>
))}
<div
className={cn(
'group',
'row justify-between p-4 py-2 border border-background/20 dark:border-foreground/20 rounded-lg hover:bg-background/5 dark:hover:bg-foreground/5 transition-colors',
'mx-2',
)}
>
<div className="whitespace-nowrap">
Over{' '}
{new Intl.NumberFormat('en-US', {}).format(
PRICING[PRICING.length - 1].events,
)}
</div>
<div className="text-md font-semibold">
<Link
href="mailto:support@openpanel.dev"
className="group-hover:underline"
>
Contact us
</Link>
</div>
</div>
</div>
<div className="self-center text-sm text-muted-foreground mt-4 text-center max-w-[70%] w-full">
<strong className="text-background/80 dark:text-foreground/80">
All features are included upfront - no hidden costs.
</strong>{' '}
You choose how many events to track each month.
</div>
</div>
</div>
</div>
</Section>
);
}

View File

@@ -0,0 +1,86 @@
import { Section, SectionHeader } from '@/components/section';
import { Tag } from '@/components/tag';
import { type Framework, frameworks } from '@openpanel/sdk-info';
import { CodeIcon, ShieldQuestionIcon } from 'lucide-react';
import Link from 'next/link';
import { HorizontalLine, PlusLine, VerticalLine } from '../line';
import { Button } from '../ui/button';
export function Sdks() {
return (
<Section className="container overflow-hidden">
<SectionHeader
tag={
<Tag>
<ShieldQuestionIcon className="size-4" strokeWidth={1.5} />
Easy to use
</Tag>
}
title="SDKs"
description="Use our modules to integrate with your favourite framework and start collecting events with ease. Enjoy quick and seamless setup."
/>
<div className="col gap-16">
<div className="relative">
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
{frameworks.slice(0, 5).map((sdk, index) => (
<SdkCard key={sdk.name} sdk={sdk} index={index} />
))}
</div>
<HorizontalLine className="opacity-40 -left-32 -right-32" />
</div>
<div className="relative">
<HorizontalLine className="-top-px opacity-40 -left-32 -right-32" />
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 container">
{frameworks.slice(5, 10).map((sdk, index) => (
<SdkCard key={sdk.name} sdk={sdk} index={index} />
))}
</div>
<HorizontalLine className="opacity-40 -left-32 -right-32" />
</div>
<div className="center-center gap-2 col">
<h3 className="text-muted-foreground text-sm">And many more!</h3>
<Button asChild>
<Link href="/docs">Read our docs</Link>
</Button>
</div>
</div>
</Section>
);
}
function SdkCard({
sdk,
index,
}: {
sdk: Framework;
index: number;
}) {
return (
<Link
key={sdk.name}
href={sdk.href}
className="group relative z-10 col gap-2 uppercase center-center aspect-video bg-background-light rounded-lg shadow-[inset_0_0_0_1px_theme(colors.border),0_0_30px_0px_hsl(var(--border)/0.5)] transition-all hover:scale-105 hover:bg-background-dark"
>
{index === 0 && <PlusLine className="opacity-30 top-0 left-0" />}
{index === 2 && <PlusLine className="opacity-80 bottom-0 right-0" />}
<VerticalLine className="left-0 opacity-40" />
<VerticalLine className="right-0 opacity-40" />
<div className="absolute inset-0 center-center overflow-hidden opacity-20">
<sdk.IconComponent className="size-32 top-[33%] relative group-hover:top-[30%] group-hover:scale-105 transition-all" />
</div>
<div
className="center-center gap-1 col w-full h-full relative rounded-lg"
style={{
background:
'radial-gradient(circle, hsl(var(--background)) 0%, hsl(var(--background)/0.7) 100%)',
}}
>
<sdk.IconComponent className="size-8" />
{/* <h4 className="text-muted-foreground text-[10px]">{sdk.name}</h4> */}
</div>
</Link>
);
}

View File

@@ -0,0 +1,93 @@
import Link from 'next/link';
import { VerticalLine } from '../line';
import { PlusLine } from '../line';
import { HorizontalLine } from '../line';
import { Section } from '../section';
import { Button } from '../ui/button';
import { WorldMap } from '../world-map';
function shortNumber(num: number) {
if (num < 1e3) return num;
if (num >= 1e3 && num < 1e6) return `${+(num / 1e3).toFixed(1)}K`;
if (num >= 1e6 && num < 1e9) return `${+(num / 1e6).toFixed(1)}M`;
if (num >= 1e9 && num < 1e12) return `${+(num / 1e9).toFixed(1)}B`;
if (num >= 1e12) return `${+(num / 1e12).toFixed(1)}T`;
}
export async function Stats() {
const { projectsCount, eventsCount, eventsLast24hCount } = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/misc/stats`,
)
.then((res) => res.json())
.catch(() => ({
projectsCount: 0,
eventsCount: 0,
eventsLast24hCount: 0,
}));
return (
<StatsPure
projectCount={projectsCount}
eventCount={eventsCount}
last24hCount={eventsLast24hCount}
/>
);
}
export function StatsPure({
projectCount,
eventCount,
last24hCount,
}: { projectCount: number; eventCount: number; last24hCount: number }) {
return (
<Section className="bg-gradient-to-b from-background via-background-dark to-background-dark py-64 pt-44 relative overflow-hidden -mt-16">
{/* Map */}
<div className="absolute inset-0 -top-20 center-center items-start select-none opacity-10">
<div className="min-w-[1400px] w-full">
<WorldMap />
{/* Gradient over Map */}
<div className="absolute inset-0 bg-gradient-to-b from-background via-transparent to-background" />
</div>
</div>
<div className="relative">
<HorizontalLine />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 container center-center">
<div className="col gap-2 uppercase center-center relative p-4">
<VerticalLine className="hidden lg:block left-0" />
<PlusLine className="hidden lg:block top-0 left-0" />
<div className="text-muted-foreground text-xs">Active projects</div>
<div className="text-5xl font-bold font-mono">{projectCount}</div>
</div>
<div className="col gap-2 uppercase center-center relative p-4">
<VerticalLine className="hidden lg:block left-0" />
<div className="text-muted-foreground text-xs">Total events</div>
<div className="text-5xl font-bold font-mono">
{shortNumber(eventCount)}
</div>
</div>
<div className="col gap-2 uppercase center-center relative p-4">
<VerticalLine className="hidden lg:block left-0" />
<VerticalLine className="hidden lg:block right-0" />
<PlusLine className="hidden lg:block bottom-0 left-0" />
<div className="text-muted-foreground text-xs">
Events last 24 h
</div>
<div className="text-5xl font-bold font-mono">
{shortNumber(last24hCount)}
</div>
</div>
</div>
<HorizontalLine />
</div>
<div className="center-center col gap-4 absolute bottom-20 left-0 right-0 z-10">
<p>Get the analytics you deserve</p>
<Button asChild>
<Link href="https://dashboard.openpanel.dev/onboarding">
Try it for free
</Link>
</Button>
</div>
</Section>
);
}

View File

@@ -0,0 +1,113 @@
import { Section, SectionHeader } from '@/components/section';
import { Tag } from '@/components/tag';
import { TwitterCard } from '@/components/twitter-card';
import { MessageCircleIcon } from 'lucide-react';
const testimonials = [
{
verified: true,
avatarUrl: '/twitter-steven.jpg',
name: 'Steven Tey',
handle: 'steventey',
content: [
'Open-source Mixpanel alternative just dropped → http://git.new/openpanel',
'It combines the power of Mixpanel + the ease of use of @PlausibleHQ into a fully open-source product.',
'Built by @CarlLindesvard and its already tracking 750K+ events 🤩',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-pontus.jpg',
name: 'Pontus Abrahamsson — oss/acc',
handle: 'pontusab',
content: ['Thanks, OpenPanel is a beast, love it!'],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-piotr.jpg',
name: 'Piotr Kulpinski',
handle: 'piotrkulpinski',
content: [
'The Overview tab in OpenPanel is great. It has everything I need from my analytics: the stats, the graph, traffic sources, locations, devices, etc.',
'The UI is beautiful ✨ Clean, modern look, very pleasing to the eye.',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-greg.png',
name: 'greg hodson 🍜',
handle: 'h0dson',
content: ['i second this, openpanel is killing it'],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-jacob.jpg',
name: 'Jacob 🍀 Build in Public',
handle: 'javayhuwx',
content: [
"🤯 wow, it's amazing! Just integrate @OpenPanelDev into http://indiehackers.site last night, and now I can see visitors coming from all round the world.",
'OpenPanel has a more beautiful UI and much more powerful features when compared to Umami.',
'#buildinpublic #indiehackers',
],
replies: 25,
retweets: 68,
likes: 648,
},
{
verified: true,
avatarUrl: '/twitter-lee.jpg',
name: 'Lee',
handle: 'DutchEngIishman',
content: [
'Day two of marketing.',
'I like this upward trend..',
'P.S. website went live on Sunday',
'P.P.S. Openpanel by @CarlLindesvard is awesome.',
],
replies: 25,
retweets: 68,
likes: 648,
},
];
export default Testimonials;
export function Testimonials() {
return (
<Section className="container">
<SectionHeader
tag={
<Tag>
<MessageCircleIcon className="size-4" strokeWidth={1.5} />
Testimonials
</Tag>
}
title="What people say"
description="What our customers say about us."
/>
<div className="col md:row gap-4">
<div className="col gap-4 flex-1">
{testimonials.slice(0, testimonials.length / 2).map((testimonial) => (
<TwitterCard key={testimonial.handle} {...testimonial} />
))}
</div>
<div className="col gap-4 flex-1">
{testimonials.slice(testimonials.length / 2).map((testimonial) => (
<TwitterCard key={testimonial.handle} {...testimonial} />
))}
</div>
</div>
</Section>
);
}

View File

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

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