Compare commits
4 Commits
api
...
397597ae7d
| Author | SHA1 | Date | |
|---|---|---|---|
|
397597ae7d
|
|||
|
655ea1f87e
|
|||
|
fcb4cf5fb0
|
|||
|
9b197abcfa
|
55
.gitea/workflows/docker-build-api.yml
Normal file
55
.gitea/workflows/docker-build-api.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Build and Push API
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.zias.be
|
||||||
|
OWNER: zias
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-api:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.OWNER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-api
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,prefix=sha-,format=short
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/api/Dockerfile
|
||||||
|
target: runner
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
provenance: false
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-api:buildcache
|
||||||
|
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-api:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
|
||||||
|
build-args: |-
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||||
53
.gitea/workflows/docker-build-dashboard.yml
Normal file
53
.gitea/workflows/docker-build-dashboard.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Build and Push Dashboard
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.zias.be
|
||||||
|
OWNER: zias
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-dashboard:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.OWNER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-dashboard
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,prefix=sha-,format=short
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/start/Dockerfile
|
||||||
|
target: runner
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
provenance: false
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-dashboard:buildcache
|
||||||
|
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-dashboard:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
|
||||||
55
.gitea/workflows/docker-build-worker.yml
Normal file
55
.gitea/workflows/docker-build-worker.yml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
name: Build and Push Worker
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["*"]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.zias.be
|
||||||
|
OWNER: zias
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-worker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ env.OWNER }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-worker
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,prefix=sha-,format=short
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/worker/Dockerfile
|
||||||
|
target: runner
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
provenance: false
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-worker:buildcache
|
||||||
|
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-worker:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
|
||||||
|
build-args: |-
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||||
@@ -24,7 +24,7 @@ async function main() {
|
|||||||
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
||||||
const REDIS_URL = process.env.REDIS_URL;
|
const REDIS_URL = process.env.REDIS_URL;
|
||||||
|
|
||||||
if (!DATABASE_URL || !CLICKHOUSE_URL || !REDIS_URL) {
|
if (!(DATABASE_URL && CLICKHOUSE_URL && REDIS_URL)) {
|
||||||
console.error('Environment variables are not set');
|
console.error('Environment variables are not set');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function clearCache() {
|
|||||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const searchFunction = async (_answers: unknown, input = '') => {
|
const searchFunction = (_answers: unknown, input = '') => {
|
||||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||||
});
|
});
|
||||||
@@ -94,7 +94,7 @@ export async function clearCache() {
|
|||||||
console.log(chalk.yellow('\n📊 Projects:\n'));
|
console.log(chalk.yellow('\n📊 Projects:\n'));
|
||||||
for (const project of organization.projects) {
|
for (const project of organization.projects) {
|
||||||
console.log(
|
console.log(
|
||||||
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`,
|
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,9 +119,11 @@ export async function clearCache() {
|
|||||||
for (const project of organization.projects) {
|
for (const project of organization.projects) {
|
||||||
// Clear project access cache for each member
|
// Clear project access cache for each member
|
||||||
for (const member of organization.members) {
|
for (const member of organization.members) {
|
||||||
if (!member.user?.id) continue;
|
if (!member.user?.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`,
|
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`
|
||||||
);
|
);
|
||||||
await getProjectAccess.clear({
|
await getProjectAccess.clear({
|
||||||
userId: member.user?.id,
|
userId: member.user?.id,
|
||||||
@@ -141,8 +143,8 @@ export async function clearCache() {
|
|||||||
console.log(chalk.gray(`Organization ID: ${organization.id}`));
|
console.log(chalk.gray(`Organization ID: ${organization.id}`));
|
||||||
console.log(
|
console.log(
|
||||||
chalk.gray(
|
chalk.gray(
|
||||||
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`,
|
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Example of what you might do:
|
// Example of what you might do:
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export async function deleteOrganization() {
|
|||||||
console.log(chalk.red('\n🗑️ Delete Organization\n'));
|
console.log(chalk.red('\n🗑️ Delete Organization\n'));
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
'⚠️ WARNING: This will permanently delete the organization and all its data!\n',
|
'⚠️ WARNING: This will permanently delete the organization and all its data!\n'
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
console.log('Loading organizations...\n');
|
console.log('Loading organizations...\n');
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export async function deleteOrganization() {
|
|||||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`,
|
displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const searchFunction = async (_answers: unknown, input = '') => {
|
const searchFunction = (_answers: unknown, input = '') => {
|
||||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||||
});
|
});
|
||||||
@@ -107,7 +107,7 @@ export async function deleteOrganization() {
|
|||||||
console.log(chalk.red('\n Projects that will be deleted:'));
|
console.log(chalk.red('\n Projects that will be deleted:'));
|
||||||
for (const project of organization.projects) {
|
for (const project of organization.projects) {
|
||||||
console.log(
|
console.log(
|
||||||
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`,
|
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,8 +122,8 @@ export async function deleteOrganization() {
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
chalk.red(
|
chalk.red(
|
||||||
'\n⚠️ This will delete ALL projects, clients, events, and data associated with this organization!',
|
'\n⚠️ This will delete ALL projects, clients, events, and data associated with this organization!'
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// First confirmation
|
// First confirmation
|
||||||
@@ -132,7 +132,7 @@ export async function deleteOrganization() {
|
|||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
name: 'confirmFirst',
|
name: 'confirmFirst',
|
||||||
message: chalk.red(
|
message: chalk.red(
|
||||||
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`,
|
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`
|
||||||
),
|
),
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -154,7 +154,7 @@ export async function deleteOrganization() {
|
|||||||
|
|
||||||
if (confirmName !== organization.name) {
|
if (confirmName !== organization.name) {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.red('\n❌ Organization name does not match. Deletion cancelled.'),
|
chalk.red('\n❌ Organization name does not match. Deletion cancelled.')
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ export async function deleteOrganization() {
|
|||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
name: 'confirmFinal',
|
name: 'confirmFinal',
|
||||||
message: chalk.red(
|
message: chalk.red(
|
||||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
'FINAL WARNING: This action CANNOT be undone. Delete now?'
|
||||||
),
|
),
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -185,8 +185,8 @@ export async function deleteOrganization() {
|
|||||||
if (projectIds.length > 0) {
|
if (projectIds.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
`Deleting data from ClickHouse for ${projectIds.length} projects...`,
|
`Deleting data from ClickHouse for ${projectIds.length} projects...`
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
await deleteFromClickhouse(projectIds);
|
await deleteFromClickhouse(projectIds);
|
||||||
console.log(chalk.green('✓ ClickHouse data deletion initiated'));
|
console.log(chalk.green('✓ ClickHouse data deletion initiated'));
|
||||||
@@ -200,13 +200,13 @@ export async function deleteOrganization() {
|
|||||||
console.log(chalk.green('\n✅ Organization deleted successfully!'));
|
console.log(chalk.green('\n✅ Organization deleted successfully!'));
|
||||||
console.log(
|
console.log(
|
||||||
chalk.gray(
|
chalk.gray(
|
||||||
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`,
|
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
chalk.gray(
|
chalk.gray(
|
||||||
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.',
|
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.'
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(chalk.red('\n❌ Error deleting organization:'), error);
|
console.error(chalk.red('\n❌ Error deleting organization:'), error);
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export async function deleteUser() {
|
|||||||
console.log(chalk.red('\n🗑️ Delete User\n'));
|
console.log(chalk.red('\n🗑️ Delete User\n'));
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n',
|
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n'
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
console.log('Loading users...\n');
|
console.log('Loading users...\n');
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export async function deleteUser() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchFunction = async (_answers: unknown, input = '') => {
|
const searchFunction = (_answers: unknown, input = '') => {
|
||||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||||
extract: (item: UserSearchItem) =>
|
extract: (item: UserSearchItem) =>
|
||||||
`${item.email} ${item.firstName || ''} ${item.lastName || ''}`,
|
`${item.email} ${item.firstName || ''} ${item.lastName || ''}`,
|
||||||
@@ -107,46 +107,46 @@ export async function deleteUser() {
|
|||||||
console.log(` ${chalk.bold('User:')} ${user.email}`);
|
console.log(` ${chalk.bold('User:')} ${user.email}`);
|
||||||
if (user.firstName || user.lastName) {
|
if (user.firstName || user.lastName) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`,
|
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log(` ${chalk.gray('ID:')} ${user.id}`);
|
console.log(` ${chalk.gray('ID:')} ${user.id}`);
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`,
|
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`
|
||||||
);
|
);
|
||||||
console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`);
|
console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`);
|
||||||
|
|
||||||
if (user.createdOrganizations.length > 0) {
|
if (user.createdOrganizations.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.red(
|
chalk.red(
|
||||||
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`,
|
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
for (const org of user.createdOrganizations) {
|
for (const org of user.createdOrganizations) {
|
||||||
console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`);
|
console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`);
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
' Note: These organizations will NOT be deleted, only the user reference.',
|
' Note: These organizations will NOT be deleted, only the user reference.'
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.membership.length > 0) {
|
if (user.membership.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
chalk.red('\n Organizations where user will be removed from:'),
|
chalk.red('\n Organizations where user will be removed from:')
|
||||||
);
|
);
|
||||||
for (const member of user.membership) {
|
for (const member of user.membership) {
|
||||||
console.log(
|
console.log(
|
||||||
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`,
|
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
chalk.red(
|
chalk.red(
|
||||||
'\n⚠️ This will delete the user account, all sessions, and remove them from all organizations!',
|
'\n⚠️ This will delete the user account, all sessions, and remove them from all organizations!'
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// First confirmation
|
// First confirmation
|
||||||
@@ -155,7 +155,7 @@ export async function deleteUser() {
|
|||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
name: 'confirmFirst',
|
name: 'confirmFirst',
|
||||||
message: chalk.red(
|
message: chalk.red(
|
||||||
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`,
|
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`
|
||||||
),
|
),
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -186,7 +186,7 @@ export async function deleteUser() {
|
|||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
name: 'confirmFinal',
|
name: 'confirmFinal',
|
||||||
message: chalk.red(
|
message: chalk.red(
|
||||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
'FINAL WARNING: This action CANNOT be undone. Delete now?'
|
||||||
),
|
),
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
@@ -210,8 +210,8 @@ export async function deleteUser() {
|
|||||||
console.log(chalk.green('\n✅ User deleted successfully!'));
|
console.log(chalk.green('\n✅ User deleted successfully!'));
|
||||||
console.log(
|
console.log(
|
||||||
chalk.gray(
|
chalk.gray(
|
||||||
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`,
|
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(chalk.red('\n❌ Error deleting user:'), error);
|
console.error(chalk.red('\n❌ Error deleting user:'), error);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function lookupByClient() {
|
|||||||
displayText: `${client.organization.name} → ${client.project?.name || '[No Project]'} → ${client.name} ${chalk.gray(`(${client.id})`)}`,
|
displayText: `${client.organization.name} → ${client.project?.name || '[No Project]'} → ${client.name} ${chalk.gray(`(${client.id})`)}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const searchFunction = async (_answers: unknown, input = '') => {
|
const searchFunction = (_answers: unknown, input = '') => {
|
||||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||||
extract: (item: ClientSearchItem) =>
|
extract: (item: ClientSearchItem) =>
|
||||||
`${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`,
|
`${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`,
|
||||||
@@ -101,4 +101,3 @@ export async function lookupByClient() {
|
|||||||
highlightClientId: selectedClient.id,
|
highlightClientId: selectedClient.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function lookupByEmail() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchFunction = async (_answers: unknown, input = '') => {
|
const searchFunction = (_answers: unknown, input = '') => {
|
||||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||||
extract: (item: EmailSearchItem) =>
|
extract: (item: EmailSearchItem) =>
|
||||||
`${item.email} ${item.organizationName}`,
|
`${item.email} ${item.organizationName}`,
|
||||||
@@ -103,10 +103,9 @@ export async function lookupByEmail() {
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(
|
chalk.yellow(
|
||||||
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`,
|
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
displayOrganizationDetails(organization);
|
displayOrganizationDetails(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function lookupByOrg() {
|
|||||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const searchFunction = async (_answers: unknown, input = '') => {
|
const searchFunction = (_answers: unknown, input = '') => {
|
||||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||||
});
|
});
|
||||||
@@ -85,4 +85,3 @@ export async function lookupByOrg() {
|
|||||||
|
|
||||||
displayOrganizationDetails(organization);
|
displayOrganizationDetails(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function lookupByProject() {
|
|||||||
displayText: `${project.organization.name} → ${project.name} ${chalk.gray(`(${project.id})`)}`,
|
displayText: `${project.organization.name} → ${project.name} ${chalk.gray(`(${project.id})`)}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const searchFunction = async (_answers: unknown, input = '') => {
|
const searchFunction = (_answers: unknown, input = '') => {
|
||||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||||
extract: (item: ProjectSearchItem) =>
|
extract: (item: ProjectSearchItem) =>
|
||||||
`${item.organizationName} ${item.name} ${item.id}`,
|
`${item.organizationName} ${item.name} ${item.id}`,
|
||||||
@@ -95,4 +95,3 @@ export async function lookupByProject() {
|
|||||||
highlightProjectId: selectedProject.id,
|
highlightProjectId: selectedProject.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ interface DisplayOptions {
|
|||||||
|
|
||||||
export function displayOrganizationDetails(
|
export function displayOrganizationDetails(
|
||||||
organization: OrganizationWithDetails,
|
organization: OrganizationWithDetails,
|
||||||
options: DisplayOptions = {},
|
options: DisplayOptions = {}
|
||||||
) {
|
) {
|
||||||
console.log(`\n${'='.repeat(80)}`);
|
console.log(`\n${'='.repeat(80)}`);
|
||||||
console.log(chalk.bold.yellow(`\n📊 ORGANIZATION: ${organization.name}`));
|
console.log(chalk.bold.yellow(`\n📊 ORGANIZATION: ${organization.name}`));
|
||||||
@@ -34,18 +34,18 @@ export function displayOrganizationDetails(
|
|||||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||||
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
|
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`,
|
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`
|
||||||
);
|
);
|
||||||
console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`);
|
console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`);
|
||||||
|
|
||||||
// Subscription info
|
// Subscription info
|
||||||
if (organization.subscriptionStatus) {
|
if (organization.subscriptionStatus) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`,
|
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`
|
||||||
);
|
);
|
||||||
if (organization.subscriptionPriceId) {
|
if (organization.subscriptionPriceId) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`,
|
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (organization.subscriptionPeriodEventsLimit) {
|
if (organization.subscriptionPeriodEventsLimit) {
|
||||||
@@ -61,24 +61,24 @@ export function displayOrganizationDetails(
|
|||||||
? chalk.yellow
|
? chalk.yellow
|
||||||
: chalk.green;
|
: chalk.green;
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`,
|
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (organization.subscriptionStartsAt) {
|
if (organization.subscriptionStartsAt) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`,
|
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (organization.subscriptionEndsAt) {
|
if (organization.subscriptionEndsAt) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`,
|
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (organization.deleteAt) {
|
if (organization.deleteAt) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`,
|
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ export function displayOrganizationDetails(
|
|||||||
for (const member of organization.members) {
|
for (const member of organization.members) {
|
||||||
const roleBadge = getRoleBadge(member.role);
|
const roleBadge = getRoleBadge(member.role);
|
||||||
console.log(
|
console.log(
|
||||||
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`,
|
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +108,7 @@ export function displayOrganizationDetails(
|
|||||||
console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`);
|
console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`);
|
||||||
console.log(` ${chalk.gray('ID:')} ${project.id}`);
|
console.log(` ${chalk.gray('ID:')} ${project.id}`);
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`,
|
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (project.domain) {
|
if (project.domain) {
|
||||||
@@ -120,15 +120,15 @@ export function displayOrganizationDetails(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`,
|
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`,
|
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (project.deleteAt) {
|
if (project.deleteAt) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`,
|
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,10 +146,10 @@ export function displayOrganizationDetails(
|
|||||||
console.log(` ${chalk.gray('ID:')} ${client.id}`);
|
console.log(` ${chalk.gray('ID:')} ${client.id}`);
|
||||||
console.log(` ${chalk.gray('Type:')} ${client.type}`);
|
console.log(` ${chalk.gray('Type:')} ${client.type}`);
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`,
|
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`,
|
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -159,7 +159,7 @@ export function displayOrganizationDetails(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clients without projects (organization-level clients)
|
// Clients without projects (organization-level clients)
|
||||||
const orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
|
const _orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
|
||||||
|
|
||||||
console.log(`\n${'='.repeat(80)}\n`);
|
console.log(`\n${'='.repeat(80)}\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,8 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
"types": [
|
"types": ["node"],
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"strictNullChecks": true
|
"strictNullChecks": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ FROM node:${NODE_VERSION}-slim AS base
|
|||||||
# FIX: Bad workaround (https://github.com/nodejs/corepack/issues/612)
|
# FIX: Bad workaround (https://github.com/nodejs/corepack/issues/612)
|
||||||
ENV COREPACK_INTEGRITY_KEYS=0
|
ENV COREPACK_INTEGRITY_KEYS=0
|
||||||
|
|
||||||
RUN corepack enable && apt-get update && \
|
RUN corepack enable && npm install -g pnpm@10.6.2 && corepack disable && apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
openssl \
|
openssl \
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path, { dirname } from 'node:path';
|
||||||
import { dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
// Regex special characters that indicate we need actual regex
|
// Regex special characters that indicate we need actual regex
|
||||||
const regexSpecialChars = /[|^$.*+?(){}\[\]\\]/;
|
const regexSpecialChars = /[|^$.*+?(){}[\]\\]/;
|
||||||
|
|
||||||
function transformBots(bots: any[]): any[] {
|
function transformBots(bots: any[]): any[] {
|
||||||
return bots.map((bot) => {
|
return bots.map((bot) => {
|
||||||
@@ -28,7 +28,7 @@ async function main() {
|
|||||||
// Get document, or throw exception on error
|
// Get document, or throw exception on error
|
||||||
try {
|
try {
|
||||||
const data = await fetch(
|
const data = await fetch(
|
||||||
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml'
|
||||||
).then((res) => res.text());
|
).then((res) => res.text());
|
||||||
|
|
||||||
const parsedData = yaml.load(data) as any[];
|
const parsedData = yaml.load(data) as any[];
|
||||||
@@ -45,11 +45,11 @@ async function main() {
|
|||||||
'export default bots;',
|
'export default bots;',
|
||||||
'',
|
'',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'utf-8',
|
'utf-8'
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Generated bots.ts with ${transformedBots.length} bot entries`,
|
`✅ Generated bots.ts with ${transformedBots.length} bot entries`
|
||||||
);
|
);
|
||||||
const regexCount = transformedBots.filter((b) => 'regex' in b).length;
|
const regexCount = transformedBots.filter((b) => 'regex' in b).length;
|
||||||
const includesCount = transformedBots.filter((b) => 'includes' in b).length;
|
const includesCount = transformedBots.filter((b) => 'includes' in b).length;
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function generateEvents(): Event[] {
|
|||||||
clientId,
|
clientId,
|
||||||
profile: profiles[i % PROFILE_COUNT]!,
|
profile: profiles[i % PROFILE_COUNT]!,
|
||||||
eventsCount: Math.floor(Math.random() * 10),
|
eventsCount: Math.floor(Math.random() * 10),
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -150,7 +150,7 @@ let lastTriggeredIndex = 0;
|
|||||||
async function triggerEvents(generatedEvents: any[]) {
|
async function triggerEvents(generatedEvents: any[]) {
|
||||||
const EVENTS_PER_SECOND = Number.parseInt(
|
const EVENTS_PER_SECOND = Number.parseInt(
|
||||||
process.env.EVENTS_PER_SECOND || '100',
|
process.env.EVENTS_PER_SECOND || '100',
|
||||||
10,
|
10
|
||||||
);
|
);
|
||||||
const INTERVAL_MS = 1000 / EVENTS_PER_SECOND;
|
const INTERVAL_MS = 1000 / EVENTS_PER_SECOND;
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ async function triggerEvents(generatedEvents: any[]) {
|
|||||||
await trackit(event);
|
await trackit(event);
|
||||||
console.log(`Event ${lastTriggeredIndex + 1} sent successfully`);
|
console.log(`Event ${lastTriggeredIndex + 1} sent successfully`);
|
||||||
console.log(
|
console.log(
|
||||||
`sending ${event.track.payload?.properties?.__path} from user ${event.headers['user-agent']}`,
|
`sending ${event.track.payload?.properties?.__path} from user ${event.headers['user-agent']}`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to send event ${lastTriggeredIndex + 1}:`, error);
|
console.error(`Failed to send event ${lastTriggeredIndex + 1}:`, error);
|
||||||
@@ -174,7 +174,7 @@ async function triggerEvents(generatedEvents: any[]) {
|
|||||||
const remainingEvents = generatedEvents.length - lastTriggeredIndex;
|
const remainingEvents = generatedEvents.length - lastTriggeredIndex;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Triggered ${lastTriggeredIndex} events. Remaining: ${remainingEvents}`,
|
`Triggered ${lastTriggeredIndex} events. Remaining: ${remainingEvents}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (remainingEvents > 0) {
|
if (remainingEvents > 0) {
|
||||||
@@ -215,7 +215,7 @@ async function createMock(file: string) {
|
|||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
file,
|
file,
|
||||||
JSON.stringify(insertFakeEvents(scrambleEvents(generateEvents())), null, 2),
|
JSON.stringify(insertFakeEvents(scrambleEvents(generateEvents())), null, 2),
|
||||||
'utf-8',
|
'utf-8'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,7 +438,7 @@ async function simultaneousRequests() {
|
|||||||
if (group.parallel && group.tracks.length > 1) {
|
if (group.parallel && group.tracks.length > 1) {
|
||||||
// Parallel execution for same-flagged tracks
|
// Parallel execution for same-flagged tracks
|
||||||
console.log(
|
console.log(
|
||||||
`Firing ${group.tracks.length} parallel requests with flag '${group.parallel}'`,
|
`Firing ${group.tracks.length} parallel requests with flag '${group.parallel}'`
|
||||||
);
|
);
|
||||||
const promises = group.tracks.map(async (track) => {
|
const promises = group.tracks.map(async (track) => {
|
||||||
const { name, parallel, ...properties } = track;
|
const { name, parallel, ...properties } = track;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const CLIENT_ID = process.env.CLIENT_ID!;
|
|||||||
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
|
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
|
||||||
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
|
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
|
||||||
|
|
||||||
if (!CLIENT_ID || !CLIENT_SECRET) {
|
if (!(CLIENT_ID && CLIENT_SECRET)) {
|
||||||
console.error('CLIENT_ID and CLIENT_SECRET must be set');
|
console.error('CLIENT_ID and CLIENT_SECRET must be set');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ const results: TestResult[] = [];
|
|||||||
async function makeRequest(
|
async function makeRequest(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: any,
|
body?: any
|
||||||
): Promise<TestResult> {
|
): Promise<TestResult> {
|
||||||
const url = `${API_BASE_URL}${path}`;
|
const url = `${API_BASE_URL}${path}`;
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@@ -90,9 +90,11 @@ async function testProjects() {
|
|||||||
});
|
});
|
||||||
results.push(createResult);
|
results.push(createResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
|
||||||
);
|
);
|
||||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
if (createResult.error) {
|
||||||
|
console.log(` Error: ${createResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
const projectId = createResult.data?.data?.id;
|
const projectId = createResult.data?.data?.id;
|
||||||
const clientId = createResult.data?.data?.client?.id;
|
const clientId = createResult.data?.data?.client?.id;
|
||||||
@@ -100,15 +102,19 @@ async function testProjects() {
|
|||||||
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
console.log(` Created project: ${projectId}`);
|
console.log(` Created project: ${projectId}`);
|
||||||
if (clientId) console.log(` Created client: ${clientId}`);
|
if (clientId) {
|
||||||
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
|
console.log(` Created client: ${clientId}`);
|
||||||
|
}
|
||||||
|
if (clientSecret) {
|
||||||
|
console.log(` Client secret: ${clientSecret}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List projects
|
// List projects
|
||||||
const listResult = await makeRequest('GET', '/manage/projects');
|
const listResult = await makeRequest('GET', '/manage/projects');
|
||||||
results.push(listResult);
|
results.push(listResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
|
||||||
);
|
);
|
||||||
if (listResult.data?.data?.length) {
|
if (listResult.data?.data?.length) {
|
||||||
console.log(` Found ${listResult.data.data.length} projects`);
|
console.log(` Found ${listResult.data.data.length} projects`);
|
||||||
@@ -119,7 +125,7 @@ async function testProjects() {
|
|||||||
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
|
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
|
||||||
results.push(getResult);
|
results.push(getResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update project
|
// Update project
|
||||||
@@ -129,21 +135,21 @@ async function testProjects() {
|
|||||||
{
|
{
|
||||||
name: 'Updated Test Project',
|
name: 'Updated Test Project',
|
||||||
crossDomain: true,
|
crossDomain: true,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
results.push(updateResult);
|
results.push(updateResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete project (soft delete)
|
// Delete project (soft delete)
|
||||||
const deleteResult = await makeRequest(
|
const deleteResult = await makeRequest(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/manage/projects/${projectId}`,
|
`/manage/projects/${projectId}`
|
||||||
);
|
);
|
||||||
results.push(deleteResult);
|
results.push(deleteResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,26 +167,30 @@ async function testClients(projectId?: string) {
|
|||||||
});
|
});
|
||||||
results.push(createResult);
|
results.push(createResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
|
||||||
);
|
);
|
||||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
if (createResult.error) {
|
||||||
|
console.log(` Error: ${createResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
const clientId = createResult.data?.data?.id;
|
const clientId = createResult.data?.data?.id;
|
||||||
const clientSecret = createResult.data?.data?.secret;
|
const clientSecret = createResult.data?.data?.secret;
|
||||||
|
|
||||||
if (clientId) {
|
if (clientId) {
|
||||||
console.log(` Created client: ${clientId}`);
|
console.log(` Created client: ${clientId}`);
|
||||||
if (clientSecret) console.log(` Client secret: ${clientSecret}`);
|
if (clientSecret) {
|
||||||
|
console.log(` Client secret: ${clientSecret}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List clients
|
// List clients
|
||||||
const listResult = await makeRequest(
|
const listResult = await makeRequest(
|
||||||
'GET',
|
'GET',
|
||||||
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients',
|
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients'
|
||||||
);
|
);
|
||||||
results.push(listResult);
|
results.push(listResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
|
||||||
);
|
);
|
||||||
if (listResult.data?.data?.length) {
|
if (listResult.data?.data?.length) {
|
||||||
console.log(` Found ${listResult.data.data.length} clients`);
|
console.log(` Found ${listResult.data.data.length} clients`);
|
||||||
@@ -191,7 +201,7 @@ async function testClients(projectId?: string) {
|
|||||||
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
|
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
|
||||||
results.push(getResult);
|
results.push(getResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update client
|
// Update client
|
||||||
@@ -200,21 +210,21 @@ async function testClients(projectId?: string) {
|
|||||||
`/manage/clients/${clientId}`,
|
`/manage/clients/${clientId}`,
|
||||||
{
|
{
|
||||||
name: 'Updated Test Client',
|
name: 'Updated Test Client',
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
results.push(updateResult);
|
results.push(updateResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete client
|
// Delete client
|
||||||
const deleteResult = await makeRequest(
|
const deleteResult = await makeRequest(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/manage/clients/${clientId}`,
|
`/manage/clients/${clientId}`
|
||||||
);
|
);
|
||||||
results.push(deleteResult);
|
results.push(deleteResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,9 +246,11 @@ async function testReferences(projectId?: string) {
|
|||||||
});
|
});
|
||||||
results.push(createResult);
|
results.push(createResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`,
|
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
|
||||||
);
|
);
|
||||||
if (createResult.error) console.log(` Error: ${createResult.error}`);
|
if (createResult.error) {
|
||||||
|
console.log(` Error: ${createResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
const referenceId = createResult.data?.data?.id;
|
const referenceId = createResult.data?.data?.id;
|
||||||
|
|
||||||
@@ -249,11 +261,11 @@ async function testReferences(projectId?: string) {
|
|||||||
// List references
|
// List references
|
||||||
const listResult = await makeRequest(
|
const listResult = await makeRequest(
|
||||||
'GET',
|
'GET',
|
||||||
`/manage/references?projectId=${projectId}`,
|
`/manage/references?projectId=${projectId}`
|
||||||
);
|
);
|
||||||
results.push(listResult);
|
results.push(listResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`,
|
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
|
||||||
);
|
);
|
||||||
if (listResult.data?.data?.length) {
|
if (listResult.data?.data?.length) {
|
||||||
console.log(` Found ${listResult.data.data.length} references`);
|
console.log(` Found ${listResult.data.data.length} references`);
|
||||||
@@ -263,11 +275,11 @@ async function testReferences(projectId?: string) {
|
|||||||
// Get reference
|
// Get reference
|
||||||
const getResult = await makeRequest(
|
const getResult = await makeRequest(
|
||||||
'GET',
|
'GET',
|
||||||
`/manage/references/${referenceId}`,
|
`/manage/references/${referenceId}`
|
||||||
);
|
);
|
||||||
results.push(getResult);
|
results.push(getResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`,
|
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update reference
|
// Update reference
|
||||||
@@ -278,21 +290,21 @@ async function testReferences(projectId?: string) {
|
|||||||
title: 'Updated Test Reference',
|
title: 'Updated Test Reference',
|
||||||
description: 'Updated description',
|
description: 'Updated description',
|
||||||
datetime: new Date().toISOString(),
|
datetime: new Date().toISOString(),
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
results.push(updateResult);
|
results.push(updateResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`,
|
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete reference
|
// Delete reference
|
||||||
const deleteResult = await makeRequest(
|
const deleteResult = await makeRequest(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
`/manage/references/${referenceId}`,
|
`/manage/references/${referenceId}`
|
||||||
);
|
);
|
||||||
results.push(deleteResult);
|
results.push(deleteResult);
|
||||||
console.log(
|
console.log(
|
||||||
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`,
|
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +340,9 @@ async function main() {
|
|||||||
.filter((r) => !r.success)
|
.filter((r) => !r.success)
|
||||||
.forEach((r) => {
|
.forEach((r) => {
|
||||||
console.log(` ❌ ${r.name} (${r.status})`);
|
console.log(` ❌ ${r.name} (${r.status})`);
|
||||||
if (r.error) console.log(` Error: ${r.error}`);
|
if (r.error) {
|
||||||
|
console.log(` Error: ${r.error}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { type IClickhouseEvent, ch, createEvent } from '@openpanel/db';
|
import { ch, formatClickhouseDate, type IClickhouseEvent } from '@openpanel/db';
|
||||||
import { formatClickhouseDate } from '@openpanel/db';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const startDate = new Date('2025-01-01T00:00:00Z');
|
const startDate = new Date('2025-01-01T00:00:00Z');
|
||||||
const endDate = new Date();
|
const endDate = new Date();
|
||||||
const eventsPerDay = 25000;
|
const eventsPerDay = 25_000;
|
||||||
const variance = 3000;
|
const variance = 3000;
|
||||||
|
|
||||||
// Event names to randomly choose from
|
// Event names to randomly choose from
|
||||||
@@ -36,7 +35,7 @@ async function main() {
|
|||||||
device_id: `device_${Math.floor(Math.random() * 1000)}`,
|
device_id: `device_${Math.floor(Math.random() * 1000)}`,
|
||||||
profile_id: `profile_${Math.floor(Math.random() * 1000)}`,
|
profile_id: `profile_${Math.floor(Math.random() * 1000)}`,
|
||||||
project_id: 'testing',
|
project_id: 'testing',
|
||||||
session_id: `session_${Math.floor(Math.random() * 10000)}`,
|
session_id: `session_${Math.floor(Math.random() * 10_000)}`,
|
||||||
properties: {
|
properties: {
|
||||||
hash: 'test-hash',
|
hash: 'test-hash',
|
||||||
'query.utm_source': 'test',
|
'query.utm_source': 'test',
|
||||||
@@ -75,7 +74,7 @@ async function main() {
|
|||||||
|
|
||||||
// Log progress
|
// Log progress
|
||||||
console.log(
|
console.log(
|
||||||
`Created ${dailyEvents} events for ${currentDate.toISOString().split('T')[0]}`,
|
`Created ${dailyEvents} events for ${currentDate.toISOString().split('T')[0]}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
|
||||||
|
import { getProjectAccess } from '@openpanel/trpc/src/access';
|
||||||
|
import { appendResponseMessages, type Message, streamText } from 'ai';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { getChatModel, getChatSystemPrompt } from '@/utils/ai';
|
import { getChatModel, getChatSystemPrompt } from '@/utils/ai';
|
||||||
import {
|
import {
|
||||||
getAllEventNames,
|
getAllEventNames,
|
||||||
@@ -8,10 +12,6 @@ import {
|
|||||||
getReport,
|
getReport,
|
||||||
} from '@/utils/ai-tools';
|
} from '@/utils/ai-tools';
|
||||||
import { HttpError } from '@/utils/errors';
|
import { HttpError } from '@/utils/errors';
|
||||||
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
|
|
||||||
import { getProjectAccess } from '@openpanel/trpc/src/access';
|
|
||||||
import { type Message, appendResponseMessages, streamText } from 'ai';
|
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
export async function chat(
|
export async function chat(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
@@ -22,7 +22,7 @@ export async function chat(
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
};
|
};
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const { session } = request.session;
|
const { session } = request.session;
|
||||||
const { messages } = request.body;
|
const { messages } = request.body;
|
||||||
@@ -117,7 +117,7 @@ export async function chat(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: async (error) => {
|
onError: (error) => {
|
||||||
request.log.error('chat error', { error });
|
request.log.error('chat error', { error });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
<meta charset="UTF-8">
|
||||||
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<title>Error - OpenPanel</title>
|
||||||
<title>Error - OpenPanel</title>
|
<link
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
|
||||||
<style>
|
rel="stylesheet"
|
||||||
|
>
|
||||||
|
<style>
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: "Inter", sans-serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -47,16 +49,21 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
|
||||||
<div class="error-container">
|
|
||||||
<img src="https://openpanel.dev/logo.svg" alt="OpenPanel Logo" class="logo">
|
|
||||||
<h1>Oops! Something went wrong</h1>
|
|
||||||
<p>We encountered an error while processing your request. Please try again later or contact support if the problem
|
|
||||||
persists.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="error-container">
|
||||||
|
<img
|
||||||
|
src="https://openpanel.dev/logo.svg"
|
||||||
|
alt="OpenPanel Logo"
|
||||||
|
class="logo"
|
||||||
|
>
|
||||||
|
<h1>Oops! Something went wrong</h1>
|
||||||
|
<p>
|
||||||
|
We encountered an error while processing your request. Please try again
|
||||||
|
later or contact support if the problem persists.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -71,22 +71,22 @@ const eventsScheme = z.object({
|
|||||||
page: z.coerce.number().optional().default(1),
|
page: z.coerce.number().optional().default(1),
|
||||||
limit: z.coerce.number().optional().default(50),
|
limit: z.coerce.number().optional().default(50),
|
||||||
includes: z
|
includes: z
|
||||||
.preprocess(
|
.preprocess((arg) => {
|
||||||
(arg) => {
|
if (arg == null) {
|
||||||
if (arg == null) {
|
return undefined;
|
||||||
return undefined;
|
}
|
||||||
}
|
if (Array.isArray(arg)) {
|
||||||
if (Array.isArray(arg)) {
|
|
||||||
return arg;
|
|
||||||
}
|
|
||||||
if (typeof arg === 'string') {
|
|
||||||
const parts = arg.split(',').map((s) => s.trim()).filter(Boolean);
|
|
||||||
return parts;
|
|
||||||
}
|
|
||||||
return arg;
|
return arg;
|
||||||
},
|
}
|
||||||
z.array(z.string())
|
if (typeof arg === 'string') {
|
||||||
)
|
const parts = arg
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
return arg;
|
||||||
|
}, z.array(z.string()))
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
import { toDots } from '@openpanel/common';
|
import { toDots } from '@openpanel/common';
|
||||||
import type { IClickhouseEvent } from '@openpanel/db';
|
import type { IClickhouseEvent } from '@openpanel/db';
|
||||||
import { TABLE_NAMES, ch, formatClickhouseDate } from '@openpanel/db';
|
import { ch, formatClickhouseDate, TABLE_NAMES } from '@openpanel/db';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
export async function importEvents(
|
export async function importEvents(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Body: IClickhouseEvent[];
|
Body: IClickhouseEvent[];
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const projectId = request.client?.projectId;
|
const projectId = request.client?.projectId;
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
|
||||||
import { getDefaultIntervalByDates } from '@openpanel/constants';
|
import { getDefaultIntervalByDates } from '@openpanel/constants';
|
||||||
import {
|
import {
|
||||||
eventBuffer,
|
eventBuffer,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
import { zChartEventFilter, zRange } from '@openpanel/validation';
|
import { zChartEventFilter, zRange } from '@openpanel/validation';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||||
|
|
||||||
const zGetMetricsQuery = z.object({
|
const zGetMetricsQuery = z.object({
|
||||||
startDate: z.string().nullish(),
|
startDate: z.string().nullish(),
|
||||||
@@ -22,7 +22,7 @@ export async function getMetrics(
|
|||||||
Params: { projectId: string };
|
Params: { projectId: string };
|
||||||
Querystring: z.infer<typeof zGetMetricsQuery>;
|
Querystring: z.infer<typeof zGetMetricsQuery>;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const { timezone } = await getSettingsForProject(request.params.projectId);
|
const { timezone } = await getSettingsForProject(request.params.projectId);
|
||||||
const parsed = zGetMetricsQuery.safeParse(parseQueryString(request.query));
|
const parsed = zGetMetricsQuery.safeParse(parseQueryString(request.query));
|
||||||
@@ -41,11 +41,11 @@ export async function getMetrics(
|
|||||||
await overviewService.getMetrics({
|
await overviewService.getMetrics({
|
||||||
projectId: request.params.projectId,
|
projectId: request.params.projectId,
|
||||||
filters: parsed.data.filters,
|
filters: parsed.data.filters,
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day',
|
interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day',
|
||||||
timezone,
|
timezone,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export async function getLiveVisitors(
|
|||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Params: { projectId: string };
|
Params: { projectId: string };
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
reply.send({
|
reply.send({
|
||||||
visitors: await eventBuffer.getActiveVisitorCount(request.params.projectId),
|
visitors: await eventBuffer.getActiveVisitorCount(request.params.projectId),
|
||||||
@@ -76,7 +76,7 @@ export async function getPages(
|
|||||||
Params: { projectId: string };
|
Params: { projectId: string };
|
||||||
Querystring: z.infer<typeof zGetTopPagesQuery>;
|
Querystring: z.infer<typeof zGetTopPagesQuery>;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const { timezone } = await getSettingsForProject(request.params.projectId);
|
const { timezone } = await getSettingsForProject(request.params.projectId);
|
||||||
const { startDate, endDate } = getChartStartEndDate(request.query, timezone);
|
const { startDate, endDate } = getChartStartEndDate(request.query, timezone);
|
||||||
@@ -93,8 +93,8 @@ export async function getPages(
|
|||||||
return overviewService.getTopPages({
|
return overviewService.getTopPages({
|
||||||
projectId: request.params.projectId,
|
projectId: request.params.projectId,
|
||||||
filters: parsed.data.filters,
|
filters: parsed.data.filters,
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
timezone,
|
timezone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,19 +132,19 @@ const zGetOverviewGenericQuery = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function getOverviewGeneric(
|
export function getOverviewGeneric(
|
||||||
column: z.infer<typeof zGetOverviewGenericQuery>['column'],
|
column: z.infer<typeof zGetOverviewGenericQuery>['column']
|
||||||
) {
|
) {
|
||||||
return async (
|
return async (
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Params: { projectId: string; key: string };
|
Params: { projectId: string; key: string };
|
||||||
Querystring: z.infer<typeof zGetOverviewGenericQuery>;
|
Querystring: z.infer<typeof zGetOverviewGenericQuery>;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) => {
|
) => {
|
||||||
const { timezone } = await getSettingsForProject(request.params.projectId);
|
const { timezone } = await getSettingsForProject(request.params.projectId);
|
||||||
const { startDate, endDate } = getChartStartEndDate(
|
const { startDate, endDate } = getChartStartEndDate(
|
||||||
request.query,
|
request.query,
|
||||||
timezone,
|
timezone
|
||||||
);
|
);
|
||||||
const parsed = zGetOverviewGenericQuery.safeParse({
|
const parsed = zGetOverviewGenericQuery.safeParse({
|
||||||
...parseQueryString(request.query),
|
...parseQueryString(request.query),
|
||||||
@@ -165,10 +165,10 @@ export function getOverviewGeneric(
|
|||||||
column,
|
column,
|
||||||
projectId: request.params.projectId,
|
projectId: request.params.projectId,
|
||||||
filters: parsed.data.filters,
|
filters: parsed.data.filters,
|
||||||
startDate: startDate,
|
startDate,
|
||||||
endDate: endDate,
|
endDate,
|
||||||
timezone,
|
timezone,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
68
apps/api/src/controllers/logs.controller.ts
Normal file
68
apps/api/src/controllers/logs.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { parseUserAgent } from '@openpanel/common/server';
|
||||||
|
import { getSalts } from '@openpanel/db';
|
||||||
|
import { getGeoLocation } from '@openpanel/geo';
|
||||||
|
import { type LogsQueuePayload, logsQueue } from '@openpanel/queue';
|
||||||
|
import { type ILogBatchPayload, zLogBatchPayload } from '@openpanel/validation';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { getDeviceId } from '@/utils/ids';
|
||||||
|
import { getStringHeaders } from './track.controller';
|
||||||
|
|
||||||
|
export async function handler(
|
||||||
|
request: FastifyRequest<{ Body: ILogBatchPayload }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const projectId = request.client?.projectId;
|
||||||
|
if (!projectId) {
|
||||||
|
return reply.status(400).send({ status: 400, error: 'Missing projectId' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationResult = zLogBatchPayload.safeParse(request.body);
|
||||||
|
if (!validationResult.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
status: 400,
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: validationResult.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { logs } = validationResult.data;
|
||||||
|
|
||||||
|
const ip = request.clientIp;
|
||||||
|
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||||
|
const headers = getStringHeaders(request.headers);
|
||||||
|
const receivedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
|
||||||
|
const { deviceId, sessionId } = await getDeviceId({ projectId, ip, ua, salts });
|
||||||
|
const uaInfo = parseUserAgent(ua, undefined);
|
||||||
|
|
||||||
|
const jobs: LogsQueuePayload[] = logs.map((log) => ({
|
||||||
|
type: 'incomingLog' as const,
|
||||||
|
payload: {
|
||||||
|
projectId,
|
||||||
|
log: {
|
||||||
|
...log,
|
||||||
|
timestamp: log.timestamp ?? receivedAt,
|
||||||
|
},
|
||||||
|
uaInfo,
|
||||||
|
geo: {
|
||||||
|
country: geo.country,
|
||||||
|
city: geo.city,
|
||||||
|
region: geo.region,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
deviceId,
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await logsQueue.addBulk(
|
||||||
|
jobs.map((job) => ({
|
||||||
|
name: 'incomingLog',
|
||||||
|
data: job,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.status(200).send({ ok: true, count: logs.length });
|
||||||
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { logger } from '@/utils/logger';
|
|
||||||
import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
import sharp from 'sharp';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_IP_HEADER_ORDER,
|
DEFAULT_IP_HEADER_ORDER,
|
||||||
getClientIpFromHeaders,
|
getClientIpFromHeaders,
|
||||||
} from '@openpanel/common/server/get-client-ip';
|
} from '@openpanel/common/server/get-client-ip';
|
||||||
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
import { ch, chQuery, formatClickhouseDate, TABLE_NAMES } from '@openpanel/db';
|
||||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||||
import { getCache, getRedisCache } from '@openpanel/redis';
|
import { getCache, getRedisCache } from '@openpanel/redis';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
||||||
|
|
||||||
interface GetFaviconParams {
|
interface GetFaviconParams {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -29,7 +28,9 @@ function createCacheKey(url: string, prefix = 'favicon'): string {
|
|||||||
|
|
||||||
function validateUrl(raw?: string): URL | null {
|
function validateUrl(raw?: string): URL | null {
|
||||||
try {
|
try {
|
||||||
if (!raw) throw new Error('Missing ?url');
|
if (!raw) {
|
||||||
|
throw new Error('Missing ?url');
|
||||||
|
}
|
||||||
const url = new URL(raw);
|
const url = new URL(raw);
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
throw new Error('Only http/https URLs are allowed');
|
throw new Error('Only http/https URLs are allowed');
|
||||||
@@ -42,7 +43,7 @@ function validateUrl(raw?: string): URL | null {
|
|||||||
|
|
||||||
// Binary cache functions (more efficient than base64)
|
// Binary cache functions (more efficient than base64)
|
||||||
async function getFromCacheBinary(
|
async function getFromCacheBinary(
|
||||||
key: string,
|
key: string
|
||||||
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||||
const redis = getRedisCache();
|
const redis = getRedisCache();
|
||||||
const [bufferBase64, contentType] = await Promise.all([
|
const [bufferBase64, contentType] = await Promise.all([
|
||||||
@@ -50,14 +51,16 @@ async function getFromCacheBinary(
|
|||||||
redis.get(`${key}:ctype`),
|
redis.get(`${key}:ctype`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!bufferBase64 || !contentType) return null;
|
if (!(bufferBase64 && contentType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return { buffer: Buffer.from(bufferBase64, 'base64'), contentType };
|
return { buffer: Buffer.from(bufferBase64, 'base64'), contentType };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setToCacheBinary(
|
async function setToCacheBinary(
|
||||||
key: string,
|
key: string,
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
contentType: string,
|
contentType: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const redis = getRedisCache();
|
const redis = getRedisCache();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -68,7 +71,7 @@ async function setToCacheBinary(
|
|||||||
|
|
||||||
// Fetch image with timeout and size limits
|
// Fetch image with timeout and size limits
|
||||||
async function fetchImage(
|
async function fetchImage(
|
||||||
url: URL,
|
url: URL
|
||||||
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
|
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 1000); // 10s timeout
|
const timeout = setTimeout(() => controller.abort(), 1000); // 10s timeout
|
||||||
@@ -132,7 +135,7 @@ function isSvgFile(url: string, contentType?: string): boolean {
|
|||||||
async function processImage(
|
async function processImage(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
originalUrl?: string,
|
originalUrl?: string,
|
||||||
contentType?: string,
|
contentType?: string
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
// If it's an ICO file, just return it as-is (no conversion needed)
|
// If it's an ICO file, just return it as-is (no conversion needed)
|
||||||
if (originalUrl && isIcoFile(originalUrl, contentType)) {
|
if (originalUrl && isIcoFile(originalUrl, contentType)) {
|
||||||
@@ -183,10 +186,10 @@ async function processImage(
|
|||||||
async function processOgImage(
|
async function processOgImage(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
originalUrl?: string,
|
originalUrl?: string,
|
||||||
contentType?: string,
|
contentType?: string
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
// If buffer is small enough, return it as-is
|
// If buffer is small enough, return it as-is
|
||||||
if (buffer.length < 10000) {
|
if (buffer.length < 10_000) {
|
||||||
logger.debug('Serving OG image directly without processing', {
|
logger.debug('Serving OG image directly without processing', {
|
||||||
originalUrl,
|
originalUrl,
|
||||||
bufferSize: buffer.length,
|
bufferSize: buffer.length,
|
||||||
@@ -227,7 +230,7 @@ export async function getFavicon(
|
|||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Querystring: GetFaviconParams;
|
Querystring: GetFaviconParams;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
logger.info('getFavicon', {
|
logger.info('getFavicon', {
|
||||||
@@ -295,7 +298,7 @@ export async function getFavicon(
|
|||||||
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
|
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
|
||||||
const { hostname } = url;
|
const { hostname } = url;
|
||||||
const duckduckgoUrl = new URL(
|
const duckduckgoUrl = new URL(
|
||||||
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
|
`https://icons.duckduckgo.com/ip3/${hostname}.ico`
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info('Trying DuckDuckGo favicon service', {
|
logger.info('Trying DuckDuckGo favicon service', {
|
||||||
@@ -328,7 +331,7 @@ export async function getFavicon(
|
|||||||
const processedBuffer = await processImage(
|
const processedBuffer = await processImage(
|
||||||
buffer,
|
buffer,
|
||||||
imageUrl.toString(),
|
imageUrl.toString(),
|
||||||
contentType,
|
contentType
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info('Favicon processing result', {
|
logger.info('Favicon processing result', {
|
||||||
@@ -380,7 +383,7 @@ export async function getFavicon(
|
|||||||
|
|
||||||
export async function clearFavicons(
|
export async function clearFavicons(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const redis = getRedisCache();
|
const redis = getRedisCache();
|
||||||
const keys = await redis.keys('favicon:*');
|
const keys = await redis.keys('favicon:*');
|
||||||
@@ -396,7 +399,7 @@ export async function clearFavicons(
|
|||||||
|
|
||||||
export async function clearOgImages(
|
export async function clearOgImages(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const redis = getRedisCache();
|
const redis = getRedisCache();
|
||||||
const keys = await redis.keys('og:*');
|
const keys = await redis.keys('og:*');
|
||||||
@@ -417,7 +420,7 @@ export async function ping(
|
|||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await ch.insert({
|
await ch.insert({
|
||||||
@@ -449,10 +452,10 @@ export async function ping(
|
|||||||
export async function stats(request: FastifyRequest, reply: FastifyReply) {
|
export async function stats(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const res = await getCache('api:stats', 60 * 60, async () => {
|
const res = await getCache('api:stats', 60 * 60, async () => {
|
||||||
const projects = await chQuery<{ project_id: string; count: number }>(
|
const projects = await chQuery<{ project_id: string; count: number }>(
|
||||||
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`,
|
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`
|
||||||
);
|
);
|
||||||
const last24h = await chQuery<{ count: number }>(
|
const last24h = await chQuery<{ count: number }>(
|
||||||
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`,
|
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`
|
||||||
);
|
);
|
||||||
return { projects, last24hCount: last24h[0]?.count || 0 };
|
return { projects, last24hCount: last24h[0]?.count || 0 };
|
||||||
});
|
});
|
||||||
@@ -474,7 +477,7 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
|||||||
ip,
|
ip,
|
||||||
geo: await getGeoLocation(ip),
|
geo: await getGeoLocation(ip),
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ip) {
|
if (!ip) {
|
||||||
@@ -492,7 +495,7 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
|||||||
acc[other.header] = other;
|
acc[other.header] = other;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>,
|
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -503,7 +506,7 @@ export async function getOgImage(
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const url = validateUrl(request.query.url);
|
const url = validateUrl(request.query.url);
|
||||||
@@ -547,7 +550,7 @@ export async function getOgImage(
|
|||||||
const processedBuffer = await processOgImage(
|
const processedBuffer = await processOgImage(
|
||||||
buffer,
|
buffer,
|
||||||
imageUrl.toString(),
|
imageUrl.toString(),
|
||||||
contentType,
|
contentType
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
||||||
import { assocPath, pathOr } from 'ramda';
|
|
||||||
|
|
||||||
import { parseUserAgent } from '@openpanel/common/server';
|
import { parseUserAgent } from '@openpanel/common/server';
|
||||||
import { getProfileById, upsertProfile } from '@openpanel/db';
|
import { getProfileById, upsertProfile } from '@openpanel/db';
|
||||||
import { getGeoLocation } from '@openpanel/geo';
|
import { getGeoLocation } from '@openpanel/geo';
|
||||||
@@ -8,12 +5,14 @@ import type {
|
|||||||
DeprecatedIncrementProfilePayload,
|
DeprecatedIncrementProfilePayload,
|
||||||
DeprecatedUpdateProfilePayload,
|
DeprecatedUpdateProfilePayload,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { assocPath, pathOr } from 'ramda';
|
||||||
|
|
||||||
export async function updateProfile(
|
export async function updateProfile(
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Body: DeprecatedUpdateProfilePayload;
|
Body: DeprecatedUpdateProfilePayload;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const payload = request.body;
|
const payload = request.body;
|
||||||
const projectId = request.client!.projectId;
|
const projectId = request.client!.projectId;
|
||||||
@@ -54,7 +53,7 @@ export async function incrementProfileProperty(
|
|||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Body: DeprecatedIncrementProfilePayload;
|
Body: DeprecatedIncrementProfilePayload;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const { profileId, property, value } = request.body;
|
const { profileId, property, value } = request.body;
|
||||||
const projectId = request.client!.projectId;
|
const projectId = request.client!.projectId;
|
||||||
@@ -69,7 +68,7 @@ export async function incrementProfileProperty(
|
|||||||
|
|
||||||
const parsed = Number.parseInt(
|
const parsed = Number.parseInt(
|
||||||
pathOr<string>('0', property.split('.'), profile.properties),
|
pathOr<string>('0', property.split('.'), profile.properties),
|
||||||
10,
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Number.isNaN(parsed)) {
|
if (Number.isNaN(parsed)) {
|
||||||
@@ -79,7 +78,7 @@ export async function incrementProfileProperty(
|
|||||||
profile.properties = assocPath(
|
profile.properties = assocPath(
|
||||||
property.split('.'),
|
property.split('.'),
|
||||||
parsed + value,
|
parsed + value,
|
||||||
profile.properties,
|
profile.properties
|
||||||
);
|
);
|
||||||
|
|
||||||
await upsertProfile({
|
await upsertProfile({
|
||||||
@@ -96,7 +95,7 @@ export async function decrementProfileProperty(
|
|||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Body: DeprecatedIncrementProfilePayload;
|
Body: DeprecatedIncrementProfilePayload;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const { profileId, property, value } = request.body;
|
const { profileId, property, value } = request.body;
|
||||||
const projectId = request.client?.projectId;
|
const projectId = request.client?.projectId;
|
||||||
@@ -111,7 +110,7 @@ export async function decrementProfileProperty(
|
|||||||
|
|
||||||
const parsed = Number.parseInt(
|
const parsed = Number.parseInt(
|
||||||
pathOr<string>('0', property.split('.'), profile.properties),
|
pathOr<string>('0', property.split('.'), profile.properties),
|
||||||
10,
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
if (Number.isNaN(parsed)) {
|
if (Number.isNaN(parsed)) {
|
||||||
@@ -121,7 +120,7 @@ export async function decrementProfileProperty(
|
|||||||
profile.properties = assocPath(
|
profile.properties = assocPath(
|
||||||
property.split('.'),
|
property.split('.'),
|
||||||
parsed - value,
|
parsed - value,
|
||||||
profile.properties,
|
profile.properties
|
||||||
);
|
);
|
||||||
|
|
||||||
await upsertProfile({
|
await upsertProfile({
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path, { dirname } from 'node:path';
|
||||||
import { dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
|
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
slackInstaller,
|
slackInstaller,
|
||||||
} from '@openpanel/integrations/src/slack';
|
} from '@openpanel/integrations/src/slack';
|
||||||
import {
|
import {
|
||||||
PolarWebhookVerificationError,
|
|
||||||
getProduct,
|
getProduct,
|
||||||
|
PolarWebhookVerificationError,
|
||||||
validatePolarEvent,
|
validatePolarEvent,
|
||||||
} from '@openpanel/payments';
|
} from '@openpanel/payments';
|
||||||
import { publishEvent } from '@openpanel/redis';
|
import { publishEvent } from '@openpanel/redis';
|
||||||
@@ -34,7 +34,7 @@ export async function slackWebhook(
|
|||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Querystring: unknown;
|
Querystring: unknown;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const parsedParams = paramsSchema.safeParse(request.query);
|
const parsedParams = paramsSchema.safeParse(request.query);
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ export async function slackWebhook(
|
|||||||
|
|
||||||
const veryfiedState = await slackInstaller.stateStore?.verifyStateParam(
|
const veryfiedState = await slackInstaller.stateStore?.verifyStateParam(
|
||||||
new Date(),
|
new Date(),
|
||||||
parsedParams.data.state,
|
parsedParams.data.state
|
||||||
);
|
);
|
||||||
const parsedMetadata = metadataSchema.safeParse(
|
const parsedMetadata = metadataSchema.safeParse(
|
||||||
JSON.parse(veryfiedState?.metadata ?? '{}'),
|
JSON.parse(veryfiedState?.metadata ?? '{}')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!parsedMetadata.success) {
|
if (!parsedMetadata.success) {
|
||||||
@@ -75,7 +75,7 @@ export async function slackWebhook(
|
|||||||
zod: parsedJson,
|
zod: parsedJson,
|
||||||
json,
|
json,
|
||||||
},
|
},
|
||||||
'Failed to parse slack auth response',
|
'Failed to parse slack auth response'
|
||||||
);
|
);
|
||||||
const html = fs.readFileSync(path.join(__dirname, 'error.html'), 'utf8');
|
const html = fs.readFileSync(path.join(__dirname, 'error.html'), 'utf8');
|
||||||
return reply.status(500).header('Content-Type', 'text/html').send(html);
|
return reply.status(500).header('Content-Type', 'text/html').send(html);
|
||||||
@@ -104,7 +104,7 @@ export async function slackWebhook(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return reply.redirect(
|
return reply.redirect(
|
||||||
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`,
|
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
request.log.error(err);
|
request.log.error(err);
|
||||||
@@ -128,13 +128,13 @@ export async function polarWebhook(
|
|||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Querystring: unknown;
|
Querystring: unknown;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const event = validatePolarEvent(
|
const event = validatePolarEvent(
|
||||||
request.rawBody!,
|
request.rawBody!,
|
||||||
request.headers as Record<string, string>,
|
request.headers as Record<string, string>,
|
||||||
process.env.POLAR_WEBHOOK_SECRET ?? '',
|
process.env.POLAR_WEBHOOK_SECRET ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
|
|
||||||
import type {
|
import type {
|
||||||
DeprecatedPostEventPayload,
|
DeprecatedPostEventPayload,
|
||||||
ITrackHandlerPayload,
|
ITrackHandlerPayload,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
|
||||||
|
|
||||||
export async function clientHook(
|
export async function clientHook(
|
||||||
req: FastifyRequest<{
|
req: FastifyRequest<{
|
||||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const client = await validateSdkRequest(req);
|
const client = await validateSdkRequest(req);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { FastifyRequest } from 'fastify';
|
||||||
FastifyReply,
|
|
||||||
FastifyRequest,
|
|
||||||
HookHandlerDoneFunction,
|
|
||||||
} from 'fastify';
|
|
||||||
|
|
||||||
export async function requestIdHook(request: FastifyRequest) {
|
export async function requestIdHook(request: FastifyRequest) {
|
||||||
if (!request.headers['request-id']) {
|
if (!request.headers['request-id']) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import gscCallbackRouter from './routes/gsc-callback.router';
|
|||||||
import importRouter from './routes/import.router';
|
import importRouter from './routes/import.router';
|
||||||
import insightsRouter from './routes/insights.router';
|
import insightsRouter from './routes/insights.router';
|
||||||
import liveRouter from './routes/live.router';
|
import liveRouter from './routes/live.router';
|
||||||
|
import logsRouter from './routes/logs.router';
|
||||||
import manageRouter from './routes/manage.router';
|
import manageRouter from './routes/manage.router';
|
||||||
import miscRouter from './routes/misc.router';
|
import miscRouter from './routes/misc.router';
|
||||||
import oauthRouter from './routes/oauth-callback.router';
|
import oauthRouter from './routes/oauth-callback.router';
|
||||||
@@ -198,6 +199,7 @@ const startServer = async () => {
|
|||||||
instance.register(gscCallbackRouter, { prefix: '/gsc' });
|
instance.register(gscCallbackRouter, { prefix: '/gsc' });
|
||||||
instance.register(miscRouter, { prefix: '/misc' });
|
instance.register(miscRouter, { prefix: '/misc' });
|
||||||
instance.register(aiRouter, { prefix: '/ai' });
|
instance.register(aiRouter, { prefix: '/ai' });
|
||||||
|
instance.register(logsRouter, { prefix: '/logs' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||||
import * as controller from '@/controllers/ai.controller';
|
import * as controller from '@/controllers/ai.controller';
|
||||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
const aiRouter: FastifyPluginCallback = async (fastify) => {
|
const aiRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
await activateRateLimiter<
|
await activateRateLimiter<
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as controller from '@/controllers/event.controller';
|
|
||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
import * as controller from '@/controllers/event.controller';
|
||||||
import { clientHook } from '@/hooks/client.hook';
|
import { clientHook } from '@/hooks/client.hook';
|
||||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { Prisma } from '@openpanel/db';
|
||||||
|
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||||
import * as controller from '@/controllers/export.controller';
|
import * as controller from '@/controllers/export.controller';
|
||||||
import { validateExportRequest } from '@/utils/auth';
|
import { validateExportRequest } from '@/utils/auth';
|
||||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||||
import { Prisma } from '@openpanel/db';
|
|
||||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
const exportRouter: FastifyPluginCallback = async (fastify) => {
|
const exportRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
await activateRateLimiter({
|
await activateRateLimiter({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
|
|
||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
|
||||||
|
|
||||||
const router: FastifyPluginCallback = async (fastify) => {
|
const router: FastifyPluginCallback = async (fastify) => {
|
||||||
fastify.route({
|
fastify.route({
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
|
import { Prisma } from '@openpanel/db';
|
||||||
|
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||||
import * as controller from '@/controllers/import.controller';
|
import * as controller from '@/controllers/import.controller';
|
||||||
import { validateImportRequest } from '@/utils/auth';
|
import { validateImportRequest } from '@/utils/auth';
|
||||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
import { Prisma } from '@openpanel/db';
|
|
||||||
|
|
||||||
const importRouter: FastifyPluginCallback = async (fastify) => {
|
const importRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { Prisma } from '@openpanel/db';
|
||||||
|
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||||
import * as controller from '@/controllers/insights.controller';
|
import * as controller from '@/controllers/insights.controller';
|
||||||
import { validateExportRequest } from '@/utils/auth';
|
import { validateExportRequest } from '@/utils/auth';
|
||||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||||
import { Prisma } from '@openpanel/db';
|
|
||||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
const insightsRouter: FastifyPluginCallback = async (fastify) => {
|
const insightsRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
await activateRateLimiter({
|
await activateRateLimiter({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as controller from '@/controllers/live.controller';
|
|
||||||
import fastifyWS from '@fastify/websocket';
|
import fastifyWS from '@fastify/websocket';
|
||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
import * as controller from '@/controllers/live.controller';
|
||||||
|
|
||||||
const liveRouter: FastifyPluginCallback = async (fastify) => {
|
const liveRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
fastify.register(fastifyWS);
|
fastify.register(fastifyWS);
|
||||||
@@ -9,22 +9,22 @@ const liveRouter: FastifyPluginCallback = async (fastify) => {
|
|||||||
fastify.get(
|
fastify.get(
|
||||||
'/organization/:organizationId',
|
'/organization/:organizationId',
|
||||||
{ websocket: true },
|
{ websocket: true },
|
||||||
controller.wsOrganizationEvents,
|
controller.wsOrganizationEvents
|
||||||
);
|
);
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/visitors/:projectId',
|
'/visitors/:projectId',
|
||||||
{ websocket: true },
|
{ websocket: true },
|
||||||
controller.wsVisitors,
|
controller.wsVisitors
|
||||||
);
|
);
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/events/:projectId',
|
'/events/:projectId',
|
||||||
{ websocket: true },
|
{ websocket: true },
|
||||||
controller.wsProjectEvents,
|
controller.wsProjectEvents
|
||||||
);
|
);
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/notifications/:projectId',
|
'/notifications/:projectId',
|
||||||
{ websocket: true },
|
{ websocket: true },
|
||||||
controller.wsProjectNotifications,
|
controller.wsProjectNotifications
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
6
apps/api/src/routes/logs.router.ts
Normal file
6
apps/api/src/routes/logs.router.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { handler } from '@/controllers/logs.controller';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
export default async function (fastify: FastifyInstance) {
|
||||||
|
fastify.post('/', handler);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { Prisma } from '@openpanel/db';
|
||||||
|
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||||
import * as controller from '@/controllers/manage.controller';
|
import * as controller from '@/controllers/manage.controller';
|
||||||
import { validateManageRequest } from '@/utils/auth';
|
import { validateManageRequest } from '@/utils/auth';
|
||||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||||
import { Prisma } from '@openpanel/db';
|
|
||||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
|
||||||
|
|
||||||
const manageRouter: FastifyPluginCallback = async (fastify) => {
|
const manageRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
await activateRateLimiter({
|
await activateRateLimiter({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as controller from '@/controllers/misc.controller';
|
|
||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
import * as controller from '@/controllers/misc.controller';
|
||||||
|
|
||||||
const miscRouter: FastifyPluginCallback = async (fastify) => {
|
const miscRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
fastify.route({
|
fastify.route({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as controller from '@/controllers/oauth-callback.controller';
|
|
||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
import * as controller from '@/controllers/oauth-callback.controller';
|
||||||
|
|
||||||
const router: FastifyPluginCallback = async (fastify) => {
|
const router: FastifyPluginCallback = async (fastify) => {
|
||||||
fastify.route({
|
fastify.route({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
import * as controller from '@/controllers/profile.controller';
|
import * as controller from '@/controllers/profile.controller';
|
||||||
import { clientHook } from '@/hooks/client.hook';
|
import { clientHook } from '@/hooks/client.hook';
|
||||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||||
import type { FastifyPluginCallback } from 'fastify';
|
|
||||||
|
|
||||||
const eventRouter: FastifyPluginCallback = async (fastify) => {
|
const eventRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
fastify.addHook('preHandler', clientHook);
|
fastify.addHook('preHandler', clientHook);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as controller from '@/controllers/webhook.controller';
|
|
||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
import * as controller from '@/controllers/webhook.controller';
|
||||||
|
|
||||||
const webhookRouter: FastifyPluginCallback = async (fastify) => {
|
const webhookRouter: FastifyPluginCallback = async (fastify) => {
|
||||||
fastify.route({
|
fastify.route({
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { chartTypes } from '@openpanel/constants';
|
import { chartTypes } from '@openpanel/constants';
|
||||||
import type { IClickhouseSession } from '@openpanel/db';
|
import type { IClickhouseSession } from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
|
ch,
|
||||||
|
clix,
|
||||||
type IClickhouseEvent,
|
type IClickhouseEvent,
|
||||||
type IClickhouseProfile,
|
type IClickhouseProfile,
|
||||||
TABLE_NAMES,
|
TABLE_NAMES,
|
||||||
ch,
|
|
||||||
clix,
|
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { ChartEngine } from '@openpanel/db';
|
|
||||||
import { getCache } from '@openpanel/redis';
|
import { getCache } from '@openpanel/redis';
|
||||||
import { zReportInput } from '@openpanel/validation';
|
import { zReportInput } from '@openpanel/validation';
|
||||||
import { tool } from 'ai';
|
import { tool } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export function getReport({
|
export function getReport({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
return tool({
|
return tool({
|
||||||
description: `Generate a report (a chart) for
|
description: `Generate a report (a chart) for
|
||||||
- ${chartTypes.area}
|
- ${chartTypes.area}
|
||||||
@@ -67,11 +62,7 @@ export function getReport({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function getConversionReport({
|
export function getConversionReport({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
return tool({
|
return tool({
|
||||||
description:
|
description:
|
||||||
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
||||||
@@ -92,11 +83,7 @@ export function getConversionReport({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function getFunnelReport({
|
export function getFunnelReport({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
return tool({
|
return tool({
|
||||||
description:
|
description:
|
||||||
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
||||||
@@ -118,11 +105,7 @@ export function getFunnelReport({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProfiles({
|
export function getProfiles({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
return tool({
|
return tool({
|
||||||
description: 'Get profiles',
|
description: 'Get profiles',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
@@ -188,11 +171,7 @@ export function getProfiles({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProfile({
|
export function getProfile({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
return tool({
|
return tool({
|
||||||
description: 'Get a specific profile',
|
description: 'Get a specific profile',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
@@ -276,11 +255,7 @@ export function getProfile({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEvents({
|
export function getEvents({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
return tool({
|
return tool({
|
||||||
description: 'Get events for a project or specific profile',
|
description: 'Get events for a project or specific profile',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
@@ -369,11 +344,7 @@ export function getEvents({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessions({
|
export function getSessions({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
return tool({
|
return tool({
|
||||||
description: 'Get sessions for a project or specific profile',
|
description: 'Get sessions for a project or specific profile',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
@@ -458,11 +429,7 @@ export function getSessions({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllEventNames({
|
export function getAllEventNames({ projectId }: { projectId: string }) {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
return tool({
|
return tool({
|
||||||
description: 'Get the top 50 event names in a comma separated list',
|
description: 'Get the top 50 event names in a comma separated list',
|
||||||
parameters: z.object({}),
|
parameters: z.object({}),
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ export const getChatModel = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getChatSystemPrompt = ({
|
export const getChatSystemPrompt = ({ projectId }: { projectId: string }) => {
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
projectId: string;
|
|
||||||
}) => {
|
|
||||||
return `You're an product and web analytics expert. Don't generate more than the user asks for. Follow all rules listed below!
|
return `You're an product and web analytics expert. Don't generate more than the user asks for. Follow all rules listed below!
|
||||||
## General:
|
## General:
|
||||||
- projectId: \`${projectId}\`
|
- projectId: \`${projectId}\`
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
|
||||||
|
|
||||||
import { verifyPassword } from '@openpanel/common/server';
|
import { verifyPassword } from '@openpanel/common/server';
|
||||||
import type { IServiceClientWithProject } from '@openpanel/db';
|
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||||
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
||||||
@@ -10,6 +8,7 @@ import type {
|
|||||||
IProjectFilterProfileId,
|
IProjectFilterProfileId,
|
||||||
ITrackHandlerPayload,
|
ITrackHandlerPayload,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
||||||
import { path } from 'ramda';
|
import { path } from 'ramda';
|
||||||
|
|
||||||
const cleanDomain = (domain: string) =>
|
const cleanDomain = (domain: string) =>
|
||||||
@@ -31,7 +30,7 @@ export class SdkAuthError extends Error {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
origin?: string;
|
origin?: string;
|
||||||
},
|
}
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'SdkAuthError';
|
this.name = 'SdkAuthError';
|
||||||
@@ -43,7 +42,7 @@ export class SdkAuthError extends Error {
|
|||||||
export async function validateSdkRequest(
|
export async function validateSdkRequest(
|
||||||
req: FastifyRequest<{
|
req: FastifyRequest<{
|
||||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||||
}>,
|
}>
|
||||||
): Promise<IServiceClientWithProject> {
|
): Promise<IServiceClientWithProject> {
|
||||||
const { headers, clientIp } = req;
|
const { headers, clientIp } = req;
|
||||||
const clientIdNew = headers['openpanel-client-id'] as string;
|
const clientIdNew = headers['openpanel-client-id'] as string;
|
||||||
@@ -70,7 +69,7 @@ export async function validateSdkRequest(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||||
clientId,
|
clientId
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw createError('Ingestion: Client ID must be a valid UUIDv4');
|
throw createError('Ingestion: Client ID must be a valid UUIDv4');
|
||||||
@@ -88,7 +87,7 @@ export async function validateSdkRequest(
|
|||||||
|
|
||||||
// Filter out blocked IPs
|
// Filter out blocked IPs
|
||||||
const ipFilter = client.project.filters.filter(
|
const ipFilter = client.project.filters.filter(
|
||||||
(filter): filter is IProjectFilterIp => filter.type === 'ip',
|
(filter): filter is IProjectFilterIp => filter.type === 'ip'
|
||||||
);
|
);
|
||||||
if (ipFilter.some((filter) => filter.ip === clientIp)) {
|
if (ipFilter.some((filter) => filter.ip === clientIp)) {
|
||||||
throw createError('Ingestion: IP address is blocked by project filter');
|
throw createError('Ingestion: IP address is blocked by project filter');
|
||||||
@@ -96,7 +95,7 @@ export async function validateSdkRequest(
|
|||||||
|
|
||||||
// Filter out blocked profile ids
|
// Filter out blocked profile ids
|
||||||
const profileFilter = client.project.filters.filter(
|
const profileFilter = client.project.filters.filter(
|
||||||
(filter): filter is IProjectFilterProfileId => filter.type === 'profile_id',
|
(filter): filter is IProjectFilterProfileId => filter.type === 'profile_id'
|
||||||
);
|
);
|
||||||
const profileId =
|
const profileId =
|
||||||
path<string | undefined>(['payload', 'profileId'], req.body) || // Track handler
|
path<string | undefined>(['payload', 'profileId'], req.body) || // Track handler
|
||||||
@@ -113,12 +112,11 @@ export async function validateSdkRequest(
|
|||||||
// Only allow revenue tracking if it was sent with a client secret
|
// Only allow revenue tracking if it was sent with a client secret
|
||||||
// or if the project has allowUnsafeRevenueTracking enabled
|
// or if the project has allowUnsafeRevenueTracking enabled
|
||||||
if (
|
if (
|
||||||
!client.project.allowUnsafeRevenueTracking &&
|
!(client.project.allowUnsafeRevenueTracking || clientSecret) &&
|
||||||
!clientSecret &&
|
|
||||||
typeof revenue !== 'undefined'
|
typeof revenue !== 'undefined'
|
||||||
) {
|
) {
|
||||||
throw createError(
|
throw createError(
|
||||||
'Ingestion: Revenue tracking is not allowed without a client secret',
|
'Ingestion: Revenue tracking is not allowed without a client secret'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +130,7 @@ export async function validateSdkRequest(
|
|||||||
// support wildcard domains `*.foo.com`
|
// support wildcard domains `*.foo.com`
|
||||||
if (cleanedDomain.includes('*')) {
|
if (cleanedDomain.includes('*')) {
|
||||||
const regex = new RegExp(
|
const regex = new RegExp(
|
||||||
`${cleanedDomain.replaceAll('.', '\\.').replaceAll('*', '.+?')}`,
|
`${cleanedDomain.replaceAll('.', '\\.').replaceAll('*', '.+?')}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return regex.test(origin || '');
|
return regex.test(origin || '');
|
||||||
@@ -157,7 +155,7 @@ export async function validateSdkRequest(
|
|||||||
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
|
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
|
||||||
60 * 5,
|
60 * 5,
|
||||||
async () => await verifyPassword(clientSecret, client.secret!),
|
async () => await verifyPassword(clientSecret, client.secret!),
|
||||||
true,
|
true
|
||||||
);
|
);
|
||||||
if (isVerified) {
|
if (isVerified) {
|
||||||
return client;
|
return client;
|
||||||
@@ -168,14 +166,14 @@ export async function validateSdkRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateExportRequest(
|
export async function validateExportRequest(
|
||||||
headers: RawRequestDefaultExpression['headers'],
|
headers: RawRequestDefaultExpression['headers']
|
||||||
): Promise<IServiceClientWithProject> {
|
): Promise<IServiceClientWithProject> {
|
||||||
const clientId = headers['openpanel-client-id'] as string;
|
const clientId = headers['openpanel-client-id'] as string;
|
||||||
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||||
clientId,
|
clientId
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error('Export: Client ID must be a valid UUIDv4');
|
throw new Error('Export: Client ID must be a valid UUIDv4');
|
||||||
@@ -203,14 +201,14 @@ export async function validateExportRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateImportRequest(
|
export async function validateImportRequest(
|
||||||
headers: RawRequestDefaultExpression['headers'],
|
headers: RawRequestDefaultExpression['headers']
|
||||||
): Promise<IServiceClientWithProject> {
|
): Promise<IServiceClientWithProject> {
|
||||||
const clientId = headers['openpanel-client-id'] as string;
|
const clientId = headers['openpanel-client-id'] as string;
|
||||||
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||||
clientId,
|
clientId
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error('Import: Client ID must be a valid UUIDv4');
|
throw new Error('Import: Client ID must be a valid UUIDv4');
|
||||||
@@ -238,14 +236,14 @@ export async function validateImportRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function validateManageRequest(
|
export async function validateManageRequest(
|
||||||
headers: RawRequestDefaultExpression['headers'],
|
headers: RawRequestDefaultExpression['headers']
|
||||||
): Promise<IServiceClientWithProject> {
|
): Promise<IServiceClientWithProject> {
|
||||||
const clientId = headers['openpanel-client-id'] as string;
|
const clientId = headers['openpanel-client-id'] as string;
|
||||||
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||||
clientId,
|
clientId
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
throw new Error('Manage: Client ID must be a valid UUIDv4');
|
throw new Error('Manage: Client ID must be a valid UUIDv4');
|
||||||
@@ -263,7 +261,7 @@ export async function validateManageRequest(
|
|||||||
|
|
||||||
if (client.type !== ClientType.root) {
|
if (client.type !== ClientType.root) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Manage: Only root clients are allowed to manage resources',
|
'Manage: Only root clients are allowed to manage resources'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export async function isDuplicatedEvent({
|
|||||||
origin,
|
origin,
|
||||||
projectId,
|
projectId,
|
||||||
},
|
},
|
||||||
'md5',
|
'md5'
|
||||||
)}`,
|
)}`,
|
||||||
'1',
|
'1',
|
||||||
100,
|
100
|
||||||
);
|
);
|
||||||
|
|
||||||
if (locked) {
|
if (locked) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export class LogError extends Error {
|
|||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
payload?: Record<string, unknown>,
|
payload?: Record<string, unknown>,
|
||||||
options?: ErrorOptions,
|
options?: ErrorOptions
|
||||||
) {
|
) {
|
||||||
super(message, options);
|
super(message, options);
|
||||||
this.name = 'LogError';
|
this.name = 'LogError';
|
||||||
@@ -26,7 +26,7 @@ export class HttpError extends Error {
|
|||||||
fingerprint?: string;
|
fingerprint?: string;
|
||||||
extra?: Record<string, unknown>;
|
extra?: Record<string, unknown>;
|
||||||
error?: Error | unknown;
|
error?: Error | unknown;
|
||||||
},
|
}
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'HttpError';
|
this.name = 'HttpError';
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function isShuttingDown() {
|
|||||||
export async function shutdown(
|
export async function shutdown(
|
||||||
fastify: FastifyInstance,
|
fastify: FastifyInstance,
|
||||||
signal: string,
|
signal: string,
|
||||||
exitCode = 0,
|
exitCode = 0
|
||||||
) {
|
) {
|
||||||
if (isShuttingDown()) {
|
if (isShuttingDown()) {
|
||||||
logger.warn('Shutdown already in progress, ignoring signal', { signal });
|
logger.warn('Shutdown already in progress, ignoring signal', { signal });
|
||||||
@@ -96,7 +96,7 @@ export async function shutdown(
|
|||||||
if (redis.status === 'ready') {
|
if (redis.status === 'ready') {
|
||||||
await redis.quit();
|
await redis.quit();
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
logger.info('Redis connections closed');
|
logger.info('Redis connections closed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,14 +3,21 @@ import { getSafeJson } from '@openpanel/json';
|
|||||||
export const parseQueryString = (obj: Record<string, any>): any => {
|
export const parseQueryString = (obj: Record<string, any>): any => {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(obj).map(([k, v]) => {
|
Object.entries(obj).map(([k, v]) => {
|
||||||
if (typeof v === 'object') return [k, parseQueryString(v)];
|
if (typeof v === 'object') {
|
||||||
|
return [k, parseQueryString(v)];
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
/^-?[0-9]+(\.[0-9]+)?$/i.test(v) &&
|
/^-?[0-9]+(\.[0-9]+)?$/i.test(v) &&
|
||||||
!Number.isNaN(Number.parseFloat(v))
|
!Number.isNaN(Number.parseFloat(v))
|
||||||
)
|
) {
|
||||||
return [k, Number.parseFloat(v)];
|
return [k, Number.parseFloat(v)];
|
||||||
if (v === 'true') return [k, true];
|
}
|
||||||
if (v === 'false') return [k, false];
|
if (v === 'true') {
|
||||||
|
return [k, true];
|
||||||
|
}
|
||||||
|
if (v === 'false') {
|
||||||
|
return [k, false];
|
||||||
|
}
|
||||||
if (typeof v === 'string') {
|
if (typeof v === 'string') {
|
||||||
if (getSafeJson(v) !== null) {
|
if (getSafeJson(v) !== null) {
|
||||||
return [k, getSafeJson(v)];
|
return [k, getSafeJson(v)];
|
||||||
@@ -18,6 +25,6 @@ export const parseQueryString = (obj: Record<string, any>): any => {
|
|||||||
return [k, v];
|
return [k, v];
|
||||||
}
|
}
|
||||||
return [k, null];
|
return [k, null];
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
|||||||
(favicon) =>
|
(favicon) =>
|
||||||
favicon.rel === 'shortcut icon' ||
|
favicon.rel === 'shortcut icon' ||
|
||||||
favicon.rel === 'icon' ||
|
favicon.rel === 'icon' ||
|
||||||
favicon.rel === 'apple-touch-icon',
|
favicon.rel === 'apple-touch-icon'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineConfig } from 'tsdown';
|
|
||||||
import type { Options } from 'tsdown';
|
import type { Options } from 'tsdown';
|
||||||
|
import { defineConfig } from 'tsdown';
|
||||||
|
|
||||||
const options: Options = {
|
const options: Options = {
|
||||||
clean: true,
|
clean: true,
|
||||||
|
|||||||
@@ -1,40 +1,61 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|
||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
|
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
|
||||||
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
<meta
|
||||||
<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.">
|
name="title"
|
||||||
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
|
content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
|
||||||
<meta name="author" content="OpenPanel">
|
>
|
||||||
<meta name="robots" content="index, follow">
|
<meta
|
||||||
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
|
name="description"
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
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 -->
|
<!-- Open Graph / Facebook -->
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
|
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
|
||||||
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
<meta
|
||||||
<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.">
|
property="og:title"
|
||||||
<meta property="og:image" content="/ogimage.png">
|
content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
|
||||||
<meta property="og:image:width" content="1200">
|
>
|
||||||
<meta property="og:image:height" content="630">
|
<meta
|
||||||
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
|
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 -->
|
<!-- Twitter -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
|
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
|
||||||
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
<meta
|
||||||
<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.">
|
name="twitter:title"
|
||||||
<meta name="twitter:image" content="/ogimage.png">
|
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 -->
|
<!-- Additional Meta Tags -->
|
||||||
<meta name="theme-color" content="#0a0a0a">
|
<meta name="theme-color" content="#0a0a0a">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -44,7 +65,9 @@
|
|||||||
body {
|
body {
|
||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
color: #e5e5e5;
|
color: #e5e5e5;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
Arial, sans-serif;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
@@ -100,7 +123,8 @@
|
|||||||
color: #60a5fa;
|
color: #60a5fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul, ol {
|
ul,
|
||||||
|
ol {
|
||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
margin-bottom: 1.25em;
|
margin-bottom: 1.25em;
|
||||||
}
|
}
|
||||||
@@ -123,7 +147,8 @@
|
|||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th,
|
||||||
|
td {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border-bottom: 1px solid #374151;
|
border-bottom: 1px solid #374151;
|
||||||
@@ -264,242 +289,479 @@
|
|||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<h1>Just Fucking Use OpenPanel</h1>
|
<h1>Just Fucking Use OpenPanel</h1>
|
||||||
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<figure class="screenshot">
|
<script>
|
||||||
<div class="screenshot-inner">
|
'use strict';
|
||||||
<div class="window-controls">
|
window.op =
|
||||||
<div class="window-dot red"></div>
|
window.op ||
|
||||||
<div class="window-dot yellow"></div>
|
(() => {
|
||||||
<div class="window-dot green"></div>
|
var n = [];
|
||||||
</div>
|
return new Proxy(
|
||||||
<div class="screenshot-image-wrapper">
|
function () {
|
||||||
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
|
arguments.length && n.push([].slice.call(arguments));
|
||||||
</div>
|
},
|
||||||
</div>
|
{
|
||||||
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
|
get(t, r) {
|
||||||
</figure>
|
returnr === 'q'
|
||||||
|
? n
|
||||||
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
|
: function () {
|
||||||
|
n.push([r].concat([].slice.call(arguments)));
|
||||||
<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>
|
has(t, r) {
|
||||||
<ul>
|
returnr === 'q';
|
||||||
<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', {
|
window.op('init', {
|
||||||
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
|
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
|
||||||
trackScreenViews: true,
|
trackScreenViews: true,
|
||||||
trackOutgoingLinks: true,
|
trackOutgoingLinks: true,
|
||||||
trackAttributes: true,
|
trackAttributes: true,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { cn } from '@/lib/utils';
|
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
HeartHandshakeIcon,
|
HeartHandshakeIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
|
PackageIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
ZapIcon,
|
ZapIcon,
|
||||||
PackageIcon,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const perks = [
|
const perks = [
|
||||||
{
|
{
|
||||||
@@ -52,17 +52,17 @@ export function SupporterPerks({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'col gap-4 p-6 rounded-xl border bg-card',
|
'col gap-4 rounded-xl border bg-card p-6',
|
||||||
'sticky top-24',
|
'sticky top-24',
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="col gap-2 mb-2">
|
<div className="col mb-2 gap-2">
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
<HeartHandshakeIcon className="size-5 text-primary" />
|
<HeartHandshakeIcon className="size-5 text-primary" />
|
||||||
<h3 className="font-semibold text-lg">Supporter Perks</h3>
|
<h3 className="font-semibold text-lg">Supporter Perks</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
Everything you get when you support OpenPanel
|
Everything you get when you support OpenPanel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,42 +72,42 @@ export function SupporterPerks({ className }: { className?: string }) {
|
|||||||
const Icon = perk.icon;
|
const Icon = perk.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'col gap-1.5 p-3 rounded-lg border transition-colors',
|
'col gap-1.5 rounded-lg border p-3 transition-colors',
|
||||||
perk.highlight
|
perk.highlight
|
||||||
? 'bg-primary/5 border-primary/20'
|
? 'border-primary/20 bg-primary/5'
|
||||||
: 'bg-background border-border',
|
: 'border-border bg-background'
|
||||||
)}
|
)}
|
||||||
|
key={index}
|
||||||
>
|
>
|
||||||
<div className="row gap-2 items-start">
|
<div className="row items-start gap-2">
|
||||||
<Icon
|
<Icon
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-4 mt-0.5 shrink-0',
|
'mt-0.5 size-4 shrink-0',
|
||||||
perk.highlight ? 'text-primary' : 'text-muted-foreground',
|
perk.highlight ? 'text-primary' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="col gap-0.5 flex-1 min-w-0">
|
<div className="col min-w-0 flex-1 gap-0.5">
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
<h4
|
<h4
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-medium text-sm',
|
'font-medium text-sm',
|
||||||
perk.highlight && 'text-primary',
|
perk.highlight && 'text-primary'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{perk.title}
|
{perk.title}
|
||||||
</h4>
|
</h4>
|
||||||
{perk.highlight && (
|
{perk.highlight && (
|
||||||
<CheckIcon className="size-3.5 text-primary shrink-0" />
|
<CheckIcon className="size-3.5 shrink-0 text-primary" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
{perk.description}
|
{perk.description}
|
||||||
</p>
|
</p>
|
||||||
{perk.href && (
|
{perk.href && (
|
||||||
<Link
|
<Link
|
||||||
|
className="mt-1 text-primary text-xs hover:underline"
|
||||||
href={perk.href}
|
href={perk.href}
|
||||||
className="text-xs text-primary hover:underline mt-1"
|
|
||||||
>
|
>
|
||||||
Learn more →
|
Learn more →
|
||||||
</Link>
|
</Link>
|
||||||
@@ -119,12 +119,11 @@ export function SupporterPerks({ className }: { className?: string }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t">
|
<div className="mt-4 border-t pt-4">
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-center text-muted-foreground text-xs">
|
||||||
Starting at <strong className="text-foreground">$20/month</strong>
|
Starting at <strong className="text-foreground">$20/month</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,7 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"heading": "Best Amplitude Alternatives",
|
"heading": "Best Amplitude Alternatives",
|
||||||
"subheading": "OpenPanel is an open-source, privacy-first alternative to Amplitude. Get powerful product analytics with web analytics built in, cookie-free tracking, and the freedom to self-host or use our cloud.",
|
"subheading": "OpenPanel is an open-source, privacy-first alternative to Amplitude. Get powerful product analytics with web analytics built in, cookie-free tracking, and the freedom to self-host or use our cloud.",
|
||||||
"badges": [
|
"badges": ["Open-source", "Cookie-free", "EU-only hosting", "Self-hostable"]
|
||||||
"Open-source",
|
|
||||||
"Cookie-free",
|
|
||||||
"EU-only hosting",
|
|
||||||
"Self-hostable"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"competitor": {
|
"competitor": {
|
||||||
"name": "Amplitude",
|
"name": "Amplitude",
|
||||||
|
|||||||
@@ -9,12 +9,7 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"heading": "Best Countly Alternative",
|
"heading": "Best Countly Alternative",
|
||||||
"subheading": "Want Countly's product analytics without the complexity? OpenPanel offers a simpler, more affordable approach to user analytics with self-hosting, mobile SDKs, and modern product analytics - all with transparent pricing.",
|
"subheading": "Want Countly's product analytics without the complexity? OpenPanel offers a simpler, more affordable approach to user analytics with self-hosting, mobile SDKs, and modern product analytics - all with transparent pricing.",
|
||||||
"badges": [
|
"badges": ["Open-source", "Simple Pricing", "Lightweight", "MIT License"]
|
||||||
"Open-source",
|
|
||||||
"Simple Pricing",
|
|
||||||
"Lightweight",
|
|
||||||
"MIT License"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"competitor": {
|
"competitor": {
|
||||||
"name": "Countly",
|
"name": "Countly",
|
||||||
|
|||||||
@@ -274,9 +274,7 @@
|
|||||||
"Android",
|
"Android",
|
||||||
"Flutter"
|
"Flutter"
|
||||||
],
|
],
|
||||||
"competitor": [
|
"competitor": ["JavaScript (web only)"],
|
||||||
"JavaScript (web only)"
|
|
||||||
],
|
|
||||||
"notes": null
|
"notes": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFile, readdir } from 'node:fs/promises';
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
interface FileStructure {
|
interface FileStructure {
|
||||||
@@ -10,7 +10,7 @@ interface FileStructure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeJsonFiles(): Promise<void> {
|
async function analyzeJsonFiles(): Promise<void> {
|
||||||
const dirPath = join(import.meta.dirname || __dirname);
|
const dirPath = join(import.meta.dirname || import.meta.dirname);
|
||||||
const files = await readdir(dirPath);
|
const files = await readdir(dirPath);
|
||||||
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
const jsonFiles = files.filter((f) => f.endsWith('.json'));
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ async function analyzeJsonFiles(): Promise<void> {
|
|||||||
console.log(separator);
|
console.log(separator);
|
||||||
|
|
||||||
const sortedGroups = Array.from(groups.entries()).sort(
|
const sortedGroups = Array.from(groups.entries()).sort(
|
||||||
(a, b) => b[1].length - a[1].length,
|
(a, b) => b[1].length - a[1].length
|
||||||
);
|
);
|
||||||
|
|
||||||
sortedGroups.forEach(([structureKey, files], index) => {
|
sortedGroups.forEach(([structureKey, files], index) => {
|
||||||
@@ -117,7 +117,7 @@ async function analyzeJsonFiles(): Promise<void> {
|
|||||||
console.log(separator);
|
console.log(separator);
|
||||||
|
|
||||||
const validFiles = structures.filter((s) => s.hasContent && !s.error);
|
const validFiles = structures.filter((s) => s.hasContent && !s.error);
|
||||||
const emptyFiles = structures.filter((s) => !s.hasContent && !s.error);
|
const emptyFiles = structures.filter((s) => !(s.hasContent || s.error));
|
||||||
const errorFiles = structures.filter((s) => s.error);
|
const errorFiles = structures.filter((s) => s.error);
|
||||||
|
|
||||||
console.log(` Total files: ${structures.length}`);
|
console.log(` Total files: ${structures.length}`);
|
||||||
@@ -148,7 +148,9 @@ async function analyzeJsonFiles(): Promise<void> {
|
|||||||
console.log(separator);
|
console.log(separator);
|
||||||
|
|
||||||
sortedGroups.forEach(([structureKey, files], index) => {
|
sortedGroups.forEach(([structureKey, files], index) => {
|
||||||
if (structureKey === 'empty' || structureKey === 'error') return;
|
if (structureKey === 'empty' || structureKey === 'error') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const groupNum = index + 1;
|
const groupNum = index + 1;
|
||||||
console.log(`\nGroup ${groupNum} structure:`);
|
console.log(`\nGroup ${groupNum} structure:`);
|
||||||
|
|||||||
@@ -9,12 +9,7 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"heading": "Best Fathom Alternative",
|
"heading": "Best Fathom Alternative",
|
||||||
"subheading": "Love Fathom's simplicity and privacy focus? OpenPanel adds product analytics capabilities - funnels, cohorts, retention, and user identification - plus self-hosting options and a free tier.",
|
"subheading": "Love Fathom's simplicity and privacy focus? OpenPanel adds product analytics capabilities - funnels, cohorts, retention, and user identification - plus self-hosting options and a free tier.",
|
||||||
"badges": [
|
"badges": ["Open-source", "Privacy-first", "Self-hostable", "Free Tier"]
|
||||||
"Open-source",
|
|
||||||
"Privacy-first",
|
|
||||||
"Self-hostable",
|
|
||||||
"Free Tier"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"competitor": {
|
"competitor": {
|
||||||
"name": "Fathom Analytics",
|
"name": "Fathom Analytics",
|
||||||
|
|||||||
@@ -9,12 +9,7 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"heading": "Best Matomo Alternative",
|
"heading": "Best Matomo Alternative",
|
||||||
"subheading": "OpenPanel is a modern, open-source alternative to Matomo. Get powerful web and product analytics with a cleaner interface, truly cookie-free tracking by default, and no premium plugins required for essential features.",
|
"subheading": "OpenPanel is a modern, open-source alternative to Matomo. Get powerful web and product analytics with a cleaner interface, truly cookie-free tracking by default, and no premium plugins required for essential features.",
|
||||||
"badges": [
|
"badges": ["Open-source", "Cookie-free", "EU-only hosting", "Self-hostable"]
|
||||||
"Open-source",
|
|
||||||
"Cookie-free",
|
|
||||||
"EU-only hosting",
|
|
||||||
"Self-hostable"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"competitor": {
|
"competitor": {
|
||||||
"name": "Matomo",
|
"name": "Matomo",
|
||||||
|
|||||||
@@ -280,9 +280,7 @@
|
|||||||
"Android",
|
"Android",
|
||||||
"Flutter"
|
"Flutter"
|
||||||
],
|
],
|
||||||
"competitor": [
|
"competitor": ["JavaScript (web only)"],
|
||||||
"JavaScript (web only)"
|
|
||||||
],
|
|
||||||
"notes": null
|
"notes": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
{
|
{
|
||||||
"pages": ["sdks", "how-it-works", "session-replay", "consent-management", "..."]
|
"pages": [
|
||||||
|
"sdks",
|
||||||
|
"how-it-works",
|
||||||
|
"session-replay",
|
||||||
|
"consent-management",
|
||||||
|
"..."
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"pages": [
|
"pages": ["understand-the-overview", "..."]
|
||||||
"understand-the-overview",
|
|
||||||
"..."
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,7 @@
|
|||||||
"seo": {
|
"seo": {
|
||||||
"title": "Retention & Cohort Analysis",
|
"title": "Retention & Cohort Analysis",
|
||||||
"description": "User retention analytics and cohort analysis that show who comes back. See product stickiness at a glance-no sampling, no guesswork. Built on your events.",
|
"description": "User retention analytics and cohort analysis that show who comes back. See product stickiness at a glance-no sampling, no guesswork. Built on your events.",
|
||||||
"keywords": [
|
"keywords": ["user retention analytics", "cohort analysis"]
|
||||||
"user retention analytics",
|
|
||||||
"cohort analysis"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"heading": "Retention: Who comes back?",
|
"heading": "Retention: Who comes back?",
|
||||||
|
|||||||
@@ -151,16 +151,34 @@
|
|||||||
"related_links": {
|
"related_links": {
|
||||||
"guides": [
|
"guides": [
|
||||||
{ "title": "Script tag SDK setup", "url": "/docs/sdks/script" },
|
{ "title": "Script tag SDK setup", "url": "/docs/sdks/script" },
|
||||||
{ "title": "Migrate from Google Analytics", "url": "/guides/migrate-from-google-analytics" }
|
{
|
||||||
|
"title": "Migrate from Google Analytics",
|
||||||
|
"url": "/guides/migrate-from-google-analytics"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"articles": [
|
"articles": [
|
||||||
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
|
{
|
||||||
{ "title": "How to self-host OpenPanel", "url": "/articles/how-to-self-host-openpanel" },
|
"title": "Cookieless analytics explained",
|
||||||
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" }
|
"url": "/articles/cookieless-analytics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How to self-host OpenPanel",
|
||||||
|
"url": "/articles/how-to-self-host-openpanel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Best open source analytics tools",
|
||||||
|
"url": "/articles/open-source-web-analytics"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"comparisons": [
|
"comparisons": [
|
||||||
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
|
{
|
||||||
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" },
|
"title": "OpenPanel vs Google Analytics",
|
||||||
|
"url": "/compare/google-analytics-alternative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "OpenPanel vs Plausible",
|
||||||
|
"url": "/compare/plausible-alternative"
|
||||||
|
},
|
||||||
{ "title": "OpenPanel vs Matomo", "url": "/compare/matomo-alternative" }
|
{ "title": "OpenPanel vs Matomo", "url": "/compare/matomo-alternative" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -156,13 +156,28 @@
|
|||||||
{ "title": "Python analytics setup", "url": "/guides/python-analytics" }
|
{ "title": "Python analytics setup", "url": "/guides/python-analytics" }
|
||||||
],
|
],
|
||||||
"articles": [
|
"articles": [
|
||||||
{ "title": "How to self-host OpenPanel", "url": "/articles/how-to-self-host-openpanel" },
|
{
|
||||||
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" },
|
"title": "How to self-host OpenPanel",
|
||||||
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" }
|
"url": "/articles/how-to-self-host-openpanel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Best open source analytics tools",
|
||||||
|
"url": "/articles/open-source-web-analytics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Cookieless analytics explained",
|
||||||
|
"url": "/articles/cookieless-analytics"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"comparisons": [
|
"comparisons": [
|
||||||
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" },
|
{
|
||||||
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" },
|
"title": "OpenPanel vs PostHog",
|
||||||
|
"url": "/compare/posthog-alternative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "OpenPanel vs Plausible",
|
||||||
|
"url": "/compare/plausible-alternative"
|
||||||
|
},
|
||||||
{ "title": "OpenPanel vs Umami", "url": "/compare/umami-alternative" }
|
{ "title": "OpenPanel vs Umami", "url": "/compare/umami-alternative" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -135,10 +135,16 @@
|
|||||||
{ "title": "Track custom events", "url": "/guides/track-custom-events" }
|
{ "title": "Track custom events", "url": "/guides/track-custom-events" }
|
||||||
],
|
],
|
||||||
"articles": [
|
"articles": [
|
||||||
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
|
{
|
||||||
|
"title": "Self-hosted web analytics",
|
||||||
|
"url": "/articles/self-hosted-web-analytics"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"comparisons": [
|
"comparisons": [
|
||||||
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
|
{
|
||||||
|
"title": "OpenPanel vs Google Analytics",
|
||||||
|
"url": "/compare/google-analytics-alternative"
|
||||||
|
},
|
||||||
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" }
|
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -143,13 +143,28 @@
|
|||||||
{ "title": "React analytics setup", "url": "/guides/react-analytics" }
|
{ "title": "React analytics setup", "url": "/guides/react-analytics" }
|
||||||
],
|
],
|
||||||
"articles": [
|
"articles": [
|
||||||
{ "title": "How to create a funnel", "url": "/articles/how-to-create-a-funnel" },
|
{
|
||||||
{ "title": "Self-hosted web analytics", "url": "/articles/self-hosted-web-analytics" }
|
"title": "How to create a funnel",
|
||||||
|
"url": "/articles/how-to-create-a-funnel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Self-hosted web analytics",
|
||||||
|
"url": "/articles/self-hosted-web-analytics"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"comparisons": [
|
"comparisons": [
|
||||||
{ "title": "OpenPanel vs Mixpanel", "url": "/compare/mixpanel-alternative" },
|
{
|
||||||
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" },
|
"title": "OpenPanel vs Mixpanel",
|
||||||
{ "title": "OpenPanel vs Amplitude", "url": "/compare/amplitude-alternative" }
|
"url": "/compare/mixpanel-alternative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "OpenPanel vs PostHog",
|
||||||
|
"url": "/compare/posthog-alternative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "OpenPanel vs Amplitude",
|
||||||
|
"url": "/compare/amplitude-alternative"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ctas": {
|
"ctas": {
|
||||||
|
|||||||
@@ -134,15 +134,30 @@
|
|||||||
},
|
},
|
||||||
"related_links": {
|
"related_links": {
|
||||||
"guides": [
|
"guides": [
|
||||||
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" }
|
{
|
||||||
|
"title": "Ecommerce tracking setup",
|
||||||
|
"url": "/guides/ecommerce-tracking"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"articles": [
|
"articles": [
|
||||||
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
|
{
|
||||||
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" }
|
"title": "Cookieless analytics explained",
|
||||||
|
"url": "/articles/cookieless-analytics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Best open source analytics tools",
|
||||||
|
"url": "/articles/open-source-web-analytics"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"comparisons": [
|
"comparisons": [
|
||||||
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
|
{
|
||||||
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" }
|
"title": "OpenPanel vs Google Analytics",
|
||||||
|
"url": "/compare/google-analytics-alternative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "OpenPanel vs Plausible",
|
||||||
|
"url": "/compare/plausible-alternative"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ctas": {
|
"ctas": {
|
||||||
|
|||||||
@@ -152,17 +152,38 @@
|
|||||||
"guides": [
|
"guides": [
|
||||||
{ "title": "Next.js analytics setup", "url": "/guides/nextjs-analytics" },
|
{ "title": "Next.js analytics setup", "url": "/guides/nextjs-analytics" },
|
||||||
{ "title": "React analytics setup", "url": "/guides/react-analytics" },
|
{ "title": "React analytics setup", "url": "/guides/react-analytics" },
|
||||||
{ "title": "Migrate from Google Analytics", "url": "/guides/migrate-from-google-analytics" }
|
{
|
||||||
|
"title": "Migrate from Google Analytics",
|
||||||
|
"url": "/guides/migrate-from-google-analytics"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"articles": [
|
"articles": [
|
||||||
{ "title": "Best open source analytics tools", "url": "/articles/open-source-web-analytics" },
|
{
|
||||||
{ "title": "How to create a funnel", "url": "/articles/how-to-create-a-funnel" },
|
"title": "Best open source analytics tools",
|
||||||
{ "title": "Cookieless analytics guide", "url": "/articles/cookieless-analytics" }
|
"url": "/articles/open-source-web-analytics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How to create a funnel",
|
||||||
|
"url": "/articles/how-to-create-a-funnel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Cookieless analytics guide",
|
||||||
|
"url": "/articles/cookieless-analytics"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"comparisons": [
|
"comparisons": [
|
||||||
{ "title": "OpenPanel vs Mixpanel", "url": "/compare/mixpanel-alternative" },
|
{
|
||||||
{ "title": "OpenPanel vs PostHog", "url": "/compare/posthog-alternative" },
|
"title": "OpenPanel vs Mixpanel",
|
||||||
{ "title": "OpenPanel vs Amplitude", "url": "/compare/amplitude-alternative" }
|
"url": "/compare/mixpanel-alternative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "OpenPanel vs PostHog",
|
||||||
|
"url": "/compare/posthog-alternative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "OpenPanel vs Amplitude",
|
||||||
|
"url": "/compare/amplitude-alternative"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ctas": {
|
"ctas": {
|
||||||
|
|||||||
@@ -130,17 +130,38 @@
|
|||||||
},
|
},
|
||||||
"related_links": {
|
"related_links": {
|
||||||
"guides": [
|
"guides": [
|
||||||
{ "title": "Ecommerce tracking setup", "url": "/guides/ecommerce-tracking" },
|
{
|
||||||
{ "title": "Website analytics setup", "url": "/guides/website-analytics-setup" },
|
"title": "Ecommerce tracking setup",
|
||||||
{ "title": "OpenPanel WordPress plugin", "url": "https://sv.wordpress.org/plugins/openpanel/" }
|
"url": "/guides/ecommerce-tracking"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Website analytics setup",
|
||||||
|
"url": "/guides/website-analytics-setup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "OpenPanel WordPress plugin",
|
||||||
|
"url": "https://sv.wordpress.org/plugins/openpanel/"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"articles": [
|
"articles": [
|
||||||
{ "title": "Cookieless analytics explained", "url": "/articles/cookieless-analytics" },
|
{
|
||||||
{ "title": "How to self-host OpenPanel", "url": "/articles/self-hosted-web-analytics" }
|
"title": "Cookieless analytics explained",
|
||||||
|
"url": "/articles/cookieless-analytics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How to self-host OpenPanel",
|
||||||
|
"url": "/articles/self-hosted-web-analytics"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"comparisons": [
|
"comparisons": [
|
||||||
{ "title": "OpenPanel vs Google Analytics", "url": "/compare/google-analytics-alternative" },
|
{
|
||||||
{ "title": "OpenPanel vs Plausible", "url": "/compare/plausible-alternative" },
|
"title": "OpenPanel vs Google Analytics",
|
||||||
|
"url": "/compare/google-analytics-alternative"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "OpenPanel vs Plausible",
|
||||||
|
"url": "/compare/plausible-alternative"
|
||||||
|
},
|
||||||
{ "title": "OpenPanel vs Matomo", "url": "/compare/matomo-alternative" }
|
{ "title": "OpenPanel vs Matomo", "url": "/compare/matomo-alternative" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,2 +1,254 @@
|
|||||||
"use strict";(()=>{function v(r){return Promise.all(Object.entries(r).map(async([e,i])=>[e,await i??""])).then(e=>Object.fromEntries(e))}function m(r){let e={"Content-Type":"application/json"};return{headers:e,async fetch(i,t,n){let s=`${r}${i}`,o,a=await v(e);return new Promise(p=>{let c=l=>{clearTimeout(o),fetch(s,{headers:a,method:"POST",body:JSON.stringify(t??{}),keepalive:!0,...n??{}}).then(async d=>{if(d.status===401)return null;if(d.status!==200&&d.status!==202)return h(l,p);let g=await d.text();if(!g)return p(null);p(g)}).catch(()=>h(l,p))};function h(l,d){if(l>1)return d(null);o=setTimeout(()=>{c(l+1)},Math.pow(2,l)*500)}c(0)})}}}var u=class{constructor(e){this.state={properties:{}};this.options=e,this.api=m(e.url??"https://api.openpanel.dev"),this.api.headers["openpanel-client-id"]=e.clientId,this.options.clientSecret&&(this.api.headers["openpanel-client-secret"]=this.options.clientSecret)}setProfileId(e){this.state.profileId=e}setProfile(e){this.setProfileId(e.profileId),this.api.fetch("/profile",{...e,properties:{...this.state.properties,...e.properties}})}increment(e,i,t){let n=t?.profileId??this.state.profileId;if(!n)return console.log("No profile id");this.api.fetch("/profile/increment",{profileId:n,property:e,value:i})}decrement(e,i,t){let n=t?.profileId??this.state.profileId;if(!n)return console.log("No profile id");this.api.fetch("/profile/decrement",{profileId:n,property:e,value:i})}event(e,i){let t=i?.profileId??this.state.profileId;delete i?.profileId,this.api.fetch("/event",{name:e,properties:{...this.state.properties,...i??{}},timestamp:this.timestamp(),deviceId:this.getDeviceId(),profileId:t}).then(n=>{this.options.setDeviceId&&n&&this.options.setDeviceId(n)})}setGlobalProperties(e){this.state.properties={...this.state.properties,...e}}clear(){this.state.deviceId=void 0,this.state.profileId=void 0,this.options.removeDeviceId&&this.options.removeDeviceId()}timestamp(){return new Date().toISOString()}getDeviceId(){if(this.state.deviceId)return this.state.deviceId;this.options.getDeviceId&&(this.state.deviceId=this.options.getDeviceId()||void 0)}};function b(r){return r.replace(/([-_][a-z])/gi,e=>e.toUpperCase().replace("-","").replace("_",""))}var f=class extends u{constructor(i){super(i);this.lastPath="";this.isServer()||(this.setGlobalProperties({__referrer:document.referrer}),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews(),this.options.trackAttributes&&this.trackAttributes())}debounce(i,t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(i,t)}isServer(){return typeof document>"u"}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",i=>{let t=i.target,n=t.closest("a");if(n&&t){let s=n.getAttribute("href");s?.startsWith("http")&&super.event("link_out",{href:s,text:n.innerText||n.getAttribute("title")||t.getAttribute("alt")||t.getAttribute("title")})}})}trackScreenViews(){if(this.isServer())return;let i=history.pushState;history.pushState=function(...o){let a=i.apply(this,o);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),a};let t=history.replaceState;history.replaceState=function(...o){let a=t.apply(this,o);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),a},window.addEventListener("popstate",function(){window.dispatchEvent(new Event("locationchange"))});let n=()=>this.debounce(()=>this.screenView(),50);this.options.hash?window.addEventListener("hashchange",n):window.addEventListener("locationchange",n),setTimeout(()=>n(),50)}trackAttributes(){this.isServer()||document.addEventListener("click",i=>{let t=i.target,n=t.closest("button"),s=t.closest("a"),o=n?.getAttribute("data-event")?n:s?.getAttribute("data-event")?s:null;if(o){let a={};for(let c of o.attributes)c.name.startsWith("data-")&&c.name!=="data-event"&&(a[b(c.name.replace(/^data-/,""))]=c.value);let p=o.getAttribute("data-event");p&&super.event(p,a)}})}screenView(i){if(this.isServer())return;let t=window.location.href;this.lastPath!==t&&(this.lastPath=t,super.event("screen_view",{...i??{},__path:t,__title:document.title}))}};(r=>{if(r.op&&"q"in r.op){let e=r.op.q||[],i=new f(e.shift()[1]);e.forEach(t=>{t[0]in i&&i[t[0]](...t.slice(1))}),r.op=(t,...n)=>{let s=i[t].bind(i);typeof s=="function"&&s(...n)}}})(window);})();
|
(() => {
|
||||||
|
function v(r) {
|
||||||
|
return Promise.all(
|
||||||
|
Object.entries(r).map(async ([e, i]) => [e, (await i) ?? ''])
|
||||||
|
).then((e) => Object.fromEntries(e));
|
||||||
|
}
|
||||||
|
function m(r) {
|
||||||
|
const e = { 'Content-Type': 'application/json' };
|
||||||
|
return {
|
||||||
|
headers: e,
|
||||||
|
async fetch(i, t, n) {
|
||||||
|
let s = `${r}${i}`,
|
||||||
|
o,
|
||||||
|
a = await v(e);
|
||||||
|
return new Promise((p) => {
|
||||||
|
const c = (l) => {
|
||||||
|
clearTimeout(o),
|
||||||
|
fetch(s, {
|
||||||
|
headers: a,
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(t ?? {}),
|
||||||
|
keepalive: !0,
|
||||||
|
...(n ?? {}),
|
||||||
|
})
|
||||||
|
.then(async (d) => {
|
||||||
|
if (d.status === 401) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (d.status !== 200 && d.status !== 202) {
|
||||||
|
return h(l, p);
|
||||||
|
}
|
||||||
|
const g = await d.text();
|
||||||
|
if (!g) {
|
||||||
|
return p(null);
|
||||||
|
}
|
||||||
|
p(g);
|
||||||
|
})
|
||||||
|
.catch(() => h(l, p));
|
||||||
|
};
|
||||||
|
function h(l, d) {
|
||||||
|
if (l > 1) {
|
||||||
|
return d(null);
|
||||||
|
}
|
||||||
|
o = setTimeout(
|
||||||
|
() => {
|
||||||
|
c(l + 1);
|
||||||
|
},
|
||||||
|
2 ** l * 500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
c(0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
var u = class {
|
||||||
|
constructor(e) {
|
||||||
|
this.state = { properties: {} };
|
||||||
|
(this.options = e),
|
||||||
|
(this.api = m(e.url ?? 'https://api.openpanel.dev')),
|
||||||
|
(this.api.headers['openpanel-client-id'] = e.clientId),
|
||||||
|
this.options.clientSecret &&
|
||||||
|
(this.api.headers['openpanel-client-secret'] =
|
||||||
|
this.options.clientSecret);
|
||||||
|
}
|
||||||
|
setProfileId(e) {
|
||||||
|
this.state.profileId = e;
|
||||||
|
}
|
||||||
|
setProfile(e) {
|
||||||
|
this.setProfileId(e.profileId),
|
||||||
|
this.api.fetch('/profile', {
|
||||||
|
...e,
|
||||||
|
properties: { ...this.state.properties, ...e.properties },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
increment(e, i, t) {
|
||||||
|
const n = t?.profileId ?? this.state.profileId;
|
||||||
|
if (!n) {
|
||||||
|
return console.log('No profile id');
|
||||||
|
}
|
||||||
|
this.api.fetch('/profile/increment', {
|
||||||
|
profileId: n,
|
||||||
|
property: e,
|
||||||
|
value: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
decrement(e, i, t) {
|
||||||
|
const n = t?.profileId ?? this.state.profileId;
|
||||||
|
if (!n) {
|
||||||
|
return console.log('No profile id');
|
||||||
|
}
|
||||||
|
this.api.fetch('/profile/decrement', {
|
||||||
|
profileId: n,
|
||||||
|
property: e,
|
||||||
|
value: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
event(e, i) {
|
||||||
|
const t = i?.profileId ?? this.state.profileId;
|
||||||
|
delete i?.profileId,
|
||||||
|
this.api
|
||||||
|
.fetch('/event', {
|
||||||
|
name: e,
|
||||||
|
properties: { ...this.state.properties, ...(i ?? {}) },
|
||||||
|
timestamp: this.timestamp(),
|
||||||
|
deviceId: this.getDeviceId(),
|
||||||
|
profileId: t,
|
||||||
|
})
|
||||||
|
.then((n) => {
|
||||||
|
this.options.setDeviceId && n && this.options.setDeviceId(n);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setGlobalProperties(e) {
|
||||||
|
this.state.properties = { ...this.state.properties, ...e };
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
(this.state.deviceId = void 0),
|
||||||
|
(this.state.profileId = void 0),
|
||||||
|
this.options.removeDeviceId && this.options.removeDeviceId();
|
||||||
|
}
|
||||||
|
timestamp() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
getDeviceId() {
|
||||||
|
if (this.state.deviceId) {
|
||||||
|
return this.state.deviceId;
|
||||||
|
}
|
||||||
|
this.options.getDeviceId &&
|
||||||
|
(this.state.deviceId = this.options.getDeviceId() || void 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function b(r) {
|
||||||
|
return r.replace(/([-_][a-z])/gi, (e) =>
|
||||||
|
e.toUpperCase().replace('-', '').replace('_', '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var f = class extends u {
|
||||||
|
constructor(i) {
|
||||||
|
super(i);
|
||||||
|
this.lastPath = '';
|
||||||
|
this.isServer() ||
|
||||||
|
(this.setGlobalProperties({ __referrer: document.referrer }),
|
||||||
|
this.options.trackOutgoingLinks && this.trackOutgoingLinks(),
|
||||||
|
this.options.trackScreenViews && this.trackScreenViews(),
|
||||||
|
this.options.trackAttributes && this.trackAttributes());
|
||||||
|
}
|
||||||
|
debounce(i, t) {
|
||||||
|
clearTimeout(this.debounceTimer), (this.debounceTimer = setTimeout(i, t));
|
||||||
|
}
|
||||||
|
isServer() {
|
||||||
|
return typeof document > 'u';
|
||||||
|
}
|
||||||
|
trackOutgoingLinks() {
|
||||||
|
this.isServer() ||
|
||||||
|
document.addEventListener('click', (i) => {
|
||||||
|
const t = i.target,
|
||||||
|
n = t.closest('a');
|
||||||
|
if (n && t) {
|
||||||
|
const s = n.getAttribute('href');
|
||||||
|
s?.startsWith('http') &&
|
||||||
|
super.event('link_out', {
|
||||||
|
href: s,
|
||||||
|
text:
|
||||||
|
n.innerText ||
|
||||||
|
n.getAttribute('title') ||
|
||||||
|
t.getAttribute('alt') ||
|
||||||
|
t.getAttribute('title'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
trackScreenViews() {
|
||||||
|
if (this.isServer()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const i = history.pushState;
|
||||||
|
history.pushState = function (...o) {
|
||||||
|
const a = i.apply(this, o);
|
||||||
|
return (
|
||||||
|
window.dispatchEvent(new Event('pushstate')),
|
||||||
|
window.dispatchEvent(new Event('locationchange')),
|
||||||
|
a
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const t = history.replaceState;
|
||||||
|
(history.replaceState = function (...o) {
|
||||||
|
const a = t.apply(this, o);
|
||||||
|
return (
|
||||||
|
window.dispatchEvent(new Event('replacestate')),
|
||||||
|
window.dispatchEvent(new Event('locationchange')),
|
||||||
|
a
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
window.dispatchEvent(new Event('locationchange'));
|
||||||
|
});
|
||||||
|
const n = () => this.debounce(() => this.screenView(), 50);
|
||||||
|
this.options.hash
|
||||||
|
? window.addEventListener('hashchange', n)
|
||||||
|
: window.addEventListener('locationchange', n),
|
||||||
|
setTimeout(() => n(), 50);
|
||||||
|
}
|
||||||
|
trackAttributes() {
|
||||||
|
this.isServer() ||
|
||||||
|
document.addEventListener('click', (i) => {
|
||||||
|
const t = i.target,
|
||||||
|
n = t.closest('button'),
|
||||||
|
s = t.closest('a'),
|
||||||
|
o = n?.getAttribute('data-event')
|
||||||
|
? n
|
||||||
|
: s?.getAttribute('data-event')
|
||||||
|
? s
|
||||||
|
: null;
|
||||||
|
if (o) {
|
||||||
|
const a = {};
|
||||||
|
for (const c of o.attributes) {
|
||||||
|
c.name.startsWith('data-') &&
|
||||||
|
c.name !== 'data-event' &&
|
||||||
|
(a[b(c.name.replace(/^data-/, ''))] = c.value);
|
||||||
|
}
|
||||||
|
const p = o.getAttribute('data-event');
|
||||||
|
p && super.event(p, a);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
screenView(i) {
|
||||||
|
if (this.isServer()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = window.location.href;
|
||||||
|
this.lastPath !== t &&
|
||||||
|
((this.lastPath = t),
|
||||||
|
super.event('screen_view', {
|
||||||
|
...(i ?? {}),
|
||||||
|
__path: t,
|
||||||
|
__title: document.title,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
((r) => {
|
||||||
|
if (r.op && 'q' in r.op) {
|
||||||
|
const e = r.op.q || [],
|
||||||
|
i = new f(e.shift()[1]);
|
||||||
|
e.forEach((t) => {
|
||||||
|
t[0] in i && i[t[0]](...t.slice(1));
|
||||||
|
}),
|
||||||
|
(r.op = (t, ...n) => {
|
||||||
|
const s = i[t].bind(i);
|
||||||
|
typeof s == 'function' && s(...n);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})(window);
|
||||||
|
})();
|
||||||
//# sourceMappingURL=cdn.global.js.map
|
//# sourceMappingURL=cdn.global.js.map
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -49,7 +49,7 @@ const zGuide = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
anchor: z.string(),
|
anchor: z.string(),
|
||||||
}),
|
})
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Script from 'next/script';
|
||||||
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
||||||
import { SectionHeader } from '@/components/section';
|
import { SectionHeader } from '@/components/section';
|
||||||
import { url } from '@/lib/layout.shared';
|
import { url } from '@/lib/layout.shared';
|
||||||
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
||||||
import { pageSource } from '@/lib/source';
|
import { pageSource } from '@/lib/source';
|
||||||
import { getMDXComponents } from '@/mdx-components';
|
import { getMDXComponents } from '@/mdx-components';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import Script from 'next/script';
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -46,7 +46,7 @@ export default async function Page({
|
|||||||
const page = await pageSource.getPage(pages);
|
const page = await pageSource.getPage(pages);
|
||||||
const Body = page?.data.body;
|
const Body = page?.data.body;
|
||||||
|
|
||||||
if (!page || !Body) {
|
if (!(page && Body)) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,16 +70,16 @@ export default async function Page({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Script
|
<Script
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
id="page-schema"
|
id="page-schema"
|
||||||
strategy="beforeInteractive"
|
strategy="beforeInteractive"
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
||||||
/>
|
/>
|
||||||
<HeroContainer>
|
<HeroContainer>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
as="h1"
|
as="h1"
|
||||||
title={page.data.title}
|
|
||||||
description={page.data.description}
|
description={page.data.description}
|
||||||
|
title={page.data.title}
|
||||||
/>
|
/>
|
||||||
</HeroContainer>
|
</HeroContainer>
|
||||||
<main className="container">
|
<main className="container">
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
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';
|
||||||
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
||||||
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
||||||
import { Testimonials } from '@/app/(home)/_sections/testimonials';
|
import { Testimonials } from '@/app/(home)/_sections/testimonials';
|
||||||
@@ -7,16 +13,10 @@ import { GetStartedButton } from '@/components/get-started-button';
|
|||||||
import { Logo } from '@/components/logo';
|
import { Logo } from '@/components/logo';
|
||||||
import { SectionHeader } from '@/components/section';
|
import { SectionHeader } from '@/components/section';
|
||||||
import { Toc } from '@/components/toc';
|
import { Toc } from '@/components/toc';
|
||||||
import { url, getAuthor } from '@/lib/layout.shared';
|
import { getAuthor, url } from '@/lib/layout.shared';
|
||||||
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
||||||
import { articleSource } from '@/lib/source';
|
import { articleSource } from '@/lib/source';
|
||||||
import { getMDXComponents } from '@/mdx-components';
|
import { getMDXComponents } from '@/mdx-components';
|
||||||
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 generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const articles = await articleSource.getPages();
|
const articles = await articleSource.getPages();
|
||||||
@@ -63,8 +63,7 @@ export default async function Page({
|
|||||||
|
|
||||||
const relatedArticles = (await articleSource.getPages())
|
const relatedArticles = (await articleSource.getPages())
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) => item.data.tag === article?.data.tag && item.url !== article?.url
|
||||||
item.data.tag === article?.data.tag && item.url !== article?.url,
|
|
||||||
)
|
)
|
||||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||||
|
|
||||||
@@ -107,29 +106,29 @@ export default async function Page({
|
|||||||
<HeroContainer>
|
<HeroContainer>
|
||||||
<div className="col">
|
<div className="col">
|
||||||
<Link
|
<Link
|
||||||
|
className="mb-4 flex items-center gap-2 text-muted-foreground"
|
||||||
href={goBackUrl}
|
href={goBackUrl}
|
||||||
className="flex items-center gap-2 mb-4 text-muted-foreground"
|
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="w-4 h-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
<span>Back to all articles</span>
|
<span>Back to all articles</span>
|
||||||
</Link>
|
</Link>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
as="h1"
|
as="h1"
|
||||||
title={article?.data.title}
|
|
||||||
description={article?.data.description}
|
description={article?.data.description}
|
||||||
|
title={article?.data.title}
|
||||||
/>
|
/>
|
||||||
<div className="row gap-4 items-center mt-8">
|
<div className="row mt-8 items-center gap-4">
|
||||||
<div className="size-10 center-center bg-black rounded-full">
|
<div className="center-center size-10 rounded-full bg-black">
|
||||||
{author.image ? (
|
{author.image ? (
|
||||||
<Image
|
<Image
|
||||||
className="size-10 object-cover rounded-full"
|
|
||||||
src={author.image}
|
|
||||||
alt={author.name}
|
alt={author.name}
|
||||||
width={48}
|
className="size-10 rounded-full object-cover"
|
||||||
height={48}
|
height={48}
|
||||||
|
src={author.image}
|
||||||
|
width={48}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Logo className="w-6 h-6 fill-white" />
|
<Logo className="h-6 w-6 fill-white" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="col">
|
<div className="col">
|
||||||
@@ -149,23 +148,23 @@ export default async function Page({
|
|||||||
</div>
|
</div>
|
||||||
</HeroContainer>
|
</HeroContainer>
|
||||||
<Script
|
<Script
|
||||||
strategy="beforeInteractive"
|
|
||||||
id="article-schema"
|
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
id="article-schema"
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
type="application/ld+json"
|
||||||
/>
|
/>
|
||||||
<article className="container max-w-5xl col">
|
<article className="col container max-w-5xl">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
|
<div className="grid grid-cols-1 gap-0 md:grid-cols-[1fr_300px]">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
|
<div className="prose [&_img]:h-auto [&_img]:max-w-full [&_table]:w-auto">
|
||||||
<Body components={getMDXComponents()} />
|
<Body components={getMDXComponents()} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<aside className="pl-12 pb-12 gap-8 col">
|
<aside className="col gap-8 pb-12 pl-12">
|
||||||
<Toc toc={article?.data.toc} />
|
<Toc toc={article?.data.toc} />
|
||||||
<FeatureCardContainer className="gap-2">
|
<FeatureCardContainer className="gap-2">
|
||||||
<span className="text-lg font-semibold">Try OpenPanel</span>
|
<span className="font-semibold text-lg">Try OpenPanel</span>
|
||||||
<p className="text-muted-foreground text-sm mb-4">
|
<p className="mb-4 text-muted-foreground text-sm">
|
||||||
Give it a spin for free. No credit card required.
|
Give it a spin for free. No credit card required.
|
||||||
</p>
|
</p>
|
||||||
<GetStartedButton />
|
<GetStartedButton />
|
||||||
@@ -175,17 +174,17 @@ export default async function Page({
|
|||||||
|
|
||||||
{relatedArticles.length > 0 && (
|
{relatedArticles.length > 0 && (
|
||||||
<div className="my-16">
|
<div className="my-16">
|
||||||
<h3 className="text-2xl font-bold mb-8">Related articles</h3>
|
<h3 className="mb-8 font-bold text-2xl">Related articles</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{relatedArticles.map((item) => (
|
{relatedArticles.map((item) => (
|
||||||
<ArticleCard
|
<ArticleCard
|
||||||
key={item.url}
|
|
||||||
url={item.url}
|
|
||||||
title={item.data.title}
|
|
||||||
tag={item.data.tag}
|
|
||||||
cover={item.data.cover}
|
cover={item.data.cover}
|
||||||
team={item.data.team}
|
|
||||||
date={item.data.date}
|
date={item.data.date}
|
||||||
|
key={item.url}
|
||||||
|
tag={item.data.tag}
|
||||||
|
team={item.data.team}
|
||||||
|
title={item.data.title}
|
||||||
|
url={item.url}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
||||||
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
||||||
import { Testimonials } from '@/app/(home)/_sections/testimonials';
|
import { Testimonials } from '@/app/(home)/_sections/testimonials';
|
||||||
@@ -6,9 +7,6 @@ import { Section, SectionHeader } from '@/components/section';
|
|||||||
import { url } from '@/lib/layout.shared';
|
import { url } from '@/lib/layout.shared';
|
||||||
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
||||||
import { articleSource } from '@/lib/source';
|
import { articleSource } from '@/lib/source';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
export const metadata: Metadata = getPageMetadata({
|
export const metadata: Metadata = getPageMetadata({
|
||||||
title: 'Articles',
|
title: 'Articles',
|
||||||
@@ -20,30 +18,30 @@ export const metadata: Metadata = getPageMetadata({
|
|||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const articles = (await articleSource.getPages()).sort(
|
const articles = (await articleSource.getPages()).sort(
|
||||||
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
|
(a, b) => b.data.date.getTime() - a.data.date.getTime()
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<HeroContainer className="-mb-32">
|
<HeroContainer className="-mb-32">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
as="h1"
|
|
||||||
align="center"
|
align="center"
|
||||||
|
as="h1"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
title="Articles"
|
|
||||||
description="Read our latest articles and stay up to date with the latest news and updates."
|
description="Read our latest articles and stay up to date with the latest news and updates."
|
||||||
|
title="Articles"
|
||||||
/>
|
/>
|
||||||
</HeroContainer>
|
</HeroContainer>
|
||||||
|
|
||||||
<Section className="container grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
|
<Section className="container grid grid-cols-1 gap-8 sm:grid-cols-2 md:grid-cols-3">
|
||||||
{articles.map((item) => (
|
{articles.map((item) => (
|
||||||
<ArticleCard
|
<ArticleCard
|
||||||
key={item.url}
|
|
||||||
url={item.url}
|
|
||||||
title={item.data.title}
|
|
||||||
tag={item.data.tag}
|
|
||||||
cover={item.data.cover}
|
cover={item.data.cover}
|
||||||
team={item.data.team}
|
|
||||||
date={item.data.date}
|
date={item.data.date}
|
||||||
|
key={item.url}
|
||||||
|
tag={item.data.tag}
|
||||||
|
team={item.data.team}
|
||||||
|
title={item.data.title}
|
||||||
|
url={item.url}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { CheckIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { Section } from '@/components/section';
|
import { Section } from '@/components/section';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { CheckIcon } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface BenefitsSectionProps {
|
interface BenefitsSectionProps {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -26,23 +26,23 @@ export function BenefitsSection({
|
|||||||
}: BenefitsSectionProps) {
|
}: BenefitsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<Section className={cn('container', className)}>
|
<Section className={cn('container', className)}>
|
||||||
<div className="max-w-3xl col gap-6">
|
<div className="col max-w-3xl gap-6">
|
||||||
{label && (
|
{label && (
|
||||||
<p className="text-sm italic text-primary font-medium">{label}</p>
|
<p className="font-medium text-primary text-sm italic">{label}</p>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-4xl md:text-5xl font-semibold leading-tight">
|
<h2 className="font-semibold text-4xl leading-tight md:text-5xl">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-muted-foreground">{description}</p>
|
<p className="text-lg text-muted-foreground">{description}</p>
|
||||||
{cta && (
|
{cta && (
|
||||||
<Button size="lg" asChild className="w-fit">
|
<Button asChild className="w-fit" size="lg">
|
||||||
<Link href={cta.href}>{cta.label}</Link>
|
<Link href={cta.href}>{cta.label}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="col gap-4 mt-4">
|
<div className="col mt-4 gap-4">
|
||||||
{benefits.map((benefit) => (
|
{benefits.map((benefit) => (
|
||||||
<div key={benefit} className="row gap-3 items-start">
|
<div className="row items-start gap-3" key={benefit}>
|
||||||
<CheckIcon className="size-5 text-green-500 shrink-0 mt-0.5" />
|
<CheckIcon className="mt-0.5 size-5 shrink-0 text-green-500" />
|
||||||
<p className="text-muted-foreground">{benefit}</p>
|
<p className="text-muted-foreground">{benefit}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { CheckCircle2Icon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { CompareToc } from './compare-toc';
|
||||||
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
import { HeroContainer } from '@/app/(home)/_sections/hero';
|
||||||
import { GetStartedButton } from '@/components/get-started-button';
|
import { GetStartedButton } from '@/components/get-started-button';
|
||||||
import { Perks } from '@/components/perks';
|
import { Perks } from '@/components/perks';
|
||||||
import { SectionHeader } from '@/components/section';
|
import { SectionHeader } from '@/components/section';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { CompareHero as CompareHeroData } from '@/lib/compare';
|
import type { CompareHero as CompareHeroData } from '@/lib/compare';
|
||||||
import { CheckCircle2Icon } from 'lucide-react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { CompareToc } from './compare-toc';
|
|
||||||
|
|
||||||
interface CompareHeroProps {
|
interface CompareHeroProps {
|
||||||
hero: CompareHeroData;
|
hero: CompareHeroData;
|
||||||
@@ -15,11 +15,11 @@ interface CompareHeroProps {
|
|||||||
|
|
||||||
export function CompareHero({ hero, tocItems = [] }: CompareHeroProps) {
|
export function CompareHero({ hero, tocItems = [] }: CompareHeroProps) {
|
||||||
return (
|
return (
|
||||||
<HeroContainer divider={false} className="-mb-32">
|
<HeroContainer className="-mb-32" divider={false}>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
tocItems.length > 0
|
tocItems.length > 0
|
||||||
? 'grid md:grid-cols-[1fr_auto] gap-8 items-start'
|
? 'grid items-start gap-8 md:grid-cols-[1fr_auto]'
|
||||||
: 'col gap-6'
|
: 'col gap-6'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -27,24 +27,24 @@ export function CompareHero({ hero, tocItems = [] }: CompareHeroProps) {
|
|||||||
<SectionHeader
|
<SectionHeader
|
||||||
as="h1"
|
as="h1"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
title={hero.heading}
|
|
||||||
description={hero.subheading}
|
description={hero.subheading}
|
||||||
|
title={hero.heading}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="row gap-4">
|
<div className="row gap-4">
|
||||||
<GetStartedButton />
|
<GetStartedButton />
|
||||||
<Button size="lg" variant="outline" asChild>
|
<Button asChild size="lg" variant="outline">
|
||||||
<Link
|
<Link
|
||||||
href={'https://demo.openpanel.dev'}
|
href={'https://demo.openpanel.dev'}
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener nofollow"
|
rel="noreferrer noopener nofollow"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
See live demo
|
See live demo
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Perks
|
<Perks
|
||||||
className="flex gap-4 flex-wrap"
|
className="flex flex-wrap gap-4"
|
||||||
perks={hero.badges.map((badge) => ({
|
perks={hero.badges.map((badge) => ({
|
||||||
text: badge,
|
text: badge,
|
||||||
icon: CheckCircle2Icon,
|
icon: CheckCircle2Icon,
|
||||||
|
|||||||
@@ -8,15 +8,13 @@ interface CompareOverviewProps {
|
|||||||
export function CompareOverview({ overview }: CompareOverviewProps) {
|
export function CompareOverview({ overview }: CompareOverviewProps) {
|
||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<article className="col gap-6 max-w-3xl">
|
<article className="col max-w-3xl gap-6">
|
||||||
<h2 className="text-3xl md:text-4xl font-semibold">
|
<h2 className="font-semibold text-3xl md:text-4xl">{overview.title}</h2>
|
||||||
{overview.title}
|
|
||||||
</h2>
|
|
||||||
<div className="col gap-4">
|
<div className="col gap-4">
|
||||||
{overview.paragraphs.map((paragraph) => (
|
{overview.paragraphs.map((paragraph) => (
|
||||||
<p
|
<p
|
||||||
|
className="text-base text-muted-foreground leading-relaxed md:text-lg"
|
||||||
key={paragraph.slice(0, 48)}
|
key={paragraph.slice(0, 48)}
|
||||||
className="text-muted-foreground leading-relaxed text-base md:text-lg"
|
|
||||||
>
|
>
|
||||||
{paragraph}
|
{paragraph}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FeatureCardContainer } from '@/components/feature-card';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { ArrowRightIcon } from 'lucide-react';
|
import { ArrowRightIcon } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { FeatureCardContainer } from '@/components/feature-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface TocItem {
|
interface TocItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,17 +22,17 @@ export function CompareToc({ items, className }: CompareTocProps) {
|
|||||||
return (
|
return (
|
||||||
<FeatureCardContainer
|
<FeatureCardContainer
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden md:block sticky top-24 h-fit w-64 shrink-0',
|
'sticky top-24 hidden h-fit w-64 shrink-0 md:block',
|
||||||
'col gap-3 p-4 rounded-xl border bg-background/50 backdrop-blur-sm',
|
'col gap-3 rounded-xl border bg-background/50 p-4 backdrop-blur-sm',
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<nav className="col gap-1">
|
<nav className="col gap-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
className="group/toc relative flex min-h-6 items-center py-1 text-muted-foreground text-sm transition-colors duration-200 hover:text-foreground"
|
||||||
href={`${pathname}#${item.id}`}
|
href={`${pathname}#${item.id}`}
|
||||||
className="group/toc relative flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors duration-200 py-1 min-h-6"
|
key={item.id}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -45,8 +45,8 @@ export function CompareToc({ items, className }: CompareTocProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute left-0 flex items-center w-0 overflow-hidden transition-all duration-300 ease-out group-hover/toc:w-5">
|
<div className="absolute left-0 flex w-0 items-center overflow-hidden transition-all duration-300 ease-out group-hover/toc:w-5">
|
||||||
<ArrowRightIcon className="size-3 shrink-0 -translate-x-full group-hover/toc:translate-x-0 transition-transform duration-300 ease-out delay-75" />
|
<ArrowRightIcon className="size-3 shrink-0 -translate-x-full transition-transform delay-75 duration-300 ease-out group-hover/toc:translate-x-0" />
|
||||||
</div>
|
</div>
|
||||||
<span className="transition-transform duration-300 ease-out group-hover/toc:translate-x-5">
|
<span className="transition-transform duration-300 ease-out group-hover/toc:translate-x-5">
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Section, SectionHeader } from '@/components/section';
|
|
||||||
import { CompareHighlights, CompareFeatureComparison } from '@/lib/compare';
|
|
||||||
import { CheckIcon, XIcon } from 'lucide-react';
|
import { CheckIcon, XIcon } from 'lucide-react';
|
||||||
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
|
import type {
|
||||||
|
CompareFeatureComparison,
|
||||||
|
CompareHighlights,
|
||||||
|
} from '@/lib/compare';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface ComparisonTableProps {
|
interface ComparisonTableProps {
|
||||||
@@ -40,7 +43,7 @@ export function ComparisonTable({
|
|||||||
openpanel: feature.openpanel,
|
openpanel: feature.openpanel,
|
||||||
competitor: feature.competitor,
|
competitor: feature.competitor,
|
||||||
notes: feature.notes,
|
notes: feature.notes,
|
||||||
})),
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const allRows = [
|
const allRows = [
|
||||||
@@ -56,42 +59,44 @@ export function ComparisonTable({
|
|||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={highlights.title}
|
|
||||||
description={highlights.intro}
|
description={highlights.intro}
|
||||||
|
title={highlights.title}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="mt-12 border rounded-2xl overflow-hidden">
|
<div className="mt-12 overflow-hidden rounded-2xl border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/30">
|
<tr className="border-b bg-muted/30">
|
||||||
<th className="text-left p-4 font-semibold">Feature</th>
|
<th className="p-4 text-left font-semibold">Feature</th>
|
||||||
<th className="text-left p-4 font-semibold">OpenPanel</th>
|
<th className="p-4 text-left font-semibold">OpenPanel</th>
|
||||||
<th className="text-left p-4 font-semibold">{competitorName}</th>
|
<th className="p-4 text-left font-semibold">
|
||||||
|
{competitorName}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{allRows.map((row, index) => (
|
{allRows.map((row, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.feature}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b last:border-b-0',
|
'border-b last:border-b-0',
|
||||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/20'
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/20'
|
||||||
)}
|
)}
|
||||||
|
key={row.feature}
|
||||||
>
|
>
|
||||||
<td className="p-4 font-medium">{row.feature}</td>
|
<td className="p-4 font-medium">{row.feature}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
{renderValue(row.openpanel)}
|
{renderValue(row.openpanel)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="col gap-1">
|
<div className="col gap-1">
|
||||||
<div className="row gap-2 items-center text-muted-foreground">
|
<div className="row items-center gap-2 text-muted-foreground">
|
||||||
{renderValue(row.competitor)}
|
{renderValue(row.competitor)}
|
||||||
</div>
|
</div>
|
||||||
{row.notes && (
|
{row.notes && (
|
||||||
<span className="text-xs text-muted-foreground/70 mt-1">
|
<span className="mt-1 text-muted-foreground/70 text-xs">
|
||||||
{row.notes}
|
{row.notes}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -106,4 +111,3 @@ export function ComparisonTable({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Section, SectionHeader } from '@/components/section';
|
|
||||||
import { CompareFeatureGroup } from '@/lib/compare';
|
|
||||||
import { CheckIcon, XIcon } from 'lucide-react';
|
import { CheckIcon, XIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from '@/components/ui/accordion';
|
} from '@/components/ui/accordion';
|
||||||
|
import type { CompareFeatureGroup } from '@/lib/compare';
|
||||||
|
|
||||||
interface FeatureComparisonProps {
|
interface FeatureComparisonProps {
|
||||||
featureGroups: CompareFeatureGroup[];
|
featureGroups: CompareFeatureGroup[];
|
||||||
@@ -21,37 +20,39 @@ function renderFeatureValue(value: boolean | string) {
|
|||||||
<XIcon className="size-5 text-red-500" />
|
<XIcon className="size-5 text-red-500" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span className="text-sm text-muted-foreground">{value}</span>;
|
return <span className="text-muted-foreground text-sm">{value}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureComparison({ featureGroups }: FeatureComparisonProps) {
|
export function FeatureComparison({ featureGroups }: FeatureComparisonProps) {
|
||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Feature comparison"
|
|
||||||
description="Detailed breakdown of capabilities"
|
|
||||||
align="center"
|
align="center"
|
||||||
|
description="Detailed breakdown of capabilities"
|
||||||
|
title="Feature comparison"
|
||||||
/>
|
/>
|
||||||
<div className="mt-12 col gap-4">
|
<div className="col mt-12 gap-4">
|
||||||
{featureGroups.map((group) => (
|
{featureGroups.map((group) => (
|
||||||
<div key={group.group} className="border rounded-3xl overflow-hidden">
|
<div className="overflow-hidden rounded-3xl border" key={group.group}>
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion className="w-full" collapsible type="single">
|
||||||
<AccordionItem value={group.group} className="border-0">
|
<AccordionItem className="border-0" value={group.group}>
|
||||||
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
<AccordionTrigger className="px-6 py-4 hover:no-underline">
|
||||||
<h3 className="text-lg font-semibold">{group.group}</h3>
|
<h3 className="font-semibold text-lg">{group.group}</h3>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-6 pb-6">
|
<AccordionContent className="px-6 pb-6">
|
||||||
<div className="col gap-4">
|
<div className="col gap-4">
|
||||||
{group.features.map((feature) => (
|
{group.features.map((feature) => (
|
||||||
<div
|
<div
|
||||||
|
className="grid gap-4 border-b py-3 last:border-b-0 md:grid-cols-3"
|
||||||
key={feature.name}
|
key={feature.name}
|
||||||
className="grid md:grid-cols-3 gap-4 py-3 border-b last:border-b-0"
|
|
||||||
>
|
>
|
||||||
<div className="font-medium text-sm">{feature.name}</div>
|
<div className="font-medium text-sm">
|
||||||
<div className="row gap-2 items-center">
|
{feature.name}
|
||||||
|
</div>
|
||||||
|
<div className="row items-center gap-2">
|
||||||
{renderFeatureValue(feature.openpanel)}
|
{renderFeatureValue(feature.openpanel)}
|
||||||
</div>
|
</div>
|
||||||
<div className="row gap-2 items-center text-muted-foreground">
|
<div className="row items-center gap-2 text-muted-foreground">
|
||||||
{renderFeatureValue(feature.competitor)}
|
{renderFeatureValue(feature.competitor)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,4 +67,3 @@ export function FeatureComparison({ featureGroups }: FeatureComparisonProps) {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Section, SectionHeader } from '@/components/section';
|
|
||||||
import { CompareFeatureComparison } from '@/lib/compare';
|
|
||||||
import {
|
import {
|
||||||
|
BellIcon,
|
||||||
|
BrainIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
|
LayoutIcon,
|
||||||
|
LockIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
LayoutIcon,
|
|
||||||
BellIcon,
|
|
||||||
BrainIcon,
|
|
||||||
LockIcon,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
|
import type { CompareFeatureComparison } from '@/lib/compare';
|
||||||
|
|
||||||
interface FeaturesShowcaseProps {
|
interface FeaturesShowcaseProps {
|
||||||
featureComparison: CompareFeatureComparison;
|
featureComparison: CompareFeatureComparison;
|
||||||
@@ -33,23 +33,23 @@ export function FeaturesShowcase({ featureComparison }: FeaturesShowcaseProps) {
|
|||||||
.filter(
|
.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
f.openpanel === true ||
|
f.openpanel === true ||
|
||||||
(typeof f.openpanel === 'string' && f.openpanel.toLowerCase() !== 'no'),
|
(typeof f.openpanel === 'string' && f.openpanel.toLowerCase() !== 'no')
|
||||||
)
|
)
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={featureComparison.title}
|
|
||||||
description={featureComparison.intro}
|
description={featureComparison.intro}
|
||||||
|
title={featureComparison.title}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
|
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{openpanelFeatures.map((feature, index) => {
|
{openpanelFeatures.map((feature, index) => {
|
||||||
const Icon = featureIcons[index] || SparklesIcon;
|
const Icon = featureIcons[index] || SparklesIcon;
|
||||||
return (
|
return (
|
||||||
<div key={feature.name} className="col gap-3">
|
<div className="col gap-3" key={feature.name}>
|
||||||
<div className="size-10 rounded-lg bg-primary/10 center-center">
|
<div className="center-center size-10 rounded-lg bg-primary/10">
|
||||||
<Icon className="size-5 text-primary" />
|
<Icon className="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-sm">{feature.name}</h3>
|
<h3 className="font-semibold text-sm">{feature.name}</h3>
|
||||||
@@ -60,4 +60,3 @@ export function FeaturesShowcase({ featureComparison }: FeaturesShowcaseProps) {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { CheckIcon, MinusIcon, XIcon } from 'lucide-react';
|
||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import { CompareHighlight } from '@/lib/compare';
|
import type { CompareHighlight } from '@/lib/compare';
|
||||||
import { CheckIcon, XIcon, MinusIcon } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface HighlightsGridProps {
|
interface HighlightsGridProps {
|
||||||
@@ -22,28 +22,28 @@ export function HighlightsGrid({ highlights }: HighlightsGridProps) {
|
|||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Key differences"
|
|
||||||
description="See how OpenPanel compares at a glance"
|
|
||||||
align="center"
|
align="center"
|
||||||
|
description="See how OpenPanel compares at a glance"
|
||||||
|
title="Key differences"
|
||||||
/>
|
/>
|
||||||
<div className="mt-12 border rounded-3xl overflow-hidden">
|
<div className="mt-12 overflow-hidden rounded-3xl border">
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{highlights.map((highlight, index) => (
|
{highlights.map((highlight, index) => (
|
||||||
<div
|
<div
|
||||||
key={highlight.label}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid md:grid-cols-3 gap-4 p-6',
|
'grid gap-4 p-6 md:grid-cols-3',
|
||||||
index % 2 === 0 ? 'bg-muted/30' : 'bg-background'
|
index % 2 === 0 ? 'bg-muted/30' : 'bg-background'
|
||||||
)}
|
)}
|
||||||
|
key={highlight.label}
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-sm md:text-base">
|
<div className="font-semibold text-sm md:text-base">
|
||||||
{highlight.label}
|
{highlight.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="row gap-3 items-center">
|
<div className="row items-center gap-3">
|
||||||
{getIcon(highlight.openpanel)}
|
{getIcon(highlight.openpanel)}
|
||||||
<span className="text-sm">{highlight.openpanel}</span>
|
<span className="text-sm">{highlight.openpanel}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row gap-3 items-center text-muted-foreground">
|
<div className="row items-center gap-3 text-muted-foreground">
|
||||||
{getIcon(highlight.competitor)}
|
{getIcon(highlight.competitor)}
|
||||||
<span className="text-sm">{highlight.competitor}</span>
|
<span className="text-sm">{highlight.competitor}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,4 +54,3 @@ export function HighlightsGrid({ highlights }: HighlightsGridProps) {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Section, SectionHeader } from '@/components/section';
|
|
||||||
import { CompareMigration } from '@/lib/compare';
|
|
||||||
import { CheckIcon, ClockIcon } from 'lucide-react';
|
import { CheckIcon, ClockIcon } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
|
import type { CompareMigration } from '@/lib/compare';
|
||||||
|
|
||||||
interface MigrationSectionProps {
|
interface MigrationSectionProps {
|
||||||
migration: CompareMigration;
|
migration: CompareMigration;
|
||||||
@@ -11,22 +10,22 @@ export function MigrationSection({ migration }: MigrationSectionProps) {
|
|||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={migration.title}
|
|
||||||
description={migration.intro}
|
description={migration.intro}
|
||||||
|
title={migration.title}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Difficulty and time */}
|
{/* Difficulty and time */}
|
||||||
<div className="row gap-6 mt-8">
|
<div className="row mt-8 gap-6">
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<div className="row gap-2 items-center text-sm text-muted-foreground">
|
<div className="row items-center gap-2 text-muted-foreground text-sm">
|
||||||
<ClockIcon className="size-4" />
|
<ClockIcon className="size-4" />
|
||||||
<span className="font-medium">Difficulty:</span>
|
<span className="font-medium">Difficulty:</span>
|
||||||
<span className="capitalize">{migration.difficulty}</span>
|
<span className="capitalize">{migration.difficulty}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<div className="row gap-2 items-center text-sm text-muted-foreground">
|
<div className="row items-center gap-2 text-muted-foreground text-sm">
|
||||||
<ClockIcon className="size-4" />
|
<ClockIcon className="size-4" />
|
||||||
<span className="font-medium">Estimated time:</span>
|
<span className="font-medium">Estimated time:</span>
|
||||||
<span>{migration.estimated_time}</span>
|
<span>{migration.estimated_time}</span>
|
||||||
@@ -35,16 +34,18 @@ export function MigrationSection({ migration }: MigrationSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Steps */}
|
{/* Steps */}
|
||||||
<div className="col gap-4 mt-12">
|
<div className="col mt-12 gap-4">
|
||||||
{migration.steps.map((step, index) => (
|
{migration.steps.map((step, index) => (
|
||||||
<div key={step.title} className="col gap-2 p-6 border rounded-2xl">
|
<div className="col gap-2 rounded-2xl border p-6" key={step.title}>
|
||||||
<div className="row gap-3 items-start">
|
<div className="row items-start gap-3">
|
||||||
<div className="size-8 rounded-full bg-primary/10 center-center shrink-0 font-semibold text-sm">
|
<div className="center-center size-8 shrink-0 rounded-full bg-primary/10 font-semibold text-sm">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-1 flex-1">
|
<div className="col flex-1 gap-1">
|
||||||
<h3 className="font-semibold">{step.title}</h3>
|
<h3 className="font-semibold">{step.title}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{step.description}</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,20 +53,22 @@ export function MigrationSection({ migration }: MigrationSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SDK Compatibility */}
|
{/* SDK Compatibility */}
|
||||||
<div className="mt-12 p-6 border rounded-2xl bg-muted/30">
|
<div className="mt-12 rounded-2xl border bg-muted/30 p-6">
|
||||||
<div className="col gap-4">
|
<div className="col gap-4">
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
<CheckIcon className="size-5 text-green-500" />
|
<CheckIcon className="size-5 text-green-500" />
|
||||||
<h3 className="font-semibold">SDK Compatibility</h3>
|
<h3 className="font-semibold">SDK Compatibility</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{migration.sdk_compatibility.notes}</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{migration.sdk_compatibility.notes}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Historical Data */}
|
{/* Historical Data */}
|
||||||
<div className="mt-6 p-6 border rounded-2xl bg-muted/30">
|
<div className="mt-6 rounded-2xl border bg-muted/30 p-6">
|
||||||
<div className="col gap-4">
|
<div className="col gap-4">
|
||||||
<div className="row gap-2 items-center">
|
<div className="row items-center gap-2">
|
||||||
{migration.historical_data.can_import ? (
|
{migration.historical_data.can_import ? (
|
||||||
<CheckIcon className="size-5 text-green-500" />
|
<CheckIcon className="size-5 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
@@ -73,10 +76,11 @@ export function MigrationSection({ migration }: MigrationSectionProps) {
|
|||||||
)}
|
)}
|
||||||
<h3 className="font-semibold">Historical Data Import</h3>
|
<h3 className="font-semibold">Historical Data Import</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{migration.historical_data.notes}</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{migration.historical_data.notes}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { DollarSignIcon } from 'lucide-react';
|
||||||
import { FeatureCard } from '@/components/feature-card';
|
import { FeatureCard } from '@/components/feature-card';
|
||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import type { ComparePricing } from '@/lib/compare';
|
import type { ComparePricing } from '@/lib/compare';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { DollarSignIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface PricingComparisonRow {
|
interface PricingComparisonRow {
|
||||||
feature: string;
|
feature: string;
|
||||||
@@ -24,34 +24,34 @@ export function PricingComparison({
|
|||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={pricing.title}
|
|
||||||
description={pricing.intro}
|
|
||||||
align="center"
|
align="center"
|
||||||
|
description={pricing.intro}
|
||||||
|
title={pricing.title}
|
||||||
/>
|
/>
|
||||||
<div className="grid md:grid-cols-2 gap-6 mt-12">
|
<div className="mt-12 grid gap-6 md:grid-cols-2">
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
title="OpenPanel"
|
className="border-green-500/20 bg-green-500/5"
|
||||||
description={pricing.openpanel.model}
|
description={pricing.openpanel.model}
|
||||||
icon={DollarSignIcon}
|
icon={DollarSignIcon}
|
||||||
className="border-green-500/20 bg-green-500/5"
|
title="OpenPanel"
|
||||||
>
|
>
|
||||||
<div className="col gap-3 mt-4">
|
<div className="col mt-4 gap-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
{pricing.openpanel.description}
|
{pricing.openpanel.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
title={competitorName}
|
|
||||||
description={pricing.competitor.model}
|
description={pricing.competitor.model}
|
||||||
icon={DollarSignIcon}
|
icon={DollarSignIcon}
|
||||||
|
title={competitorName}
|
||||||
>
|
>
|
||||||
<div className="col gap-3 mt-4">
|
<div className="col mt-4 gap-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-muted-foreground text-sm">
|
||||||
{pricing.competitor.description}
|
{pricing.competitor.description}
|
||||||
</p>
|
</p>
|
||||||
{pricing.competitor.free_tier && (
|
{pricing.competitor.free_tier && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
Free tier: {pricing.competitor.free_tier}
|
Free tier: {pricing.competitor.free_tier}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -59,21 +59,21 @@ export function PricingComparison({
|
|||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
</div>
|
</div>
|
||||||
{pricingTable.length > 0 && (
|
{pricingTable.length > 0 && (
|
||||||
<div className="mt-12 border rounded-3xl overflow-hidden">
|
<div className="mt-12 overflow-hidden rounded-3xl border">
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
{pricingTable.map((row, index) => (
|
{pricingTable.map((row, index) => (
|
||||||
<div
|
<div
|
||||||
key={row.feature}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid md:grid-cols-3 gap-4 p-6',
|
'grid gap-4 p-6 md:grid-cols-3',
|
||||||
index % 2 === 0 ? 'bg-muted/30' : 'bg-background',
|
index % 2 === 0 ? 'bg-muted/30' : 'bg-background'
|
||||||
)}
|
)}
|
||||||
|
key={row.feature}
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-sm md:text-base">
|
<div className="font-semibold text-sm md:text-base">
|
||||||
{row.feature}
|
{row.feature}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">{row.openpanel}</div>
|
<div className="text-sm">{row.openpanel}</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
{row.competitor}
|
{row.competitor}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { ArrowRightIcon, CheckIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import type { ComparePricing } from '@/lib/compare';
|
import type { ComparePricing } from '@/lib/compare';
|
||||||
import { ArrowRightIcon, CheckIcon } from 'lucide-react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface PricingSectionProps {
|
interface PricingSectionProps {
|
||||||
pricing: ComparePricing;
|
pricing: ComparePricing;
|
||||||
@@ -47,71 +47,69 @@ export function PricingSection({
|
|||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={pricing.title}
|
|
||||||
description={pricing.intro}
|
description={pricing.intro}
|
||||||
|
title={pricing.title}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Pricing comparison */}
|
{/* Pricing comparison */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="mt-12 grid gap-6 md:grid-cols-2"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
|
||||||
viewport={{ once: true, margin: '-100px' }}
|
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
className="grid md:grid-cols-2 gap-6 mt-12"
|
viewport={{ once: true, margin: '-100px' }}
|
||||||
|
whileInView="visible"
|
||||||
>
|
>
|
||||||
{/* OpenPanel Card */}
|
{/* OpenPanel Card */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="col group relative gap-4 overflow-hidden rounded-2xl border bg-background p-6 transition-all duration-300 hover:border-emerald-500/30"
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
className="col gap-4 p-6 rounded-2xl border bg-background group relative overflow-hidden hover:border-emerald-500/30 transition-all duration-300"
|
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-br opacity-100 blur-2xl dark:from-emerald-500/5 dark:via-transparent dark:to-green-500/5 light:from-emerald-800/10 light:via-transparent light:to-green-900/10 group-hover:opacity-150 transition-opacity duration-500" />
|
<div className="pointer-events-none absolute inset-0 bg-linear-to-br light:from-emerald-800/10 light:via-transparent light:to-green-900/10 opacity-100 blur-2xl transition-opacity duration-500 group-hover:opacity-150 dark:from-emerald-500/5 dark:via-transparent dark:to-green-500/5" />
|
||||||
<div className="col gap-3 relative z-10">
|
<div className="col relative z-10 gap-3">
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<h3 className="text-xl font-semibold">OpenPanel</h3>
|
<h3 className="font-semibold text-xl">OpenPanel</h3>
|
||||||
<p className="text-sm text-muted-foreground font-medium">
|
<p className="font-medium text-muted-foreground text-sm">
|
||||||
{pricing.openpanel.model}
|
{pricing.openpanel.model}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2 mt-2">
|
<div className="col mt-2 gap-2">
|
||||||
{openpanelPoints.map((point, index) => (
|
{openpanelPoints.map((point, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
className="row group/item items-start gap-2"
|
||||||
initial={{ opacity: 0, x: -10 }}
|
initial={{ opacity: 0, x: -10 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
key={index}
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
className="row gap-2 items-start group/item"
|
viewport={{ once: true }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
>
|
>
|
||||||
<CheckIcon className="size-4 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5 group-hover/item:scale-110 transition-transform duration-300" />
|
<CheckIcon className="mt-0.5 size-4 shrink-0 text-emerald-600 transition-transform duration-300 group-hover/item:scale-110 dark:text-emerald-400" />
|
||||||
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
|
<p className="flex-1 text-muted-foreground text-sm transition-colors duration-300 group-hover/item:text-foreground">
|
||||||
{point.trim()}
|
{point.trim()}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="col mt-2 gap-2"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
whileInView={{ opacity: 1 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.3 }}
|
transition={{ delay: 0.3 }}
|
||||||
className="col gap-2 mt-2"
|
viewport={{ once: true }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
>
|
>
|
||||||
<div className="row gap-2 items-center p-3 rounded-lg bg-muted/30 border border-emerald-500/10">
|
<div className="row items-center gap-2 rounded-lg border border-emerald-500/10 bg-muted/30 p-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="font-medium text-muted-foreground text-xs">
|
||||||
Free tier:
|
Free tier:
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
Self-hosting (unlimited events)
|
Self-hosting (unlimited events)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row gap-2 items-center p-3 rounded-lg bg-muted/30 border border-emerald-500/10">
|
<div className="row items-center gap-2 rounded-lg border border-emerald-500/10 bg-muted/30 p-3">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="font-medium text-muted-foreground text-xs">
|
||||||
Free trial:
|
Free trial:
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">30 days</span>
|
||||||
30 days
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,29 +117,29 @@ export function PricingSection({
|
|||||||
|
|
||||||
{/* Competitor Card */}
|
{/* Competitor Card */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="col group relative gap-4 overflow-hidden rounded-2xl border bg-background p-6 transition-all duration-300 hover:border-orange-500/30"
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
className="col gap-4 p-6 rounded-2xl border bg-background group relative overflow-hidden hover:border-orange-500/30 transition-all duration-300"
|
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-br opacity-100 blur-2xl dark:from-orange-500/5 dark:via-transparent dark:to-amber-500/5 light:from-orange-800/10 light:via-transparent light:to-amber-900/10 group-hover:opacity-150 transition-opacity duration-500" />
|
<div className="pointer-events-none absolute inset-0 bg-linear-to-br light:from-orange-800/10 light:via-transparent light:to-amber-900/10 opacity-100 blur-2xl transition-opacity duration-500 group-hover:opacity-150 dark:from-orange-500/5 dark:via-transparent dark:to-amber-500/5" />
|
||||||
<div className="col gap-3 relative z-10">
|
<div className="col relative z-10 gap-3">
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<h3 className="text-xl font-semibold">{competitorName}</h3>
|
<h3 className="font-semibold text-xl">{competitorName}</h3>
|
||||||
<p className="text-sm text-muted-foreground font-medium">
|
<p className="font-medium text-muted-foreground text-sm">
|
||||||
{pricing.competitor.model}
|
{pricing.competitor.model}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2 mt-2">
|
<div className="col mt-2 gap-2">
|
||||||
{competitorPoints.map((point, index) => (
|
{competitorPoints.map((point, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
className="row group/item items-start gap-2"
|
||||||
initial={{ opacity: 0, x: -10 }}
|
initial={{ opacity: 0, x: -10 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
key={index}
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
className="row gap-2 items-start group/item"
|
viewport={{ once: true }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
>
|
>
|
||||||
<CheckIcon className="size-4 text-orange-600 dark:text-orange-400 shrink-0 mt-0.5 group-hover/item:scale-110 transition-transform duration-300" />
|
<CheckIcon className="mt-0.5 size-4 shrink-0 text-orange-600 transition-transform duration-300 group-hover/item:scale-110 dark:text-orange-400" />
|
||||||
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
|
<p className="flex-1 text-muted-foreground text-sm transition-colors duration-300 group-hover/item:text-foreground">
|
||||||
{point.trim()}
|
{point.trim()}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -149,16 +147,16 @@ export function PricingSection({
|
|||||||
</div>
|
</div>
|
||||||
{pricing.competitor.free_tier && (
|
{pricing.competitor.free_tier && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="row mt-2 items-center gap-2 rounded-lg border border-orange-500/10 bg-muted/30 p-3"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
whileInView={{ opacity: 1 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.3 }}
|
transition={{ delay: 0.3 }}
|
||||||
className="row gap-2 items-center mt-2 p-3 rounded-lg bg-muted/30 border border-orange-500/10"
|
viewport={{ once: true }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="font-medium text-muted-foreground text-xs">
|
||||||
Free tier:
|
Free tier:
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
{pricing.competitor.free_tier}
|
{pricing.competitor.free_tier}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -166,18 +164,18 @@ export function PricingSection({
|
|||||||
{pricing.competitor.pricing_url && (
|
{pricing.competitor.pricing_url && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
whileInView={{ opacity: 1 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.4 }}
|
transition={{ delay: 0.4 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
whileInView={{ opacity: 1 }}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
|
className="row group/link mt-2 items-center gap-2 text-primary text-xs transition-colors duration-300 hover:text-primary/80"
|
||||||
href={pricing.competitor.pricing_url}
|
href={pricing.competitor.pricing_url}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="row gap-2 items-center text-xs text-primary hover:text-primary/80 transition-colors duration-300 mt-2 group/link"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<span>View pricing</span>
|
<span>View pricing</span>
|
||||||
<ArrowRightIcon className="size-3 group-hover/link:translate-x-1 transition-transform duration-300" />
|
<ArrowRightIcon className="size-3 transition-transform duration-300 group-hover/link:translate-x-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -187,4 +185,3 @@ export function PricingSection({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import {
|
||||||
|
PuzzleIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
TrendingUpIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import { CompareSummary } from '@/lib/compare';
|
import type { CompareSummary } from '@/lib/compare';
|
||||||
import { UsersIcon, TrendingUpIcon, PuzzleIcon, ShieldIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ProblemSectionProps {
|
interface ProblemSectionProps {
|
||||||
summary: CompareSummary;
|
summary: CompareSummary;
|
||||||
@@ -9,25 +14,28 @@ interface ProblemSectionProps {
|
|||||||
|
|
||||||
const problemIcons = [UsersIcon, TrendingUpIcon, PuzzleIcon, ShieldIcon];
|
const problemIcons = [UsersIcon, TrendingUpIcon, PuzzleIcon, ShieldIcon];
|
||||||
|
|
||||||
export function ProblemSection({ summary, competitorName }: ProblemSectionProps) {
|
export function ProblemSection({
|
||||||
|
summary,
|
||||||
|
competitorName,
|
||||||
|
}: ProblemSectionProps) {
|
||||||
const problems = summary.best_for_competitor.slice(0, 4);
|
const problems = summary.best_for_competitor.slice(0, 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={summary.title}
|
|
||||||
description={summary.intro}
|
description={summary.intro}
|
||||||
|
title={summary.title}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mt-12">
|
<div className="mt-12 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{problems.map((problem, index) => {
|
{problems.map((problem, index) => {
|
||||||
const Icon = problemIcons[index] || UsersIcon;
|
const Icon = problemIcons[index] || UsersIcon;
|
||||||
return (
|
return (
|
||||||
<div key={problem} className="col gap-3 text-center">
|
<div className="col gap-3 text-center" key={problem}>
|
||||||
<div className="size-12 rounded-full bg-muted center-center mx-auto">
|
<div className="center-center mx-auto size-12 rounded-full bg-muted">
|
||||||
<Icon className="size-6 text-muted-foreground" />
|
<Icon className="size-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{problem}</p>
|
<p className="text-muted-foreground text-sm">{problem}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -35,4 +43,3 @@ export function ProblemSection({ summary, competitorName }: ProblemSectionProps)
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ interface RelatedLinksProps {
|
|||||||
|
|
||||||
export function RelatedLinksSection({ relatedLinks }: RelatedLinksProps) {
|
export function RelatedLinksSection({ relatedLinks }: RelatedLinksProps) {
|
||||||
if (
|
if (
|
||||||
!relatedLinks ||
|
!(
|
||||||
(!relatedLinks.guides?.length && !relatedLinks.articles?.length && !relatedLinks.alternatives?.length)
|
relatedLinks &&
|
||||||
|
(relatedLinks.guides?.length ||
|
||||||
|
relatedLinks.articles?.length ||
|
||||||
|
relatedLinks.alternatives?.length)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,48 @@
|
|||||||
|
import { CheckIcon, XIcon } from 'lucide-react';
|
||||||
import { FeatureCard } from '@/components/feature-card';
|
import { FeatureCard } from '@/components/feature-card';
|
||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import { CompareSummary } from '@/lib/compare';
|
import type { CompareSummary } from '@/lib/compare';
|
||||||
import { CheckIcon, XIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface SummaryComparisonProps {
|
interface SummaryComparisonProps {
|
||||||
summary: CompareSummary;
|
summary: CompareSummary;
|
||||||
competitorName: string;
|
competitorName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SummaryComparison({ summary, competitorName }: SummaryComparisonProps) {
|
export function SummaryComparison({
|
||||||
|
summary,
|
||||||
|
competitorName,
|
||||||
|
}: SummaryComparisonProps) {
|
||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Quick comparison"
|
|
||||||
description={summary.one_liner}
|
|
||||||
align="center"
|
align="center"
|
||||||
|
description={summary.one_liner}
|
||||||
|
title="Quick comparison"
|
||||||
/>
|
/>
|
||||||
<div className="grid md:grid-cols-2 gap-6 mt-12">
|
<div className="mt-12 grid gap-6 md:grid-cols-2">
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
title="Best for OpenPanel"
|
|
||||||
description=""
|
|
||||||
className="border-green-500/20 bg-green-500/5"
|
className="border-green-500/20 bg-green-500/5"
|
||||||
|
description=""
|
||||||
|
title="Best for OpenPanel"
|
||||||
>
|
>
|
||||||
<ul className="col gap-3 mt-4">
|
<ul className="col mt-4 gap-3">
|
||||||
{summary.best_for_openpanel.map((item) => (
|
{summary.best_for_openpanel.map((item) => (
|
||||||
<li key={item} className="row gap-2 items-start text-sm">
|
<li className="row items-start gap-2 text-sm" key={item}>
|
||||||
<CheckIcon className="size-4 shrink-0 mt-0.5 text-green-500" />
|
<CheckIcon className="mt-0.5 size-4 shrink-0 text-green-500" />
|
||||||
<span>{item}</span>
|
<span>{item}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
title={`Best for ${competitorName}`}
|
|
||||||
description=""
|
|
||||||
className="border-muted"
|
className="border-muted"
|
||||||
|
description=""
|
||||||
|
title={`Best for ${competitorName}`}
|
||||||
>
|
>
|
||||||
<ul className="col gap-3 mt-4">
|
<ul className="col mt-4 gap-3">
|
||||||
{summary.best_for_competitor.map((item) => (
|
{summary.best_for_competitor.map((item) => (
|
||||||
<li key={item} className="row gap-2 items-start text-sm">
|
<li className="row items-start gap-2 text-sm" key={item}>
|
||||||
<XIcon className="size-4 shrink-0 mt-0.5 text-muted-foreground" />
|
<XIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="text-muted-foreground">{item}</span>
|
<span className="text-muted-foreground">{item}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -49,4 +52,3 @@ export function SummaryComparison({ summary, competitorName }: SummaryComparison
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import { CompareTechnicalComparison } from '@/lib/compare';
|
import type { CompareTechnicalComparison } from '@/lib/compare';
|
||||||
import { CheckIcon, XIcon } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface TechnicalComparisonProps {
|
interface TechnicalComparisonProps {
|
||||||
@@ -13,7 +12,7 @@ function renderValue(value: string | string[]) {
|
|||||||
return (
|
return (
|
||||||
<ul className="col gap-1">
|
<ul className="col gap-1">
|
||||||
{value.map((item, idx) => (
|
{value.map((item, idx) => (
|
||||||
<li key={idx} className="text-sm">
|
<li className="text-sm" key={idx}>
|
||||||
{item}
|
{item}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -30,28 +29,30 @@ export function TechnicalComparison({
|
|||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={technical.title}
|
|
||||||
description={technical.intro}
|
description={technical.intro}
|
||||||
|
title={technical.title}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="mt-12 border rounded-2xl overflow-hidden">
|
<div className="mt-12 overflow-hidden rounded-2xl border">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/30">
|
<tr className="border-b bg-muted/30">
|
||||||
<th className="text-left p-4 font-semibold">Feature</th>
|
<th className="p-4 text-left font-semibold">Feature</th>
|
||||||
<th className="text-left p-4 font-semibold">OpenPanel</th>
|
<th className="p-4 text-left font-semibold">OpenPanel</th>
|
||||||
<th className="text-left p-4 font-semibold">{competitorName}</th>
|
<th className="p-4 text-left font-semibold">
|
||||||
|
{competitorName}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{technical.items.map((item, index) => (
|
{technical.items.map((item, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={item.label}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b last:border-b-0',
|
'border-b last:border-b-0',
|
||||||
index % 2 === 0 ? 'bg-background' : 'bg-muted/20'
|
index % 2 === 0 ? 'bg-background' : 'bg-muted/20'
|
||||||
)}
|
)}
|
||||||
|
key={item.label}
|
||||||
>
|
>
|
||||||
<td className="p-4 font-medium">{item.label}</td>
|
<td className="p-4 font-medium">{item.label}</td>
|
||||||
<td className="p-4">{renderValue(item.openpanel)}</td>
|
<td className="p-4">{renderValue(item.openpanel)}</td>
|
||||||
@@ -59,7 +60,7 @@ export function TechnicalComparison({
|
|||||||
<div className="col gap-1">
|
<div className="col gap-1">
|
||||||
{renderValue(item.competitor)}
|
{renderValue(item.competitor)}
|
||||||
{item.notes && (
|
{item.notes && (
|
||||||
<span className="text-xs text-muted-foreground/70 mt-1">
|
<span className="mt-1 text-muted-foreground/70 text-xs">
|
||||||
{item.notes}
|
{item.notes}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -74,4 +75,3 @@ export function TechnicalComparison({
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
XIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { FeatureCard } from '@/components/feature-card';
|
import { FeatureCard } from '@/components/feature-card';
|
||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import { CompareTrustCompliance } from '@/lib/compare';
|
import type { CompareTrustCompliance } from '@/lib/compare';
|
||||||
import { ShieldIcon, MapPinIcon, ServerIcon } from 'lucide-react';
|
|
||||||
import { CheckIcon, XIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface TrustComplianceProps {
|
interface TrustComplianceProps {
|
||||||
trust: CompareTrustCompliance;
|
trust: CompareTrustCompliance;
|
||||||
@@ -12,41 +17,41 @@ export function TrustCompliance({ trust }: TrustComplianceProps) {
|
|||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={trust.title}
|
|
||||||
description={trust.intro}
|
description={trust.intro}
|
||||||
|
title={trust.title}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="grid md:grid-cols-2 gap-6 mt-12">
|
<div className="mt-12 grid gap-6 md:grid-cols-2">
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
title="OpenPanel"
|
|
||||||
description=""
|
|
||||||
className="border-green-500/20 bg-green-500/5"
|
className="border-green-500/20 bg-green-500/5"
|
||||||
|
description=""
|
||||||
|
title="OpenPanel"
|
||||||
>
|
>
|
||||||
<div className="col gap-4 mt-4">
|
<div className="col mt-4 gap-4">
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<div className="row gap-2 items-center text-sm">
|
<div className="row items-center gap-2 text-sm">
|
||||||
<ShieldIcon className="size-4" />
|
<ShieldIcon className="size-4" />
|
||||||
<span className="font-medium">Data Processing</span>
|
<span className="font-medium">Data Processing</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground ml-6">
|
<p className="ml-6 text-muted-foreground text-sm">
|
||||||
{trust.openpanel.data_processing}
|
{trust.openpanel.data_processing}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<div className="row gap-2 items-center text-sm">
|
<div className="row items-center gap-2 text-sm">
|
||||||
<MapPinIcon className="size-4" />
|
<MapPinIcon className="size-4" />
|
||||||
<span className="font-medium">Data Location</span>
|
<span className="font-medium">Data Location</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground ml-6">
|
<p className="ml-6 text-muted-foreground text-sm">
|
||||||
{trust.openpanel.data_location}
|
{trust.openpanel.data_location}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<div className="row gap-2 items-center text-sm">
|
<div className="row items-center gap-2 text-sm">
|
||||||
<ServerIcon className="size-4" />
|
<ServerIcon className="size-4" />
|
||||||
<span className="font-medium">Self-Hosting</span>
|
<span className="font-medium">Self-Hosting</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row gap-2 items-center text-sm ml-6">
|
<div className="row ml-6 items-center gap-2 text-sm">
|
||||||
{trust.openpanel.self_hosting ? (
|
{trust.openpanel.self_hosting ? (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="size-4 text-green-500" />
|
<CheckIcon className="size-4 text-green-500" />
|
||||||
@@ -62,32 +67,32 @@ export function TrustCompliance({ trust }: TrustComplianceProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
<FeatureCard title="Competitor" description="">
|
<FeatureCard description="" title="Competitor">
|
||||||
<div className="col gap-4 mt-4">
|
<div className="col mt-4 gap-4">
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<div className="row gap-2 items-center text-sm">
|
<div className="row items-center gap-2 text-sm">
|
||||||
<ShieldIcon className="size-4" />
|
<ShieldIcon className="size-4" />
|
||||||
<span className="font-medium">Data Processing</span>
|
<span className="font-medium">Data Processing</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground ml-6">
|
<p className="ml-6 text-muted-foreground text-sm">
|
||||||
{trust.competitor.data_processing}
|
{trust.competitor.data_processing}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<div className="row gap-2 items-center text-sm">
|
<div className="row items-center gap-2 text-sm">
|
||||||
<MapPinIcon className="size-4" />
|
<MapPinIcon className="size-4" />
|
||||||
<span className="font-medium">Data Location</span>
|
<span className="font-medium">Data Location</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground ml-6">
|
<p className="ml-6 text-muted-foreground text-sm">
|
||||||
{trust.competitor.data_location}
|
{trust.competitor.data_location}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<div className="row gap-2 items-center text-sm">
|
<div className="row items-center gap-2 text-sm">
|
||||||
<ServerIcon className="size-4" />
|
<ServerIcon className="size-4" />
|
||||||
<span className="font-medium">Self-Hosting</span>
|
<span className="font-medium">Self-Hosting</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="row gap-2 items-center text-sm ml-6">
|
<div className="row ml-6 items-center gap-2 text-sm">
|
||||||
{trust.competitor.self_hosting ? (
|
{trust.competitor.self_hosting ? (
|
||||||
<>
|
<>
|
||||||
<CheckIcon className="size-4 text-green-500" />
|
<CheckIcon className="size-4 text-green-500" />
|
||||||
@@ -107,4 +112,3 @@ export function TrustCompliance({ trust }: TrustComplianceProps) {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import { CompareUseCases } from '@/lib/compare';
|
import type { CompareUseCases } from '@/lib/compare';
|
||||||
|
|
||||||
interface UseCasesProps {
|
interface UseCasesProps {
|
||||||
useCases: CompareUseCases;
|
useCases: CompareUseCases;
|
||||||
@@ -9,15 +9,17 @@ export function UseCases({ useCases }: UseCasesProps) {
|
|||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={useCases.title}
|
|
||||||
description={useCases.intro}
|
description={useCases.intro}
|
||||||
|
title={useCases.title}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="grid md:grid-cols-2 gap-6 mt-12">
|
<div className="mt-12 grid gap-6 md:grid-cols-2">
|
||||||
{useCases.items.map((useCase) => (
|
{useCases.items.map((useCase) => (
|
||||||
<div key={useCase.title} className="col gap-2 p-6 border rounded-2xl">
|
<div className="col gap-2 rounded-2xl border p-6" key={useCase.title}>
|
||||||
<h3 className="font-semibold">{useCase.title}</h3>
|
<h3 className="font-semibold">{useCase.title}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{useCase.description}</p>
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{useCase.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { Section } from '@/components/section';
|
import { Section } from '@/components/section';
|
||||||
import type { CompareSummary } from '@/lib/compare';
|
import type { CompareSummary } from '@/lib/compare';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface WhoShouldChooseProps {
|
interface WhoShouldChooseProps {
|
||||||
summary: CompareSummary;
|
summary: CompareSummary;
|
||||||
@@ -36,43 +36,43 @@ export function WhoShouldChoose({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<div className="col gap-4 mb-12">
|
<div className="col mb-12 gap-4">
|
||||||
<h2 className="text-3xl md:text-4xl font-semibold">{summary.title}</h2>
|
<h2 className="font-semibold text-3xl md:text-4xl">{summary.title}</h2>
|
||||||
<p className="text-muted-foreground max-w-3xl">{summary.intro}</p>
|
<p className="max-w-3xl text-muted-foreground">{summary.intro}</p>
|
||||||
</div>
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="grid gap-6 md:grid-cols-2"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
|
||||||
viewport={{ once: true, margin: '-100px' }}
|
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
className="grid md:grid-cols-2 gap-6"
|
viewport={{ once: true, margin: '-100px' }}
|
||||||
|
whileInView="visible"
|
||||||
>
|
>
|
||||||
{/* OpenPanel Card */}
|
{/* OpenPanel Card */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="col group relative gap-4 overflow-hidden rounded-2xl border bg-background p-6 transition-all duration-300 hover:border-emerald-500/30"
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
className="col gap-4 p-6 rounded-2xl border bg-background group relative overflow-hidden hover:border-emerald-500/30 transition-all duration-300"
|
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-br opacity-100 blur-2xl dark:from-emerald-500/5 dark:via-transparent dark:to-green-500/5 light:from-emerald-800/10 light:via-transparent light:to-green-900/10 group-hover:opacity-150 transition-opacity duration-500" />
|
<div className="pointer-events-none absolute inset-0 bg-linear-to-br light:from-emerald-800/10 light:via-transparent light:to-green-900/10 opacity-100 blur-2xl transition-opacity duration-500 group-hover:opacity-150 dark:from-emerald-500/5 dark:via-transparent dark:to-green-500/5" />
|
||||||
<div className="col gap-3 relative z-10">
|
<div className="col relative z-10 gap-3">
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<h3 className="text-xl font-semibold">Choose OpenPanel if...</h3>
|
<h3 className="font-semibold text-xl">Choose OpenPanel if...</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2 mt-2">
|
<div className="col mt-2 gap-2">
|
||||||
{openpanelItems.map((item, index) => (
|
{openpanelItems.map((item, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={item}
|
className="row group/item items-start gap-2"
|
||||||
initial={{ opacity: 0, x: -10 }}
|
initial={{ opacity: 0, x: -10 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
key={item}
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
className="row gap-2 items-start group/item"
|
viewport={{ once: true }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
>
|
>
|
||||||
<div className="size-4 rounded-full bg-emerald-600 dark:bg-emerald-400 shrink-0 mt-0.5 flex items-center justify-center group-hover/item:scale-110 transition-transform duration-300">
|
<div className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-emerald-600 transition-transform duration-300 group-hover/item:scale-110 dark:bg-emerald-400">
|
||||||
<span className="text-[10px] font-bold text-white">
|
<span className="font-bold text-[10px] text-white">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
|
<p className="flex-1 text-muted-foreground text-sm transition-colors duration-300 group-hover/item:text-foreground">
|
||||||
{item}
|
{item}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -83,32 +83,32 @@ export function WhoShouldChoose({
|
|||||||
|
|
||||||
{/* Competitor Card */}
|
{/* Competitor Card */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="col group relative gap-4 overflow-hidden rounded-2xl border bg-background p-6 transition-all duration-300 hover:border-orange-500/30"
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
className="col gap-4 p-6 rounded-2xl border bg-background group relative overflow-hidden hover:border-orange-500/30 transition-all duration-300"
|
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-br opacity-100 blur-2xl dark:from-orange-500/5 dark:via-transparent dark:to-amber-500/5 light:from-orange-800/10 light:via-transparent light:to-amber-900/10 group-hover:opacity-150 transition-opacity duration-500" />
|
<div className="pointer-events-none absolute inset-0 bg-linear-to-br light:from-orange-800/10 light:via-transparent light:to-amber-900/10 opacity-100 blur-2xl transition-opacity duration-500 group-hover:opacity-150 dark:from-orange-500/5 dark:via-transparent dark:to-amber-500/5" />
|
||||||
<div className="col gap-3 relative z-10">
|
<div className="col relative z-10 gap-3">
|
||||||
<div className="col gap-2">
|
<div className="col gap-2">
|
||||||
<h3 className="text-xl font-semibold">
|
<h3 className="font-semibold text-xl">
|
||||||
Choose {competitorName} if...
|
Choose {competitorName} if...
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="col gap-2 mt-2">
|
<div className="col mt-2 gap-2">
|
||||||
{competitorItems.map((item, index) => (
|
{competitorItems.map((item, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={item}
|
className="row group/item items-start gap-2"
|
||||||
initial={{ opacity: 0, x: -10 }}
|
initial={{ opacity: 0, x: -10 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
key={item}
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
className="row gap-2 items-start group/item"
|
viewport={{ once: true }}
|
||||||
|
whileInView={{ opacity: 1, x: 0 }}
|
||||||
>
|
>
|
||||||
<div className="size-4 rounded-full bg-orange-600 dark:bg-orange-400 shrink-0 mt-0.5 flex items-center justify-center group-hover/item:scale-110 transition-transform duration-300">
|
<div className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full bg-orange-600 transition-transform duration-300 group-hover/item:scale-110 dark:bg-orange-400">
|
||||||
<span className="text-[10px] font-bold text-white">
|
<span className="font-bold text-[10px] text-white">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground flex-1 group-hover/item:text-foreground transition-colors duration-300">
|
<p className="flex-1 text-muted-foreground text-sm transition-colors duration-300 group-hover/item:text-foreground">
|
||||||
{item}
|
{item}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Section, SectionHeader } from '@/components/section';
|
|
||||||
import { CompareSummary } from '@/lib/compare';
|
|
||||||
import {
|
import {
|
||||||
UsersIcon,
|
|
||||||
SparklesIcon,
|
|
||||||
SearchIcon,
|
|
||||||
MoonIcon,
|
|
||||||
ShieldIcon,
|
|
||||||
ServerIcon,
|
|
||||||
ZapIcon,
|
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
|
MoonIcon,
|
||||||
|
SearchIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
UsersIcon,
|
||||||
|
ZapIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
|
import type { CompareSummary } from '@/lib/compare';
|
||||||
|
|
||||||
interface WhySwitchProps {
|
interface WhySwitchProps {
|
||||||
summary: CompareSummary;
|
summary: CompareSummary;
|
||||||
@@ -32,16 +32,16 @@ export function WhySwitch({ summary }: WhySwitchProps) {
|
|||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={summary.title}
|
|
||||||
description={summary.intro}
|
description={summary.intro}
|
||||||
|
title={summary.title}
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 mt-12">
|
<div className="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||||
{benefits.map((benefit, index) => {
|
{benefits.map((benefit, index) => {
|
||||||
const Icon = benefitIcons[index] || CheckCircleIcon;
|
const Icon = benefitIcons[index] || CheckCircleIcon;
|
||||||
return (
|
return (
|
||||||
<div key={benefit} className="col gap-3">
|
<div className="col gap-3" key={benefit}>
|
||||||
<div className="size-10 rounded-lg bg-primary/10 center-center">
|
<div className="center-center size-10 rounded-lg bg-primary/10">
|
||||||
<Icon className="size-5 text-primary" />
|
<Icon className="size-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-semibold text-sm">{benefit}</h3>
|
<h3 className="font-semibold text-sm">{benefit}</h3>
|
||||||
@@ -52,4 +52,3 @@ export function WhySwitch({ summary }: WhySwitchProps) {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { FeatureCard, FeatureCardContainer } from '@/components/feature-card';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { ArrowRightIcon } from 'lucide-react';
|
import { ArrowRightIcon } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { FeatureCardContainer } from '@/components/feature-card';
|
||||||
|
|
||||||
interface CompareCardProps {
|
interface CompareCardProps {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,30 +19,29 @@ export function CompareCard({
|
|||||||
return (
|
return (
|
||||||
<Link href={url}>
|
<Link href={url}>
|
||||||
<FeatureCardContainer>
|
<FeatureCardContainer>
|
||||||
<div className="row gap-3 items-center">
|
<div className="row items-center gap-3">
|
||||||
{logo && (
|
{logo && (
|
||||||
<div className="relative size-10 shrink-0 rounded-lg overflow-hidden border bg-background p-1.5">
|
<div className="relative size-10 shrink-0 overflow-hidden rounded-lg border bg-background p-1.5">
|
||||||
<Image
|
<Image
|
||||||
src={logo}
|
|
||||||
alt={`${name} logo`}
|
alt={`${name} logo`}
|
||||||
width={40}
|
className="h-full w-full object-contain"
|
||||||
height={40}
|
height={40}
|
||||||
className="object-contain w-full h-full"
|
src={logo}
|
||||||
|
width={40}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="col gap-1 flex-1 min-w-0">
|
<div className="col min-w-0 flex-1 gap-1">
|
||||||
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors">
|
<h3 className="font-semibold text-lg transition-colors group-hover:text-primary">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
<p className="line-clamp-2 text-muted-foreground text-sm">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="opacity-0 group-hover:opacity-100 size-5 shrink-0 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all duration-300" />
|
<ArrowRightIcon className="size-5 shrink-0 text-muted-foreground opacity-0 transition-all duration-300 group-hover:translate-x-1 group-hover:text-primary group-hover:opacity-100" />
|
||||||
</div>
|
</div>
|
||||||
</FeatureCardContainer>
|
</FeatureCardContainer>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { CompareCard } from './_components/compare-card';
|
||||||
|
import { CompareHero } from './[slug]/_components/compare-hero';
|
||||||
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
import { CtaBanner } from '@/app/(home)/_sections/cta-banner';
|
||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import { WindowImage } from '@/components/window-image';
|
import { WindowImage } from '@/components/window-image';
|
||||||
import { url } from '@/lib/layout.shared';
|
import { url } from '@/lib/layout.shared';
|
||||||
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
import { getOgImageUrl, getPageMetadata } from '@/lib/metadata';
|
||||||
import { compareSource } from '@/lib/source';
|
import { compareSource } from '@/lib/source';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { CompareHero } from './[slug]/_components/compare-hero';
|
|
||||||
import { CompareCard } from './_components/compare-card';
|
|
||||||
|
|
||||||
const title = 'Compare OpenPanel with alternatives';
|
const title = 'Compare OpenPanel with alternatives';
|
||||||
const description =
|
const description =
|
||||||
@@ -28,7 +28,7 @@ const heroData = {
|
|||||||
|
|
||||||
export default async function CompareIndexPage() {
|
export default async function CompareIndexPage() {
|
||||||
const comparisons = compareSource.sort((a, b) =>
|
const comparisons = compareSource.sort((a, b) =>
|
||||||
a.competitor.name.localeCompare(b.competitor.name),
|
a.competitor.name.localeCompare(b.competitor.name)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,36 +37,36 @@ export default async function CompareIndexPage() {
|
|||||||
|
|
||||||
<div className="container my-16">
|
<div className="container my-16">
|
||||||
<WindowImage
|
<WindowImage
|
||||||
srcDark="/screenshots/overview-dark.png"
|
|
||||||
srcLight="/screenshots/overview-light.png"
|
|
||||||
alt="OpenPanel Dashboard Overview"
|
alt="OpenPanel Dashboard Overview"
|
||||||
caption="This is our web analytics dashboard, its an out-of-the-box experience so you can start understanding your traffic and engagement right away."
|
caption="This is our web analytics dashboard, its an out-of-the-box experience so you can start understanding your traffic and engagement right away."
|
||||||
|
srcDark="/screenshots/overview-dark.png"
|
||||||
|
srcLight="/screenshots/overview-light.png"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="All product comparisons"
|
|
||||||
description="Browse our complete list of detailed comparisons. See how OpenPanel stacks up against each competitor on features, pricing, and value."
|
description="Browse our complete list of detailed comparisons. See how OpenPanel stacks up against each competitor on features, pricing, and value."
|
||||||
|
title="All product comparisons"
|
||||||
variant="sm"
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-12">
|
<div className="mt-12 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{comparisons.map((comparison) => (
|
{comparisons.map((comparison) => (
|
||||||
<CompareCard
|
<CompareCard
|
||||||
key={comparison.slug}
|
|
||||||
url={comparison.url}
|
|
||||||
name={comparison.competitor.name}
|
|
||||||
description={comparison.competitor.short_description}
|
description={comparison.competitor.short_description}
|
||||||
|
key={comparison.slug}
|
||||||
|
name={comparison.competitor.name}
|
||||||
|
url={comparison.url}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<CtaBanner
|
<CtaBanner
|
||||||
title="Ready to get started?"
|
|
||||||
description="Join thousands of teams using OpenPanel for their analytics needs."
|
|
||||||
ctaText="Get Started Free"
|
|
||||||
ctaLink="https://dashboard.openpanel.dev/onboarding"
|
ctaLink="https://dashboard.openpanel.dev/onboarding"
|
||||||
|
ctaText="Get Started Free"
|
||||||
|
description="Join thousands of teams using OpenPanel for their analytics needs."
|
||||||
|
title="Ready to get started?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { ZapIcon } from 'lucide-react';
|
||||||
import { FeatureCard } from '@/components/feature-card';
|
import { FeatureCard } from '@/components/feature-card';
|
||||||
import { Section, SectionHeader } from '@/components/section';
|
import { Section, SectionHeader } from '@/components/section';
|
||||||
import type { FeatureCapability } from '@/lib/features';
|
import type { FeatureCapability } from '@/lib/features';
|
||||||
import { ZapIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface CapabilitiesProps {
|
interface CapabilitiesProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -9,22 +9,26 @@ interface CapabilitiesProps {
|
|||||||
capabilities: FeatureCapability[];
|
capabilities: FeatureCapability[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Capabilities({ title, intro, capabilities }: CapabilitiesProps) {
|
export function Capabilities({
|
||||||
|
title,
|
||||||
|
intro,
|
||||||
|
capabilities,
|
||||||
|
}: CapabilitiesProps) {
|
||||||
return (
|
return (
|
||||||
<Section className="container">
|
<Section className="container">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={title}
|
|
||||||
description={intro}
|
|
||||||
variant="sm"
|
|
||||||
className="mb-12"
|
className="mb-12"
|
||||||
|
description={intro}
|
||||||
|
title={title}
|
||||||
|
variant="sm"
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{capabilities.map((cap) => (
|
{capabilities.map((cap) => (
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
key={cap.title}
|
|
||||||
title={cap.title}
|
|
||||||
description={cap.description}
|
description={cap.description}
|
||||||
icon={ZapIcon}
|
icon={ZapIcon}
|
||||||
|
key={cap.title}
|
||||||
|
title={cap.title}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user